マクロ・コンパイラプラグインのモジュール化はパス依存型により結構面倒です。ベストプラクティスはよくわかりませんが、1年前にinverse macroをリファクタリング1したときに田山さんのcake pattern23を参考にしたのでそれを紹介します。
もっといいプラクティスがあれば随時情報募集中です。
モチベーション
背景: マクロ・コンパイラプラグインの作り
マクロ・コンパイラプラグインのモジュール化の前に、Scalaのマクロ・コンパイラプラグインの開発について簡単に説明したいと思います。マクロ・コンパイラプラグインの開発においては、ある1つのオブジェクトを引数に取るようなクラス、言い換えるとDIにおけるコンストラクタインジェクションの形式になっているクラスを定義することが肝となります。
例えば、マクロでは以下のようにクラスを定義します。
import scala.language.experimental.macros
class Bundle(val c: scala.reflect.macros.blackbox.Context) {
import c.universe._
def transformImpl(a: Tree): Tree = ... // マクロの本体
}
このようにContext
型の引数を注入される形となります。ここで、Context
はマクロ展開の文脈に関する情報を持ったオブジェクトですが、それだけでなく様々なクラス定義を含んでいます。例えば、構文木を示すTree
クラスや型を示すType
クラスなどです。そのため、import c.universe._
のようにインポートを行って使います。
コンパイラプラグインの場合も同様に以下のようにGlobal
型を引数に取るクラスを定義します。
class Plugin(override val global: scala.tools.nsc.Global)
extends scala.tools.nsc.plugins.Plugin {
import global._
import analyzer._
val Analyzer = new AnalyzerPlugin {...} // 一例
override def init(options: List[String], error: (String) => Unit): Boolean = {
addAnalyzerPlugin(Analyzer)
true
}
}
問題点
1回コンストラクタで受け取れば明示的に引き回さなくてもクラス内のメソッドで共有できるので、ある意味スッキリしたフレームワーク設計になっています。しかしながら、モジュール化を考えたときには、この設計には、パス依存型というとても面倒な問題を抱えることになります。例えば、symbolを考慮して構文木をコピーするメソッドなど複数のユーティリティメソッドをUtil
という別クラスに切り出すことを考えましょう。各メソッドごとにContext
オブジェクトを受け取ってインポートを書くのは面倒なので以下のような感じに定義することになると思います。
import scala.language.experimental.macros
class Util(val c: scala.reflect.macros.blackbox.Context) {
import c.universe._
def copy(a: Tree, n: Int): Seq[Tree] = ... // symbolを適宜付け替えつつ複製
...
}
また使う側は以下のようにすると思います。
class Bundle(val c: scala.reflect.macros.blackbox.Context) {
import c.universe._
val util = new Util(c)
def transformImpl(a: Tree): Tree = {
...
val trees: Seq[Tree] = util.copy(tree, 2) // ???
...
}
}
しかしこれは問題が起きます。transformImpl
の中のTree
をもっと正確に書くとc.universe.Tree
型で、copy
の中ではutil.c.universe.Tree
型で実は微妙に違いそうに見える型です。実際Scalaの型システムでは違うと言われてエラーになります。キャストしまくればなんとかなったりしますがかなり残念な気持ちになります。
補足: 私にはパス依存型は難しすぎました。パス依存型のプロによるよりいい感じの解説待ってます。。
Scala Asyncの場合: cake pattern
他のマクロライブラリではどうしているのでしょうか?一般にどうといえるほど多くのマクロライブラリの中身を解析したわけではないのですが、Scala Asyncという有名ライブラリ4ですと複数のトレイトに分割して組み上げるcake patternの一種を採用しています。別クラスにしてしまうと上記の問題が発生してしまうので、同じクラスとして1つに組み上げてしまうわけですね。
しかし惜しいことにScala Asyncではself type annotationが組み上げ後のクラスにfixされてしまっています。5 このままでは他のマクロに使いまわそうとしたとき問題が生じてしまうため、今回のように汎用ライブラリを切り出すという目的ではもう一捻りが必要です。
田山さんのcake patternを使った方法
今回は田山さんのcake patternを採用してみました。基本的にやることはScala Asyncと同様にtraitに分割するだけですが、トレイトの定義に一工夫入れます。以下マクロで説明しますが、コンパイラプラグインでもほぼ同じです。
まず、以下のようにUseContext
トレイトを定義します。オリジナルの田山さんのcake patternと違って、Context
オブジェクトはインポートする必要があるのでdef
ではダメですので注意してください。
trait UseContext {
val c: scala.reflect.macros.blackbox.Context
}
パーツとして分けられた各トレイトではこれを継承します。これによって、パス依存型の問題に悩まされることも、組み上げ後のクラスを固定されることもなくUtil
を切り出すことができました。
trait Util extends UseContext {
import c.universe._
def copy(a: Tree, n: Int): Seq[Tree] = ... // symbolを適宜付け替えつつ複製
...
}
ところで、オリジナルの田山さんのcake patternと違ってMixInContext
は存在しません。Scalaのクラスパラメータに不慣れな人にはわかりにくいかと思いますが、クラスパラメータがそれに相当します。以下でいうとval c: scala.reflect.macros.blackbox.Context
の部分です。オリジナルと違ってあくまでミックスインではなくコンストラクタ経由での注入なのでこのような違いが出ているのだと思います。67
class Bundle(val c: scala.reflect.macros.blackbox.Context)
extends ... {}
Inline/meta系のマクロでは…?
そもそもなぜ現行のマクロでこんなに面倒になったのかというと、Context
オブジェクトがTree
とかType
とかの定義を持っていてパス依存になるからでした。Scala.metaベースのinline/meta系マクロは、scala.meta
が持つことになるようです。つまりファイルの先頭にimport scala.meta._
と書くだけでよいです。Context
オブジェクトを引き回してインポートする必要はもはやありません。
冷静に考えてみると、構文木や型の定義が文脈依存というのはなんか設計が間違ってそうな気がします。今までの設計は何だったのか…というため息が出ますね。はー
-
https://github.com/scala/async/blob/v0.9.6/src/main/scala/scala/async/internal/AnfTransform.scala#L12 ↩
-
というわけなのでこれをミックスイン・インジェクションと呼ぶのはちょっと違うかなと思います ↩
-
セッター・インジェクションでも似たようなことをやってみてもいいのではないでしょうか ↩