Scala マクロに入門するための準備体操
サーバサイドエンジニアの飛田です。
マイクロアドの開発チームでは、以下のようにマクロアノテーションを使って、 処理時間が重要なメソッドのマイクロベンチマークを計測することがあります。
// コンパイル時に対象のメソッドの実行時間をログに吐くようなアノテーション(プロダクトコードではもっと複雑な処理をしていますが、このサンプルコードでは分かりやすさ重視でメソッドのシグネチャ等を簡素化しています) @profiled def process(???)
私自身、Scalaマクロおよびメタプログラミング自体が初めてで、 マクロを理解するのに苦戦したことを踏まえて、 この記事では、Scala2.13のマクロについて共有させていただきます。
対象読者はScalaマクロを使ったことがない かつ メタプログラミングをやったことがない人を想定しています。
Scalaのマクロは、コンパイル時に構文木を操作することにより、プログラムの書き換えを実現します。 Scalaマクロを全く触ったことがない人にとっては、 いきなりScalaコンパイラが取り扱う型や構文木について考えたりするのは、イメージが湧きにくいのかなと推測されますので、
この記事では、Scala マクロに入門するための準備体操と題しまして、 簡単なサンプルコードを通して、マクロの入門的な内容をお伝えさせていただきます。
マクロのサンプルプログラム
具体例として、コンパイル時にメソッドのメソッドボディを"Hello World"に書き換えるようなマクロを作成して、実行してみます。
ディレクトリ構成とbuild.sbtは以下のようになります。
$ tree --gitignore
.
├── build.sbt
├── lib
│ └── src
│ └── main
│ └── scala
│ └── HelloWorld.scala
├── project
│ └── build.properties
└── src
└── main
└── scala
└── Main.scala
ThisBuild / version := "0.1.0-SNAPSHOT" ThisBuild / scalaVersion := "2.13.8" lazy val root = (project in file(".")).settings( scalacOptions ++= Seq("-Ymacro-annotations") ).dependsOn(lib) lazy val lib = (project in file("lib")) .settings( scalaVersion := "2.13.8", libraryDependencies ++= Seq( "org.scala-lang" % "scala-reflect" % "2.13.8", "org.scala-lang" % "scala-library" % "2.13.8" ), scalacOptions ++= Seq("-Ymacro-annotations"), )
Main.scalaがプログラムのエントリーポイントになっており、
HelloWorld.scalaがマクロを定義したファイルになっております。
// Main.scala object Main extends App { @HelloWorld def hoge = "あああ" println(hoge) }
Main#hogeに対して、@HelloWorldアノテーションが付与されていますが、
コンパイル時にMain#hogeメソッドのメソッドボディの構文木が書き換えられます。
// HelloWorld.scala import scala.annotation.StaticAnnotation import scala.language.experimental.macros import scala.reflect.macros.whitebox class HelloWorld extends StaticAnnotation { def macroTransform(annottees: Any*): Any = macro HelloWorld.impl } object HelloWorld { def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = { import c.universe._ annottees.map(_.tree).toList match { case (defDef: DefDef) :: Nil => val newDefDef = DefDef( defDef.mods, defDef.name, defDef.tparams, defDef.vparamss, defDef.tpt, q""" "Hello World" """ ) c.Expr[Any](newDefDef) case _ => c.abort(c.enclosingPosition, "Annottee is not a method") } } }
HelloWorldクラスがアノテーションの定義になっていまして、
コンパイル時にHelloWorld#implメソッドに従って、アノテーションが付与されたものの構文木が書き換えられるようになっています。
HelloWorld#implでは annotteesがアノテーション対象でして、
annottees.map(_.tree).toList match {...}のところでアノテーションが付与されたものの構文木をチェックするようにしています。
DefDefはメソッドを表す型でして、
matchの中ではcase (defDef: DefDef) :: Nil, case _という2つのケースがありますが、
前者にマッチした場合は、newDefDefというメソッドの構文木を返すようにしています。
後者にマッチした場合 つまり @HelloWorldアノテーションがメソッド以外に付与された場合は、
コンパイルエラーになります。
DefDefについて
サンプルプログラムのcase (defDef: DefDef) :: Nilのところですが、
defDefからは以下の値が取れるようになっています。
defDef.mods
こちらはメソッドの修飾子の情報を持ったものになります。
例えば、アノテーション付与対象のメソッド定義を以下のような定義にしてやって、
@HelloWorld final protected def hoge = "あああ"
println(c.universe.showRaw(defDef.mods))で構文木をチェックしてやると、
コンパイル時にModifiers(PROTECTED | FINAL)が標準出力されるようになります。
※ c.universe.showRawは生の構文木を文字列に変換してくれるメソッドになります。
defDef.name
こちらはメソッド名になります。
同様に println(c.universe.showRaw(defDef.name))で構文木をチェックしてやると、
hogeと標準出力されます。
defDef.tparams
こちらはメソッドの型パラメータ情報になります。
同様にprintln(c.universe.showRaw(defDef.tparams))でチェックしてやると、
List()と標準出力されます。
今回のケースですと、型パラメータは使用していませんので、空のリストが表示されます。
defDef.vparamss
こちらは引数一覧です。
println(c.universe.showRaw(defDef.vparamss))でチェックしてやると、
List()と標準出力されます。
今回のケースですと、引数はありませんので、空のリストが表示されます。
defDef.tpt
こちらは戻り値の方です。
println(c.universe.showRaw(defDef.tpt))でチェックしてやると、
TypeTree()と標準出力されます。
今回のケースですと戻り値の方を明示的に指定していないためTypeTree()という参考にならないものが標準出力されましたが、
def hoge: String = "あああ"のようにアノテーション対象のメソッドの戻り値を指定してやるとIdent(TypeName("String"))と標準出力され
戻り値の型が取得できます。
defDef.rhs
こちらはメソッドボディです。
println(c.universe.showRaw(defDef.rhs))でチェックしてやると、
Literal(Constant("あああ"))と標準出力されます。
実行
以下のようにプログラムを実行してやると、
$ sbt "runMain Main" Hello World
「あああ」ではなくて、「Hello World」が表示されます。
おわりに
ご閲覧ありがとうございました。
Scala 3がリリースされてScala 2.13のマクロを記事にするのは今更感がありますが、 Scala 2.13のマクロをScala3のものにリプレースしたりする際などにこの記事がご参考になれば幸いです!