酢鶏、パート1

in

実験的 sbt として、酢鶏 (sudori) という小さなプロジェクトを作っている。当面の予定はマクロ周りを Scala 3 に移植することだ。sbt のマクロを分解して、土台から作り直すという課題だ。これは Scala 2 と 3 でも上級者向けのトピックで、僕自身も試行錯誤しながらやっているので、覚え書きのようなものだと思ってほしい。

参考:
- Scala 3 Reference: Metaprogramming

Convert

何にも依存していない基礎となる Convert というものを特定できた。

abstract class Convert {
  def apply[T: c.WeakTypeTag](c: blackbox.Context)(nme: String, in: c.Tree): Converted[c.type]
 
  ....
}

Tree を受け取って Converted という抽象データ型を返す部分関数の豪華版みたいなものに見える。Converted は、以下のように型パラメータとして [C <: blackbox.Context with Singleton] を取る:

  final case class Success[C <: blackbox.Context with Singleton](
      tree: C#Tree,
      finalTransform: C#Tree => C#Tree
  ) extends Converted[C] {
    def isSuccess = true
    def transform(f: C#Tree => C#Tree): Converted[C] = Success(f(tree), finalTransform)
  }

このように直接 Tree、つまり抽象構文木 (AST) を扱う古い Scala 2 マクロの実装の典型的な例だが、Scala 3 ではもっと綺麗に高度なレベルでメタプログラミングを行う仕掛けとして inline などがあるので、そこから始めるのを通常は推奨される。

ただし、この場合は既存のマクロを移植しているのでクォートリフレクション (quote reflection) にひとっ飛びする。これは Scala 2 マクロに似ている感じだ。

Enums

enum の定義はこんな感じになる:

import scala.quoted.*
 
enum Converted[C <: Quotes]:
  case Success() extends Converted[C]
  case Failure() extends Converted[C]
  case NotApplicable() extends Converted[C]
end Converted

sealed trait と case class の組み合わせと違って、ADT にぶら下がるメソッドも enum 内で定義される:

import scala.quoted.*
 
enum Converted[C <: Quotes]:
  def isSuccess: Boolean = this match
    case Success() => true
    case _         => false
 
  case Success() extends Converted[C]
  case Failure() extends Converted[C]
  case NotApplicable() extends Converted[C]
end Converted

Success()Failure()Converted[C] 型を持つ値だと捉えるとこれも納得がいく。

Type projection is gone

Scala 3 は型射影 (type projection) C#A を廃止した。実際の SuccessC#TreeC#Tree => C#Tree という 2つのパラメータを受け取るので、いきなり難題となった。What does Dotty offer to replace type projections? という StackOverflow での質問がある。

そこで示されている 1つの解法はパス依存型を使うことだ。この場合、quote reflection の Treeqctx.reflection.Tree というふうに qctx.reflection にぶら下がっているので、この方法でいけるかもしれない。

SuccessFailure は以下のようになる:

enum Converted[C <: Quotes](val qctx: C):
  def isSuccess: Boolean = this match
    case _: Success[C] => true
    case _             => false
 
  case Success(override val qctx: C)(
      val tree: qctx.reflect.Term,
      val finalTransform: qctx.reflect.Term => qctx.reflect.Term)
    extends Converted[C](qctx)
 
  case Failure(override val qctx: C)(
      val position: qctx.reflect.Position,
      val message: String)
    extends Converted[C](qctx)
end Converted

パラメータとして qctx.reflect.Term を受け取るためにこれらの case は複数のパラメータリストを持ち、最初のパラメータリストで qctx を受け取る。次は transform メソッドの実装で、これもややこしい。

enum Converted[C <: Quotes](val qctx: C):
  def isSuccess: Boolean = this match
    case _: Success[C] => true
    case _             => false
 
  def transform(f: qctx.reflect.Term => qctx.reflect.Term): Converted[C] = this match
    case x: Failure[C]       => Failure(x.qctx)(x.position, x.message)
    case x: Success[C] if x.qctx == qctx =>
      Success(x.qctx)(
        f(x.tree.asInstanceOf[qctx.reflect.Term]).asInstanceOf[x.qctx.reflect.Term],
        x.finalTransform)
    case x: NotApplicable[C] => x
    case x                   => sys.error(s"Unknown case $x")
 
end Converted

transform は関数 fSucess(...) に格納された構文木に適用するが、transform で使われている qctxSuccess(...) で捕捉されたものと同じだということをコンパイラに伝える方法があるのか分からない。

Cake trait

この醜いキャストを取り除く方法があって、それは外囲 trait (outer trait) を定義することだ。

trait Convert[C <: Quotes & Singleton](val qctx: C):
  import qctx.reflect.*
  given qctx.type = qctx
 
  ....
 
end Convert

これで Convert trait 内では、Term は常に qctx.reflect.Term を意味するようになった。型パラメータ C を使っていないので、ここで C を定義する必要があるのかは良く分かっていない。

trait Convert[C <: Quotes & Singleton](val qctx: C):
  import qctx.reflect.*
  given qctx.type = qctx
 
  def convert[A: Type](nme: String, in: Term): Converted
 
  object Converted:
    def success(tree: Term) = Converted.Success(tree, Types.idFun)
 
  enum Converted:
    def isSuccess: Boolean = this match
      case Success(_, _) => true
      case _             => false
 
    def transform(f: Term => Term): Converted = this match
      case Success(tree, finalTransform) => Success(f(tree), finalTransform)
      case x: Failure       => x
      case x: NotApplicable => x
 
    case Success(tree: Term, finalTransform: Term => Term) extends Converted
    case Failure(position: Position, message: String) extends Converted
    case NotApplicable() extends Converted
  end Converted
end Convert

実装はシンプルで前より短いものとなった。一つの欠点は ConvertedConvert の入れ子型になるため、それを使うのに後でまたパス依存型が出てくるだろうことだ。

後で詰まないようにこの trait が合成可能か確認したい。まず、Convert 内の関数が別のモジュールの関数に Term を渡せるか確認しよう。この qctx だけのパス依存性から逃れられないと困るからだ。以下のようなモジュールを考える:

object SomeModule:
  def something(using qctx0: Quotes)(tree: qctx0.reflect.Term): qctx0.reflect.Term =
    tree
 
end SomeModule

以下のようにして SomeModule.something を呼び出せる:

trait Convert[C <: Quotes & Singleton](override val qctx: C):
  import qctx.reflect.*
  given qctx.type = qctx
 
  def test(term: Term): Term =
    SomeModule.something(term)
 
  ....

キャスト無しでコンパイルできたので良い兆しだ。qctx.typegiven インスタンスを定義して明示的に qctx を渡さなくてもいいようにしてある。Cake trait を合成するもう 1つの方法は、別の trait を積み上げることだ:

import scala.quoted.*
 
trait ContextUtil[C <: Quotes & Singleton](val qctx: C):
  import qctx.reflect.*
  given qctx.type = qctx
 
  def something1(tree: Term): Term =
    tree
end ContextUtil

ConvertContextUtil から拡張することで共通の関数を再利用できる:

trait Convert[C <: Quotes & Singleton](override val qctx: C) extends ContextUtil[C]:
  import qctx.reflect.*
 
  def test(term: Term): Term =
    something1(term)
 
  ....

これもキャスト無しでコンパイルできた。

TreeMap

マクロでよくあるのは、渡された抽象構文木 (AST) を走査して、何らかの条件を満たす木の一部を変換するというパターンだ。この走査は "tree walking"「木を歩く」と言われたりする。この走査と変換はよく出てきすぎるのでそれを簡単にする API がある。

Scala 2 では Transformer を拡張することでこれを行う。Scala 3 では、これは TreeMap と呼ばれている。気の利いた名前だが、scala.collection.immutable.TreeMap と混同されないか心配になる。TreeMap を使うには実装を読んでどのメソッドをオーバーライドするかを選ぶ必要がある。一見 transformTree だと思うかもしれないが、おそらく求めているのは transformTerm であることが多いと思う。

  def transformWrappers(
    tree: Term,
    subWrapper: (String, Type[_], Term, Term) => Converted
  ): Term =
    // the main tree transformer that replaces calls to InputWrapper.wrap(x) with
    //  plain Idents that reference the actual input value
    object appTransformer extends TreeMap:
      override def transformTerm(tree: Term)(owner: Symbol): Term =
        tree match
          case Apply(TypeApply(Select(_, nme), targ :: Nil), qual :: Nil) =>
            subWrapper(nme, targ.tpe.asType, qual, tree) match
              case Converted.Success(tree, finalTransform) =>
                finalTransform(tree)
              case Converted.Failure(position, message) =>
                report.error(message, position)
                sys.error("macro error: " + message)
              case _ =>
                super.transformTerm(tree)(owner)
          case _ =>
            super.transformTerm(tree)(owner)
    end appTransformer
    appTransformer.transformTerm(tree)(Symbol.spliceOwner)

convert の用例

convert を使ってみる:

  final val WrapInitName = "wrapInit"
  final val WrapInitTaskName = "wrapInitTask"
 
  class InputInitConvert[C <: Quotes & Singleton](override val qctx: C) extends Convert[C](qctx):
    import qctx.reflect.*
    def convert[A: Type](nme: String, in: Term): Converted =
      nme match
        case WrapInitName     => Converted.success(in)
        case WrapInitTaskName => Converted.Failure(in.pos, initTaskErrorMessage)
        case _                => Converted.NotApplicable()
 
    private def initTaskErrorMessage = "Internal sbt error: initialize+task wrapper not split"
  end InputInitConvert

これは sbt で実際に使われている convert に似てて、wrapInit メソッドにマッチするようにしてある。これを使って ConvertTest.wrapInit(1)2 に置換するマクロを定義できる。

  inline def someMacro(inline expr: Boolean): Boolean =
    ${ someMacroImpl('expr) }
 
  def someMacroImpl(expr: Expr[Boolean])(using qctx0: Quotes) =
    val convert1: Convert[qctx.type] = new InputInitConvert(qctx)
    import convert1.qctx.reflect.*
    def substitute(name: String, tpe: Type[_], qual: Term, replace: Term) =
      convert1.convert[Boolean](name, qual) transform { (tree: Term) =>
        '{ 2 }.asTerm
      }
    convert1.transformWrappers(expr.asTerm, substitute).asExprOf[Boolean]

Verify を使って以下のようにテストできる:

import verify.*
import ConvertTestMacro._
 
object ConvertTest extends BasicTestSuite:
  test("convert") {
    assert(someMacro(ConvertTest.wrapInit(1) == 2))
  }
 
  def wrapInit[A](a: A): Int = 2
end ConvertTest

ここでは 2つのレイヤーのフィルタリングが起こっている。第一に、appTransformer という名前で定義した TreeMap は単一のパラメータを受け取るジェネリック関数の呼び出しのみ見るようになっている。次に、convert1wrapInit というメソッド名のみを成功したメソッドとする。

レイファイ型とそれを型に戻す方法

木歩きで補足しておきたいのは、この時点で型情報が得られることだ。例えば、wrapInit[A](...) の型引数は TypeApply(...) 木に渡されている。これは targ.tpe.asType を使って Type[_] データ型に変換することができる。Type[T] は「型消去されていない型 T の表象」だと説明されている。

これが substitute 関数に Type[_] として渡されている。これは wrapInit[A](...) を捕獲しているわけだから、Type[_] より特定なものは無い。だけども、これをアンマーシャル (unmarshal) して実際に使える T に解凍したい。これに関連した How do I summon an expression for statically unknown types? という質問が Scala 3 マクロ FAQ にある。

val tpe: Type[_] = ...
tpe match
  // (1) Use `a` as the name of the unknown type and (2) bring a given `Type[a]` into scope
  case '[a] => Expr.summon[a]

これはなかなか面白い。このテクニックを使って AOption[A] で包む addType(...) を実装してみよう。

  inline def someMacro(inline expr: Boolean): Boolean =
    ${ someMacroImpl('expr) }
 
  def someMacroImpl(expr: Expr[Boolean])(using qctx0: Quotes) =
    val convert1: Convert[qctx.type] = new InputInitConvert(qctx)
    import convert1.qctx.reflect.*
    def addTypeCon(tpe: Type[_], qual: Term, selection: Term): Term =
      tpe match
        case '[a] =>
          '{
            Option[a](${selection.asExprOf[a]})
          }.asTerm
    def substitute(name: String, tpe: Type[_], qual: Term, replace: Term) =
      convert1.convert[Boolean](name, qual) transform { (tree: Term) =>
        addTypeCon(tpe, tree, replace)
      }
    convert1.transformWrappers(expr.asTerm, substitute).asExprOf[Boolean]

テストするとこうなる:

object ConvertTest extends BasicTestSuite:
  test("convert") {
    assert(someMacro(ConvertTest.wrapInit(1).toString == "Some(2)"))
  }
 
  def wrapInit[A](a: A): Int = 2
end ConvertTest

つまり、2 を返す ConvertTest.wrapInit(1)Option(2) へと書き換えるマクロを書くことができた。このように型コンストラクタで値を包み込んだりというのは正に build.sbt で行っていることだ。

酢鶏

ケチャップという言葉は、中国南部海岸沿い福建の膎汁 (kôe-chiap もしくは kê-chiap) という言葉に由来していて魚醤という意味だ。魚醤はしばらくは醤油などの豆系調味料に駆逐されていたが、1700年代にベトナムなどから再導入された。貿易を通じて魚醤はイギリスでも流行して、輸入する代わりに生産されマッシュルームペーストへと変化した。1800年代にはアメリカ人がこれをトマトで作り出す。そういう意味では、酢豚といった広東料理がレシピにケチャップを取り入れているのは興味深いと言える。広東語では咕嚕肉 (gūlōuyuhk) と呼ばれグールーというのはお腹が鳴る音を表している。酢豚というのは日本での名前だが、これは sbt のバクロニムでもある。豚肉を鶏肉に置き換えた酢鶏 (sudori) は酢豚からの派生だ。

まとめ

  • クォートリフレクション (quote reflection) は構文木の操作を提供する
  • パス依存型の一貫性を保つのは難しい。cake trait が使えるかもしれない。
  • 木を歩くのには TreeMap を使う
  • 型情報は Type[T] で表すことができ、それを型として埋め込み直すことができる