ハードウェアの気になるあれこれ

技術的に興味のあることを調べて書いてくブログ。主にハードウェアがネタ。

Scalaの勉強 - トレイト(3)

スポンサーリンク

今日でトレイトの章は最後。

落とし穴:トレイトの初期化順序

この段落の始まりは以下のような不穏な文で始まる。

Scalaのトレイトのvalの初期化順序はトレイトを使う上で大きな落とし穴になります。

どういうことか例を使って見ていこう。

資料には以下のようにシンプルな例が示されている。

trait TraitA {
  val foo: String
}

trait TraitB extends TraitA {
  val bar = foo + "World"
}

class ClassC extends TraitB {
  val foo = "Hello"

  def printBar(): Unit = println(bar)
}

object InitializeOrder {
  def main(args: Array[String]): Unit = {
    val o_c = new ClassC()

    o_c.printBar()
    println(o_c.foo)
  }
}

ClassCval fooを有意な文字列"Hello"で初期化している。が、実際に意図したとおりに動くのか、、ということだ。実際に動かしてみると以下のようになる。

$ scala 20180930_01_trait_3_initiliaze_order.scala 
nullWorld

"nullWorld"と表示された。ということは、初期化は以下のように実行されていることになる。

  1. TraitAval foo:実際には初期化はされていないので値はnullになる。
  2. TraitBval barTraitAval fooを使って初期化されるのでnullが文字列"null"として連結される
  3. ClassCval foo:これは、、、初期化されてるのか??

よくわからんので、ついでにClassCval fooを表示してみる。

  def main(args: Array[String]): Unit = {
    val o_c = new ClassC()

    o_c.printBar()
    println(o_c.foo)
  }

結果は以下のようになった。

$ scala 20180930_01_trait_3_initialize_order.scala 
nullWorld
Hello

うん、初期化されているようだ。このあたりの初期化順は他のオブジェクト指向言語と変わらずで、基底クラスに相当するものから積み上げられていくということで良さそう。

ただ、資料によると

先ほど自分型で紹介した「依存性の注入」は、上位のトレイトで宣言したものを、中間のトレイトで使い、最終的にインスタンス化するときにミックスインするという手法です。ここでもうっかりすると同じような罠を踏んでしまいます。 Scala上級者でもやってしまうのがvalの初期化順の罠なのです。

とのことで、注意が必要なようだ。なお、ここで書いてある「依存性の注入」は今見ているページには特に記載が無い。おそらく別立ての章になっているトレイトの応用編のことを指していると思われるので、まだ追って確認していくことにする。

トレイトのvalの初期化順序の回避方法

上記の例の落とし穴を回避するための方法が文法的に準備されているらしいので、それを見ていく。

上記の例で言えば、使う前にちゃんとfooが初期化されるように、barの初期化を遅延させることです。

trait TraitA {
  val foo: String
}

trait TraitB extends TraitA {
  lazy val bar = foo + "World" // lazyをつけただけ
}

class ClassC extends TraitB {
  val foo = "Hello"

  def printBar(): Unit = println(bar)
}

object InitializeOrder {

  def main(args: Array[String]): Unit = {
    val o_c = new ClassC()

    o_c.printBar()
    println(o_c.foo)
  }
}

違いはただひとつTraitBbar変数の宣言時にlazyをつけただけ。実行してみると以下のように、先ほど"null"と表示された部分が"Hello"になる。

$ scala 20180930_02_trait_3_lazy_initialize.scala 
HelloWorld
Hello

注意点としては

  • lazy valvalより処理が重い
  • 複雑な呼び出しの際にはデッドロックすることがある

が挙げられている。

なお、先に書いたように変数の宣言を下記のようにdefをつけて行うことでも可。

trait TraitA {
  val foo: String
}

trait TraitB extends TraitA {
  def bar = foo + "World"
}

class ClassC extends TraitB {
  val foo = "Hello"

  def printBar(): Unit = println(bar)
}

object DefInitialize {

  def main(args: Array[String]): Unit = {
    val o_c = new ClassC()

    o_c.printBar()
    println(o_c.foo)
  }
}

実行すると以下のように、lazy valを使用した場合と同様の結果を得られた。

$ scala 20180930_03_trait_3_def_initialize.scala 
HelloWorld
Hello

defを使うと毎回を値を計算してしまうという副作用が存在するらしい。要は関数としてbarを定義したことになるということになるのだろう。pythonのデコレータ使ったクラスのフィールドに相当する処理なのだろう。

もうひとつ初期化順序の回避方法が紹介されており、それは事前定義(Early Definitions)という手法だそうだ。早速例を見てみる。

trait TraitA {
  val foo: String
}

trait TraitB extends TraitA {
  val bar = foo + "World"
}

class ClassC extends {
  val foo = "Hello"
}
with TraitB {
  def printBar(): Unit = println(bar)
}

object DefInitialize {

  def main(args: Array[String]): Unit = {
    val o_c = new ClassC()

    o_c.printBar()
    println(o_c.foo)
  }
}

上記のように、ClassCの定義時にwithの前に定義処理を挟むことが出来るらしい。実行結果も下記の通りで、先の2つと同様の結果が得られた

$ scala 20180930_04_trait_3_early_definitions_initialize.scala 
HelloWorld
Hello

なお、上記で回避は可能だがこの例ではTraitBの方に問題があるのでそちら修正するのが真っ当な方法で、これだけでなくトレイトの初期化問題は継承されるトレイト側で解決したほうがいいケースが多いので、見かけるケースは少ないそうだ。

ということで、トレイトの章はこれで終わり。あー、ボリュームあった。。