Scalaにおける細かいclassの定義方法とインスタンス毎のサイズ

問題です。

以下のA2, B2, B3は、細かい定義方法は違うけれど、実際にはほとんど同じ定義ですが、インスタンス毎のサイズ(アロケーションされるメモリの量)、としては、どれが一番効率がいいでしょうか?どれも同じでしょうか?

この問題に完璧に答えられる自信がある人は、この先を読む必要がありません。

package example

sealed abstract class A1(val x: Int)

case class A2(y: String) extends A1(2)

// 実用的には、その他のA1やB1のsub classやobjectも
// あるかもしれないが、直接関係ないので省略

sealed abstract class B1 {
  def x: Int
}

case class B2(y: String) extends B1 {
  override def x: Int = 2
}

case class B3(y: String) extends B1 {
  override val x: Int = 2
}

正解は

  • B2が1番効率が良い
  • A2とB3は、B2と比較すると、少しだけ無駄がある

となります。ここでポイントというか前提条件は、少なくとも上記で示したclassについては x: Int が定数、ということです。

また、Scalaのversionによってはおそらく一切変わらないはずですが、とりあえず3.3.1という前提で話をします。

稀に?割と多く?深く考えずに?

「定数だからvalにしておけばいいな!!!」

と思って(?) 効率の観点だけを考えると、効率悪い形式で定義する人がいますが

「定数だからこそdefにするべき」

という、ScalaのコードがJVMのclassファイル的にどう表現されるか?を知らないと、若干意外な結果に感じる人がいるかもしれません。

そもそもこれは、昔にtweetしたことがあるのですが

継承するabstract classの引数部分でval定義するパターンも同様だけど解説してなかったので、せっかくなので、しっかりblogに書いておくことにしました。

以下、今回の例でのJOLでの結果や出し方を貼ります

project/plugins.sbt

libraryDependencies += "org.openjdk.jol" % "jol-core" % "0.17"

build.sbt

import sbt.complete.DefaultParsers._
import sbt.complete.Parser
import xsbti.api.ClassLike
import org.openjdk.jol.info.ClassLayout

val defaultParser: Parser[String] =
  Space ~> token(StringBasic, "<class name>")

InputKey[Unit]("jol") := {
  val clazz = defaultParser.parsed
  val loader = (Test / testLoader).value
  val layout = ClassLayout.parseClass(loader.loadClass(clazz))
  val str = layout.toPrintable
  println(str)
}

scalaVersion := "3.3.1"

実行結果

> jol example.B2
example.B2 object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     N/A
  8   4                    (object header: class)    N/A
 12   4   java.lang.String B2.y                      N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
> jol example.B3
example.B3 object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     N/A
  8   4                    (object header: class)    N/A
 12   4                int B3.x                      N/A
 16   4   java.lang.String B3.y                      N/A
 20   4                    (object alignment gap)    
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
> jol example.A2
example.A2 object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     N/A
  8   4                    (object header: class)    N/A
 12   4                int A1.x                      N/A
 16   4   java.lang.String A2.y                      N/A
 20   4                    (object alignment gap)    
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

効率観点以外やその他の細かい解説として

  • 定数でないなら、つまりインスタンス毎に変わるなら case class A3(x: Int, y: String) extends A1(x) にしても case class B4(override val x: Int, y: String) extends B1 にしても変わらないので、その観点からすれば、なんでも良い
  • sub classではなくobjectならば、どうせインスタンスは一つなので、どの定義でもメモリ量的には実質関係ない
  • あくまでそのclassのインスタンスが大量に生成される場合で、すごく効率を気にしたい場合の話であって、せいぜい1つのインスタンスというかfieldあたり8 byte増減したりするだけで、これそのものがボトルネックになることはかなり稀なので、そういう意味ではあまり気にする必要はない(けど明らかに違いはあるぞ!という豆知識を書いただけ)
  • 抽象メソッドではなく、abstract class側の引数にしておくと、override def 名前override val 名前 するのと違い、名前省略して渡せるが
    • とにかくコードを短く書きたいならメリット
    • (流石にテストなどで気がつくとは思うが) 同じ型の引数が複数あったりすると、最悪渡し方ミスしたりするデメリット。つまり、それなら最初から明示的にそれぞれ引数ではなくoverride defやoverride valで書いたほうが、という
    • case class A4(override val x: Int, y: String) extends A1(x) みたいに、overrideもするけど引数にも渡す二度手間!?!?みたいなのもたまに見かけるけど、だったら引数にしておかずに case class A4(override val x: Int, y: String) extends A1 でよくない???などもある