突然だがこのScala 3のコードはコンパイルするだろうか。先に正解を言っておくと「コンパイルする」(Scala 3.5.0)。
class Foo: val x = y val y = 42
では、このFoo
に向けて以下の呼び出しを行うとどうなるだろう?
Foo().x
驚くなかれ、この式は0
に評価される。
!?!?!?!?!?!?
ナンデ?
Scalaのクラスにおけるコンストラクタ
Scalaでは、class
やobject
の上にいきなり地で書かれたコードはコンストラクタとしてすぐ実行してもらえる。
class Foo(x: Int, y: Int): private val computed = x + y val field = computed Foo(1, 2).field // => 3
これにより、他の言語のようにconstructor
といった名前のメソッドを特別に用意する必要がない。
そのままval
でフィールドを定義していくことにより、フィールドが初期化されずにnull
で埋まっている宙ぶらりんな状態を文法上回避できるようになっている。
TypeScriptでは、コンパイラがフィールドの未初期化を検出できる:
class Foo { x: number y: number constructor () { this.x = 42 // Property 'y' has no initializer and is not definitely assigned in the constructor. } }
対してScalaは「その場で」定義と初期化を行うため、フィールドが初期化されていないということはない(コンパイラが検出する):
class Foo { val x: Int = 42 val y: Int // 初期化していないのが一目瞭然 } // => class Foo needs to be abstract, since val y: Int in class Foo is not defined
初期化順序
前述の仕様により、自明なことに、フィールドは定義した順に初期化しなければならない(定義した場所で初期化するのだからそうなる)。
しかし初期化していないフィールドを後方参照してしまうようなコードはコンパイルしてしまう:
class Foo: val x = y // この時点でyの値は不明だが、コンパイルが通って0になってしまう val y = 42
このことをScalaわいわいランドでしたところ、id:tanishiking24 に教えてもらった:
内部的にはまず各フィールドを0値で初期化したインスタンスを生成して、そのあとコンストラクタで各フィールドをx=y、y=42みたいな代入が発生するのでこうなる〜
ScalaはJVM言語なのでコンパイルされた後のコードの都合でこうなってしまうようだ。自分はこれを年に1度くらい踏んでいる気がする・・・。
lazy val
による回避
当たり前だが、「順序に気をつける」というのも立派な回避策だ。Scalaでは基本的に「小さい要素から順に」フィールドを書いていくのが良さそう。
しかし順序が混み合っていたりする場合はlazy val
にすることでもこの問題を回避できる。フィールドが初めて呼ばれたタイミングで中身を評価し、その後はその値が使われるようになる。
class Foo { lazy val x = y val y = 42 } Foo().x // => 42
Safe Initialization
ところで、Scala 3ではSafe Initialization機能がコンパイラオプションとして追加された。これはフィールドの呼び出し順を追跡して、初期化していないフィールドの呼び出しを検知して警告する機能だ。
このオプションは-Wsafe-init
をscalacOptions
に付けることで利用できる:
// build.sbt lazy val root = project .in(file(".")) .settings( // ... scalacOptions ++= Seq("-Wsafe-init"), )
この機能は継承関係が挟まっていても正常に動作するようだ。
まとめ
- Scalaでは、クラスフィールドは書いたタイミングで初期化する
- Scalaコンパイラは、あるフィールドを初期化するタイミングで別のフィールドが初期化されているかを検知できない
- Safe Initializationはコンパイラの機能を強化し、フィールドの呼び出しタイミングを追跡して危険な呼び出しを察知できる