uehaj's blog

Grな日々 - GroovyとかGrailsとかElmとかRustとかHaskellとかReactとかFregeとかJavaとか -

Groovy 2.3.0-betaが出たのでtraitを触ってみたメモ

Groovy 2.3.0-beta-1beta-2が出たので新機能traitをかるく触ってみました。

注意! 以下は現時点で2.3.0-beta-1と2の振舞いとドキュメントを調べた限りの情報です。正式リリースに向けて変更される可能性があります。

traitの概要と目的

Groovyのtraitは、一言で言って「実装の多重継承」を可能とする仕組みです。詳しくはこちらの本家ドキュメント(英語)をどうぞ。

GroovyおよびJava 7までのJavaでは、インターフェースは多重継承することができましたが、クラスは多重継承できませんでした。実装、すなわちメソッド本体の定義や、非public static finalなフィールド(インスタンス変数)定義はクラスでのみ可能であり、そしてクラスの継承は単一継承のみ(親が1つだけ)が可能なので、実装の継承は、ツリー型に制限されていました。北斗真拳と南斗聖拳の系列があったときDAG型の「両方の継承者」という統合はできないわけです(少なくとも実装の意味では。ジャギは実装は継承してない。上っ面のインターフェースのみです謎)。とにかく、いままでは実装の多重継承はできなかったということです。

Groovyのtraitでは以下の両方ができます。

  • クラスのようにメソッド本体やフィールドを定義する
  • インターフェースのように多重継承する

結果として、実装の多重継承ができるようになりました。

Java8のインターフェースでは、デフォルトとしての実装(メソッド本体)が定義でき、実装の多重継承もできるので、近いものがありますが、以下のような差異があります。

  • groovyのtraitではインスタンス変数(フィールド)も定義できる。つまりtraitで定義されたメソッド群の間でインスタンス固有のデータを共有・保持できる。(Scalaのtraitも同様)
  • groovyのtraitは、メソッドのデフォルト実装を定義するだけではなく、親traitのメソッドをオーバーライド定義することもできる。
  • groovyのtraitで定義したメソッドは、親クラスや他のtraitのメソッドに優先する(@ForceOverride使用。(Groovy.2.3正式版では@ForceOverrideはデフォルトかつ必須の動作になりアノテーションは削除された。) Scalaのabstract overrideの動作)
  • groovyのtraitはJava 8以前のJava VM(Java 6,7..)でgroovyを実行する場合でも利用できる(Scalaのtraitも同様)

GroovyのtraitはScalaのtraitと極めて良く似ています。traitの定義と静的な使用についてはscalaのそれとほぼ同様です*1。差異は、主に動的なtraitの実装に関するところであり、後述します。

表にまとめるとこんな感じ。

Java/Groovyクラス Java(〜Java7)およびGroovyのインターフェース Java8以降のインターフェース Groovyのtrait Scalaのtrait
定義に使用するキーワード class interface interface trait 同左
実装の単一継承 × ○(メソッドのデフォルト実装のみ) ○(フィールド使用・定義およびtrait側メソッド優先も可) 同左
実装の多重継承 × × ○(メソッドのデフォルト実装のみ) ○(フィールド使用・定義およびtrait側メソッド優先も可) 同左

コード例

たとえばこんな感じです。

trait A {}

trait B extends A {}

trait C extends A {}

class D implements B, C {}

詳しくはドキュメントをみてください。
以下もよろしければどうぞ。

www.slideshare.net

衝突!

さて実装の継承においては、多重継承だろうが単一継承であろうが、衝突というものを考慮する必要があります。

継承というのは親のフィールドやメソッドを引き継ぐということなので、子供は親のメソッドやフィールドを持っているかのように振る舞う必要があります。

単一継承であれば、お爺ちゃんと父で衝突すれば(名前が同じで実装が異なるようなものがあれば)、子供は(子供自身でオーバーライドしない限り)より近い祖先である父のものを持っているかのように振舞います。

