トレイト

私たちの作るプログラムはしばしば数万行、多くなると数十万行やそれ以上に及ぶことがあります。その全てを一度に把握することは難しいので、プログラムを意味のあるわかりやすい単位で分割しなければなりません。さらに、その分割された部品はなるべく柔軟に組み立てられ、大きなプログラムを作れると良いでしょう。

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

この節ではScalaのトレイトの機能を一通り見ていきましょう。

トレイト定義

Scalaのトレイトは、クラスからコンストラクタを定義する機能を抜いたようなもので、おおまかには次のように定義することができます。

trait TraitName {
  (a field or a method definition)の0回以上の繰り返し
}

フィールド定義またはメソッド定義は本体がなくても構いません。

トレイトの基本

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

  • 複数のトレイトを1つのクラスやトレイトにミックスインできる
  • 直接インスタンス化できない
  • クラスパラメータ(コンストラクタの引数)を取ることができない

以下、それぞれの特徴の紹介をしていきます。

複数のトレイトを1つのクラスやトレイトにミックスインできる

Scalaのトレイトはクラスとは違い、複数のトレイトを1つのクラスやトレイトにミックスインすることができます。

trait TraitA

trait TraitB

class ClassA

class ClassB

// コンパイルできる
class ClassC extends ClassA with TraitA with TraitB
scala> // コンパイルエラー!
     | class ClassD extends ClassA with ClassB
<console>:15: error: class ClassB needs to be a trait to be mixed in
       class ClassD extends ClassA with ClassB
                                        ^

上記の例ではClassATraitATraitBを継承したClassCを作ることはできますがClassAClassBを継承したClassDは作ることができません。「class ClassB needs to be a trait to be mixed in」というエラーメッセージが出ますが、これは「ClassBをミックスインさせるためにはトレイトにする必要がある」という意味です。複数のクラスを継承させたい場合はクラスをトレイトにしましょう。

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

Scalaのトレイトはクラスと違い、直接インスタンス化できません。

scala> trait TraitA
defined trait TraitA
scala> object ObjectA {
     |   // コンパイルエラー!
     |   val a = new TraitA
     | }
<console>:15: error: trait TraitA is abstract; cannot be instantiated
         val a = new TraitA
                 ^

これは、トレイトが単体で使われることをそもそも想定していないための制限です。トレイトを使うときは、通常、それを継承したクラスを作ります。

trait TraitA

class ClassA extends TraitA

object ObjectA {
  // クラスにすればインスタンス化できる
  val a = new ClassA

}

なお、new Trait {}という記法を使うと、トレイトをインスタンス化できているように見えますが、これは、Traitを継承した無名のクラスを作って、そのインスタンスを生成する構文なので、トレイトそのものをインスタンス化できているわけではありません。

クラスパラメータ(コンストラクタの引数)を取ることができない

Scalaのトレイトはクラスと違いパラメータ(コンストラクタの引数)を取ることができないという制限があります1

// 正しいプログラム
class ClassA(name: String) {
  def printName() = println(name)
}
scala> // コンパイルエラー!
     | trait TraitA(name: String)
<console>:3: error: traits or objects may not have parameters
       trait TraitA(name: String)
                   ^

これもあまり問題になることはありません。トレイトに抽象メンバーを持たせることで値を渡すことができます。インスタンス化できない問題のときと同じようにクラスに継承させたり、インスタンス化のときに抽象メンバーを実装をすることでトレイトに値を渡すことができます。

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

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

object ObjectA {
  val a = new ClassA("dwango")

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

以上のようにトレイトの制限は実用上ほとんど問題にならないようなものであり、その他の点ではクラスと同じように使うことができます。つまり実質的に多重継承と同じようなことができるわけです。そしてトレイトのミックスインはモジュラリティに大きな恩恵をもたらします。是非使いこなせるようになりましょう。

「トレイト」という用語について

この節では、トレイトやミックスインなどオブジェクトの指向の用語が用いられますが、他の言語などで用いられる用語とは少し違う意味を持つかもしれないので、注意が必要です。

トレイトはSchärliらによる2003年のECOOPに採択された論文『Traits: Composable Units of Behaviour』がオリジナルとされていますが、この論文中のトレイトの定義とScalaのトレイトの仕様は、合成時の動作や、状態変数の取り扱いなどについて、異なっているように見えます。

しかし、トレイトやミックスインという用語は言語によって異なるものであり、我々が参照しているScalaの公式ドキュメントや『Scalaスケーラブルプログラミング』でも「トレイトをミックスインする」という表現が使われていますので、ここではそれに倣いたいと思います。

トレイトの様々な機能

菱形継承問題

以上見てきたようにトレイトはクラスに近い機能を持ちながら実質的な多重継承が可能であるという便利なものなのですが、 1つ考えなければならないことがあります。多重継承を持つプログラミング言語が直面する「菱形継承問題」というものです。

以下のような継承関係を考えてみましょう。 greetメソッドを定義したTraitAと、greetを実装したTraitBTraitC、そしてTraitBTraitCのどちらも継承したClassAです。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  def greet(): Unit = println("Good morning!")
}

