Scalaのマクロには、ある程度複雑なマクロを書こうとしたときに立ちはだかる壁があります。それがsymbolとownerです。この壁の厄介なところは、ほぼ確実に引っかかる罠でありながら英語も含めてドキュメントがほぼゼロという点です。これについて簡単に解説したいと思います。
Symbol
Scalaのマクロにはsymbolという概念があります。12 Symbolは、変数・メソッド・クラスなどの識別子同士を区別したり、メンバ等のメタ情報へのアクセスを仲介したりするために使われます。Cの字句マクロやLispの構文マクロなどにおいては名前被り・名前空間の汚染等を防ぐこと、一言で言うとhygiene性が大きな問題となっていますが、Scalaのマクロにおいては同様の問題は起きにくくなっています。3 なぜなら、Scalaのtyper(より厳密にいうとnamer)が各定義・参照の構文木に適切なsymbolオブジェクトを割り付けてくれるため、同じ名前の識別子があっても簡単に区別できるからです。4
ハマりどころ
この親切心(?)ですが、ある程度複雑なマクロを書くときには逆に仇となります。例えば、以下のように変数宣言を含むコードをコピーするマクロを考えましょう。何が起こるでしょうか?
val a = 10
println(a)
{
val a = 10
println(a)
}
{
val a = 10
println(a)
}
普通に作ると2つの変数a
が同じsymbolを参照してしまい、おかしなことが起きてしまいます。つまり、片方の変数a
について新しいsymbolを生成してsymbolを付け替える必要があります。c.internal
に必要なメソッドが用意されているのでTransformApi
を使って頑張って再帰的に付け替えていきましょう。substituteSymbols
で多少手抜きできるかもしれませんが、そもそも定義を全列挙しないといけないので割とどうしようもないところがあります。
Symbolを持つ構文木
ところでsymbolを持つ構文木にはどういうものがあるのでしょうか?書いてある文献が全然ないのでここにまとめておこうと思います。
-
DefTree
族:ValDef
,DefDef
などDefTree
を継承しているものです。Bind
など名前にDef
が入っていないものもあります。 -
Function
: 無名関数です。 -
Template
: クラス定義のbodyです。 -
RefTree
族:Select
,Ident
などです。これはsubstituteSymbol
を使うと気にする必要がないので忘れてもよいです。
その他にもTypeTree
の中にも実は色々あったりしますが、そちらはコンパイラプラグインでも書かないとあまり触れる機会がないので気にしなくてよいです。実のところ私も把握しきれていないので誰かまとめた資料ください。
小ネタ
Symbolオブジェクトはmutable
Scalaのマクロは基本的に構文木がimmutableになるように設計されています。が、symbol周りはmutableです。定義と参照の両方で共有するものなので当然といえば当然ですが。ついうっかり関係ない変数でsymbolを共有するととても良くわからないエラーに悩まされるので気をつけましょう。
2種類のコピー
右辺だけ書き換えたいなど、変数宣言をコピーするときは、初めは以下のように書いたりすることも多いと思います。
$mods val $name: $tpt = $rhs
しかし、玄人のコードを読むと普通treeCopy.ValDef
が使われていると思います。
treeCopy.ValDef(original, mods, name, tpt, rhs)
これは、後者だとoriginal
のsymbolが新しいtreeにも割り付けられるからです。前者だと消えてしまい、あとでタイプチェッカを通したときに新しいsymbolが割り付けられます。このため、右辺だけ書き換えたいというようなときに前者のように書くと、symbolの対応関係が崩れて『そのsymbolを持つ変数ないよ』というコンパイルエラーに悩まされることになります。逆にインライン展開など同じ構文木を複製したいときには後者でやってしまうと困ったことになるかも…しれません。どうせ後で書き換えないといけないのですが。
Owner
もう一つ面倒な問題はsymbolのownerです。Ownerとはそのsymbolがどの文脈に出てくるかを示すものであり、他のsymbolへの参照で表されます。例えば以下のようなプログラムを考えます。
class Hoge {
def piyo(): Unit = {
val fuga = 10
}
}
このときfuga
に対応するsymbol(面倒なので以下fuga
と同一視します)のownerは、piyo
のローカル変数なのでpiyo
となります。また、piyo
のownerはHoge
です。
Symbolのownerが適切に設定されていないと、マクロ展開時点ではエラーが出ませんが、コンパイラの後ろの方のフェーズでコケます。(完全に実装上の手抜きだと思うのですが。。)例えば、以下のようなメソッドが入れ子になっているプログラムを考えます。
class Hoge {
def piyo(): Unit = {
val piyoLocalVal = 10
def fuga(): Unit = {
println(piyoLocalVal)
}
fuga()
}
}
Javaを知っている人ならわかるようにJVMではメソッド内で定義するメソッドをサポートしていないので、Scalaコンパイラでは以下のようにfuga
をpiyo
の外に出します。5 このとき、piyoLocalVal
はpiyo
の中でだけ通用する変数なので、fuga
の追加の引数として渡されることになります。
class Hoge {
def piyo(): Unit = {
val piyoLocalVal = 10
fuga(piyoLocalVal)
}
def fuga(piyoLocalVal: Int): Unit = {
println(piyoLocalVal)
}
}
ここでpiyoLocalVal
のownerが間違っていると何が起きるでしょうか。たとえば、Hoge
になっているとします。するとscalacはpiyoLocalVal
をHoge
のインスタンス変数と誤認し、fuga
をpiyo
の外に出して相変わらず見えると誤解します。したがって次のようなプログラムを出力してしまいます。
class Hoge {
def piyo(): Unit = {
val piyoLocalVal = 10
fuga(piyoLocalVal)
}
def fuga(): Unit = {
println(piyoLocalVal)
}
}
この結果、あとでfuga
から見たとき、存在しないはずのpiyoLocalVal
を参照してしまっているためうまくコードが出力できずscalacが例外で死んでしまいます。なお、そもそもscalacの設計上想定されていないエラーのため、ユーザーフレンドリーなエラーメッセージは出てくれません。。
正しいownerの付け替え方
このようにownerを付け替えないといけないというのは理解していただけたと思うのですが、ではどう付け替えるのが正しいのかというと…そんな資料はどこにもありませんでした。なのでソースコードを見ることになります。この辺が一番わかり易いのではないでしょうか。
直感的には外側の構文木で直近のsymbolを持っている木がownerです。が、CaseDef
などownerになれないものが幾つかあります。Ownerになれるものをまとめると以下のようになります。
-
ClassDef
,ModuleDef
,ValDef
,TypeDef
,DefDef
の5種。 -
Function
(無名関数)
この原則に従って以下のソースコードのようにsetOwner
, changeOwner
を頑張ればあなたもowner chainマスターです!がんばってください。私はこれを調べるために2015年をすべて溶かしたと言っても過言じゃないです。。
ところでよく見るとソースコードには嘘が1つあります。Template
はownerになれません。間違えないでください。
[3/1加筆] 中の人に聞いた結果、型なし構文木など一部の場合には Template がownerになることがあるそうです。が、型チェッカを通すとそのような場合は消滅するので上記のように覚えておけば良いです。
自動で修正するプラグイン
ちょっと考えればわかると思いますが、この問題はある程度自動で解決できます。以前簡単に試作したものがこちらにあります。Inverse macro plugin に組み込んでみました。
残念なことに Scala 2.12 では動かなくなってしまっているのでそのうち修正したいと思います。
そもそも本体になぜ自動修正、あるいはせめて検証だけでも入っていないのか極めて疑問ですが…
まとめ
この記事を読めばsymbolとownerへの対策はバッチリだと思います。よいScalaマクロライフを!
とはいえ、こんな変なハマりどころがあるのもScalaコンパイラのAPIをハックして無理やりマクロとして使えるようにしているからなので、scala.metaベースの次世代マクロではその辺改善されることを期待しています。。
おまけ: ありがちなworkaroundに対するコメント
untypecheck
- 一部のパターンマッチなど幾つかuntypecheckできない構文木がある
- 元のソースコードにあった型注釈のうち一部はtyperによってsymbolに移され構文木上から消えるため、untypecheckすると本来必要な型注釈がなくtyperを通らなくなる場合がある
show+parse
- カッコを付ける場所がおかしいなど幾つか挙動の怪しいところがある
- 特殊なmodifierを
<>
で出力するなどそもそも仕様上scalaでコンパイルできることを意図した出力をするメソッドではない -
-Y
系のデバッグ用オプションを付けると出力文字列が変わって動かなくなる
-
http://docs.scala-lang.org/ja/overviews/reflection/symbols-trees-types ↩
-
Scalaにはシンボルリテラル(例:
'symbol
)というマイナーな機能がありますが、おそらくこれとは関係ありません。 ↩ -
たまに勘違いしている人がいますが、hygiene性は100%問題が起きないことを保証するものではなく、安全性を保障しようとする何らかの機構を持っているかどうかです。故意に破壊しようと思えば簡単に壊せます。 ↩
-
今回の話とは関係ありませんが、必要があればfresh nameを生成できるAPIの方もあります。 ↩
-
関数型言語コンパイラではこういう操作をラムダリフティングというらしいです。そのためこの操作をするscalacのフェーズ名として
lambdalift
という名前がついています。 ↩