Scalaの定数の初期化失敗時にnullになる理由

初期化失敗時にnullになるとは??

cake patternを利用したDI時などで定数が束縛される前にその定数を呼び出すとnullになります。1

これはScalaを始めた頃によく陥る罠2 で私も昔これでよく悩みました。

最近友人とこの話題になり、なぜnullになるのか疑問に思い調査してみました。

環境

  • Scala: 2.13.4
  • Java: openjdk 11.0.10 2021-01-19

調査

再現する例を示す

class Example {
  println(s)
  val s = "hello world"
  println(s)
}

classインスタンスを生成したいのでREPLで実行する

$ scala
Welcome to Scala 2.13.4 (Eclipse OpenJ9 VM, Java 1.8.0_282).
Type in expressions for evaluation. Or try :help.

scala> class Example {
     |   println(s)
     |   val s = "hello world"
     |   println(s)
     | }
     |
         println(s)
                 ^
On line 2: warning: Reference to uninitialized value s
class Example

scala> new Example
null
hello world
val res0: Example = Example@9a44ab10

定数sを束縛する前に呼び出すと確かにnullになる

束縛する前に値を呼び出せるのがそもそもよくわからないが、インスタンスが作られていない状態で呼び出すとnullになるという挙動がよくわからない

たとえば当たり前だがjavaだとコンパイル時にcannot find symbolでエラーになる

class Example {
  Example(){
    System.out.println(s);
    final String s = "hello java";
    System.out.println(s);        
  }
}
$ javac example.java
example.java:3: エラー: シンボルを見つけられません
    System.out.println(s);
                       ^
  シンボル:   変数 s
  場所: クラス Example
エラー1個

つまり String型の挙動ではない

Scalaコンパイルフェーズでconstructorsというものがあり、フィールド定義をコンストラクタへ移動するということを行う

このコンパイラフェーズでコンストラクタに持ち上げる時にnullを入れているのだと予想した

scalaコンパイラはこのコンパイルフェーズの途中のものを出力することができる

$ scalac example.scala -Xprint:constructors
example.scala:2: warning: Reference to uninitialized value s
  println(s)
          ^
[[syntax trees at end of              constructors]] // example.scala
package  {
  class Example extends Object {
    private[this] val s: String = _;
      def s(): String = Example.this.s;
    def (): Example = {
      Example.super.();
      scala.Predef.println(Example.this.s());
      Example.this.s = "hello world";
      scala.Predef.println(Example.this.s());
      ()
    }
  }
}

1 warning

private[this] val s: String = _;

予想したとおりsに_という初期値を代入している箇所を見つけた

この_

https://scala-lang.org/files/archive/spec/2.13/04-basic-declarations-and-definitions.html#variable-declarations-and-definitions

に書いてあるとおり

var かつ 型アノテーションがついている場合のみ利用できる

_はTTの型によって値が変わり、AnyRefを継承していた場合はnull、AnyValを継承していた場合は0っぽい値が代入される

結果

AnyRefを継承している型をインスタンス化せずに呼び出そうとするとコンパイルフェーズ18のconstructorsの際に初期値として代入された_が呼び出され、その値のnullになる

感想

業務や趣味でScalaのコンパイルフェーズを一つづつ表示していたのでコンパイルフェーズ18でなにかやっているのではないか?`という仮定にたどり着けたのだと思う

またDIなどをするときはほとんどが独自型なのでnullになるが、AnyVal(Int, Long, etc..)を束縛前に利用すると0っぽい値が入り、実行時にも中々気が付けない場合があると考えると結構怖い

scala3(dotty)の話

Scala3ではuninitializedというメソッドに変えられている(コンパイルフェーズ中にしか利用できないメソッドになっている)

https://dotty.epfl.ch/api/scala/compiletime.html#uninitialized-0

追記1

nullになる場合はlazy valなどを使って回避しましょう

追記2

記事中のScalaとJavaの比較の箇所が正しく比較できていないようです

ちょっと僕には難しすぎて正しく解説できないので吉田さんのツイートをそのまま貼っておきます

https://gist.github.com/xuwei-k/8c8baf19f4067ca465023c44254b6973

ref