読者です 読者をやめる 読者になる 読者になる

新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。気づけば社会人3年目に突入。

学習記録:ドワンゴ 新人向けScala研修テキスト

学習記録 関数型 プログラミング Scala

参考文献

dwango.github.io

9. トレイト

プログラムの分割(モジュール化)と組み立て(合成)は,オブジェクト指向プログラミングでも関数型プログラミングにおいても重要な設計の概念になる。
Scalaオブジェクト指向プログラミングにおけるモジュール化の中心的な概念になるのがトレイトである。

トレイトの基本

トレイトはクラスに比べて以下のような特徴がある。

複数のトレイトを1つのクラスやトレイトにミックスインできる
// コンパイルできる
class ClassC extends classA with TraitA with TraitB

// コンパイルエラー
class ClassD extends ClassA with ClassB

上記のように,ClassAとClassBを継承したClassDは作ることができない。
複数のクラスを継承させたい場合はクラスをトレイトにする必要がある。

直接インスタンス化できない
trait TraitA

object ObjectA {
  // コンパイルエラー
  val a = new TraitA
}

上記のように,トレイトは直接インスタンス化できない。
この制限を回避するには,インスタンス化できるようにトレイトを継承したクラスを作るか,トレイトに実装を与えるかのどちらかになる。

trait TraitA
// クラスにすればインスタンス化できる
class ClassA extends TraitA

object ObjectA {
  // クラスなのでインスタンス化できる
  val a = new ClassA

  // 実装を与えてもインスタンス化可能
  val b = new TraitA { }
クラスパラメータ(コンストラクタの引数)を取れない
// クラスなのでクラスパラメータを取れる
class ClassA(name: String) {
  def printName() = println(name)
}

// トレイトはパラメータを取れない
trait TraitA(name: String) {
  def printName: Unit = println(name)
}

トレイトに抽象メンバを持たせることで値を渡すことができる。
また,クラスに継承させたり,抽象メンバを実装することでもトレイトに値を渡すことが可能である。

trait TraitA {
  val name: String
  def printName(): Unit = println(name)
}

// クラスにしてnameを上書きする
class ClassA(val name: String) extends TraitA

object ObjectA {
  // nameを上書きするような実装を与えてもよい
  val a = new TraitA { val name = "hoge" }
}

以上のように,トレイトの制限は実用上ほとんど問題にならず,実質的に多重継承と同じようなことができるクラスとして扱うことができる。

トレイトの様々な機能

菱型継承問題

トレイトはクラスに近い機能を持ちながら,実質的な多重継承が可能であるという便利なものだが,菱型継承問題については考えなければならない。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  def greet(): Unit = println("hoge")
}

trait TraitC extends TraitA {
  def greet(): Unit = println("huga")
}

class ClassA extends TraitB with TraitC

上記のプログラムでは,TraitBとTraitCのgreetメソッドの実装が衝突している。
この場合,ClassAのgreetはどのような動作をすべきか?

上記の例をScalaコンパイルすると,以下のようなエラーになる。

ClassA.scala:13: error: class ClassA inherits conflicting members:
  method greet in trait TraitB of type ()Unit  and
  method greet in trait TraitC of type ()Unit
(Note: this can be resolved by declaring an override in class ClassA.)
class ClassA extends TraitB with TraitC
      ^
one error found

Scalaでは,override指定なしの場合メソッド定義の衝突はエラーになる。
この場合の解法の一つは,コンパイルエラーにあるように,ClassAでgreetをoverrideすることである。

class ClassA extends TraitB with TraitC {
   override def greet(): Unit = println("foo")
}

class ClassB extends TraitB with TraitC {
   // superに型を指定して呼び出すことで親トレイトのメソッドも呼び出せる
   override def greet(): Unit = super[TraitB].greet()
}

scala> (new ClassA).greet()
foo

scala> (new ClassB).greet()
hoge

