petitviolet blog

    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