多重継承の場合、加えて、父親(もしくはその祖先)と母親(もしくはその祖先)がそれぞれ同名で異なるメソッド実装やフィールドを持っているとき(=衝突)の考慮が必要ですが、子供はどっちのものを持っているかのように振る舞うべきでしょうか。

Groovyのtraitにおける衝突の解決もしくは回避

メソッド名に関しては、Groovyのtraitの衝突解決のデフォルトは指定したトレイトの順に、後勝ちです。つまり「implements 父,」もしくは後述の形式「withTrait(父,)」のように複数トレイトを親として指定したときに、所属トレイトを明示指定しないメソッド名が衝突していれば、指定がより後ろである側のメソッドが指定されたとみなされます。後勝ちが嫌な場合、子供でオーバーライド定義して明示的に後じゃない方を呼ぶこともできます(「トレイト名.super.メソッド名」で指定する)。ちなみにScalaでは潜在的に衝突しているとき、衝突しているメソッドを子供でオーバーライドしないとエラーになり、常に明示的な手動の衝突の解決が求められるそうですが、Groovyではそんな配慮は無いので「意図せざる偶然の衝突」に注意が必要になります。

コード例としてはこういうことです。

trait A { String foo(){"A"}}
trait B { String foo(){"B"}}

class C implements A,B {
}

class D implements B,A {
}

c = new C()
assert c.foo() == "B"
d = new D()
assert d.foo() == "A"

class E implements A,B {
    String foo(){A.super.foo()}
}

d = new E()
assert d.foo() == "A"

フィールドに関しては、フィールドの値を保持する変数名のリネームによって衝突が事前回避されます。トレイトで定義したフィールドは、そのトレイトの実装時に、

トレイトのFQCNの'.'を'_'に置換したもの+'__'+フィールド名

にリネームされた変数名のフィールドが、子クラス側で暗黙に定義されます。フィールドがprivateの場合も、publicの場合もいずれもです。子クラス側のインスタンスのフィールド名を直接指定する場合、後勝ちもクソもなく、このリネームされた名前で常に明示する必要があります。(リネームされる前、トレイト内のメソッドの定義においては、リネーム前の本来のフィールド名をソース記述上は使えます。しかしリフレクションとかでは違う名前になっているでしょうから注意)。

もっとも、フィールドがprivateであれば外部から指定することはできない(建前上不可視*2 )ので、問題になるとすればフィールドがpublicな場合のみでしょう。以下は例です。

trait A {
    public int a;
}

trait B extends A {
    public int b;
}

trait C extends A {
    public int c;
}

class D implements B, C {}

def d = new D()
println d.A__a
println d.B__b
println d.C__c

このようなフィールド名のリネームは、やや不自然に感じるかもしれませんが、フィールドを直接参照するのではなくgetter/setterを通じて扱えば、メソッド名の解決の話になりリネームされたフィールド名は隠蔽されるので、プログラマが意識することはなく、実際問題としては意識する機会はあまり多くないでしょう。

重要なのは、このリネームルールから、継承経路上に表われるすべてのトレイト実装は、そのFQCNによって単一化されるということです。ダイヤモンド継承(菱形継承)問題はこの形で解決されています。C++で言えば「仮想基底からの継承(仮想継承)」だけの扱いになるわけです。まあ、それでいいね。なんぼか直観的です。Scalaではどうなるかは知らない。

trait定義

classやinterfaceの代わりにキーワードtraitを使用します。それでだいたい期待通りに動作するでしょう。traitのコンパイル結果は、実体としては(クラスファイル上は)インターフェースといくつかの内部ヘルパクラス群になります。Javaからはトレイトはインターフェースとして見えるので、Groovyのtraitを実装しているGroovyのインスタンスJavaから扱えます。Groovyのtraitをtraitとして継承したクラスをJavaで定義するのはおそらく無理でしょう(traitを単なるインターフェースとしてJava側でimplementsすることはたぶんできる)。

静的なtrait実装

クラス定義時にトレイトを(インターフェースのように)implementsします。それでだいたい期待通りに動作するでしょう。

実行時のtrait実装