trait TraitC extends TraitA {
  def greet(): Unit = println("Good evening!")
}
class ClassA extends TraitB with TraitC

TraitBTraitCgreetメソッドの実装が衝突しています。この場合ClassAgreetはどのような動作をすべきなのでしょうか? TraitBgreetメソッドを実行すべきなのか、TraitCgreetメソッドを実行すべきなのか。多重継承をサポートする言語はどれもこのようなあいまいさの問題を抱えており、対処が求められます。

ちなみに、上記の例をScalaでコンパイルすると以下のようなエラーが出ます。

scala> class ClassA extends TraitB with TraitC
<console>:14: 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
             ^

Scalaではoverride指定なしの場合メソッド定義の衝突はエラーになります。

この場合の1つの解法は、コンパイルエラーに「Note: this can be resolved by declaring an override in class ClassA.」とあるようにClassAgreetをoverrideすることです。

class ClassA extends TraitB with TraitC {
  override def greet(): Unit = println("How are you?")
}

このときClassAsuperに型を指定してメソッドを呼びだすことで、TraitBTraitCのメソッドを指定して使うこともできます。

class ClassB extends TraitB with TraitC {
  override def greet(): Unit = super[TraitB].greet()
}

実行結果は以下にようになります。

scala> (new ClassA).greet()
How are you?

scala> (new ClassB).greet()
Good morning!

では、TraitBTraitCの両方のメソッドを呼び出したい場合はどうでしょうか? 1つの方法は上記と同じようにTraitBTraitCの両方のクラスを明示して呼びだすことです。

class ClassA extends TraitB with TraitC {
  override def greet(): Unit = {
    super[TraitB].greet()
    super[TraitC].greet()
  }
}

しかし、継承関係が複雑になった場合にすべてを明示的に呼ぶのは大変です。また、コンストラクタのように必ず呼び出されるメソッドもあります。

Scalaのトレイトにはこの問題を解決するために「線形化(linearization)」という機能があります。

線形化(linearization)

Scalaのトレイトの線形化機能とは、トレイトがミックスインされた順番をトレイトの継承順番と見做すことです。

次に以下の例を考えてみます。先程の例との違いはTraitBTraitCgreetメソッド定義にoverride修飾子が付いていることです。

trait TraitA {
  def greet(): Unit
}

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

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

class ClassA extends TraitB with TraitC

この場合はコンパイルエラーにはなりません。ではClassAgreetメソッドを呼び出した場合、いったい何が表示されるのでしょうか?実際に実行してみましょう。

scala> (new ClassA).greet()
Good evening!

ClassAgreetメソッドの呼び出しで、TraitCgreetメソッドが実行されました。これはトレイトの継承順番が線形化されて、後からミックスインしたTraitCが優先されているためです。つまりトレイトのミックスインの順番を逆にするとTraitBが優先されるようになります。以下のようにミックスインの順番を変えてみます。

class ClassB extends TraitC with TraitB

するとClassBgreetメソッドの呼び出しで、今度はTraitBgreetメソッドが実行されます。

scala> (new ClassB).greet()
Good morning!

superを使うことで線形化された親トレイトを使うこともできます

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

trait TraitB extends TraitA {
  override def greet(): Unit = {
    super.greet()
    println("My name is Terebi-chan.")
  }
}

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

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

このgreetメソッドの結果もまた継承された順番で変わります。

scala> (new ClassA).greet()
Hello!
My name is Terebi-chan.
I like niconico.

scala> (new ClassB).greet()
Hello!
I like niconico.
My name is Terebi-chan.

線形化の機能によりミックスインされたすべてのトレイトの処理を簡単に呼び出せるようになりました。このような線形化によるトレイトの積み重ねの処理をScalaの用語では積み重ね可能なトレイト(Stackable Trait)と呼ぶことがあります。

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

自分型

Scalaにはクラスやトレイトの中で自分自身の型にアノテーションを記述することができる機能があります。これを自分型アノテーション(self type annotations)や単に自分型(self types)などと呼びます。

例を見てみましょう。

trait Greeter {
  def greet(): Unit
}

trait Robot {
  self: Greeter =>

  def start(): Unit = greet()
  override final def toString = "Robot"
}

このロボット(Robot)は起動(start)するときに挨拶(greet)するようです。 Robotは直接Greeterを継承していないのにもかかわらずgreetメソッドを使えていることに注意してください。

このロボットのオブジェクトを実際に作るためにはgreetメソッドを実装したトレイトが必要になります。 REPLを使って動作を確認してみましょう。

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

scala> val r = new Robot with HelloGreeter
r: Robot with HelloGreeter = Robot

scala> r.start()
Hello!

自分型を使う場合は、抽象トレイトを指定し、後から実装を追加するという形になります。このように後から(もしくは外から)利用するモジュールの実装を与えることを依存性の注入(Dependency Injection)と呼ぶことがあります。自分型を使われている場合、この依存性の注入のパターンが使われていると考えてよいでしょう。