では,TraitBとTraitCの両方のメソッドを呼び出したい場合は?
1つの方法は,super[TraitB].greet()とsuper[TraitC].greet()の両方を明示的に呼び出すことだが,継承関係が複雑になった場合,全てを明示的に呼び出すのは大変である。

そこで,Scalaのトレイトには線形化という機能がある。

線形化

トレイトの線形化とは,トレイトがミックスインされた順番をトレイトの継承順番とみなすことである。

以下の例は,先ほどの例のTraitB/Cのgreetメソッド定義にoverride修飾子をつけたものである。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  override def greet(): Unit = println("hoge")
}

trait TraitC extends TraitA {
  override def greet(): Unit = println("huga")
}

class ClassA extends TraitB with TraitC

この場合はコンパイルエラーにならない。何故か?
それはトレイトの継承順番が線形化され,後からミックスインしたTraitCが優先されているためである。
そのため,ClassAのgreetメソッドを呼び出すと,TraitCのgreetメソッドが呼び出される。

scala> (new ClassA).greet()
huga

superを使うことで,線形化された親トレイトを使うこともできる。

trait TraitA {
  def greet(): Unit = println("TraitA!")
}

trait TraitB extends TraitA {
  override def greet(): Unit = {
    super.greet()
    println("TraitB!")
  }
}

trait TraitC extends TraitA {
  override def greet(): Unit = {
    super.greet()
    println("TraitC!")
  }
}

class ClassA extends TraitB with TraitC
class ClassB extends TraitC with TraitB

このgreetメソッドの結果も,継承された順番によって変わることになる。

scala> (new ClassA).greet()
TraitA!
TraitB!
TraitC!

scala> (new ClassB).greet()
TraitA!
TraitC!
TraitB!

線形化の機能により,ミックスインされた全てのトレイトの処理を簡単に呼び出せるようになった。
線形化によるトレイトの積み重ねの処理を,Scalaでは積み重ね可能なトレイトと呼ぶ。

この線形化がScalaの菱型継承問題に対する対処法となる。

abstract override

メソッドのオーバーライドでsuperを使ってスーパークラスメソッドを呼び出す場合,当然継承元のスーパークラスにそのメソッドの実装が無ければならない。
しかし,Scalaには継承元にメソッドの実装がない場合でもメソッドのオーバーライドが可能なabstract overrideという機能がある。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  // abstractが無いとコンパイルエラーになる
  abstract override def greet(): Unit = {
    super.greet()
    println("TraitB!")
  }
}

オーバーライドをabstract overrideにすることで,抽象クラスに対しても積み重ねの処理が書けるということになる。
しかし,ミックスインされてクラスが作られるときにはスーパークラスメソッドが実装されている必要がある。

// コンパイルエラー
class ClassA extends TraitB

// TraitAのgreetを呼び出さないトレイトを作成
trait TraitC extends TraitA {
  def greet(): Unit =
    println("Hello!")
}

// こちらはコンパイルできる
// TraitBのsuperで呼び出されるのはTraitCのgreetなため
class ClassB extends TraitC with TraitB

自分型

クラスやトレイトの中では,自分自身の型にアノテーションを記述することができる機能がある。

trait Greeter {
  def greet(): Unit
}

trait Robot {
  self: Greeter =>

  def start(): Unit = greet()
}

このRobotトレイトは,startメソッドを呼び出されるとgreetメソッドを呼び出している。
ここで,Robotは直接Greeterを継承していないのにも関わらず,greetメソッドを使えていることに注意。

このRobotのオブジェクトを実際に作るためには,greetメソッドを実装したトレイトが必要になる。

trait HelloGreeter extends Greeter {
  def greet(): Unit = println("Hello!")
}

これでRobotのオブジェクトを作ることができる。

scala> val r = new Robot with HelloGreeter
r: Robot with HelloGreeter = $anon$1@3352aa2a

scala> r.start
Hello!

