【Goで理解するオブジェクト指向設計 #2】 ─ 汎化~「良い抽象」こそが最強の武器である~
汎化(Generalization)とは?
まず、汎化の意味を考えてみてください。
汎化とは、「コードの再利用」のことではありません。
それは、複数の具象的なモノ(Dog, Cat)から、共通の概念や振る舞いを見出し、それに 「意味づけ」 をすることです。
『「犬 is-a ペットである」「猫 is-a ペットである」という共通の 「is-a」関係 を見出すこと』
これが、前回の記事で見たポリモーフィズム(多態性)の基盤となります。
このように、汎化とはis-a関係の発見を通した「概念の一般化(抽象化)」を実現することが真の目的です。
汎化がもたらす「最大のメリット」とは?
では、この「汎化」という思考が、私たちに何をもたらすのでしょうか?
ここで、前回の記事の多態性のセクションの実装を思い出してください。
switch文の実装
OwnerがDogやCatの 「具象」 の詳細(typeフィールド)に依存していました。これは密結合であり、具象が増えるたびにOwnerが修正を余儀なくされる「不安定」な設計でした。
ポリモーフィズムの実装
OwnerはPetという 「抽象」 にのみ依存していました。
まさに、このswitch文を消滅させたプロセスこそが、「汎化」の力です。
私たちが「DogもCatも『ペット』である」という共通の is-a関係を見抜き、「Petインターフェース」という「意味づけ(抽象化)」を行った行為。あれこそが汎化なのです。
その結果、OwnerはDogやCatという「具象」の詳細に依存するのをやめ、「Pet」という「抽象」にのみ依存できるようになりました。
そして、この「抽象(Pet)」こそが、システムがどんなに拡張されても変わることのない 「安定した抽象」 なのです。
この「安定した抽象」を定義し、依存の方向をコントロール可能にすること。これこそが汎化がもたらす最大のパワーです。
では、その「良い抽象(あるいは悪い抽象)」とは一体何なのでしょうか?
どうすれば「良い抽象」を見つけられるか?
汎化の力を正しく使うためには、「良い抽象」を見つける必要があります。
しかし、多くの開発者がここで罠に陥ります。
「万能すぎる抽象」の誘惑(悪い抽象)
PetShop(ペットショップ)で売るCat(猫)をモデル化する際に、こう考えてしまうかもしれません。
「世の中にはWildCat(野良猫)もいる。だから、Catは必ずしもPet(ペット)ではないな…」
そして、「動物(Animal)」という、より万能で「完璧な」抽象概念を作り出そうとします。
真実:コンテキスト(文脈)こそが全て
しかし、そのシステムは「ペットショップ」という文脈(コンテキスト)で動いていますその文脈において、「野良猫」という概念はノイズ(無関係な情報)でしかありません。
システム化対象の問題を逸脱して万能な抽象を考えた時点で、そのシステムは機能不全に陥ります
汎化を継承で実装する際の「最大の罠」
前のセクションで、「汎化(is-a関係)」が「良い抽象」を生み出し、システムを安定させるというメリットを解説しました。
この「汎化」を実現するために、PHPやJavaといった多くのオブジェクト指向言語では「継承」(abstract classなど)を利用するのが一般的です。
しかし、この「継承」という機能そのものに、カプセル化を破壊し、密結合を生み出す「最大の罠」が潜んでいます。
汎化とコードの再利用を「ごちゃ混ぜ」にするな
PHPのような言語が持つ「継承」の罠とは、言語の仕様として、目的が全く異なる2つのことをごちゃ混ぜにできてしまう点にあります。
-
汎化(
is-a):抽象的な「契約」を定義すること。(例:abstract public function speak()) -
コードの再利用:共通の「実装」を提供すること。(例:
public function eat() { ... })
例えば、Petという抽象クラスをPHPで書くと、この「ごちゃ混ぜ」が簡単に発生します。
<?php
// "Pet"という抽象クラス
abstract class Pet {
// ① 汎化(抽象)の側面
// 子クラスに「鳴く」という振る舞いを強制する
abstract public function speak(): string;
// ② 実装の再利用の側面
// ★★★罠★★★
// 「食べる」という共通の実装を、親クラス(抽象)が
// 持ってしまっている。
public function eat(): string {
return "is eating standard food.";
}
}
このコードでは、「抽象」であるはずのPetクラスが、「食べる」という 具体的な「実装」 を持ってしまっています。
これがなぜ最悪の密結合を引き起こすのか?
Owner(飼い主)がPet(抽象)に依存しているつもりでも、Petが持つeat()メソッド(実装)にも間接的に依存してしまうからです。
もしeat()の共通実装を変更したら、そのeat()を使っていたDogやCat、そしてOwnerにまで影響が及ぶ可能性があります。
Go言語が選んだ「賢い分離」─ インターフェースとコンポジション
Goは、この継承の危険性を避けるため、古典的な継承が持っていた2つの役割を、言語レベルで強制的に分離しています。
1. 汎化(is-a) → interface が担当
Goのinterfaceは 純粋な「抽象」 です。メソッドのシグネチャ(契約)しか定義できません。
PHPのabstract classのように、実装や状態(フィールド)を持つことができないため、「ごちゃ混ぜ」の発生のしようがないのです。
(Goのコード例:pet/pet.go)
package pet
// 「汎化」だけを担当する、純粋な「契約」
type Pet interface {
Speak() string
Eat()
}
2. コードの再利用(has-a) → コンポジション(埋め込み) が担当
一方で、純粋に「コードの再利用」がしたい時のために、Goはコンポジション(埋め込み)を用意しています。
これは「is-a(の一種だ)」ではなく、「has-a(を持っている)」という、より疎結合な関係性です。
(Goのコード例:behavior/common.go)
package behavior
// 「食べる」という「実装」だけを担当する「部品」
// Petインターフェースのことは何も知らない
type CommonEater struct {
Name string
}
func (e *CommonEater) Eat() {
fmt.Printf("%s is eating standard food.\n", e.Name)
}
Goによる「疎結合」な解決
この2つの道具(interfaceとcomposition)を組み合わせることで、PHPで密結合していた問題を、Goは以下のようにクリーンに解決します。
(Goのコード例:pets/dog.go)
package pets
import "example.com/pet"
import "example.com/behavior"
// Dogストラクト
type Dog struct {
behavior.CommonEater // ② コードの再利用(has-a)
}
// ① 汎化(is-a)
// pet.Petインターフェースの「契約」を実装する
func (d *Dog) Speak() string {
return "Woof!"
}
// Eat()の契約も、埋め込んだ部品が満たしてくれる
Ownerがpet.Petインターフェースを使う時、OwnerはPetという純粋な「抽象」にのみ依存します。
DogやCatが、そのEatメソッドをどう実装しているか(CommonEaterを部品として使っているか、独自に実装しているか)を、Ownerは一切知る必要がありません。
Goは、言語の設計によって「汎化」と「実装の再利用」をごちゃ混ぜにすることを防ぎ、開発者に疎結合な設計を強制します。これこそが、Goが選んだ「賢い分離」なのです。
Discussion