MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Scala マクロアノテーションに入門するための準備体操

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のものにリプレースしたりする際などにこの記事がご参考になれば幸いです!