takaha.siの技術メモ

勉強したことをお伝えします。ちょっとでも誰かの役に立てればいいな…

scalaのtraitの危険性

踏んだのでメモ

Scalaにはtraitという機能があります。

traitは「それ単体で動作することはなく、他classを拡張(mixin)するか、他classから継承されることによって動作するエンティティ」と定義して良いと思います。

JavaのInterfaceにたとえられることもありますけども、JavaのInterfaceが実装を持つことができないのにたいして、Scalaのtraitは実装を持つことができます。

Scalaのtraitは便利で、mixinしてclassに機能をどんどん追加していくという事ができます。つまり、実質、多重継承のようなこと事ができてしまいます。

が、この機能、利便性と引き換えに危険性が伴います。乱用すると複雑な継承関係を容易に生み出してしまい、コードの可読性が低下します。そして、最悪NullPointerException(ヌルポ)でプログラムが死にます。ScalaはOptionさえ適切に使ってればヌルポは根絶できるのだと無邪気に考えていた私は甘かったです。

以下にコードの例を示します:

class RPC(target: String) {
  def send(msg: String): Unit = {
    println(s"sent! ${msg} to ${target}\n")
  }
}

class RPCBinder(rpc: RPC) {
  def send(msg: String): Unit = {
    rpc.send(msg)
  }
}

trait Base {
  val rpc: RPC
  val binder: RPCBinder = new RPCBinder(rpc)
}

trait BaseDefault { base: Base =>
  val rpc = new RPC("Node0")
  def sendHello(): Unit = {
    binder.send("Hello")
  }
}

trait BaseFunc1 { base: Base =>
  def sendFunc1(): Unit = {
    binder.send("Func1")
  }
}

class BaseMode1 extends Base with BaseDefault with BaseFunc1 {
  def doSend(): Unit = {
    sendHello()
    sendFunc1()
  }
}

object Main {
  def main(args: Array[String]){
    println("start!!")
    val base = new BaseMode1()
    base.doSend()
    println("end!!")
  }
}

これは、基底のtraitであるBaseに対して、Base traitに限ってmixinできるBaseDefault, BaseFunc1を定義し、BaseMode1でBaseDefault, BaseFunc1をmixinしています。

このコード。実行すると、NullPointerExceptionが起こります。

[thoth@~/programs]$ scala bbbb.scala
start!!
java.lang.NullPointerException
        at RPCBinder.send(bbbb.scala:9)
        at BaseDefault.sendHello(bbbb.scala:21)
        at BaseDefault.sendHello$(bbbb.scala:20)
        at BaseMode1.sendHello(bbbb.scala:31)
        at BaseMode1.doSend(bbbb.scala:33)
        at Main$.main(bbbb.scala:42)
        at Main.main(bbbb.scala)

まあちょっと考えれば、このコードがなぜヌルポが引き起こすのかはすぐわかると思います。trait BaseでRPCBinderをnewしたときに(15行目)参照しているtrait Baseのval rpc(14行目)がまだ決定されておらず、RPCBinderにnullが放り込まれてnewされちゃうんですね。

trait Baseのval rpcはインプリされていないとコンパイルエラーが出るからヌルポは回避できるのでは?と思いきや、trait BaseDefaultの方でインプリ(19行目)されており、BaseModel1でmixinされてしまっている(31行目)ので、コンパイルは通ってしまうわけです。

そもそもこんな意味不明なコードを書くんじゃない!というのはもちろんそのとおりです。しかし、脳死プログラミングしているときでさえこんなコードには絶対ならない!と断言はできないと思いますし、なまじコンパイルが通ってしまうのでエラーは実行時に当該のパスを踏むまで見抜けません。厄介です。ちなみに私はリファクタリングをしているときにこれに陥りました。

というわけで、scalaのtraitは注意して使わないといけないなと思いました。mixinがされているコードをレビューするときは、こういうエラーを引き起こす潜在因子がないかどうかちょっと考えてみるべきかもしれません。

なお、このコードは、trait Baseを以下のように変更すれば問題なく実行されます。

trait Base {
  val rpc: RPC
  lazy val binder: RPCBinder = new RPCBinder(rpc)
}

RPCbinderをnewするときにlazyをつければ、意図したとおり、ヌルポはでずに動きます。

動くには動きますが、コードとして明示的に記述されない初期化の順番というものを、コードを読む人に意識させてしまうという点でこのコードが良くないということに変わりはないと思います。

こういうコードを書くべきではない。陥らないように注意すべきであるというそもそも論に変わりはないでしょう。

p.s.

lazyは、Scalaに対して初期化のコードの実行順番の変更を行うものでありますが、使い方によってはもっとヤバイ自体に陥ります。それはデッドロックです。以下に例が上げられています。

gist.github.com

ヌルポはまだ可愛い方で、プログラムが落ちてくれるのでわかりやすいですよね。