継承とは何か
継承(inheritance)は、オブジェクト指向プログラミングの三大要素(カプセル化、継承、多態性)のひとつとして、古くから教えられてきた仕組みです。
具体的には、あるクラス(スーパークラス、基底クラス)が持つフィールド(データ)とメソッド(振る舞い)を、別のクラス(サブクラス、派生クラス)がそのまま引き継ぎ、さらに独自のものを追加・上書きできるといった機能とされる場合が多いです(実装継承)。
この記事では断りなく継承と言った場合、この実装継承を指します。
たとえばJavaでは以下のようなコードになります。
class Animal {
String name; // データ
void eat() { System.out.println("食べている"); } // 振る舞い
}
class Dog extends Animal { // Animalを継承
void bark() { System.out.println("ワンワン"); }
}
これで、DogはAnimalのnameフィールドとeatメソッドを自動的に持つことになります。
一見すると「コードの再利用が楽そう」に見えますが、ここに最大の罠が潜んでいます。
オブジェクト指向の本来の思想
現代のオブジェクト指向の基礎は、1970年代後半から1980年代にかけて、アラン・ケイらによってSmalltalkで形作られたものです。
アラン・ケイの考えるオブジェクト指向の本質は「オブジェクト同士がメッセージを送り合う」ことで、複雑なシステムを構築することにありました(これは本人が度々口にしていることです)。
Smalltalkでは、オブジェクトはメッセージを受け取ってそれに応答します。
複雑なデータ構造が絡む処理を振る舞い(メソッド)の背後に隠蔽することで、外部からはシンプルなインターフェース(メソッド呼び出し)だけが見えるのです。
つまり、
- オブジェクトは「何をするか(do what)」(振る舞い、メソッド)だけが重要
- データはあくまでその振る舞いを支える内部的なものであり、主役ではない
- 「何を持つか(have what)」(内部のデータや実装詳細)は隠蔽されるべき
という考え方でした。
メソッドが主、データは従。
これが本来のオブジェクト指向の思想だったのです。
これにより、複雑なシステムでも各オブジェクトの役割が明確になり、全体として理解しやすくなるはずでした。
しかし現実の多くのオブジェクト指向型言語(Java、C++、C#など)は、この思想から大きく逸脱しています。
その最大の原因が実装継承です。
継承はなぜオブジェクト指向の原則に紛れ込んだのか?
なぜ継承がOOの中心的な機能として広まってしまったのか。
その原因は歴史的経緯にあります。
オブジェクト指向の先駆けとなったSimula 67には継承に似た機能があり、Smalltalkにも継承が取り込まれました。
当時のプログラマは「同じコードを再利用したい」という強い欲求を持っており、継承はその欲求に最も手軽に応える仕組みだったのです。
そして、それらを元に継承を取り込んだJavaやC++の普及とともに、「継承=オブジェクト指向の原則」という誤解が定着していったのです。
しかし、これはデータ構造の再利用を優先する考え方であり、本来のメッセージング中心のオブジェクト指向とは異なる方向性の機能でした。
継承はデータ主体の思想である
実装継承の最大の問題はそのまま、本質が「データ構造の再利用」にある点です。
サブクラスはスーパークラスのフィールドをそのまま引き継ぎ、そこに新たなフィールドを追加します。
先ほどのAnimal → Dogの例で考えてみると、Dogが欲しかったのはnameやageなどのデータフィールドです。
このように、継承は「is-a関係」という名のもとにデータ構造の階層化を促進しています。
最初から「データ構造の再利用」が目的であればこれで良いのですが、継承をオブジェクト指向の機能と考えてしまった場合、問題が起こります。
親クラスのデータ構造を引き継ぐことが前提となり、データ設計が先行する思考パターンを生むのです。
これにより、本来主であるべきメソッドが「データに付随するもの」という従属的な位置づけになってしまいます。
現実のコードにおいても、「親クラスの膨大なフィールドをそのまま引き継ぎ、少しメソッドを追加・上書きする」だけのクラスが多く、クラス定義の中心は巨大なデータ構造になっています。
データが主、メソッドは従。
これがデータ主体のOOであり、本来のオブジェクト指向の思想とは根本的に異なるものです。
データ主体のOOというアンチパターン
データ主体のOOが蔓延したことで、
- データが肥大化し、クラスが「神オブジェクト」化する
- 不要なカプセル化が爆発的に増え、コードが冗長になる
- 本来シンプルなロジックが深い継承階層の奥に埋もれ、追跡が困難になる
- 変更が様々な場所に波及し、予期せぬバグが頻発する
といった問題が発生し、結果として世界中で莫大な時間と労力の損失を生むことになりました。
オブジェクト指向型言語で書かれたコードの多くが読みにくいのは、このデータ主体のOOが原因なのです。
指摘されてきた継承の問題は、症状に過ぎない
これまで、幾度となく継承の問題点は取り上げられてきました。
-
壊れやすい基底クラス問題
見た目上は安全そうなのに、基底クラスの変更が子クラスの動作を突然壊してしまう -
バナナ/ゴリラ/ジャングル問題1
特定の機能や振る舞いだけを利用したいのに、無関係で膨大な依存や状態の塊まで抱え込んでしまう -
ダイヤモンド問題
同じ祖先クラスを持つ複数の親クラスから継承すると、共通の祖先のメンバのいずれを引き継ぐべきか曖昧になってしまう
しかし、これらは全て継承がデータ再利用を前提とした仕組みであることが原因で発生する、症状に過ぎません。
データを共有・拡張しなければ、当然これらの問題は発生しないのです。
正しい共有の方法、インターフェースと委譲
同じ振る舞いを複数のクラスで共有したい、これは自然な発想です。
しかし、そのための手段として継承を選ぶのは、これまでに述べた理由から常に誤っています。
正しい方法は、インターフェースと委譲を用いることです。
-
インターフェースで契約を定義する
振る舞いの「何をするか」を純粋に宣言する。interface Flyable { void fly(); } -
実装は委譲(delegation / composition)で共有する
共通ロジックは別のオブジェクトに任せ、呼び出す。class FlyingBehavior { void fly() { System.out.println("飛んでいる"); } } class Bird implements Flyable { private FlyingBehavior flying = new FlyingBehavior(); public void fly() { flying.fly(); } } class Airplane implements Flyable { private FlyingBehavior flying = new FlyingBehavior(); public void fly() { flying.fly(); } }
そうすることで、
- データと振る舞いが疎結合になる
- データ構造を各クラスで独自に最適化可能
- ロジックの再利用も容易になる
- 継承では親の内部表現に固定されるが、委譲では依存を差し替え可能となる
- 継承階層が不要になり、コードの深さが浅くなる
- 変更の影響範囲が限定される
といった効果が得られます。
GoやRustが継承を採用しなかったのはこの思想を最初から徹底した結果で、実際コードはシンプルで理解しやすいものとなっています。
継承では子が親の内部表現に結合するのに対し、移譲では利用側が依存を選べる
加えて、継承を利用しないことでメソッドがデータに従属することがなくなり、本来のオブジェクト指向(メソッドが主、データが従)が実現しやすくなります。
これにより、不要なクラスが減少し、シンプルなロジックはシンプルなままに保たれるのです。
予想される反論への回答
「継承そのものを否定するのは極端だ」と感じる人もいると思います。
以下に予想される反論と、それに対する回答を示します。
-
反論1:継承はポリモーフィズムを実現するのに便利だ
ポリモーフィズムはインターフェースだけで実現可能です。
継承は「is-a」関係を強制しますが、多くの場合それは不要です。
たとえば、「円は楕円である」は数学的には正しいですが、その階層構造を実際に処理に用いる機会はないでしょう。
そのため、インターフェースと委譲のみで十分に柔軟なポリモーフィズムが得られるのです。 -
反論2:実世界のモデリングに継承は自然だ(動物 → 犬など)
「円-楕円問題(Circle-ellipse problem)」が示すように、「is-a」関係は変更に弱く、実世界の階層構造を十分に分類しきれません。
そして、そもそも実世界の階層分類をソフトウェア設計にそのまま持ち込む必要もありません。
実世界のモデリングは「振る舞いベース」で行えばよく、データ階層に縛られる必要はないのです。 -
反論3:既存のフレームワーク(Javaのコレクションなど)が継承を前提としている
過去の遺産においてはやむを得ない利用はあるでしょう。
しかし、新規コードでは継承を避け、ラッパーやアダプターで対応するべきです。
徐々にリファクタリングすれば、長期的に保守性が向上するでしょう。 -
反論4:継承を使わないとコードが冗長になる
たしかに若干冗長になるかもしれませんが、継承の不使用は長期的な可読性や保守性、変更容易性で圧倒的に勝ります。
冗長さは抽象化のコストで、継承はそのコストを下げる代わりに結合度を上げてしまいます。
結論
ここまでの内容の通り、実装継承は多くの場合「便利な機能」ではなく、オブジェクト指向プログラミングを蝕む「毒」だと言えます。
継承を残す限り、データ主体の誤ったオブジェクト指向が蔓延し続け、世界中の開発者は無駄な複雑さと戦い続けることになります。
もちろん、何事にも例外はあります。フレームワークが要求する場合もあるでしょう。
しかし、例外は例外であって原則ではありません。
ですから、原則として今すぐ継承を捨てましょう。
インターフェースと委譲を徹底し、メソッドが主役の本来のオブジェクト指向に立ち返れば、シンプルで美しく、保守しやすいコードが待っているはずです。
-
「欲しかったのはバナナだけなのに、バナナを持っていたゴリラと、ジャングル全体がついてきた」というたとえから。 ↩