blog.petitviolet.net

scalametaに入門する

2017-02-19

QiitaScalametaprogrammingmeta

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 文字列から構文木を生成してくれる

Tree

文法は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 { ??? }が大体テンプレ。 内部で使うSeqcollection.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" + ")"
  }
}

from: https://qiita.com/petitviolet/items/c18bbda8c431711db462