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
ではない(の割にはコンパイル時計算はエラーで止まるように見える??)ので少し面倒, みたいなことはあった.
完全なソースコード
ということで, 非常にシンプルな拡張可能レコードを実装してみた.