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 // ... }
こんな感じで型パラメータに与えられたクラスの情報を元にチェックできたり。ソースは下記の模様。
比較的安全に悪そうなこと出来たりしますが、乱用するとパッと見でどんなコードが生成されるか分かりづらいので、お約束ながら用法用量を守っていきたいですね。