Introduction
dwangoのチュートリアルをそのまま移行してきたもので、自分が勉強していくついでに編集して自分の用語に置き換えたりしていきます。
トレイト
私たちの作るプログラムはしばしば数万行、多くなると数十万行やそれ以上に及ぶことがあります。その全てを一度に把握することは難しいので、プログラムを意味のあるわかりやすい単位で分割しなければなりません。さらに、その分割された部品はなるべく柔軟に組み立てられ、大きなプログラムを作れると良いでしょう。
プログラムの分割(モジュール化)と組み立て(合成)は、オブジェクト指向プログラミングでも関数型プログラミングにおいても重要な設計の概念になります。そして、Scalaのオブジェクト指向プログラミングにおけるモジュール化の中心的な概念になるのがトレイトです。
この節ではScalaのトレイトの機能を一通り見ていきましょう。
トレイト定義
Scalaのトレイトは、クラスからコンストラクタを定義する機能を抜いたようなもので、おおまかには次のように定義することができます。
trait <トレイト名> {
(<フィールド定義> | <メソッド定義>)*
}
フィールド定義とメソッド定義は本体がなくても構いません。トレイト名で指定した名前がトレイトとして定義されます。
トレイトの基本
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
^
上記の例ではClassAとTraitAとTraitBを継承したClassCを作ることはできますがClassAとClassBを継承した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を実装したTraitBとTraitC、そしてTraitBとTraitCのどちらも継承した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
TraitBとTraitCのgreetメソッドの実装が衝突しています。この場合ClassAのgreetはどのような動作をすべきなのでしょうか? TraitBのgreetメソッドを実行すべきなのか、TraitCのgreetメソッドを実行すべきなのか。多重継承をサポートする言語はどれもこのようなあいまいさの問題を抱えており、対処が求められます。
ちなみに、上記の例を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.」とあるようにClassAでgreetをoverrideすることです。
class ClassA extends TraitB with TraitC {
override def greet(): Unit = println("How are you?")
}
このときClassAでsuperに型を指定してメソッドを呼びだすことで、TraitBやTraitCのメソッドを指定して使うこともできます。
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!
では、TraitBとTraitCの両方のメソッドを呼び出したい場合はどうでしょうか? 1つの方法は上記と同じようにTraitBとTraitCの両方のクラスを明示して呼びだすことです。
class ClassA extends TraitB with TraitC {
override def greet(): Unit = {
super[TraitB].greet()
super[TraitC].greet()
}
}
しかし、継承関係が複雑になった場合にすべてを明示的に呼ぶのは大変です。また、コンストラクタのように必ず呼び出されるメソッドもあります。
Scalaのトレイトにはこの問題を解決するために「線形化(linearization)」という機能があります。
線形化(linearization)
Scalaのトレイトの線形化機能とは、トレイトがミックスインされた順番をトレイトの継承順番と見做すことです。
次に以下の例を考えてみます。先程の例との違いはTraitBとTraitCのgreetメソッド定義に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
この場合はコンパイルエラーにはなりません。ではClassAのgreetメソッドを呼び出した場合、いったい何が表示されるのでしょうか?実際に実行してみましょう。
scala> (new ClassA).greet()
Good evening!
ClassAのgreetメソッドの呼び出しで、TraitCのgreetメソッドが実行されました。これはトレイトの継承順番が線形化されて、後からミックスインしたTraitCが優先されているためです。つまりトレイトのミックスインの順番を逆にするとTraitBが優先されるようになります。以下のようにミックスインの順番を変えてみます。
class ClassB extends TraitC with TraitB
するとClassBのgreetメソッドの呼び出しで、今度はTraitBのgreetメソッドが実行されます。
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のトレイトの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)
}
REPLでクラスCのprintBarメソッドを呼び出してみましょう。
scala> (new C).printBar()
nullWorld
nullWorldと表示されてしまいました。クラスCでfooに代入した値が反映されていないようです。どうしてこのようなことが起きるかというと、Scalaのクラスおよびトレイトはスーパークラスから順番に初期化されるからです。この例で言えば、クラスCはトレイトBを継承し、トレイトBはトレイトAを継承しています。つまり初期化はトレイトAが一番先におこなわれ、変数fooが宣言され、中身は何も代入されていないので、nullになります。次にトレイトBで変数barが宣言されnullであるfooと"World"という文字列から"nullWorld"という文字列が作られ、変数barに代入されます。先ほど表示された文字列はこれになります。
トレイトのvalの初期化順序の回避方法
では、この罠はどうやれば回避できるのでしょうか。上記の例で言えば、使う前にちゃんとfooが初期化されるように、barの初期化を遅延させることです。処理を遅延させるにはlazy valかdefを使います。
具体的なコードを見てみましょう。
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の初期化が実際に使われるまで遅延されることになります。その間にクラスCでfooが初期化されることにより、初期化前のfooが使われることがなくなるわけです。
今度はクラスCのprintBarメソッドを呼び出してもちゃんとHelloWorldと表示されます。
scala> (new C).printBar()
HelloWorld
lazy valはvalに比べて若干処理が重く、複雑な呼び出しでデッドロックが発生する場合があります。 valのかわりにdefを使うと毎回値を計算してしまうという問題があります。しかし、両方とも大きな問題にならない場合が多いので、特にvalの値を使ってvalの値を作り出すような場合はlazy valかdefを使うことを検討しましょう。
トレイトの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)
}
上記のCのprintBarを呼び出しても正しくHelloWorldと表示されます。
この事前定義は利用側からの回避方法ですが、この例の場合はトレイトBのほうに問題がある(普通に使うと初期化の問題が発生してしまう)ので、トレイトBのほうを修正したほうがいいかもしれません。
トレイトの初期化問題は継承されるトレイト側で解決したほうが良いことが多いので、この事前定義の機能は実際のコードではあまり見ることはないかもしれません。
おわり
次回は型パラメータと変位指定について勉強していきます。
参考
本文書は、CC BY-NC-SA 3.0