はじめに
「継承は悪」――最近こんな言葉をよく見かけるようになりました。クリーンアーキテクチャやDDD、モダンな言語仕様の解説を読んでいると、「継承よりコンポジション」という結論に行き着くことが多い気がします。
とはいえ、少しだけ立ち止まって考えてみたくなります。あなたは最後にいつ、意図を持って継承を選びましたか。なんとなく避けている、あるいはなんとなく使っている、という方も多いのではないでしょうか。
継承は、オブジェクト指向をここまで広く浸透させた立役者の一つだったはずです。それから20年、30年という歳月が経ち、なぜここまで敬遠されるようになったのか。そして、コード生成やAIによる開発支援が当たり前になりつつある今、この設計手法をどう捉え直せばよいのか。本記事では、少し歴史を振り返りながら、私なりにこの問いと向き合ってみたいと思います。
1. 継承がOOPをメジャーにした理由
1-1. Smalltalk〜C++〜Javaという普及の流れ
オブジェクト指向という考え方自体は1967年のSimulaに端を発し、それを土台にアラン・ケイが提唱したSmalltalkによって広く知られるようになりました。それを世界中の開発現場に広めたのはC++であり、その流れを受け継いだJavaでした。この普及の過程で、継承はずっと中心的な機能として扱われてきたように思います。
継承が支持された大きな理由は、「再利用」と「多態性(ポリモーフィズム)」という、一見別々の課題を一つの言語機能でまとめて解決できるように見えたことではないでしょうか。親クラスに共通処理を書いておけば子クラスはそれを引き継げますし、親クラス型の変数に子クラスのインスタンスを代入すれば、呼び出し側のコードを変えずに振る舞いを切り替えられる。この二つの利点が同時に手に入るというのは、当時としてはかなり魅力的な提案だったはずです。
1-2. GUIフレームワークが刷り込んだ「継承できる=強力」という成功体験
継承の有用性を業界に強く印象づけたのは、AWT/SwingやMFCといったGUIフレームワークだったように思います。ボタンやウィンドウの振る舞いを、既存クラスを継承してオーバーライドするだけでカスタマイズできる体験は、多くのエンジニアに「継承できる=拡張性が高い=優れた設計」という感覚を自然と植え付けたのではないでしょうか。
この成功体験が、GUI以外の領域、たとえば業務ロジックやドメインモデルの設計にまで、あまり検証されないまま持ち込まれていったことが、後の混乱の一因になったのかもしれません。
1-3. デザインパターンと継承前提の設計思想
GoFのデザインパターンにも、Template MethodやFactory Methodのように、継承を前提としたパターンが数多く含まれています。1990年代後半から2000年代にかけて、こうしたパターンを学ぶことが優れた設計を学ぶことと同じように扱われ、結果として「継承を使いこなせること」がオブジェクト指向を理解している証のように見なされた時期があったように思います。私自身、GoFのデザインパターンを深く知って、オブジェクト指向って楽しいと思えるようになりました。
よろしければ、僭越ながら私がまとめたGoFのデザインパターンの記事をご参照ください。
面白いのは、GoFの書籍(1994年)にも "Favor object composition over class inheritance" という一文が明記されており、後年のJoshua Blochによる『Effective Java』でも「継承よりコンポジションを選ぶ」として改めて強調されているにもかかわらず、実務ではその原則よりも継承を使ったテクニックの方が広まってしまった、という経緯です。
2. なぜ「百害あって一利なし」と言われるようになったのか
2-1. Fragile Base Class問題とLSP違反
継承の技術的な難点としてよく挙げられるのが、Fragile Base Class(脆弱な基底クラス)問題です。親クラスの実装をほんの少し変更しただけで、その変更が意図しない形で子クラス群の振る舞いに波及してしまう。継承階層が深く、広くなるほど、この影響範囲は開発者の予測を超えやすくなります。
リスコフの置換原則(LSP)違反も、地味に頻発する問題です。「子クラスは親クラスと置き換え可能であるべき」という原則は理論上はとても分かりやすいのですが、実装が進むにつれて子クラスが親クラスの契約を静かに破っていく、というケースは珍しくありません。
2-2. is-a関係の誤用(実装再利用としての継承)
継承が乱用されがちだった大きな原因は、「is-a関係の表現」ではなく「実装の再利用」を目的として使われてしまったことにあるように思います。「共通のコードがあるから継承させよう」という発想は一見合理的に見えますが、意味的なis-a関係が成立していない場合、後から仕様変更が入った際につまずきやすくなります。これは継承という機能そのものの問題というより使い方の問題ですが、言語側がそれを止める仕組みを持っていなかったことも一因だったのではないでしょうか。
2-3. 言語仕様側の変化(Go/Rustにクラス継承がない設計思想)
こうした反省は、後発の言語設計にはっきりと表れています。Goにはクラス継承という概念自体が存在せず、インターフェースの実装と構造体の埋め込み(コンポジション)によって拡張性を実現する設計になっています。Rustも同様に、クラス継承ではなくtraitとその実装、および委譲によって多態性を表現します。
// Goの場合:継承ではなく「埋め込み」でコンポジションを表現する
type Engine struct{}
func (e Engine) Start() string { return "エンジン始動" }
type Car struct {
Engine // 継承ではなく埋め込み
Name string
}
func main() {
c := Car{Name: "MyCar"}
println(c.Start()) // 委譲された振る舞いを呼び出す
}
これは偶然ではなく、C++やJavaでの数十年にわたる継承の運用実績を踏まえた、わりと明確な設計判断のように見えます。
2-4. 合成優先(Composition over Inheritance)はなぜ実務で軽視され続けたのか
前述の通り、「継承よりコンポジションを優先せよ」という原則自体は古くから存在していました。それでも実務で継承が使われ続けた理由は、コンポジションの方が初期実装の手間がかかるように見えたことが大きいのではないかと思います。委譲のためのボイラープレートコードを書くよりも、extends一つで済ませられる継承の方が、短期的には「楽」に見えてしまう。この短期的な楽さと長期的な保守コストのトレードオフを、設計判断の時点で正しく見積もるのは、なかなか難しいことなのかもしれません。
3. 概念モデルと物理実装の乖離という問題
3-1. 概念の世界では継承は今でも強力
ここまで継承への批判めいた話を並べてきましたが、誤解してほしくないのは、「概念モデリング」の世界において継承は今でもかなり強力なツールだということです。業界標準モデルやドメインモデルにおいて、is-a関係による分類体系は、人間同士が仕様について合意形成するためのコミュニケーションツールとして優れていると感じます。「これは何の一種であるか」という分類軸で物事を整理する行為そのものは、人間の認知の仕方に合っているのだと思います。
3-2. 物理への写像で顕在化する不都合
問題が顕在化しやすいのは、この概念モデル上の継承階層を、そのままデータベースのスキーマやオブジェクトの永続化構造に落とし込もうとする局面です。
概念設計の段階では美しく見えた継承階層も、物理実装に落とし込む際にはいくつかの方式(クラステーブル継承、単一テーブル継承、具象テーブル継承など)から選ぶことになり、どの方式を選んでも一長一短のトレードオフを抱え込むことになりがちです。
- 継承階層が深く、広くなるほど、どの物理実装方式を選んでもデータ整合性を担保するためのコストが増えていく
- 属性の追加や仕様変更が発生するたびに、親子間の整合性チェックを自動化しきれず、人手でのレビューや運用ルールに頼らざるを得なくなる
- 「概念上は正しい分類」であることと、「物理的に変更が容易であること」は別の評価軸であり、両者のトレードオフは設計の初期段階では見えにくい
これらは、継承という機能そのものの欠陥というより、「概念のための道具」を「物理実装のための道具」としてそのまま転用してしまうことに起因する問題、という整理ができそうです。
3-3. 現場で起きている綱引き(一般化した実感として)
私自身、電力・エネルギー系のドメインモデルのように、業界標準として整備された継承ベースの概念モデルを、そのまま物理実装まで一気通貫で適用しようとする現場に携わった経験があります。概念モデルとしての継承階層は業界内の合意形成のツールとしてはよく機能している一方で、それを忠実に物理層まで持ち込もうとすると、データの整合性担保をどうしても人手に頼らざるを得ない場面が随所に出てきます。
これは特定のプロジェクト固有の問題というより、「概念モデリングにおける継承の強さ」と「物理実装における継承の弱さ」という、構造的に相反する性質が同じ設計手法の中に同居していることに起因する、わりと一般的な現象なのだろうと感じています。
4. AI全盛期に継承をどう見るか
ここからが本題です。コード生成AIやAIによるリファクタリング支援が当たり前になりつつある今、継承という設計手法の評価軸には、これまでとは少し違う変数が加わりつつあるのではないか、というのが私の見立てです。
4-1. コード生成との相性 ― フラットな構成 vs 深いクラス階層
LLMを使ったコード生成や既存コードの理解において、深く入り組んだクラス階層は、フラットな構成やインターフェースベースの設計に比べてコンテキストの把握がやや難しくなる傾向があるように感じています。
// 継承階層が深いケース:振る舞いがどこにあるか追いにくい
class Vehicle { void move() { /* ... */ } }
class LandVehicle extends Vehicle { void steer() { /* ... */ } }
class Car extends LandVehicle { void honk() { /* ... */ } }
class SportsCar extends Car { void turbo() { /* ... */ } }
// SportsCar.move() の実体を知るには3階層遡る必要がある
// コンポジションで表現した場合:必要な振る舞いがその場に見える
class SportsCar {
private final Engine engine;
private final Steering steering;
SportsCar(Engine engine, Steering steering) {
this.engine = engine;
this.steering = steering;
}
void move() { engine.drive(); }
void steer() { steering.turn(); }
}
ある振る舞いがどのクラスに定義されているかを何段階も遡って確認する必要がある継承階層は、AIにとっても人間にとっても、少し認知負荷が高くなりやすい構造なのかもしれません。
4-2. DRYの価値の相対化 ― 重複はAIが後から一括修正できる時代
継承の主要な目的の一つだった「コードの重複を避けること」自体の価値も、少しずつ相対的に下がってきているように感じます。
# 多少の重複があっても、まずは素直に書いておく
class InvoicePdfExporter:
def export(self, invoice):
header = f"請求書番号: {invoice.id}"
# ...
class ReportPdfExporter:
def export(self, report):
header = f"レポート番号: {report.id}"
# ちょっと似ているが、無理に共通の親クラスは作らない
多少の重複があったとしても、AIによる横断的な検索や一括修正が以前よりずっと簡単になったことで、「今すぐ重複を排除する構造にしておかなければ」という切迫感は薄れてきているように思います。重複の排除を急いで無理な抽象化(継承)を導入するくらいなら、いったん重複を許容しておいて、後からAIに整理を手伝ってもらう、という選択肢も現実的になってきました。
4-3. 可読性コストの逆転 ― AIによる要約 vs 暗黙のオーバーライド
継承のメリットの一つとして「共通処理を省略して書ける」という可読性の側面がよく挙げられていましたが、コードベース全体をAIに要約してもらうことが簡単になった今、このメリットの重要度は少し下がってきているように感じます。
一方で、継承特有のリスクである「オーバーライドによる暗黙の振る舞い変更」は、AIにとっても誤読しやすいポイントとして残り続けます。省略によって得られる可読性の向上よりも、暗黙の変更によって生じるリスクの方が、相対的に目立つようになってきたと言えるかもしれません。
4-4. テスト自動生成との相性 ― 合成・DIの方が境界が明確
AIによるテストコードの自動生成という観点でも、継承よりコンポジションやDI(依存性注入)を用いたコードの方が少し相性が良いように感じます。
# DIされたクラスはモックしやすく、AIもテストを組み立てやすい
class OrderService:
def __init__(self, payment_gateway):
self.payment_gateway = payment_gateway
def checkout(self, order):
return self.payment_gateway.charge(order.total)
# テスト側
def test_checkout():
mock_gateway = MockPaymentGateway()
service = OrderService(mock_gateway)
assert service.checkout(sample_order()) == "success"
境界がはっきり分離されているほど、AIはモックの作り方やテストケースの範囲を判断しやすくなります。継承階層の途中に位置するクラスは、どこまでを単体テストの対象とすべきかの判断が難しく、AIによるテスト生成の精度もやや落ちやすい傾向があるように思います。
4-5. それでも継承が有効な場面
とはいえ、継承のすべてを否定する必要はまったくありません。フレームワーク内部でのTemplate Methodパターンのように、あらかじめ決められた処理の骨格に対して一部分だけを差し替えるような用途や、型としての多態性そのものが本質的に必要とされる場面では、継承は今でも十分合理的な選択肢だと思います。
// Template Method的な用途では継承がまだ自然に馴染む
abstract class BatchJob {
final void run() {
loadData();
process();
saveResult();
}
abstract void process();
void loadData() { /* 共通処理 */ }
void saveResult() { /* 共通処理 */ }
}
大事なのは「継承かコンポジションか」を思考停止で選ぶのではなく、用途に応じて使い分ける、という当たり前の判断力を持ち続けることなのだと思います。
4-6. 「概念は継承、物理は合成」をAIが橋渡しする時代
3章で触れた「概念モデルの継承階層を物理層にそのまま持ち込むことで、人手による整合性担保が必要になる」という問題は、AIによる自動整合性チェックや自動マイグレーション生成によって、これから少しずつ緩和されていくかもしれません。
つまり、概念設計の段階では人間にとって理解しやすい継承モデルを維持しつつ、物理実装への変換過程はAIが担う、という役割分担が、これから現実的になっていくのではないでしょうか。「概念は継承、物理は合成」という使い分けを、これまでは人間が手作業で橋渡ししていたところを、少しずつAIが手伝ってくれる時代が来つつあるのかもしれません。
5. 結論:継承は「禁止」ではなく「デフォルト思考の解除」
継承は、決して「使ってはいけない機能」ではないと思います。問題だったのは、継承が「特に理由もなく選ばれるデフォルトの選択肢」になってしまっていたことなのではないでしょうか。
今、設計の現場に求められているのは、「継承を選んだ理由をきちんと言語化できるか」という基準なのだと思います。そして、AIが当たり前に開発を支援してくれる今、そこにもう一つ基準が加わりつつあるように感じます。それは「その設計は、AIにとっても読み書きしやすいものになっているか」という視点です。
継承というオブジェクト指向の功労者を、感情的に断罪するのでも、なんとなく使い続けるのでもなく、この二つの基準に照らして少し立ち止まって評価してみること。それが、継承の「今」における、ちょうどいい向き合い方なのではないかと私は考えています。
おわりに
継承をめぐる議論は、ともすると「継承 vs コンポジション」という二項対立の技術論争に矮小化されがちです。ただ、こうして振り返ってみると、これは単なる実装テクニックの好みの問題ではなく、「概念をどう表現するか」と「その概念をどう物理的に実装し、変化に耐えさせるか」という、設計における普遍的な問いの一形態なのだと感じます。
私自身、継承をめぐる議論に触れるたびに、20年以上前に初めてオブジェクト指向を学んだときの高揚感を思い出します。「クラスを継承すれば振る舞いを引き継げる」という仕組みに、当時は本当にワクワクさせられたものです。その仕組みが今、批判の的になっているというのは、少し寂しくもあり、同時にとても自然な技術の成熟過程のようにも思えます。
どんな設計手法も、生まれた瞬間から完璧だったわけではなく、使われ続ける中で長所と短所の両方が明らかになっていくものなのだと思います。継承もその例外ではなく、むしろオブジェクト指向の歴史の中でもっとも多くのことを教えてくれた機能の一つだったのかもしれません。
AIという新しい変数が加わったことで、この30年来の議論に向き合うための材料がまた一つ増えた、という程度の気持ちで、今回の記事を締めくくりたいと思います。もしよろしければ、皆さんが継承と付き合ってきた経験や、逆に「これは今でも継承で正解だった」と感じている場面があれば、コメントなどで教えていただけると嬉しいです。