少しふざけたタイトルをつけましたが、以下、基本的に超真面目なマニアックな話をします。
Scala 3から、ある程度の定型的なtype classのinstance生成時に低レベルなmacroを書かずに便利に綺麗に書けるMirrorという仕組みが標準で追加されています。
https://github.com/lampepfl/dotty/blob/3.1.3-RC2/library/src/scala/deriving/Mirror.scala
雑に一言で説明すると、shapeless 2における Generic
や LabelledGeneric
に近いものが標準に入りました。
今回は、それ自体の紹介ではなく、タイトルに書いた通り、それの内部実装の話です。よってMirrorそのものの詳細な使い方や説明はしません。
また、versionはひとまずScala 3.1.3-RC2、としておきます。
さて、
捉え方によりますが、Mirrorには、Sumの場合もProductの場合も、以下の2つの側面があると思います。
- compile timeにコード生成をするために
MirroredMonoType
,MirroredLabel
,MirroredElemTypes
などを使う- あくまでコンパイル時に使う情報としての側面であって、通常、使った後の実行時に実態は必要ないはず?
- Sumの場合は
def ordinal(x: MirroredMonoType): Int
, Productの場合はdef fromProduct(p: scala.Product): MirroredMonoType
というメソッドを、コード生成後のコード内部で(必要なら)使う
後者のordinalとfromProductというメソッドは、実行時に必要なので、どこかに実体があるはずですが、どこに存在するでしょうか?というのが、ある意味今回の主題です。
結論から言うと、現状のScala 3では
- companion object
- その場で毎回生成
の2つのはずです。
まずProductでの単純な場合ですが、以下のようにするとすぐわかります。
scala -Xprint:inline -e "case class A(x: Int)"
-e
の指定は、最近追加されたので、最近のversionでないと動かないので注意してください。
以下該当部分のみ抜粋と多少整形。
final module class A() extends AnyRef(), scala.deriving.Mirror.Product { def apply(x: Int): A = new A(x) def unapply(x$1: A): A = x$1 override def toString: String = "A" type MirroredMonoType = A def fromProduct(x$0: Product):
また、実行時にreflection使っても確かめることが可能です。
Welcome to Scala 3.1.3-RC2 (11.0.13, Java OpenJDK 64-Bit Server VM). Type in expressions for evaluation. Or try :help. scala> case class A(x: Int) // defined case class A scala> A.getClass.getInterfaces val res0: Array[Class[?]] = Array(interface scala.deriving.Mirror$Product, interface java.io.Serializable)
つまり
「(可能な場合)Scala 3において、case classのcompanion objectは勝手に scala.deriving.Mirror.Product
を継承」
します。
当然Scala 2では継承出来るはずがないので、これは、Scala 2とScala 3でそれぞれコンパイルした場合に、バイナリ互換がなくなる要因の1つでもあります。
さて、すでに結論を述べているので
- 当然Scala 2では継承出来るはずがない
- ので
その場で毎回生成
となります。
例えば、以下のようなコードを書いて、生成されるclassファイル一覧( target/scala-3.1.3-RC2/classes
)を見てみましょう。
import scala.deriving.Mirror.ProductOf object A { def a1 = summon[ProductOf[Tuple2[Int, Int]]] def a2 = summon[ProductOf[Tuple2[Int, Int]]] def a3 = summon[ProductOf[Tuple2[Int, Int]]] def b1 = summon[ProductOf[Some[String]]] def b2 = summon[ProductOf[Some[String]]] def b3 = summon[ProductOf[Some[String]]] }
以下のように、Scala 2のclassに対してProductOfを参照( = compilerが生成)しようとすると、毎回それぞれ無名classが生成されていることがわかります。 (SomeやTuple2はScala 2のscala-library.jarのclassなので)
A$$anon$1.class A$$anon$2.class A$$anon$3.class A$$anon$4.class A$$anon$5.class A$$anon$6.class A$.class A.class A.tasty
-Xprint:inline
の結果は以下です(一部のみ抜粋、多少整形)
def b1: ( deriving.Mirror.Product{ MirroredType = Some[String]; MirroredMonoType = Some[String]; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Product{ MirroredMonoType = Some[String]; MirroredType = Some[String]; MirroredLabel = ("Some" : String) } ){ MirroredElemTypes = String *: EmptyTuple.type; MirroredElemLabels = ("value" : String) *: EmptyTuple.type } = { final class $anon() extends Object(), scala.deriving.Mirror.Product { type MirroredMonoType = Some[String] def fromProduct(x$0: Product): MirroredMonoType = new Some[Any](x$0.productElement(0)).$asInstanceOf[MirroredMonoType] } (new Object with scala.deriving.Mirror.Product {...}():Object) }.$asInstanceOf[ ( deriving.Mirror.Product{ MirroredType = Some[String]; MirroredMonoType = Some[String]; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Product{ MirroredMonoType = Some[String]; MirroredType = Some[String]; MirroredLabel = ("Some" : String) } ){ MirroredElemTypes = String *: EmptyTuple.type; MirroredElemLabels = ("value" : String) *: EmptyTuple.type } ]
すぐ近くで全く同じものが必要だとしても、もし共通化をするとなると、どこにそれを設定するべきか?を決め難いので、現状では毎回その場で生成するようです。
ここまではProductの場合ですが、次はSumの場合を考えてみましょう。
同じように、以下をコンパイルして、生成されるclassファイル一覧と -Xprint:inline
の結果がこちらです。
import scala.deriving.Mirror.SumOf sealed trait A case class A1(x: Int) extends A object B { def a1 = summon[SumOf[A]] def a2 = summon[SumOf[A]] def a3 = summon[SumOf[A]] def a4 = summon[SumOf[A]] def a5 = summon[SumOf[A]] }
A.class A.tasty A1$.class A1.class A1.tasty B$$anon$1.class B$$anon$2.class B$$anon$3.class B$$anon$4.class B$$anon$5.class B$.class B.class B.tasty
def a1: ( deriving.Mirror.Sum{ MirroredType = A; MirroredMonoType = A; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Sum{ MirroredMonoType = A; MirroredType = A; MirroredLabel = ("A" : String) } ){ MirroredElemTypes = A1 *: EmptyTuple.type; MirroredElemLabels = ("A1" : String) *: EmptyTuple.type } = { final class $anon() extends Object(), scala.deriving.Mirror.Sum { type MirroredMonoType = A def ordinal(x$0: MirroredMonoType): Int = matchResult4[(0 : Int)]: { case val x5: (x$0 : MirroredMonoType) @unchecked = x$0:(x$0 : MirroredMonoType) @unchecked if x5.$isInstanceOf[A1] then return[matchResult4] 0 else () throw new MatchError(x5) } } (new Object with scala.deriving.Mirror.Sum {...}():Object) }.$asInstanceOf[ ( deriving.Mirror.Sum{ MirroredType = A; MirroredMonoType = A; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Sum{ MirroredMonoType = A; MirroredType = A; MirroredLabel = ("A" : String) } ){ MirroredElemTypes = A1 *: EmptyTuple.type; MirroredElemLabels = ("A1" : String) *: EmptyTuple.type } ]
Sumの場合も、少なくとも上記のコード例では、ProductでのScala 2と同様に、毎回生成されているようですね。
しかし、少しコードを変えるだけで、この状況を割と一変することが可能です。
それは
「sealed trait, classのcompanion objectの明示的定義」
です。
つまり上記の例に object A
を追加するだけで、以下のようになります。
A$.class A.class A.tasty A1$.class A1.class A1.tasty B$.class B.class B.tasty
final module class A() extends Object(), scala.deriving.Mirror.Sum { private def writeReplace(): AnyRef = new scala.runtime.ModuleSerializationProxy(classOf[A.type]) type MirroredMonoType = A def ordinal(x$0: A.MirroredMonoType): Int = matchResult4[(0 : Int)]: { case val x5: (x$0 : A.MirroredMonoType) @unchecked = x$0:(x$0 : A.MirroredMonoType) @unchecked if x5.$isInstanceOf[A1] then return[matchResult4] 0 else () throw new MatchError(x5) } }
def a1: ( deriving.Mirror.Sum{ MirroredType = A; MirroredMonoType = A; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Sum{ MirroredMonoType = A; MirroredType = A; MirroredLabel = ("A" : String) } ){ MirroredElemTypes = A1 *: EmptyTuple.type; MirroredElemLabels = ("A1" : String) *: EmptyTuple.type } = A.$asInstanceOf[ ( deriving.Mirror.Sum{ MirroredType = A; MirroredMonoType = A; MirroredElemTypes <: Tuple } & scala.deriving.Mirror.Sum{ MirroredMonoType = A; MirroredType = A; MirroredLabel = ("A" : String) } ){ MirroredElemTypes = A1 *: EmptyTuple.type; MirroredElemLabels = ("A1" : String) *: EmptyTuple.type } ]
companion objectを明示的に定義することにより
companion object
が暗黙的にscala.deriving.Mirror.Sum
を継承SumOf
の実装に毎回無名classが作られていたのが、companion object
が再利用される
ということになります。
Scala上のコード量は object A
の分だけ増えたにも関わらず、生成されるclassファイル含めた合計の大きさは、少なくともこの場合減る、というのは面白い結果ですね?
しかし、macroやinlineが関係すると、このような
「コード量は増やしたのに、生成されるclassファイルの合計は減る」
といったことは、しばしば起こります。
こういった内部実装を意識しないと最適化が出来ないのは難しいですが、原理上ある程度仕方ない、あるいはScala 3自体が成熟してない?といった面もあるかもしれません。
とはいえ、Mirrorを使ったmeta programmingを、がっつりやる必要がある場合以外には、あまり関係ない話ではあるので、心配し過ぎる必要はありません。 (とはいえ、自分で書かなくてもcirceのautoみたいなものを大量に使ったら影響する可能性があるので、知っておいて損はない?)
ただ、現状のScala 3での結論の一つとしては
「多少なりともMirrorを使って操作される可能性のあるsealed classやtraitには、中身が空でもcompanion objectを定義しておいた方が良いかも?」
ということになります。
さて、解説をあえて省いたというか、順番がある意味前後するのですが、なぜSumの「sealed classやtrait」の場合のみであって、Product( = case class)の場合は微妙に事情が異なるのでしょうか?
これは詳細を調べたわけではないので、半分想像なのですが、Scala 2において
- case classは、以前から勝手にcompanion objectが生成される(applyやunapply置き場といった役割があるため)
- しかし、sealed traitやclassのコンパニオンは、Scala 2においては自動生成する必要がなかったため、(Scala 3では上記のように必ず自動生成した方が良い場合すらあるが)、それを引き継いで?自動生成はおこなっていない
といったところでしょうか? 詳しいことを知っている人がいたら教えてください。
さて、ここで終わりでもよいのですが、さらなる主張というか思ったこととして、最初の方に多少説明した伏線回収になるのですが
- compile timeにコード生成をするためにMirroredMonoType, MirroredLabel, MirroredElemTypesなどを使う
- あくまでコンパイル時に使う情報としての側面であって、通常、使った後の実行時に実態は必要ない
の部分です。
Mirrorが、それらのtypeをcompile時に使うだけであって、ordinalやfromProductが必要ないのならば、
「コンパイル時にそれらだけを提供して、コンパイル後には消えていて欲しい。無名クラス生成は必要ない」
ということにならないでしょうか? つまりScala 3本体のcompiler側でそういう機能を提供して欲しい、という。
そうでなくても、companionが使えない場合、毎回単純にその場で無名クラスを必ず作るよりも、何かもう少しマシな方法がある気もしますが、どうなんでしょうか? 例えば、別に単純な無名ではなくinvoke dynamicで生成する?など。
この問題提起は、我ながらなかなか面白いと思うのですが、雑にScala 3のGitHubなどを検索した限り、これを提案しているのは特に見つけられませんでした。
ちなみに、某projectで、コンパニオンが存在しないことにより、同じSumOfが何個生成されているかな?というのを以下のbash芸で調べたら、最大80越えの場合がありました!これは草・・・ (そもそもcirceのauto的に、再帰的に生成してるのが悪い・・・)
find . -name "*.class" -type f | xargs javap | grep -5 "anon.* implements scala.deriving.Mirror" | grep " public int ordinal(" | grep -v "java.lang.Object" | sort | uniq -c | sort
ただし、まだ調査中ですが、おそらくこの無名Mirror大量生成問題自体は、そこまで 再帰的に生成した場合のinlineの爆発? で問題にならなくて、結局以下のような対策の方が抜群の効果がある気がしますが、まだ色々試行錯誤中です。
Scala 3のinline使った型クラス生成で、再帰的に完全自動にすると爆発する問題の対策として、同じ型に対する木が複数生成されていて全く再利用されてないことを理解したので、依存解析したDAG作ってtopological sortして、
— Kenji Yoshida (@xuwei_k) 2022年4月20日
Block(重複排除した複数のValDef)
という木を手動生成すれば良いのでは?
これの実装詳細やアイデアの説明は、また機会があったら別に書くかもしれません。
いかがでしたか!
macroやinlineやcompilerの内部実装まで考えると、面白いけど難しいですね!!!
最後に、これらの(無名の場合の)コード生成している関連するScala 3 compilerの該当部分のコードを貼っておきますね。
- https://github.com/lampepfl/dotty/blob/3.1.3-RC2/compiler/src/dotty/tools/dotc/typer/Synthesizer.scala#L221-L234
- https://github.com/lampepfl/dotty/blob/3.1.3-RC2/compiler/src/dotty/tools/dotc/typer/Synthesizer.scala#L310
- https://github.com/lampepfl/dotty/blob/3.1.3-RC2/compiler/src/dotty/tools/dotc/typer/Synthesizer.scala#L377-L378