この記事は Go7 Advent Calendar 2019 11日目の穴埋め記事です。
背景
以前、 @mattn さんが↓のようなツイートをされていまして、
embedded と継承は(似てる様な気がするのは分かりますが)一緒にしない方がいいと思います。https://t.co/zalv2y5TVG
— mattn (@mattn_jp) August 15, 2019
その際に「ほんそれ!」と激しく同意しながらも、ではなぜ「embeddedを継承と思うべきではないのか」が自分の中でうまく言語化できていませんでした。この機会に整理しておこうと思った次第です。
復習:Embedded Field とは
次の Mom
という struct を例に考えてみましょう:
type Mom struct {
name string
}
func newMom(n string) *Mom {
m := new(Mom)
m.name = n
return m
}
func (m *Mom) Hello() string {
return m.name + "でございまーす"
}
Mom
は name
というフィールドだけを持つシンプルな struct です。また、文字列を返す Hello
というメソッドを持っています。簡単ですね!
これに、次の Kid
という struct を加えてみます。
type Kid struct {
Mom // ←これが embedded field
nickname string // ←Kid独自のフィールド
}
func newKid(n, m string) *Kid {
k := new(Kid)
k.name = n
k.nickname = m
return k
}
Kid
の定義では、フィールド名なしで Mom
と型名だけが書かれています。これがembedded field です。メソッドは何も定義されていません。
で、これを次のようなmain関数で実行すると、
func main() {
mom := newMom("サザエ")
kid := newKid("タラオ", "タラちゃん")
fmt.Println(mom.Hello())
fmt.Println(kid.Hello())
}
このように表示されます:
サザエでございまーす
タラオでございまーす
(The Go Playgroundで実行する)
Kid
にはなにもメソッドを実装していなかったのに、なんのエラーもなく Hello()
が呼び出せていますね。しかもそれは Mom
の Hello
メソッドです。
そうです。 Kid
は Mom
を「継承」しているように見えますね。
では、ここで次のメソッドを追加してみましょう:
func (k *Kid) Hello() string {
return k.nickname + "ですう"
}
そして再び上のmain関数を実行すると、
サザエでございまーす
タラちゃんですう
(The Go Playgroundで実行する)
表示が変わりましたね。
はい、皆さんのご期待のとおり、Hello
メソッドがオーバーライドされました。
ここまでくればもう皆さん、こんな顔↓してますよね?
これでもうGoでオブジェクト指向なプログラムをバリバリ書いちゃうぜ!って思うじゃないですか?1でも、ここからが罠なんです。
罠1: フハハハハ、代入できると誰が言った!
「継承」のつもりで、当たり前のようにこんなコードを書くと、
var m *Mom
m = newKid("タラオ", "タラちゃん")
コンパイルエラーになります:
./prog.go:37:4: cannot use newKid("タラオ", "タラちゃん") (type *Kid) as type *Mom in assignment
(The Go Playgroundで実行する)
Mom
型の変数に Kid
型の値は代入できません。
こう言ったほうがいいかもしれませんね。親の型の変数に派生型の値は代入できません。2
よくオブジェクト指向言語における継承は「is-a」関係だと言われるように、「子型 is a 親型」として親の型が使える場所では子の型が代わりに使えるんでした。でも、Goではそれはできません。
それでは embedded field のメソッドをオーバーライドしても多相性3が実現できないじゃないか!と思われるでしょう。そうです、Go の embedded field は多相性を提供しません。(※Goで多相性を実現する方法は後述します)
罠2: 継承したかと思った?残念!丸投げちゃんでした!
上の Kid
の例を見ると、Kid
は name
と nickname
の2つのフィールドを持っているように思えますね。もちろん、name
は Mom
から「継承」したフィールドです。
そう思うと、Kid
を初期化するときに短くこうやって↓書きたくなるじゃないですか:
kid := &Kid{name: "タラオ", nickname: "タラちゃん"}
しかし、こんなふうに書こうものなら、
./prog.go:36:14: cannot use promoted field Mom.name in struct literal of type Kid
(The Go Playgroundで実行する)
コンパイラに怒られます。
ちゃんとコンパイルを通すためには、こう書きます:
kid := &Kid{
Mom: Mom{name: "タラオ"},
nickname: "タラちゃん",
}
(The Go Playgroundで実行する)
なんか、突然 Mom
という名前のフィールドが出てきましたね。これが embedded field です。
Kid
は Mom
の型名と同じフィールド名で、実際にフィールドに Mom
型の値を持っているんですね。そうです。Kid
と Mom
は「is-a」関係ではなく「has-a」関係なのです。
これまでの例では裏側で何が起こっていたのかというと、kid.name
と書くとコンパイラが kid.Mom.name
に読み替えてくれていたというわけです。要は省略形です。
同様に kid.Hello()
も kid.Mom.Hello()
と省略できるようにいいかんじにやってくれていただけです。コンパイラが、次のようなラッパーメソッドを自動的に生成してくれていると思ってもいいでしょう:
func (k *Kid) Hello() {
k.Mom.Hello()
}
罠3: embeddedの前にオーバーライドなど無力!
もうおわかりのように、Kid
は Mom
から何も「継承」していません。ただ、同名のフィールド/メソッドの処理を Mom
フィールドの値に丸投げしているだけだったのです。
従って、Mom
にこんなメソッド↓を追加して、
func (m *Mom) Sing() string {
return m.Hello() + "!お魚くわえたドラ猫~♪"
}
次のように実行すると、
func main() {
mom := newMom("サザエ")
kid := newKid("タラオ", "タラちゃん")
fmt.Println(mom.Sing())
fmt.Println(kid.Sing())
}
どう表示されると思います?
答えは次の通り:
サザエでございまーす!お魚くわえたドラ猫~♪
タラオでございまーす!お魚くわえたドラ猫~♪
(The Go Playgroundで実行する)
どうです?予想通りでしたか。
kid.Sing()
の中でも Mom
の Hello
の実装が呼び出されていますね。でも、 Kid
は Hello
をオーバーライドしたんじゃなかったでしたっけ?
これも、上記のラッパーの要領で読み替えればわかりやすいと思います:
kid.Sing()
→ kid.Mom.Sing()
そうです。Sing
は Kid
のメソッドではなく、kid
のフィールドの値である Mom
のメソッドなのですから、その中では Mom
の Hello
が呼び出されるのが正しいのです。
継承じゃないよ、委譲だよ
このように、embedded field は継承ではなくただの「丸投げ」だったわけですが、このように自分の処理を他のオブジェクトに丸投げするパターンを、オブジェクト指向言語ではよく「委譲(delegation)」と呼びます4。
そうです。Goは言語機能として「継承」を取り入れず、「委譲」を言語機能として直接サポートするという選択をしたのですね。
『Effective Java』でも語られているように、近年はオブジェクト指向言語でも「継承より委譲」と言われることが多くなっています。
ではなぜ、「継承より委譲」のほうが良いのでしょうか?
「実装の再利用」と「多相性」とを分離する
Wikipedia によると、オブジェクト指向で「継承」を使う際には次の2つの目的で使われます:
- コードの再利用
- 派生型による共用と置換、多態性5
私個人の見解ですが、「継承」はこの2つの機能が同時に入ってしまっていることが問題だと感じています。たとえば、既存コードを再利用したかっただけなのに、それに継承を使ってしまったために、思わぬ使われ方(派生元クラスのコンテキストで使われるとか、さらに派生先でオーバーライドされた結果カプセル化が壊れるとか)がされてしまうということが起こります。逆に、多相性が必要だっただけなのに、不必要な実装まで継承してしまうといったこともありえます。
本来「継承」は不必要なところに「継承」を使ってしまうと事故ることが多いので、実装の再利用には委譲を、多相性だけが必要なところにはインターフェイスを使おう、そして本当に必要なときにだけ継承を使おうね、というのがベストプラクティスとなっていったわけです。
一方でGoでは、実装の再利用はembedded fieldで、多相性はインターフェイスで実現する、と設計の段階から完全に分離しました。これは、不必要な場面で継承を使ってしまって事故るということが、設計上起こらないように工夫された結果なのですね。6
「再利用の使者・embedded」「多相性の使者・interface」「ふたりはプリ(ry」
しかしもちろん、この2つが分けて提供されていたとしても、組合せて使うことで「継承のようなこと」が実現できるように工夫されています。
例として、Kid
も自分流の自己紹介をしてから歌うように上の例を修正してみましょう。
まず、多相性を使うためインターフェイスを定義します:
type Helloer interface {
Hello() string
}
この Helloer
は Hello
メソッドを持っている値すべてを表します。
そして Sing
はこの定義した Helloer
を引数に受け取る関数として次のように定義します:
func Sing(h Helloer) string {
return h.Hello() + "!お魚くわえたドラ猫~♪"
}
そしてこれを次のような main
関数で実行しますと:
func main() {
hs := []Helloer{
newMom("サザエ"),
newKid("タラオ", "タラちゃん"),
}
for _, h := range hs {
fmt.Println(Sing(h))
}
}
次のように、ちゃんと期待通りの結果になりましたね:
サザエでございまーす!お魚くわえたドラ猫~♪
タラちゃんですう!お魚くわえたドラ猫~♪
(The Go Playgroundで実行する)
というわけで、Goで「継承のようなこと」をするには、embedded だけでは足りません。embedded と interface、この2つが揃ってはじめて「継承のようなこと」ができる、とおぼえておいてくださいね!
-
かくいう自分も、最初に A Tour of Go を終えて「完全に理解した」顔をして、従来の発想のまま既存プログラムの移植をガーっと書き始めました。んで、300行くらいのクラス構成を書き上げてからコンパイルを通してみようとしてみて、「ん?これ全然アカンやん!」となって再入門したクチです。 ↩
-
念のため、Go言語としては親の型とか派生型といった概念はないので、Goの世界においては不正確な表現です。 ↩
-
ポリモーフィズム (Polymorphism)、多態性とも。ここでは、部分型付け(部分型多相)のことをさす。要は、実行時の値によって、実際に呼ばれるメソッドが変わるアレです。 ↩
-
本来の用語としては「委譲」ではなく「転送 (forwarding)」が正しいという話もありますが、もはやこの用語で定着してしまったので、ここでは委譲で通します。 ↩
-
原文ママ。多相性と同義。 ↩
-
昨年のアドベントカレンダーでは、Goは「例外」の役割を返却値の型と大域脱出の2つに分離したのだ というような筋書きで1本書いたのですが、実は今回もそれと同じような話になっています。Goの言語設計は、言語が提供すべき機能を注意深く分解して、ひとつの言語要素が複数の機能をまぜて提供してしまわないように本当に注意深く設計されていると本当に感心します。これが、Goが学びやすく、そして失敗をおかしにくい言語になっている秘訣だと思っています。その一方で「なんとこれはこんなことにも使えます!」みたいな驚きがないため、そういうトリッキーな派手さ/便利さが好きな人からは魅力的に映らないんだろうな、というのもよくわかります。 ↩