Scala 3 の Mirror で型クラスの汎用実装を楽に定義
Scala 3 には scala.deriving.Mirror という便利な型が導入されました。
ある型の構造(case class なのか、enum なのか、フィールド名、サブタイプ)などなどが macro を自前で定義しなくても型レベルの操作で取得・判定できるようになるので、型クラスの汎用実装が驚くほど簡単になります。
ここでは ScalaCheck の Arbitrary を実装するコードをサンプルとして Mirror の具体的な使い方を紹介します。
この記事のサンプルコードは Scala 3.7.4 で確認しています。
事前準備
依存ライブラリとして ScalaCheck を使います。
// build.sbt
libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test
各コードは以下の import がある前提です。
import scala.deriving.Mirror
import scala.compiletime.{erasedValue, summonInline, summonAll}
import org.scalacheck.{Arbitrary, Gen}
(0引数の)enum の Arbitrary を定義する
0 引数の enum(case オブジェクトの集合)は、各ケースを均等に選ぶ Gen.oneOf で簡単に定義することができます。
enum Status {
case Ok, NotFound, Forbidden
}
given Arbitrary[Status] = Arbitrary {
Gen.oneOf(Status.Ok, Status.NotFound, Status.Forbidden)
}
ただし、これではすべての enum 毎に手書きする必要があり、数が増えると大変かつ面倒です。また、あとになって Status に要素を追加した際に Arbitrary の定義を更新し忘れる恐れもあります。
そこで Mirror を使用し汎用的に定義する方法を見ていきましょう。
Mirror.SumOf を使って enum の汎用的な Arbitrary を定義する
enum の各要素の Arbitrary を定義する
enum 全体の Arbitrary を定義する前に、部品として enum の各要素(上記の例であれば Ok や NotFound など)の Arbitrary を準備しておきます。
これら enum の 0引数 case や Singleton オブジェクトでは、 ValueOf という型クラスを使うことでインスタンスが簡単に取得できます。これを使って Arbitrary を定義します。
object GenericArbitrary {
// シングルトン型(enum の 0 引数 case など)
given [A](using v: ValueOf[A]): Arbitrary[A] = Arbitrary(Gen.const(v.value))
}
Mirror.SumOf を利用して各要素の Arbitrary を合成する
そしてついに Mirror の出番です。
Mirror.SumOf という型クラスを使うと、 enum や sealed trait といった、サブタイプの全てをコンパイラが把握している型(直和型とも呼ばれるやつですね)について色々と操作が可能になります。
さっそく Mirror.SumOf を使って Arbitrary を定義してみましょう。
object GenericArbitrary {
// シングルトン型(enum の 0 引数 case など)
given [A](using v: ValueOf[A]): Arbitrary[A] = Arbitrary(Gen.const(v.value))
// --- Sum(ADT / enum)用 ---
inline given [A](using m: Mirror.SumOf[A]): Arbitrary[A] = ???
}
シグネチャとしては上記のような形になります。内部で scala.compiletime の API を利用するため、 inline で定義する必要があります。
さくっと実装も書いてしまいましょう。
object GenericArbitrary {
// シングルトン型(enum の 0 引数 case など)
given [A](using v: ValueOf[A]): Arbitrary[A] = Arbitrary(Gen.const(v.value))
// --- Sum(ADT / enum)用 ---
inline given [A](using m: Mirror.SumOf[A]): Arbitrary[A] = Arbitrary {
val subGens: List[Gen[A]] = summonAll[Tuple.Map[m.MirroredElemTypes, Arbitrary]]
.toList
.asInstanceOf[List[Arbitrary[A]]]
.map(_.arbitrary)
Gen.frequency(subGens.map(1 -> _)*) // Gen.oneOf が使い辛いので手抜き実装
}
}
いきなりちょっとヤヤコシクなりましたが一つずつ見ていきましょう。
ここでキモになるのが m.MirroredElemTypes です。 Mirror には様々な抽象型メンバーが定義されていて対象の型に関係する様々な項目の型を取得する事が可能なのです。
そして m: Mirror.SumOf[A] の m.MirroredElemTypes では「その直和を構成する全てのサブタイプ」をタプル型として得られます。
例えば最初に定義した Status を表す m: Mirror.SumOf[Status] であれば、
// 再掲
enum Status {
case Ok, NotFound, Forbidden
}
m.MirroredElemTypes は各要素の型を全て集めたタプル型、
つまり (Ok.type, NotFound.type, Forbidden.type) という型になります。
// 再掲
val subGens: List[Gen[A]] = summonAll[Tuple.Map[m.MirroredElemTypes, Arbitrary]]
.toList
.asInstanceOf[List[Arbitrary[A]]]
.map(_.arbitrary)
そして次の Tuple.Map[m.MirroredElemTypes, Arbitrary] ですが、これは Collection API の Key-Value の Map ではなく、型レベルの map 関数です。
したがって、型 (Ok.type, NotFound.type, Forbidden.type) に対して型レベルで Arbitray という型関数を map することで、 (Arbitray[Ok.type], Arbitray[NotFound.type], Arbitray[Forbidden.type]) という型が得られるわけです。
そして compiletime API の summonAll を使う事で、 (Arbitray[Ok.type], Arbitray[NotFound.type], Arbitray[Forbidden.type]) の型の実際の値を手に入れる事ができます。(Arbitray[Ok.type] などの各要素の値は先に ValueOf を使って定義したのを覚えていますか?)
あとはそれを List にして、 Arbitray から Gen を取り出しているだけです。
これによって全てのサブタイプの Gen が手に入ったので、 あとは Gen.oneOf でそのうちのどれかを選ぶ Gen を作ってあげればいいんですが、 List[Gen[A]] を渡して選択するような oneOf が提供されていないので、 代わりに Gen.frequency で全ての確率を等しくすることでお茶を濁します。
// 再掲
inline given [A](using m: Mirror.SumOf[A]): Arbitrary[A] = Arbitrary {
val subGens: List[Gen[A]] = summonAll[Tuple.Map[m.MirroredElemTypes, Arbitrary]]
.toList
.asInstanceOf[List[Arbitrary[A]]]
.map(_.arbitrary)
Gen.frequency(subGens.map(1 -> _)*) // Gen.oneOf が使い辛いので手抜き実装
}
これで汎用の enum の Arbitrary 定義が手に入りました。
使ってみる
scala> import org.scalacheck.{Arbitrary, Gen}
scala> import GenericArbitrary.given
scala> enum Status {
| case Ok, NotFound, Forbidden
| }
// defined class Status
scala> val statusGen = summon[Arbitrary[Status]].arbitrary
val statusGen: org.scalacheck.Gen[Status] = org.scalacheck.Gen$$anon$7@4e16f986
scala> statusGen.sample
val res0: Option[Status] = Some(NotFound)
scala> statusGen.sample
val res1: Option[Status] = Some(Forbidden)
summon[Arbitrary[Status]] で Arbitrary[Status] が手に入りました。 sample メソッドを実行するとランダムで要素が返ってくることが見て取れますね。
Mirror.ProductOf を利用して引数ありのケースに対応する
Mirror.ProductOf を導入する
全ての enum の case が引数を持たない object であればこれで十分なのですが、 Scala の enum では case に引数を持たせる事が可能です。
引数のある case を持ったした enum に対しても Arbitrary を定義したいですよね。
そこで利用できるのが Mirror.ProductOf になります。 この型クラスを使うと、 case class や引数ありの enum の case について操作が可能になります。
Mirror.ProductOf を使って Arbitrary を定義してみましょう。
// --- Product(case class 等)用 ---
inline given [A](using m: Mirror.ProductOf[A]): Arbitrary[A] = ???
型クラスインスタンスの優先度を調整する
ここでちょっと工夫が必要になります。 以前に定義した ValueOf を使った Arbitrary の定義ですが、 companion object や引数無しの enum の case は、引数が 0 個の case class ともみなせるので、 ProductOf を利用した定義でも対象になってしまうのです。
実際のところは ValueOf を使った実装を優先して欲しいので、優先度が区別付けられるように定義個所を分けます。
object GenericArbitrary extends LowImplicit {
// シングルトン型(enum の 0 引数 case など)
given [A](using v: ValueOf[A]): Arbitrary[A] = ...
}
trait LowImplicit {
// --- Sum(ADT / enum)用 ---
inline given [A](using m: Mirror.SumOf[A]): Arbitrary[A] = ...
// --- Product(case class 等)用 ---
inline given [A](using m: Mirror.ProductOf[A]): Arbitrary[A] = ???
こうする事で、 ProductOf を使った定義よりも ValueOf を使った定義の方が優先されるようになります。
Mirror.ProductOf を使って実装してみる
では中身を実装してみましょう。
// --- Product(case class 等)用 ---
inline given [A](using m: Mirror.ProductOf[A]): Arbitrary[A] = {
val tupleGen: Gen[m.MirroredElemTypes] = genTuple[m.MirroredElemTypes]
Arbitrary(tupleGen.map(m.fromProduct))
}
inline def genTuple[T <: Tuple]: Gen[T] = {
inline erasedValue[T] match {
case _: EmptyTuple => Gen.const(EmptyTuple).asInstanceOf[Gen[T]]
case _: (h *: t) =>
for {
head <- summonInline[Arbitrary[h]].arbitrary
tail <- genTuple[t]
} yield (head *: tail).asInstanceOf[T]
}
}
m: SumOf[A] の m.MirroredElemTypes では「その直和を構成する全てのサブタイプ」をタプル型として得られました。
それに対し、 m: ProductOf[A] では「その直積を構成する全ての要素の型」をタプル型として得られます。
例えば以下のような User クラスがあったとします。
case class User(id: Long, name: String, kind: UserType)
この場合、 m: ProductOf[User] の m.MirroredElemTypes は (Long, String, UserType) になる訳です。
この m.MirroredElemTypes の Gen を取得するヘルパー関数 genTuple を導入します。
gentTuple はタプルのサブタイプである型引数 T を受け取り、先頭から型を一つ取り出して、再帰的に Gen を取得していきます。
genTuple に m: ProductOf[User] の m.MirroredElemTypes、つまり (Long, String, UserType) を渡すと、結果として Gen[(Long, String, UserType)] 型の値が得られるという訳です。
あとは m.fromProduct を使って要素のタプル型から元の型を復元する事で、目的の Gen が得られるようになります。
使ってみる
scala> import org.scalacheck.{Arbitrary, Gen}
scala> import GenericArbitrary.given
scala> enum UserType { case Regular, Admin }
// defined class UserType
scala> case class User(id: Long, age: Int, kind: UserType)
// defined case class User
scala> val userGen = summon[Arbitrary[User]].arbitrary
val userGen: org.scalacheck.Gen[User] = org.scalacheck.Gen$$anon$7@28b311cf
scala> userGen.sample
val res0: Option[User] = Some(User(-2607296649268542384,-1,Admin))
scala> userGen.sample
val res1: Option[User] = Some(User(-1,0,Regular))
case class の Arbitrary が取得できていることが見て取れますね。
⚠️注意
この記事では手抜きのために SumOf や ProductOf を使った Arbitrary 定義に直接 given を付与していますが、ProductOf のようなネストした呼び出しが起こる可能性のある定義は、実際の業務コード規模だと問題が起きる場合があります。
例えば以下のような共通した要素を持つクラスがあった場合、
case class Address(id: Int, code: PostalCode, prefecture: Prefecture)
case class User(id: Int, name: String, address: Address)
case class Organization(id: Int, name: String, address: Address)
Arbitrary[User] と Arbitrary[Organization] の定義の両方に Arbitrary[Address] の定義が inline 展開されてしまうため重複コードとなり、コンパイル時間が伸びたり出力される bytecode のサイズが大きくなったりします。
こういった場合では、 Mirror を使った汎用的な定義には直接 given を付与せず、それぞれ具体の型毎に given インスタンスを定義するアプローチをとってあげる方が良いでしょう。
object GenericArbitrary extends LowImplicit {
// 具体型ごとに given インスタンスを定義する
given Arbitrary[Address] = productOf
given Arbitrary[User] = productOf
given Arbitrary[Organization] = productOf
}
trait LowImplicit {
// シングルトン型(enum の 0 引数 case など)
// productOf に given が無いので同じ優先度でも衝突しない
given [A](using v: ValueOf[A]): Arbitrary[A] = ...
// 再帰する可能性のある汎用的な実装は given つけない
// --- Sum(ADT / enum)用 ---
inline sumOf[A](using m: Mirror.SumOf[A]): Arbitrary[A] = ...
// --- Product(case class 等)用 ---
inline productOf[A](using m: Mirror.ProductOf[A]): Arbitrary[A] = ...
Circe の auto と semiauto の使い分けと同じです。
参考: Scalaでcirceのautoのような再帰的に全自動で導出するものはcompile速度が爆発するので絶対に使うな作るな、という話
まとめ
Scala 3 には scala.deriving.Mirror という便利な型が導入されました。
Mirror.SumOf や Mirror.ProductOf を使う事で、マクロも使わず簡単に enum や case class などの型に対して汎用的な実装を書く事ができます。
ぜひ活用してボイラープレートを減らしていきましょう。
Discussion