🙉

「継承は人類には早すぎた」— シニアエンジニアに学ぶオブジェクト指向の弱み

に公開2

「継承は人類には早すぎた」— シニアエンジニアに学ぶオブジェクト指向の弱み

「Abstract classとInterface + Trait、どっちを使うべきですか?」

実務でこの質問をしたとき、Geminiはこう答えました。

最近の設計トレンドは「継承(Inheritance)よりも構成(Composition)」です。Abstractクラスを作ると、将来的に「別の親クラスからも継承したくなった」時に詰んでしまいます。

なるほど、と思いつつチームのシニアエンジニアに共有したところ、こんなコメントが返ってきました。

「実装の継承、人類には早すぎた道具だと思ってます笑」

自分は継承使っていてもまだ困ったことないな、、、と思い「継承 vs 構成」について改めて調べてみました。今回はその学びを共有します!


なぜ「継承」は避けられるようになったのか

オブジェクト指向を学ぶと、真っ先に出てくるのが「継承」です。

「犬は動物を継承する」「社員は人を継承する」—教科書的には綺麗に見えます。

でも、実際のプロダクト開発で継承を多用すると、いくつかの罠にハマるそうです。

1. 壊れやすい基底クラス問題

親クラスを少し修正しただけで、それを継承するすべての子クラスが壊れる可能性があります。

// 親クラスのメソッドを変更したら...
class Animal {
    public function move() {
        // 実装を変更
    }
}

// すべての子クラスに影響が波及する
class Dog extends Animal { }
class Cat extends Animal { }
class Bird extends Animal { }

影響範囲が予測しづらく、「触るのが怖いコード」が生まれます。

2. 不自然な継承関係

有名な例が「ペンギン問題」です。

class Bird {
    public function fly() {
        // 空を飛ぶ
    }
}

class Penguin extends Bird {
    public function fly() {
        throw new Exception("ペンギンは飛べません");
    }
}

「鳥は飛ぶ」という前提で設計したのに、飛べない鳥が出てきて破綻する。継承は概念的に100%同一である場合にしか使えないのです。

3. 多重継承の壁

「空を飛ぶ」「泳ぐ」「走る」—これらの能力を組み合わせたいとき、継承では対応が難しくなります。

多くの言語では多重継承ができない(PHPやJavaなど)ため、継承ツリーが複雑化するか、妥協した設計になります。


「構成(Composition)」という解決策

構成とは、「機能を部品(パーツ)として持つ」 という考え方です。

「〜である(Is-a)」ではなく、「〜を持っている(Has-a)」 で設計します。

ロボットの例で比較

継承の発想:

「空飛ぶロボット」は「ロボット」を継承する

構成の発想:

「ロボット」に「飛行ユニット」を装着する

構成なら、飛行ユニットを取り外して「水中ユニット」に付け替えることもできます。

コードで見る違い

継承パターン:

abstract class Robot {
    abstract public function move();
}

class FlyingRobot extends Robot {
    public function move() {
        echo "空を飛ぶ";
    }
}

構成パターン:

interface Movable {
    public function move(): void;
}

class JetEngine implements Movable {
    public function move(): void {
        echo "ジェットで飛ぶ";
    }
}

class Propeller implements Movable {
    public function move(): void {
        echo "プロペラで飛ぶ";
    }
}

class Robot {
    public function __construct(
        private Movable $movementUnit
    ) {}

    public function move(): void {
        $this->movementUnit->move();
    }
}

// 使う側
$robot = new Robot(new JetEngine());
$robot->move(); // ジェットで飛ぶ

// 実行時に変更も可能
$robot = new Robot(new Propeller());
$robot->move(); // プロペラで飛ぶ

継承 vs 構成:比較表

比較項目 継承(Inheritance) 構成(Composition)
関係性 Is-a(犬は動物である) Has-a(犬は心臓を持っている)
結合度 強い(親を変えると子に影響) 弱い(パーツを入れ替え可能)
柔軟性 実行時に変更できない 実行時にパーツを差し替えられる
設計の難易度 最初は簡単、後で地獄 最初は少し複雑、後で楽

Interface + Trait:PHPでの実践

PHPでは、InterfaceTraitを組み合わせることで構成を実現できます。

Interfaceで「契約」を定義

interface Flyable {
    public function fly(): void;
}

interface Swimmable {
    public function swim(): void;
}

Interfaceは「何ができるか」だけを定義し、実装は持ちません。

Traitで「共通実装」を配る

trait FlyingAbility {
    public function fly(): void {
        echo "空を飛ぶ";
    }
}

trait SwimmingAbility {
    public function swim(): void {
        echo "泳ぐ";
    }
}

Traitは実装を持ち、複数のクラスで再利用できます。

組み合わせる

