問題です。
以下の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したことがあるのですが
Scala豆知識です
— Kenji Yoshida (@xuwei_k) November 6, 2020
(以前どこかで言ってる気がする) pic.twitter.com/ZTJ6vBB3ma
継承する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
でよくない???などもある