今日でトレイトの章は最後。
落とし穴:トレイトの初期化順序
この段落の始まりは以下のような不穏な文で始まる。
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) } }
ClassC
でval foo
を有意な文字列"Hello"で初期化している。が、実際に意図したとおりに動くのか、、ということだ。実際に動かしてみると以下のようになる。
$ scala 20180930_01_trait_3_initiliaze_order.scala nullWorld
"nullWorld"と表示された。ということは、初期化は以下のように実行されていることになる。
TraitA
のval foo
:実際には初期化はされていないので値はnull
になる。TraitB
のval bar
:TraitA
のval foo
を使って初期化されるのでnull
が文字列"null"として連結されるClassC
のval foo
:これは、、、初期化されてるのか??
よくわからんので、ついでにClassC
のval 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) } }
違いはただひとつTraitB
のbar
変数の宣言時にlazy
をつけただけ。実行してみると以下のように、先ほど"null"と表示された部分が"Hello"になる。
$ scala 20180930_02_trait_3_lazy_initialize.scala HelloWorld Hello
注意点としては
lazy val
はval
より処理が重い- 複雑な呼び出しの際にはデッドロックすることがある
が挙げられている。
なお、先に書いたように変数の宣言を下記のように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
の方に問題があるのでそちら修正するのが真っ当な方法で、これだけでなくトレイトの初期化問題は継承されるトレイト側で解決したほうがいいケースが多いので、見かけるケースは少ないそうだ。
ということで、トレイトの章はこれで終わり。あー、ボリュームあった。。