Groovyのtraitは、実行時にそれを実装したオブジェクトを作り出すことができます。Scalaも表面上似たことができるのですが、ここはGroovyとScalaで考え方が一番違うところです。

Scalaの場合を参考に

Scalaでtraitをnew時に実装するには、new..with構文を使用します。この構文は無名内部クラス構文によるインスタンスnewの拡張と言えましょう。無名内部クラスのように、内部的にクラスを生成した上でそのインスタンスを作るのです。Javaの無名内部クラスによるnewではできないこととして、複数のtraitを実装(with)する、対象クラスのサブクラスである無名内部クラスのインスタンスを生成します。これは完全に静的なものです。

たとえば、Xがクラス、Tがtraitだとしたとき

var x:X = new X() with T

であれば、xはXを継承したクラスのインスタンスであり、同時にトレイトTを継承(Scala的にはmixin)したインスタンスです。帰結として、finalクラスにはtraitを実装させることはできません。XがfinalならXのサブクラスが作れないからです。

Groovyでの実行時トレイト実装

Groovyでは、newと独立したタイミングで、既存の任意のインスタンスに対して、traitを実装した新規のプロキシインスタンス*3を作成します。

def x = new X() as T

こうです。トレイトが複数の時はこう。

def x = new X().withTrait(T1,T2)

new Xしてはいますが、そのインスタンスに対するメソッド呼び出しです。だから上はこうも書けます。

def tmp = new X()
def x = tmp as T
def tmp = new X()
def x = tmp.withTrait(T1,T2)

xはトレイトであるTやT1,T2を実装する、実行時に生成される動的プロキシのクラスのインスタンスです。Scalaとの重要な違いとして、このときのtmpとxはインスタンスが別で、かつxはXのサブクラスのインスタンスではない*4ということです。xはtmpに対するプロキシで、インスタンスのライフサイクルが違うのです。一つのtmpに対して複数回asやwithTraitで複数のプロキシを得ることもできるでしょう。

構文上の類似性があるので混同してしまうかもしれませんが、Scalaのnew時のtrait実装が無名クラス構文によるnewの拡張的な静的なものであるのに対して、Groovyの実行時trait実装はデコレータの生成であると言えます。つまり全然違います。traitの定番(?)の用途であろうDCIへの適用、つまりtraitをロールとして使用する際においては、Groovyの動作もできた方が親和性が高いとワシは思います。

Stringなどのfinalクラスにtraitを注入できる、という点も結果的な差異になります。まあ本当に注入したいのか、というのは置いておいて、ですが*5

なお、proxyから元のオブジェクトのproxyTargetというプロパティを取得できます(GROOVY-6692GROOVY-6695)。

落穂拾い

Groovyのtraitは、metaClass.mixinメソッドの上位互換*6的な代替であると見ることもできるかもしれません。metaClassは静的Groovy(@TypeChecked,@CompileStatic)配下では使えませんが、traitは使えるので、静的型チェックに親和性が高いバージョンのmixinと見ることができるかもしれません。さらに憶測ですが、traitの実行時実装の動作で奇妙にも思えるところは、metaClass.mixinのユースケースをカバーするためにそうなっているのかもしれません。

とりあえずおしまい。

*1:たぶん。Scalaは良く知らないので間違いがありましたらご指摘をお願いします。

*2:実際は思い切り見えるがw

*3:この機能は実はインターフェースに関してもともと従来のGroovyにある。「"String" as Runnable」とかやれる!!!。

*4:Groovy 2.3beta1,2では、xに対するメソッドコールはx的にmethodMissingなものについてはtmpに転送されます。そういうディスパッチを行なう「proxy」なのです。しかしXのインスタンスではなく代入互換ではない、さらにDGM非対応とか、mehodMissingなのでXよりもObjectのメソッドequals,hashCodeなどが優先されるとか、静的型チェックには対応できないとか、初学者には混乱を招き得る点がある気もする。

*5:DGMや拡張メソッド、metaClassによるメソッド注入では状態が単純には保持できないので意味あるかといえば意味はある。

*6:mixinはインスタンスを書き替えproxyを生成しないので厳密には上位互換ではない。