ではこの自分型によるトレイトの指定は以下のように直接継承する場合と比べてどのような違いがあるのでしょうか。

trait Greeter {
  def greet(): Unit
}

trait Robot2 extends Greeter {
  def start(): Unit = greet()
  override final def toString = "Robot2"
}

オブジェクトを生成するという点では変わりません。 Robot2も先程と同じように作成することができます。ただし、このトレイトを利用する側や、継承したトレイトやクラスにはGreeterトレイトの見え方に違いができます。

scala> val r: Robot = new Robot with HelloGreeter
r: Robot = Robot
scala> r.greet()
<console>:14: error: value greet is not a member of Robot
       r.greet()
         ^
scala> val r: Robot2 = new Robot2 with HelloGreeter
r: Robot2 = Robot2

scala> r.greet()
Hello!

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

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

もう1つ自分型の特徴としては型の循環参照を許す点です。

自分型を使う場合は以下のようなトレイトの相互参照を許しますが、

trait Robot {
  self: Greeter =>

  def name: String

  def start(): Unit = greet()
}

// コンパイルできる
trait Greeter {
  self: Robot =>

  def greet(): Unit = println(s"My name is $name")
}

これを先ほどのように継承に置き換えることではできません。

scala> trait Robot extends Greeter {
     |   def name: String
     | 
     |   def start(): Unit = greet()
     | }
<console>:13: error: illegal inheritance;
 self-type Robot does not conform to Greeter's selftype Greeter with Robot
       trait Robot extends Greeter {
                           ^

scala> // コンパイルエラー
     | trait Greeter extends Robot {
     |   def greet(): Unit = println(s"My name is $name")
     | }
<console>:14: error: illegal inheritance;
 self-type Greeter does not conform to Robot's selftype Robot with Greeter
       trait Greeter extends Robot {
                             ^

しかし、このように循環するような型構成を有効に使うのは難しいかもしれません。

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

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

Scalaのトレイトのvalの初期化順序はトレイトを使う上で大きな落とし穴になります。以下のような例を考えてみましょう。トレイトAで変数fooを宣言し、トレイトBfooを使って変数barを作成し、クラスCfooに値を代入してから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)
}

REPLでクラスCprintBarメソッドを呼び出してみましょう。

scala> (new C).printBar()
nullWorld

nullWorldと表示されてしまいました。クラスCfooに代入した値が反映されていないようです。どうしてこのようなことが起きるかというと、Scalaのクラスおよびトレイトはスーパークラスから順番に初期化されるからです。この例で言えば、クラスCはトレイトBを継承し、トレイトBはトレイトAを継承しています。つまり初期化はトレイトAが一番先におこなわれ、変数fooが宣言され、中身は何も代入されていないので、nullになります。次にトレイトBで変数barが宣言されnullであるfooと"World"という文字列から"nullWorld"という文字列が作られ、変数barに代入されます。先ほど表示された文字列はこれになります。

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

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

では、この罠はどうやれば回避できるのでしょうか。上記の例で言えば、使う前にちゃんとfooが初期化されるように、barの初期化を遅延させることです。処理を遅延させるにはlazy valdefを使います。

具体的なコードを見てみましょう。

trait A {
  val foo: String
}

trait B extends A {
  lazy val bar = foo + "World" // もしくは def bar でもよい
}

class C extends B {
  val foo = "Hello"

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

先ほどのnullWorldが表示されてしまった例と違い、barの初期化にlazy valが使われるようになりました。これによりbarの初期化が実際に使われるまで遅延されることになります。その間にクラスCfooが初期化されることにより、初期化前のfooが使われることがなくなるわけです。

今度はクラスCprintBarメソッドを呼び出してもちゃんとHelloWorldと表示されます。

scala> (new C).printBar()
HelloWorld

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

トレイトのvalの初期化順序を回避するもう1つの方法としては事前定義(Early Definitions)を使う方法もあります。事前定義というのはフィールドの初期化をスーパークラスより先におこなう方法です。

trait A {
  val foo: String
}

trait B extends A {
  val bar = foo + "World" // valのままでよい
}

class C extends {
  val foo = "Hello" // スーパークラスの初期化の前に呼び出される
} with B {
  def printBar(): Unit = println(bar)
}

上記のCprintBarを呼び出しても正しくHelloWorldと表示されます。

この事前定義は利用側からの回避方法ですが、この例の場合はトレイトBのほうに問題がある(普通に使うと初期化の問題が発生してしまう)ので、トレイトBのほうを修正したほうがいいかもしれません。

トレイトの初期化問題は継承されるトレイト側で解決したほうが良いことが多いので、この事前定義の機能は実際のコードではあまり見ることはないかもしれません。

1. 次世代ScalaコンパイラであるDottyでは、トレイトがパラメータを取ることができます。

results matching ""

    No results matching ""