Scala dynamics + macros

Type Dynamic

Scalaはメソッドなどの有無をコンパイルの時点で解決しますが、Dynamic traitを使うことによって動的にすることが出来ます。

// Dynamic traitを使うために必要。コンパイラオプションに `-language:dynamics` を渡してもOK
import scala.language.dynamics

object Adder extends Dynamic {
  // ...
}

こうするだけ。で、オブジェクト(この場合は Adder オブジェクト)に存在しないフィールドやメソッドを指定すると、特定のメソッドが呼ばれます。特定のメソッドは4種類あり、ケースに応じて呼び出されるメソッドが変わります。

  • フィールドの参照: selectDynamic
  • フィールドへの代入: updateDynamic
  • メソッド呼び出し: applyDynamic
  • 名前付き引数を伴ったメソッド呼び出し: applyDynamicNamed

という具合。試しに applyDynamic メソッドを定義してみます。

object Adder extends Dynamic {
  def applyDynamic(name: String)(value: Int): Int = {
    if (!increments.isDefinedAt(name)) {
      throw new IllegalArgumentException(s"$name is not supported")
    }

    val increment = increments(name)

    value + increment
  }

  val increments: Map[String, Int] = Map(
    "one" -> 1,
    "two" -> 2,
    "three" -> 3
  )
}

で実際に使ってみるとこんな感じになる。

println(Adder.one(100))   // => 101
println(Adder.two(100))   // => 102
println(Adder.three(100)) // => 103

本来 Adder オブジェクトには one メソッドなどが定義されていないわけですが、Dynamic traitを使用しているので、applyDynamic メソッドが呼ばれ、その中の処理が実行されるわけです。

先に書いたとおり、他にもメソッドがあるわけですが、これについては割愛。Dynamic traitのドキュメント読むだけでもなんとなく分かるんじゃないかなあと。

マクロ

Type Dynamic便利ですね。しかしこのケースにおいては Adder.four(100) などと呼ぼうものならエラーになります。実行時エラーです。Type Dynamic使わなければコンパイルエラーなのに。

だからこそType Dynamicとも思うのですが、場合によってはコンパイルエラーに出来ます。マクロと併用します。順を追って Adder.four(100)コンパイルエラーになるようにしてみましょう。

まず Adder.applyDynamic メソッドがマクロの呼び出しになるように書き換えます。

import scala.language.dynamics

// `macro` キーワードを使うために必要。コンパイラオプションに `-language:experimental.macros` を渡してもOK
import scala.language.experimental.macros

object Adder extends Dynamic {
  def applyDynamic(name: String)(value: Int): Int = macro AdderMacro.applyDynamic
}

使うのは macro キーワードで、このあとにマクロの実態となるメソッドの識別子を渡します。元のメソッド名と合わせる必要は特になく、macro Nyan.nyan とかでも大丈夫です。

で、実態側を定義する。

import scala.reflect.macros.blackbox.Context

object AdderMacro {
  def applyDynamic(c: Context)(name: c.Expr[String])(value: c.Expr[Int]): c.Expr[Int] = {
    import c.universe._

    val Literal(Constant(numName: String)) = name.tree

    if (!increments.isDefinedAt(numName)) {
      c.error(c.enclosingPosition, s"$numName is not supported")
    }

    val increment = Literal(Constant(increments(numName)))

    c.Expr[Int](q"$value + $increment")
  }

  val increments: Map[String, Int] = Map(
    "one" -> 1,
    "two" -> 2,
    "three" -> 3
  )
}

AdderMacro.applyDynamic がマクロの実態なわけですが、まずシグネチャをマクロ用のものにする必要があります。ひとつ目の引数リストで scala.reflect.macros.{blackbox,whitebox}.Context を受け取るようにし、あとは元のメソッドと同じ引数リストとなります。ただし、c.Expr で包んであげます(または c.Tree にする)。

これで Adder.one(100) を呼び出すと、Type Dynamicによって Adder.applyDynamic("one")(100) になり、マクロを通じてコンパイル時に 100 + 1 に置き換えられます。なので全体的にASTを操作する処理になります。

で、ポイントは c.error のところ。これでコンパイルエラーになります。実際に sbt console で試してみます。

[info] Starting scala interpreter...
Welcome to Scala 2.12.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_131).
Type in expressions for evaluation. Or try :help.

scala> Adder.one(100)
res0: Int = 101

scala> Adder.four(100)
<console>:12: error: four is not supported
       Adder.four(100)

意図した通りにコンパイルエラーになっています。と、まあこんな感じでType Dynamicしつつも、マクロを介して妥当かどうかをチェックしてあげられます。

ちなみにマクロの実態の方を先にコンパイルしてあげる必要などがあるので、sbtではプロジェクトを分けたりする必要があります。詳細はサンプルを参照してみてください。

実例

ScalikeJDBCのコードに良い実例があります。

import scalikejdbc._

case class PersonRecord(name: String, age: Int)

object PersonTable extends SQLSyntaxSupport[PersonRecord] {
  // ...
  
  // `column` がType Dynamicになっており、`SQLSyntaxSupport` に与えたクラスの
  // プライマリコンストラクタの引数のみ許可している。よって以下はコンパイルが通る
  column.name
  column.age
  
  // これは通らない。以下のようにコンパイルエラーとなる。
  //   PersonRecord#birthday not found. Expected fields are #name, #age.
  column.birthday
  
  // ...
}

こんな感じで型パラメータに与えられたクラスの情報を元にチェックできたり。ソースは下記の模様。

比較的安全に悪そうなこと出来たりしますが、乱用するとパッと見でどんなコードが生成されるか分かりづらいので、お約束ながら用法用量を守っていきたいですね。