Edited at

オブジェクト指向言語としてGolangをやろうとするとハマること

埋め込み(embedded)に要注意というお話です。あるいは、GolangもC++のようなゼロオーバーヘッドを目指していると考えれば腑に落ちるよね、的な。

Goはオブジェクト指向言語っぽく使うことができます。次のような機能を提供しています。


  • interfaceを使ったコーディング

  • 埋め込み(embedded)を使った実装継承

インタフェースは次のような感じです。

// ポニーは歩ける

type Pony interface {
Walk()
}

// アースポニーも歩けるので、Ponyインタフェースに渡せる
type EarthPony struct {
}

func (ep *EarthPony) Walk() {
fmt.Println("歩くよ")
}

インタフェースはメソッド宣言しかかけません。実装は書けません。でも、定義されたメソッドを持てば、それはすべて「これの仲間だ」という感じで扱えます。上記の例だと、「Walk」というメソッドさえ持てばPonyとみなせる、という感じです。ダックタイピングですね。

埋め込みは次のような感じです。

// ペガサスはアースポニーの上位互換

type Pegasus struct {
EarthPony
}

func (u *Pegasus) Fly() {
fmt.Println("飛ぶよ")
}

func (u *Pegasus) Walk() {
u.Fly() // 歩けと言われても、ペガサスは飛ぶよ!
}

変数名のないメンバー変数みたいな感じで書きます。実体としては次のように動作します。


  • EarthPonyという構造体をEarthPonyという同名のメンバー変数として子供に持っているのと同じ

  • 変数名なしで、Pegasusの直接のメンバーとしてEarthPonyのメンバーにアクセスできる

  • 明示的に親クラスのメソッド実装を呼び出す必要があれば、EarthPonyという名前の変数に入っていると考えて使える(u.EarthPony.Walkみたいに)

どちらかというと、後者のメリットが強く感じられるため、前者は普段あまり強く意識をする必要はありません。でも、オブジェクト指向的な型が複雑な木構造の様子を呈し始めた時にGoは突然牙をむきます。

上位構造体のEarthPonyにメンバーメソッドを追加します。ついでに、EarthPony(≒Ponyインタフェースを完備した)をEmbedした構造体は走れるはずなので、Ponyインタフェースにも追加します。

// ポニーは歩けるし、走れるよ

type Pony interface {
Walk()
Sprint()
}

// 走るよ!走るのは歩くのを3倍ぐらい頑張ってるよ
func (ep *EarthPony) Sprint() {
ep.Walk()
ep.Walk()
ep.Walk()
}

この状態でペガサスを走らせたらどうなるでしょうか?Javaなどのオブジェクト指向言語に慣れた方は「Walkは子クラスでオーバーライドされたのだから、走れと言われたら3回飛ぶに違いない」と思われるでしょう。でも違います。3回歩きます。

Golangはどちらかというと性格はC++似なところもあるようです。C++はさんざん文句を言われたりしながらも一部に熱狂的なファンを生み、使われ続けているには理由があって、それは一途にパフォーマンス重視なところです。オブジェクト指向のポリモーフィズムを実現するには、常に「自分が誰か?」を問うて、適切なメソッドを呼び出す必要があります。具体的には、関数ポインタテーブルを毎回参照して呼ぶ関数を決定する必要があるということです。C++は違います。例えJavaやRubyな人に散々Disられても、C++が一番大事にしていることは「ゼロオーバーヘッド」です。C言語よりも遅くちゃいけないんです。「このメソッドはオーバーライドするかもしれないよ?(virtual宣言)」があるまでは、なるべく呼び出し先のメソッドは静的にジャンプアドレスを固定しようとします。

Golangも基本的にそのような思想のようです。相手が決まっている場合は、インタフェースとか関係ありません。埋め込みはあくまでも「名前なしでアクセスできるメンバー変数」というシンタックスシュガーと考えるべきです。「自分の型が決定できない」ということがないかぎりは、「名前による参照」は発生しません。

// 走るよ!走るのは歩くのを3倍ぐらい頑張ってるよ

func (ep *EarthPony) Sprint() {
ep.Walk()
ep.Walk()
ep.Walk()
}

上記のメソッドを呼ぶ時は、Walk()メソッドのレシーバーは、EarthPonyである、とGoは判断します。たとえ、Walkという呼び出し規約を定義したインタフェースがあって、Pegasusもそれに準拠している・・・みたいなのは関係ありません。レキシカルに挙動が決定されます。このコードの断片だけを取り出してみれば、PegasusもPonyもありません。

これはGoの中での埋め込みが、「メンバー変数の特殊系」でしかなく、EarthPonyとPegasusに特別な親子関係ができたとは解釈されないことから起きます。

オーバーロードを実現するには、インタフェースとしてきちんと明示的に定義されている変数に対してメソッドを呼び出す必要があります。Sprintメソッドを最短で直すとすると、次のような形になります。

// 走るよ!走るのは歩くのを3倍ぐらい頑張ってるよ

func (ep *EarthPony) Sprint(self Pony) {
self.Walk()
self.Walk()
self.Walk()
}

これを呼ぶには、レシーバーの構造体と同じものを引数に渡します。

rainbowDash.Sprint(rainbowDash)

冗長だけど仕方がありません。ここまで来ると、レシーバーの変数を使っていないので、もうメンバーメソッドにする必要はありません。インタフェースを使った実装継承ができないのでレシーバーを書く理由はもうありません。ムダを省くと、インタフェースに対するメソッドは、メソッドではなくて、引数にターゲットとなるメンバーを持つ独立した関数になります。

// 走るよ!走るのは歩くのを3倍ぐらい頑張ってるよ

func Sprint(self Pony) {
self.Walk()
self.Walk()
self.Walk()
}

「ゼロオーバーヘッドにできるチャンスがあればいつでもそれを狙ってくるのがGo」と考えれば(C++経験がある人は)すっきりするでしょう。そうでなければ、「一度子クラスのオブジェクトとして明示された変数(上記の最初のSprintメソッドのレシーバーも含む)に入ってしまったら、親クラスとしてのステータスはすべて失い、アップキャストはできない」と考えればいいかもしれません。親クラスと思っていたけど実体は単に、プロパティとして持つ子供のオブジェクトでしかないってことですからね。

ちなみに、洞察力の強い方はもうお分かりの通りだと思いますが、アースポニーとかペガサスとかユニコーンとかはマイリトルポニーからアイディアを拝借しました。みかんとりんごよりもいいでしょ?みんなで第3期以降の日本語放送を祈りましょう。

追記

mattnさんから新たな闇のテクニックが!