自分型を使う場合,抽象トレイト(Greeter)を指定し,後から実装を追加(with HelloGreeter)するという形になる。
このように,後から利用するモジュールの実装を与えることを依存性の注入と呼ぶ。
自分型が使われている場合,この依存性の注入のパターンが使われていると考えてよい。

ではこの自分型の指定は,以下のように直接継承する場合と比べてどう異なるのか。

trait Greeter {
  def greet(): Unit
}

trait Robot2 extends Greeter {
  def start(): Unit = greet()
}

オブジェクトを生成するという点では変わらないが,トレイトを利用する側や,継承したトレイトやクラスにはGreeterトレイトの見え方に違いができる。

scala> val r: Robot = new Robot with HelloGreeter
r: Robot = $anon$1@65a8d818

scala> r.greet()
<console>:16: error: value greet is not a member of Robot
       r.greet()
         ^

scala> val r2: Robot2 = new Robot2 with HelloGreeter
r2: Robot2 = $anon$1@dcd702c

scala> r2.greet()
Hello!

継承で作られたRobot2オブジェクトからは,Greeterトレイトのgreetメソッドを呼び出せてしまう。
一方,自分型で作られたRobotオブジェクトでは,greetメソッドを呼び出すことができない。

Robotが利用を宣言するためにあるGreeterのメソッドが外から呼び出せてしまうことはあまり良いことではない。
この点が自分型を使うメリットとなるが,逆に単に依存性の注入がしたいだけならば,この動作は煩わしく感じられるかもしれない。

依存性の注入を使う場合,継承を使うか自分型を使うかというのは若干悩ましい問題かもしれない。
機能的には継承で問題ないが,上記のような可視性の問題と,自分型を使うことで依存性の注入を利用しているとわかりやすくなるという効果もある。
利用する場合はチームで相談すると良い。

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

トレイトのvalの初期化順序はトレイトを使う上で大きな落とし穴になる。
以下の例では,

  • トレイトAで変数fooを宣言し,
  • トレイトBがfooを使って変数barを作成し,
  • クラスCでfooに値を代入してからbarを使っている。
trait A {
  val foo: String
}

trait B extends A {
  val bar = foo + ", World!"
}

class C extends B {
  val foo = "Hello"

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

さて,この状態でCのprintBarメソッドを呼び出すと,

scala> (new C).printBar()
null, World!

クラスCでfooに代入した値が反映されておらず,nullになっている。
この現象の理由は,Scalaのクラスおよびトレイトがスーパークラスから順番に初期化されるためである。
この例でいえば,初期化はトレイトAから順に行われ,変数fooが宣言された後(中身はnull),トレイトBで変数barが宣言されて,nullであるfooと", World!"という文字列から"null, World!"という文字列が出来上がってしまう。

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

では,この罠はどうやれば回避できるのだろうか。
上記の例でいえば,使う前にfooが初期化されるように,barの初期化を遅延させることだ。
処理を遅延させるには,lazy valかdefを使う。

scala> :paste
// Entering paste mode (ctrl-D to finish)

trait A {
  val foo: String
}

trait B extends A {
  // def barでも良い
  lazy val bar = foo + ", World!"
}

class C extends B {
  val foo = "Hello"

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

// Exiting paste mode, now interpreting.

defined trait A
defined trait B
defined class C

scala> (new C).printBar()
Hello, World!

barの初期化にlazy valを使うことで,barの初期化が宣言時ではなく,実際に使われるまで遅延される。
これにより,printBarメソッドを呼ぶまでbarの初期化はされず,その間にクラスCでfooが初期化されることにより,正常な表示になる。

lazy valはvalに比べて若干処理が重く,複雑な呼び出しでデッドロックが発生する場合がある。
valの代わりにdefを使うと,毎回値を計算してしまうという問題もある。
しかし,両方とも大きな問題にはならない場合が多いので,特にvalの値を使ってvalの値を作り出すような場合には,lazy valかdefの使用を検討すること。