class Duck implements Flyable, Swimmable {
    use FlyingAbility, SwimmingAbility;
}

class Penguin implements Swimmable {
    use SwimmingAbility;
    // Flyableは実装しない = 飛べない
}

ペンギン問題も自然に解決できます。


「未来が見えている上位種にしか使えない」

冒頭で紹介したシニアエンジニアのもう一つのコメントです。

「未来が見えている上位種にしか使えない」

これは継承を使った設計の難しさを端的に表しています。

継承で設計するには、将来どんな派生クラスが必要になるかを予測しなければなりません。でも、要件は変わるし、予測は外れる。

構成なら、「今必要な部品」だけを作って組み合わせればいい。将来新しい機能が必要になったら、新しい部品を作って差し込むだけです。


モダン言語は「継承を捨てた」

ここまで読んで、「継承の問題点はわかった。でもこれって設計の話であって、言語の問題じゃないよね?」と思った方もいるかもしれません。

実は、Go、Rust、Nimといったモダン言語は、言語設計レベルで継承を採用していません。

これは偶然ではなく、意図的な設計判断です。

Go:継承は存在しない

Goの言語仕様書には「inherit」という単語が1回しか登場せず、「inheritance」は一度も出てきません。

Goにはクラスがありません。代わりにstruct(構造体)interfaceを使います。

// Goには "extends" がない
// 代わりに struct embedding(埋め込み)を使う

type Engine struct {
    Power int
}

func (e Engine) Start() {
    fmt.Println("エンジン始動")
}

// Robot は Engine を「継承」するのではなく「持っている」
type Robot struct {
    Engine  // 埋め込み(Embedding)
    Name string
}

func main() {
    r := Robot{Engine: Engine{Power: 100}, Name: "ロボ太郎"}
    r.Start()  // Engine のメソッドが使える
}

これは継承ではなく構成です。RobotEngineを「持っている」のであって、「である」わけではありません。

Rust:継承という概念自体がない

Rustはさらに徹底しています。

  • クラスがない
  • 継承がない
  • 代わりにTraitで振る舞いを定義
// Trait = 振る舞いの定義(PHPのInterfaceに近い)
trait Movable {
    fn move_forward(&self);
}

// 各structがTraitを実装
struct Car;
impl Movable for Car {
    fn move_forward(&self) {
        println!("車が走る");
    }
}

struct Boat;
impl Movable for Boat {
    fn move_forward(&self) {
        println!("船が進む");
    }
}

Rustの設計者は、継承が引き起こす密結合の問題を避けるため、最初から継承を入れない選択をしました。

なぜモダン言語は継承を捨てたのか

言語 継承 代替手段
Go なし struct embedding + interface
Rust なし Trait + struct composition
Nim なし Concept + object variants

これらの言語に共通するのは:

  1. 密結合を避ける:親子関係による暗黙の依存を排除
  2. 明示的な所有権:誰が何を持っているかが明確
  3. 柔軟な組み合わせ:機能を自由に組み合わせられる

GoやRustを触ったことがある人なら、「継承がなくても困らない」どころか、「むしろ設計がシンプルになる」と感じた経験があるかもしれません。

JavaでさえComposition推奨

面白いことに、継承を前提に設計されたJavaでさえ、『Effective Java』のItem 18で:

「継承よりコンポジションを選べ(Favor composition over inheritance)」

と明記しています。

継承が使える言語でも、使わない方がいい場面が多い。だからこそ、モダン言語は最初から継承を入れなかったのです。


じゃあ継承は使わないの?

完全に使わないわけではありません。

継承を使っていい場面:

  • 概念として100%「Is-a」の関係が成り立つ
  • 親クラスが安定していて、今後変更される可能性が低い
  • フレームワークが継承を前提としている(Laravelのコントローラなど)

迷ったら構成:

  • 「〜の機能を持つ」と表現できるなら構成
  • 将来の拡張が予想されるなら構成
  • 複数の機能を組み合わせたいなら構成

まとめ

  • 継承は強力だが、密結合を生みやすく、変更に弱い
  • 構成は部品の組み合わせで柔軟性が高い
  • PHPではInterface + Traitで構成を実現できる
  • 迷ったらまず構成で考え、「100% Is-a」の場合のみ継承を検討

「実装の継承、人類には早すぎた道具」—この言葉を胸に、今日も設計と向き合っています。


参考


免責事項
この記事はインターン中の大学院生が執筆しており、内容に誤りがある可能性があります。
もし誤りや改善点を見つけた際は、ぜひコメントで教えていただけると嬉しいです。
一緒に学んでいけたらと思います!

Discussion

flathill404flathill404

「composition over inheritance」は日本語だと「継承より委譲」と訳されることが多いですよね、なぜか。