Scala 3のマクロの練習に拡張可能レコードを実装してみる

Scala 3でコンパイル時計算がいろいろ便利になっていそうなので, 練習として拡張可能レコード(extensible record)を実装してみた. 前回はmatch typesで型レベルの操作のみでいろいろできたから, 今回もそういうつもりでやろうと思ったものの, けっきょくマクロが必要で, Scala 3のマクロの練習という感じになった. Scala 3のマクロはScala 2からはだいぶ変わっていて, かなり便利になっていることがわかった.

追記 (2023-11-22)

ちゃんと実装してライブラリ化した:

拡張可能レコードとは

拡張可能レコードは, フィールドが追加される度に型が拡張されて, 追加したフィールドにアクセスできるようなもののこと. 追加していないフィールドにはアクセスできない. 途中でフィールドを足していける構造体という感じのもの. 以下のようなイメージ

val r1 = Record.empty             // r1: Record
// println(r1.name)               // 最初は空なのでアクセスできない (コンパイルエラー)
//         ^^^^^^^
//         value name is not a member of Record

val r2 = r1 + ("name" -> "tarao") // r2: Record { val name: String }
println(r2.name)                  // => tarao
// println(r2.age)                // ageは追加していないのでアクセスできない (コンパイルエラー)
//         ^^^^^^
//         value age is not a member of Record{name: String}

val r3 = r2 + ("age" -> 37)       // r3: Record { val name: String, val age: Int }
println(r3.name)                  // => tarao
println(r3.age)                   // => 37
// println(r3.job)                // jobは追加していないのでアクセスできない (コンパイルエラー)
//         ^^^^^^
//     value job is not a member of Record{name: String; age: Int}

Scala 2では

Scala 2にもマクロはあって, Dynamic.selectDynamicをマクロにすれば実現できる. PoC実装としてはCompossibleというのがある.

これをscala-recordsというライブラリに組み込もうという話もされていたけど, やってる人が他で忙しそうで進んでいない.

Scala 3では

Scala 3にはSelectableというものがあり, マクロを書かなくてもDynamic.selectDynamic + マクロに相当することができる. というか, structural typeの説明のところに拡張可能レコードっぽいものを実現する方法が例として書かれている.

class Record(elems: (String, Any)*) extends Selectable:
  private val fields = elems.toMap
  def selectDynamic(name: String): Any = fields(name)

type Person = Record {
  val name: String
  val age: Int
}

...

val person = Record(
  "name" -> "Emma",
  "age" -> 42
).asInstanceOf[Person]

println(s"${person.name} is ${person.age} years old.")
Structural Types | Scala 3 — Book | Scala Documentation

しかしこれはasInstanceOfを使っているし, 型安全ではない. もし, フィールドを追加したときに裏側で自動的に.asInstanceOf[Record{ val name: String }]のようなことをやってくれるようにできれば, 型安全な拡張可能レコードができそうだ.

という発想でScala 3コンパイラ(dotty)を拡張してみる試みは論文になっている.

Extending Scala with Records.
Olof Karlsson and Philipp Haller.
In Scala '18, September 28, 2018, St. Louis, MO, USA.

コンパイラを拡張して言語レベルでサポートするのは大袈裟だけど, フィールドを追加する部分をマクロにすればなんとかなりそうだった(Scala 2と違ってフィールドにアクセスするところはマクロにしなくてよい).

マクロによるレコード型の拡張

マクロで拡張可能レコードを実現するには, 以下の2つのことが必要.

  • (1) メソッドの返り値型をマクロの返り値で決める (いわゆるwhiteboxマクロ)
  • (2) Record{ val name: String }のようなstructural typeをマクロで生成する

(1)についてはtransparent inlineなメソッドでマクロを使うと実現できる.

(2)については, quotes.reflect.Refinementインスタンスを作ってその型をとるとよい.

といった話は以下に書かれていてたいへん参考になった.

ここまで分かればあとは書くだけ. '$Lispのマクロにおけるクォートとアンクォートと同じものだと思えばよい(正確には多段階計算における段階をずらす操作). ただ, 型は$を使わずにそのまま埋め込んだらいいらしい.

変なものが渡されたときにコンパイルエラーにしたい場合はquotes.reflect.report.error()を呼び出すとよいようだ. これは返り値がUnitであってNothingではない(の割にはコンパイル時計算はエラーで止まるように見える??)ので少し面倒, みたいなことはあった.

完全なソースコード

ということで, 非常にシンプルな拡張可能レコードを実装してみた.