scalametaはinline macroと呼ばれるmacro annotationのためのツールキット。
型安全にmacro出来る。
公式とチュートリアルは以下。
setup
まずは使えるようにするためにbuild.sbtに追記。
libraryDependencies += "org.scalameta" %% "scalameta" % "1.5.0"
ちなみにAmmonite-REPLを使っているなら以下。
import $ivy.`org.scalameta::scalameta:1.5.0`
import
importはこう。
とりあえずワイルドカードでimportしてしまう。
import scala.meta._
以下のサンプルでは省略している。
scalametaの基礎概念
Tokens
Scalaの文を切り分けたもの。
metaの基本になる。
とりあえずxxx.syntax
としておけば、そのtokenを表現する文字列が手に入ることをおぼえておけば便利。
@ "val x = 1".tokenize
res7: Tokenized = val x = 1
@ "val x = 1".tokenize.get
res8: Tokens = Tokens(, val, , x, , =, , 1, )
@ "val x = 1".tokenize.get.syntax
res9: String = "val x = 1"
@ "val x = 1".tokenize.get.structure
res10: String = "Tokens(BOF [0..0), val [0..3), [3..4), x [4..5), [5..6), = [6..7), [7..8), 1 [8..9), EOF [9..9))"
@ val tokens = "val x = 1".tokenize.get
tokens: Tokens = Tokens(, val, , x, , =, , 1, )
@ tokens.map { x => f"${x.structure}%10s -> ${x.getClass}" }.mkString("\n")
res13: String = """
BOF [0..0) -> class scala.meta.tokens.Token$BOF
val [0..3) -> class scala.meta.tokens.Token$KwVal
[3..4) -> class scala.meta.tokens.Token$Space
x [4..5) -> class scala.meta.tokens.Token$Ident
[5..6) -> class scala.meta.tokens.Token$Space
= [6..7) -> class scala.meta.tokens.Token$Equals
[7..8) -> class scala.meta.tokens.Token$Space
1 [8..9) -> class scala.meta.tokens.Token$Constant$Int
EOF [9..9) -> class scala.meta.tokens.Token$EOF
"""
@ "val x = \"10".tokenize.get
<input>:1: error: unclosed string literal
val x = "10
^
"""
Quasiquotes
準クオート。scalametaというよりmacro全般に共通する。
http://docs.scala-lang.org/overviews/quasiquotes/intro.html
文字列から構文木を生成してくれる
文法はq"xxx"
。
string-interpolationと同じように使用できる。
クオートした文字列がどういうものか、val
なのかdef
なのか{???}
なのか...、で
型が確定し、クオート内で展開するにも文法が正しくないとコンパイルエラーになる。
ちなみにその型の実装はこの辺で見れる scalameta/scalameta/Trees.scala。
注意点はscala.Seq
ではなくてscala.collection.immutable.Seq
を使うこと。
使い方のサンプルはこんな感じ。
@ val s = collection.immutable.Seq(q"val x = 10", q"def double(value: Int) = value * 2")
res38: Seq[Defn] = List(val x = 10, def double(value: Int) = value * 2)
@ q"""..$s"""
res39: Term.Block = {
val x = 10
def double(value: Int) = value * 2
}
@ q"""start { ..$s } end"""
res21: Term.Select = start {
val x = 10
def double(value: Int) = value * 2
}.end
@ val var10 = q"x = 10"
var10: Term.Assign = x = 10
@ val var20 = q"x = 20"
var20: Term.Assign = x = 20
@ val ifVar = q"if (true) $var10 else $var20"
ifVar: Term.If = if (true) x = 10 else x = 20
unapplyで取り出すことも出来る。
@ val statement = q"val x = if (y > 0) 100 else 0"
statement: Defn.Val = val x = if (y > 0) 100 else 0
@ val q"val $x = if ($cond) $trueResult else $falseResult" = statement
x: Pat = x
cond: Term = y > 0
trueResult: Term = 100
falseResult: Term = 0
文字列をparse
して構文木も取れる。
@ val statement = "val x = if (y > 0) 100 else 0".parse[Stat].get
statement: Stat = val x = if (y > 0) 100 else 0
@ val traitSource = "trait Hoge { def double(n: Int) = n * 2 }".parse[Source].get
traitSource: Source = trait Hoge { def double(n: Int) = n * 2 }
@ val source"trait $traitName { ..$body }" = traitSource
traitName: Type.Name = Hoge
body: collection.immutable.Seq[Stat] = List(def double(n: Int) = n * 2)
ここでsource""
という準クオートが出てきているが、他にもたくさんあり、scalameta/Api.scalaに実装がある。
などなど色々あるが、tokenと準クオート(q""
)を抑えておけば、とりあえず手を動かせる。
macro annotationを実装してみる
scalametaでMacro Annotationを実装する。
A Whirlwind Tour of scala.meta
普通のclassだけどtoString
はcase classのようにしたい、というのはよくあるはず。
JavaならLombokで[@ToString]がある。
これに近いことをscalametaでやってみる。
実装
scala.annotation.StaticAnnotation
をextendsしたclassを定義する。
クラスの本体はinline def apply(defn: Any): Any = meta { ??? }
が大体テンプレ。
内部で使うSeq
はcollection.immutable.Seq
なのでimportしておくこと。
全体像は以下。
import scala.meta._
import scala.collection.immutable.Seq
class ToString extends scala.annotation.StaticAnnotation {
inline def apply(defn: Any): Any = meta {
defn match {
case cls @ Defn.Class(_, name, _, ctor, template) =>
val templateStats: Seq[Stat] =
if (template.syntax.contains("toString")) {
template.stats getOrElse Nil
} else {
val toStringMethod: Defn.Def = createToString(name, ctor.paramss)
toStringMethod +: template.stats.getOrElse(Nil)
}
cls.copy(templ = template.copy(stats = Some(templateStats)))
case _ =>
println(defn.structure)
abort("@ToString must annotate a class.")
}
}
}
annotateしている対象がdefn
として取得出来るので、パターンマッチ等で必要な情報を取り出す。
元々のクラス定義にtoString
メソッドが実装されている場合は何もしないようにする。
定義されていない場合には、クラス名とパラメータからtoString
メソッドを作成し、
もとの定義に追加した上でそれをクラス定義として上書きするような形。
toString
メソッドを作成するcreateToString
の実装は以下。
def createToString(name: Type.Name, paramss: Seq[Seq[Term.Param]]): Defn.Def = {
val args: Seq[String] = paramss.flatMap { params: Seq[Param] =>
params.map { param: Param =>
val paramName = s""""${param.name}""""
val value = s"${param.name}.toString".parse[Term].get
s"""$paramName + ": " + $value"""
}
}
val joinedParamStrings: Term = args.mkString(""" + ", " + """).parse[Term].get
q"""
override def toString: String = {
${name.syntax} + "(" + $joinedParamStrings + ")"
}
"""
}
クラスのコンストラクタを一つずつ値と型を:
でくっつけ、全体を,
でmkString
し、parse[Term]
でTerm
型として扱う。
クラス名は.syntax
で文字列として取得し、先程のTerm
を()
の中に入れて出力したい文字列とする。
それをoverrider def toString
の実装として渡す。
準クオート(q
)とStringContextによるinterpolation(s
)を混ぜて使用することが出来ず、
このような実装になってしまった。
実装全体はこちら。
scalameta-prac/ToString.scala
使ってみる
適当なclassに@ToString
でアノテーションしてみる。
@ToString
class ToStringClassA(n: Int, label: String)
マクロを展開すると以下のようになり、うまくいっていることが分かる。
class ToStringClassA(n: Int, label: String) {
override def toString: String = {
"ToStringClassA" + "(" + ("n" + ": " + n.toString + ", " + "label" + ": " + label.toString) + ")"
}
}
なぜTerm
を使うのか
実装ではparse[Term].get
を呼んでる箇所があるが、ぱっと見String
のままでも良さそうに見える。
しかし、うまくTerm
を渡さないと意図しない形になってしまうので注意。
例えば以下のようにTerm
だった箇所をString
にすると、
// val joinedParamStrings: Term = args.mkString(""" + ", " + """).parse[Term].get
val joinedParamStrings: String = args.mkString(""" + ", " + """)
生成されるtoString
は以下のように、n.toString
などがエスケープされた文字列になってしまっている。
class ToStringClassA(n: Int, label: String) {
override def toString: String = {
"ToStringClassA" + "(" + "\"n\" + \": \" + n.toString + \", \" + \"label\" + \": \" + label.toString" + ")"
}
}