Scala上級者向けのマクロ入門

March 30, 2023

Tags /

はじめに

Scala のマクロは、通常の開発においてはあまり使用されることがないため、Scala 初心者が学習する必要はありません。しかし、独自の仕組みやツールを作成する際には、マクロは非常に強力な武器となります。本記事は Scala 2 系を前提として説明しています。

マクロを実装するまでの道

Scala で提供されているリフレクションについて

マクロを理解するためには、まずリフレクションについて触れる必要があります。

Scala では、リフレクションにはランタイムリフレクションとコンパイルタイムリフレクションの 2 つがあります。1 コンパイルタイムリフレクションを利用することで、抽象構文木(AST: Abstract Syntax Tree)を変更したり作成したりすることができます。2 これにより、抽象構文木を操作できることから、メタプログラミングが可能になります。そして、マクロはコンパイルタイムリフレクションを利用して実現される機能です。

マクロを使うことで、例えばボイラープレートコードの排除ができます。一方、マクロを多用すると、コンパイルが遅くなる要因になることがあるため、注意が必要です。 また、マクロに詳しい人はそれほど多くないため、詳しい人がいなくなった場合には、調査や改修の難易度が上がることがあります。

抽象構文木とその操作方法

抽象構文木とは、プログラムの構文をツリー状に表現したものです。簡単に言うと、ソースコードを解析して得られるデータ構造です。

以下に例を示します。

def plus(x: Int, y: Int): Int = x + y

この関数の、x + y の部分の抽象構文木を確認してみます。

import scala.reflect.runtime.universe._
showRaw(q"x + y")

res1: String = Apply(Select(Ident(TermName("x")), TermName("$plus")), List(Ident(TermName("y"))))

またメソッド全体の抽象構文木は次のようになります。

import scala.reflect.runtime.universe._
showRaw(q"def plus(x: Int, y: Int): Int = x + y")

res2: String = DefDef(Modifiers(), TermName("plus"), List(), List(List(ValDef(Modifiers(PARAM), TermName("x"), Ident(TypeName("Int")), EmptyTree), ValDef(Modifiers(PARAM), TermName("y"), Ident(TypeName("Int")), EmptyTree))), Ident(TypeName("Int")), Apply(Select(Ident(TermName("x")), TermName("$plus")), List(Ident(TermName("y")))))

メタプログラミングを行う場合、これらのような、抽象構文木を作成したり、変更したりすることで実現します。ここで、より簡単に AST を操作する方法があります。 それが 準クォート(Quasiquotes) と呼ばれる表記法です。例えば、前述の例の q"..." がその表記法です。詳細な使い方は公式ドキュメントを参照してください。3

マクロの実装

例として、実践では使うことはないようなマクロではありますが、ここでは足し算をするマクロを実装してみます。

まず公式ドキュメントに倣って書くと次のようになります。4

import scala.language.experimental.macros
import scala.reflect.macros.Context

object PlusMacros {

  def plus(x: Int, y: Int): Int = macro plusImpl

  def plusImpl(c: Context)(x: c.Expr[Int], y: c.Expr[Int]): c.Expr[Int] = {
    import c.universe._
    reify(x.splice + y.splice)
  }
}

準クォートを使った表記法に書き換えると次のようになります。

object PlusMacros {

  def plus(x: Int, y: Int): Int = macro plusImpl

  def plusImpl(c: Context)(x: c.Expr[Int], y: c.Expr[Int]): c.Expr[Int] = {
    import c.universe._
    c.Expr(q"$x + $y")
  }
}

さらに、scala 2.11 以降では c.Expr[Int]c.Tree として次のように書くこともできます。

object PlusMacros {

  def plus(x: Int, y: Int): Int = macro plusImpl

  def plusImpl(c: Context)(x: c.Tree, y: c.Tree): c.Tree = {
    import c.universe._
    q"$x + $y"
  }
}

これに加えてマクロバンドルを使うと次のようになります。

object PlusMacros {

  def plus(x: Int, y: Int): Int = macro PlusMacrosImpl.plusImpl

}

class PlusMacrosImpl(val c: Context) {

  import c.universe._

  def plusImpl(x: c.Tree, y: c.Tree): c.Tree = q"$x + $y"
}

このときマクロを使用する側では次のようになります。

// ソースコードの段階
val sum = PlusMacros.plus(2, 3)

// コンパイルをした段階
val sum = 2 + 3

注意点として、マクロは一番最初にコンパイルする必要があります。

マクロアノテーション

マクロアノテーションとは、自作のアノテーションを付与するだけでマクロを展開できる仕組みです。 例として、アノテーションを付けたメソッドに対して事前処理を割り込ませる仕組みを実装してみます。 全体の流れとしては、共通処理を用意しておき、それをマクロを使って割り込ませる方式をとります。ここでは、事前処理はログ出力をするものとします。

まず、次のようなログ出力を割り込ませるための共通処理を関数として用意しておきます。具体的な実装は省略します。

object Logging {
  def before[T](f: => T): T = ???
}

つぎに、マクロアノテーションの作成です。

class BeforeLogging extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro BeforeLoggingMacroImpl.impl
}

続いて、マクロの実装です。ただし見通しを良くするため作り込んではいません。

class BeforeLoggingMacroImpl(val c: Context) {

  import c.universe._

  def impl(annottees: Tree*): Tree = {

    // マクロアノテーションを付与したメソッドの抽象構文木を取得します
    val method = annottees.head

    // パターンマッチで各パーツを抽出します。
    val q"$mods def $name[..$tparams](...$paramss): $tpt = $body" = method

    // 共通処理を元の本体に埋め込みます
    val newBody = q"Logging.before { $body }"

    // 共通処理を埋め込んだ本体を含めた新しい抽象構文木を返します
    q"$mods def $name[..$tparams](...$paramss): $tpt = $newBody"
  }
}

最後に、マクロアノテーションの利用側の実装です。次のようにアノテーションを付与するだけで、事前に準備した処理を割り込ませることができます。

def main(args: Array[String]): Unit = doSomething()

@BeforeLogging
private def doSomething(): Unit = println("Do something.")

おわりに

本記事ではベストプラクティスとはいえないものを含んでいる可能性がありますのでご了承ください。

脚注


Written by Gayamasan who lives and works in Japan.