Scala 3のscala.deriving.Mirrorの実装詳細は?生成コードは?SumOfとProductOfの違いは?Scala 2の場合は?調べてみました!

少しふざけたタイトルをつけましたが、以下、基本的に超真面目なマニアックな話をします。

Scala 3から、ある程度の定型的なtype classのinstance生成時に低レベルなmacroを書かずに便利に綺麗に書けるMirrorという仕組みが標準で追加されています。

docs.scala-lang.org

https://github.com/lampepfl/dotty/blob/3.1.3-RC2/library/src/scala/deriving/Mirror.scala

雑に一言で説明すると、shapeless 2における GenericLabelledGeneric に近いものが標準に入りました。

今回は、それ自体の紹介ではなく、タイトルに書いた通り、それの内部実装の話です。よって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の爆発? で問題にならなくて、結局以下のような対策の方が抜群の効果がある気がしますが、まだ色々試行錯誤中です。

これの実装詳細やアイデアの説明は、また機会があったら別に書くかもしれません。


いかがでしたか!


macroやinlineやcompilerの内部実装まで考えると、面白いけど難しいですね!!!

最後に、これらの(無名の場合の)コード生成している関連するScala 3 compilerの該当部分のコードを貼っておきますね。