あらゆるプログラミング言語の最先端を行くScala 3のマクロ

この記事はScala Advent Calendar 2023の11日目です.

最近, 趣味でScala 3のコードをだいぶ書いていて, マクロの使い心地のよさに感心しました. 理論的な背景も含めて, 産業界で多く使われているプログラミング言語の中では筆者の知る限りぶっちぎりに優れたマクロを備えています. 他の言語にも見習ってほしいですね. たぶん見習おうとすると処理系を作り直す羽目になりますが.

この記事ではScala 3のマクロのすごいところを例を使って紹介します.

マクロの実践的な例

具体的なマクロの例として, あまりに簡単なものだとイメージを掴みづらいと思うので, ある程度実践的で複雑なものを用意しました.

準備

マクロとは入力コードを別のコードに展開する機能ですが, どういったコードに展開されるか確認できるようにしておくとデバッグに役立ちます. 公式ドキュメントにもあるように, 以下のinspectメソッドで展開コードを確認できるようにしておきます. (このメソッド自体がマクロを使って実現されています.)

import scala.quoted.*

def inspectImpl[T: Type](x: Expr[T])(using Quotes): Expr[T] = {
  println(x.show)
  x
}

inline def inspect[T](inline x: T): T = ${ inspectImpl('x) }
実践的な例: NamedArray – 名前でアクセスできる配列

例として, 配列(的な, インデックスでアクセスできる列)に名前でアクセスできるようにするクラスNamedArrayを作ってみます. 使い方としては, 要素を並べて初期化し, namesメソッドで名前をつける想定です. 名前はコンパイル時にのみ存在し, 実行時のオーバヘッドは全くないようにします.

val arr = NamedArray("tarao", "tarao@example.com")
val contact = arr.names[("name", "email")]

contact.name
// res0: String = "tarao"
contact.email
// res1: String = "tarao@example.com"

もちろん, 未定義の名前はコンパイルエラーになってほしいです.

contact.nickname
// error:
// 'nickname' is not found in scala.Tuple2["name", "email"]
// contact.nickname
//         ^^^^^^^^

このようなNamedArrayクラスの, マクロとは無関係な部分の実装は以下のようになるでしょう. 標準ライブラリのDynamicを使うと任意の名前のフィールドアクセスをselectDynamicメソッドの呼出しに置き換えてくれるのでそれを使います. ???の部分がこのあとマクロで実装すべき部分です.

import scala.language.dynamics

class NamedArray[T, Names <: Tuple](array: IndexedSeq[T]) extends Dynamic {
  def names[NN <: Tuple]: NamedArray[T, NN] =
    this.asInstanceOf[NamedArray[T, NN]]

  def selectDynamic[S <: String](name: S): T =
    ???
}

object NamedArray {
  def apply[T](args: T*): NamedArray[T, EmptyTuple] =
    new NamedArray(Vector(args: _*))
}
NamedArrayのマクロ実装

マクロで実現したいことは, 名前によるアクセスをインデックスによるアクセスに置き換えることです. こういうイメージです(実際はコンパイラの生成コードなので見た目はもう少し複雑な感じになります).

inspect(contact.email)
// contact.array.apply(1)

selectDynamicをマクロにすることで, コンパイル時にselectDynamicの呼出しがマクロの返すコードに置換されます. Scala 3ではマクロにしたいメソッドにはinlineをつけ, メソッド本体は${ ... }で括ってマクロ実装のメソッドを呼び出すようにします. 引数には'をつけます.

マクロ実装のメソッドは, 型パラメータには: Typeをつけ, 式の型にはExpr[_]をかぶせ, using Quotesしたものになります. 以下ではselectDynamicの実装をNamedArray.selectImplでやっています.

...
import scala.annotation.tailrec
import scala.quoted.*

class NamedArray[T, Names <: Tuple](array: IndexedSeq[T]) extends Dynamic {
  ...

  inline def selectDynamic[S <: String](name: S): T =
    ${ NamedArray.selectImpl[T, Names, S]('array, 'name) }
}

object NamedArray {
  ...

  def selectImpl[T: Type, Names <: Tuple: Type, S <: String](
    array: Expr[IndexedSeq[T]],
    name: Expr[S],
  )(using Quotes): Expr[T] = {
    import quotes.reflect.*

    // コンパイル時の値(つまり定数値)を得る
    val constName: String = name.valueOrAbort

    val index = ConstantType(StringConstant(constName)).asType match {
      case '[name] =>
        // ("name", "email") のようなタプル型から name 型を線形に探索して
        // インデックスを得るメソッド
        @tailrec def findIndexOrAbort(tpe: Type[?], index: Int = 0): Int =
          tpe match {
            case '[`name` *: tail] =>
              index
            case '[_ *: tail] =>
              findIndexOrAbort(Type.of[tail], index + 1)
            case _ =>
              // 名前が見つからなかったらコンパイルエラーにする
              report.errorAndAbort(
                s"'${constName}' is not found in ${Type.show[Names]}",
                name,
              )
          }

        findIndexOrAbort(Type.of[Names])
    }

    // 名前が見つかったらインデックスアクセスのコードを生成
    '{ ${ array }.apply(${ Expr(index) }) }
  }
}

selectImplの大部分のコードはコンパイル時に実行される処理です. そして呼出し元の式が最後の'{ ... }の式に置換されます.

実際に生成コードを確認するとこのようになります.

inspect(contact.email)
// {
//   val NamedArray_this: rs$line$5.contact.type = rs$line$5.contact
// 
//   (NamedArray_this.MacroExample1$NamedArray$$inline$array.apply(1): java.lang.String)
// }
// val res0: Any = tarao@example.com

このNamedArrayの完全な実装はこちらにあります*1.

記述が明瞭

マクロのコードは一般的に複雑になりがちですが, 例で見てもらった通りScala 3のマクロはほんの一部のおまじない(using Quotesimport quotes.reflect.*など)に目をつむればかなり読みやすい素直なコードになっていると思います.

メタレベルのプログラムの扱い

Scala 3のマクロは, 文字列やトークンの操作ではなく, あとは実行するだけの「実行前の式」のようなものでコードの置換を扱います. 裏側には実体として意味解析済みの抽象構文木があるわけですが, もう少し抽象的に「メタレベルの式」を表すようになっていて, T型の式をメタレベルに扱うものはExpr[T]型に, 型Tそのものをメタレベルに扱うものはType[T]型になります.

この型の区別の仕方によって, 今は普通のレベルにいるのか, それともメタレベル(=マクロによる生成コード)なのか分かるようになっています.

クォートとスプライスがある

式のメタレベルはクォートとスプライスで操作することができます. 例に登場したマクロ生成コードの'{ ... }${ ... }がクォートとスプライスです.

'{ ${ array }.apply(${ Expr(index) }) }

これは'{ ... }${ ... }(とExpr(...))を取り除くと普通のScalaのコードに見えます.

array.apply(index)

実際このコードは, このマクロによって生成してほしい結果のコード(contact.array.apply(1)のようなコード)に非常によく似ています. 実は, '{ ... }${ ... }はメタレベルの上げ下げをするだけの操作で, それ以外は普通のプログラムと同様に書くことができます. Lispの準クォート(quasiquote)に馴染みがある人はそれと同じだと思ってください.

'{ ... }はメタレベルを上げる操作で, 中の式にExpr[_]を1段噛ませます. 逆に${ ... }はメタレベルを下げる操作で, 中の式のExpr[_]を1段剥がします.

例の場合は, 全体はまず'{ ... }で始まっているので, 結果はExpr[...]型になり, '{ ... }の中の...の部分にはメタではないレベルの普通の式を書くことになります.

'{ ... }の中には${ array }がありますが, arrayはもともとExpr[IndexedSeq[T]]なので, ${ array }Expr[_]が1段剥がれたIndexedSeq[T]型の普通の式になります. 続いてそれに対してapplyを呼んでいる, という具合です.

なんだかややこしい話をしていると思ったかもしれませんが, Scala 3のマクロは意味解析済みの抽象構文木の操作になるため, クォートとスプライスなしで書こうとするともっとややこしいことになります. 今回の例をクォートとスプライスなしで書くと次のようになります.

Select
  .unique(array.asTerm, "apply")
  .appliedToArgs(List(Expr(index).asTerm))
  .asExprOf[T]

例がそこまで巨大な式ではないのと, 抽象構文木を操作するAPIも比較的充実しているためまだ読める範囲内ですが, いかにも抽象構文木を操作しているなという感じのコードで, より大きな式になるとかなり読むのが難しくなります. リフレクションAPIをよく知っていないと書けない点も難しいですね. ちなみにAPIの使い方を間違えるとコンパイラがクラッシュします.

パターンマッチもある

ExprTypeにはパターンマッチができ, クォートの記法で書けます. 例では以下のようなTypeのパターンマッチをやっています.

tpe match {
  case '[`name` *: tail] =>
    ...
  case '[_ *: tail] =>
    ...
  case _ =>
    ...
}

この例ではtpeが(空でない)タプル型かどうか, 先頭要素が指定のものと一致するかどうかで場合わけしています.

生成コードに型がつく

ここまでの例でもExpr[T]のように, メタレベルのコードの型が明示されていたのが分かると思いますが, この型はメタでない普通の型と同様に型安全性の対象です. たとえば, 寝惚けていたか泥酔していてselectImplで生成するコードを以下のように間違えてしまったとすると, コンパイル時に怒られます.

'{ ${ array }.apply(${ Expr(constName) }) }
//                          ^^^^^^^^^
//                          Found:    (constName : String)
//                          Required: Int

文字列やトークン列としてマクロを定義する言語だと, 実際にそのマクロを使うことで生成コードがコンパイルされてはじめてエラーが検出されると思います. 少しの変更で生成コードが壊れることもよくあります. コード生成をするライブラリの場合は利用者がコーナーケースを突いてうまく動かないこともあるでしょう. トラブルを防ぐために多大な労力が必要になってしまいます.

Scalaの場合は, まだ使用されていないコード片の時点で型検査されるためこういった苦労はなくなります.

前述したように, リフレクションAPIを使った場合はコンパイラがクラッシュする可能性があるので, 極力クォートとスプライスで書きたいですね.

多段階計算に基づいている

ここまでで既に他の言語にはなかなか真似できない機能になっていると思いますが, 本当にすごいのはここからです.

上述の例では「メタレベル」と「普通のレベル」の2つに区別していましたが, この階層はもっとネストすることができます. 任意の数の計算ステージがあるプログラミング体系を「多段階計算(multi-stage programming)」と言いますが, Scala 3は実はこれになっています.

クォートとスプライスの本当の意味

どういうことなのか説明するために, いったんNamedArrayselectDynamicの定義を見直してみましょう.

inline def selectDynamic[S <: String](name: S): T =
  ${ NamedArray.selectImpl[T, Names, S]('array, 'name) }

この定義では普通のコードの中に突然${ ... }が登場しています. ここまではこれを「メタレベルを下げる」と表現していましたが, これを「計算ステージを早める」と思うことにします. 逆に'{ ... }は「計算ステージを遅らせる」ですね.

普通のコードはランタイムに実行されます. 当たり前のことを言っていますね. では計算ステージを早めたコードはいつ実行されるでしょうか? 答えはコンパイル時です.

実はマクロを呼び出す${ ... }は「ここからはコンパイル時に実行せよ」という意味だったのです. この中で普通のコードを書けばそれはコンパイル時に実行され, ${ ... }の中で'{ ... }を書けばそれはコンパイル時より1段実行を遅らせる, つまりランタイムに実行されるコードになります. 以下のような関係です.

eが実行されるコンテキスト
コンパイル ${ e }, ${ '{ ${ e } } }
ランタイム e, ${ '{ e } }

ようするに, ネストした$'の数が, $の方が多ければコンパイル時に, 同数ならランタイムに実行されるということです.

ネストしたスプライス

では, $の数が2つ以上多くなることはないのでしょうか? 実はこれはマクロの中で他のマクロを使うと普通に起きます.

たとえば, 以下のlogのようなマクロを考えてみましょう. loggingtrueのときにはメッセージを出力し, falseのときは何もせず, オーバヘッドも一切ない(ログ出力に関する一切のコードが生成されない)というものです.

object MacroUtil {
  import scala.quoted.*

  val logging = true

  inline def log(msg: String): Unit =
    ${ logImpl('msg) }

  def logImpl(msg: Expr[String])(using Quotes): Expr[Unit] = {
    if (logging)
      '{ println(${ msg }) }
    else
      '{ () }
  }
}

これを使って, デバッグ用にNamedArrayのインデックス変換がどうなっているか確認してみたとしましょう.

  def selectImpl[T: Type, Names <: Tuple: Type, S <: String](
    array: Expr[IndexedSeq[T]],
    name: Expr[S],
  )(using Quotes): Expr[T] = {
    import quotes.reflect.*

    // コンパイル時の値(つまり定数値)を得る
    val constName: String = name.valueOrAbort

    val index = ...

    // 名前が見つかったらインデックスアクセスのコードを生成
    MacroUtil.log(s"${constName} ~> ${index}")
    '{ ${ array }.apply(${ Expr(index) }) }
  }
val arr = NamedArray("tarao", "tarao@example.com")
val contact = arr.names[("name", "email")]

contact.email
// email ~> 1
// res0: String = "tarao@example.com"

contact.emailコンパイルされるときにemail ~> 1が出力され, インデックス1に変換されたとわかります.

もう一度思い出してほしいのですが, selectImplは以下のようにselectDynamicから${ ... }内で呼ばれるのでした.

  inline def selectDynamic[S <: String](name: S): T =
    ${ NamedArray.selectImpl[T, Names, S]('array, 'name) }

つまり, '{ ${ array }.apply(${ Expr(index) }) }の部分は計算ステージとしては

${
  ...
  '{ ${ array }.apply(${ Expr(index) })
}

となっていて, ${ '{ ... } }の中身はランタイムに実行されるのでした.

ではlogの方はどうでしょうか? 同じようにselectDynamicからの呼出しまで戻ると, 計算ステージはこうなっているはずです.

${
  ...
  MacroUtil.log(s"${constName} ~> ${index}")
  '{ ${ array }.apply(${ Expr(index) })
}

logはマクロだったので展開するとこうなります.

${
  ...
  ${
    if (logging)
      '{ println(s"${constName} ~> ${index}") }
    else
      '{ () }
  }
  '{ ${ array }.apply(${ Expr(index) })
}

さて, このときif (logging)はいつ実行されるのでしょうか? ...の部分は$が1段噛まさっているので, ランタイムより計算ステージが早められていて, コンパイル時に実行されるのでした. しかしif$が2段噛まさっています. 実はこれはコンパイル時に実行するコード(...の部分)をコンパイルするときに実行されることを意味します!! 誤植で同じ言葉を繰り返しているのではありません. 「コンパイル時に実行するコードのコンパイル時」です.

Scala 3のコンパイラは, 計算ステージが一番早い部分をまずコンパイルして実行し, 次のステージの部分をコンパイルして実行し, ...というのをランタイムのステージがコンパイルされるまで繰り返します*2.

つまり, 計算ステージは2段ではなく, 普通のコンパイル時より前の段階があるのです.

eが実行されるコンテキスト
ステージ -n $ { ... ${ e } ... }
ステージ -2 $ { ${ e } }
ステージ -1 (コンパイル時) ${ e }
ステージ 0 (ランタイム) e
ネストしたクォート

スプライスをネストしてもいいなら, クォートをネストしてもいいのでは? 理論上はそうで, 実際Scala 3ではクォートをネストしたExpr[Expr[T]]のような型も扱えます.

とはいえ, クォートをネストして計算ステージを多段に遅らせたとしても, それだけだとランタイムに抽象構文木が手に入るだけでとくに嬉しさはありません. 遅らせた計算ステージを進められるようになって初めて意味を持ちます. そこで, Scala 3では遅らせた計算ステージを進めるstaging.run(つまりeval)という仕組みが用意されています. ただしこのためにはランタイムにコンパイラの実装がまるごと必要なため, scala3-stagingを依存に追加する必要があります.

libraryDependencies += "org.scala-lang" %% "scala3-staging" % scalaVersion.value

(詳細は公式ドキュメントを参照してください.)

結局, Scala 3の計算ステージは全体像としては以下のようになっています.

eが実行されるコンテキスト
ステージ -n $ { ... ${ e } ... }
ステージ -2 $ { ${ e } }
ステージ -1 (コンパイル時) ${ e }
ステージ 0 (ランタイム) e
ステージ 1 '{ e }
ステージ 2 '{ '{ e } }
ステージ n '{ ... '{ e } ... }
理論

Scala 3の多段階計算は以下の論文に基づいており, これまでの他の(型付き)多段階計算に関する研究を下地にしっかり組み立てられたものになっています.

Multi-stage Programming with Generative and Analytical Macros.
Nicolas Stucki, Jonathan Immanuel, and Martin Odersky.
In GPCE '21, Chicago, 2021.

マクロアノテーション

Scala 2までのマクロでは呼出し時のコンパイラのコンテキストを直接参照してかなりいろいろなことができましたが, Scala 3ではマクロはメソッド呼出しの形に制限されていて, あくまで計算ステージをずらした普通のメソッドとして実行されているため, できなくなったことも多くあります.

しかしこれはまだScala 3のマクロのインタフェースが揃いきっていないだけに過ぎず, ただ闇雲にコンパイラのコンテキストを利用できるようにするのではなく, 整理された形でマクロを提供しようとしているからです.

たとえば, マクロアノテーションが既に実験的機能として入っており, Pythonのデコレータのように, 関数定義やクラス定義を書き換えることができます.

まとめ

Scala 3のマクロは以下の点でかなり優れています.

  • クォートとスプライスで明瞭に書ける
  • 生成コードにも型がつく
  • 理論的基盤のしっかりした多段階計算に基づいている
  • マクロのインタフェースが非常に整理されている

とくに, 型がつく点と多段階計算の理論に基づいている点は, 産業界で利用される実用的なプログラミング言語で他に実現できているものを筆者は知りません. (研究目的の言語を含めても数えるほどしかありません.)

*1:この実装では範囲外のインデックスにアクセスするような場合は実行時エラーになります. そういった場合にコンパイルエラーにすることもできますが, 実装が複雑になってわかりにくくなるため省略しています. 省略していないバージョンはこちらです.

*2:これはあくまで概念上の話で, 実際はおそらくステージがより早い部分が見つかったらその場でその部分だけ先にコンパイル・実行するような動作だと思われます. 公式ドキュメントで"towers of interpreters"と表現されている部分のことです.