Help us understand the problem. What is going on with this article?

scalametaに入門する

More than 3 years have passed since last update.

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" + ")"
  }
}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away