まず、継承のデメリットとは何か
継承は便利ですが、「親と子が強く結び付く」ため、変更や保守の難易度を上げる要因になりがちです。意図しない依存が広がり、拡張やテストが難しくなる“落とし穴”が複数あります。ポイントは、is-a(〜は〜である)が真でないのに継承を選ぶと、設計が崩れやすいことです。代わりに委譲やインターフェースを選ぶ場面を見極める目が重要です。
強い結合と「壊れやすい基底クラス」問題(重要)
親変更が子へ波及する
症状: 親クラスの仕様や内部構造を変えると、子クラスが壊れたり振る舞いが変わったりします。子が親の詳細に依存しているほど、影響が読みにくくなります。
class Base {
protected String format(String s) { return s.trim(); }
}
class Sub extends Base {
@Override protected String format(String s) { return super.format(s).toLowerCase(); }
}
// Base.format を「trim せずそのまま返す」に変更 → Sub の想定が崩れ、出力が狂う
Java深掘り: これは「壊れやすい基底クラス(Fragile Base Class)」の典型です。親が“契約”を狭めたり、テンプレートの呼び順を変えるだけで、子の前提が崩壊します。親で骨格を final で固定し、拡張ポイントを限定(protected/abstract)するほど回避しやすくなります。
is-a の誤用と LSP 違反(置換可能性の破綻)
本質が「持つ(has-a)」なのに「継承」してしまう
症状: 子が親として扱われる場面で振る舞いが破綻し、呼び出し側に特別扱いの分岐が増えます。LSP(リスコフ置換原則)に反する例です。
class Queue { void addLast(int x) {/*...*/} int removeFirst(){/*...*/ return 0; } }
// 「スタック」を作りたいのに Queue を継承してメソッドを逆転
class Stack extends Queue {
@Override void addLast(int x) { /* 実質 push */ }
@Override int removeFirst() { /* 実質 pop */ }
}
// Queue として使うと契約と動作が食い違い、呼び出し側が壊れる
Java深掘り: 「X は Y の一種か?」が揺らぐなら継承は不適切です。インターフェース(契約)+委譲(内部利用)へ切り替えると置換可能性が保たれ、分岐が消えます。
カプセル化の破壊と拡張ポイントの過露出
protected フィールドの直接操作で整合性が崩れる
症状: 子が親の内部状態を好きに書き換えられるため、親が守りたい不変条件(インバリアント)が壊れます。テストでの再現が難しく、デバッグが長期化します。
abstract class Account {
protected int balance; // 露出し過ぎ
protected void add(int x) { balance += x; } // 本来は検証付きメソッドで守るべき
}
class PointAccount extends Account {
void earn(int pts) { balance += pts; } // 検証抜け、負値やオーバーフローが入り得る
}
Java深掘り: フィールドは private にし、protected は“検証付きの小さなフック(final メソッド)”に限るのが安全です。親が整合性の番人であるべきです。
テスト・保守の難易度上昇
階層が深いほど振る舞いの追跡が難しくなる
症状: どのメソッドがどこで上書かれているか、super 呼び出しの順序、テンプレートの流れなど、読解コストが高騰します。モック化も「継承前提の差し替え」に縛られ、柔軟性が落ちます。
abstract class Importer { /* run -> load -> normalize -> save の鎖 */ }
class CsvImporter extends Importer { /* normalize を上書き */ }
class SpecialCsvImporter extends CsvImporter { /* さらに上書き */ }
// 実行パスの解読が困難に
Java深掘り: 「浅い階層+最小の拡張ポイント」に抑えると可読性が戻ります。階層が増えるなら、インターフェース+委譲(戦略パターン)へ切り替えてテスト容易性を優先します。
望まない継承・API汚染・進化の足かせ
親の余計なメソッドが子に“相続”される
症状: 子から本来使わせたくないメソッドが見えてしまい、API表面積が増大。呼び出し側が誤用しやすく、後方互換性の足かせになります。
class BaseRepo {
public void dropAll() {} // 危険なメソッド
}
class UserRepo extends BaseRepo {
// UserRepo に dropAll が見えてしまう(意図せず公開)
}
Java深掘り: 親に「広すぎる責務」を持たせない、危険な操作は別コンポーネントへ分離、あるいは委譲に切り替える。親の API は最小限が鉄則です。
コンストラクタ連鎖と初期化の罠
super 呼び出しの順序依存・未初期化参照
症状: 親のコンストラクタで呼ばれるメソッドを子がオーバーライドしていて、子のフィールドが未初期化のまま参照される、といったバグが起きます。
class Base {
Base() { init(); } // コンストラクタ中に呼ぶのが危険
void init() { /* 子で上書き想定 */ }
}
class Sub extends Base {
private String name;
@Override void init() { System.out.println(name.length()); } // name はまだ null
}
Java深掘り: コンストラクタ内でオーバーライド可能メソッドを呼ばない。初期化順序は「親の完成→子の完成」を崩さない設計(ファクトリ・ビルダ)へ。
例題で「継承のデメリットと回避」を体験
例 1: 委譲への置き換えで依存を緩める
// 継承の乱用(NG)
class CachedHttpClient extends java.net.http.HttpClient { /* 実装変更で破綻しやすい */ }
// 委譲(OK)
final class CachedHttp {
private final java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
java.net.http.HttpResponse<String> get(String url) throws Exception {
var req = java.net.http.HttpRequest.newBuilder(java.net.URI.create(url)).GET().build();
return client.send(req, java.net.http.HttpResponse.BodyHandlers.ofString());
}
}
Java委譲にするとライフサイクルや差し替えが自由になり、親実装の変更に引きずられません。
例 2: インターフェース+戦略で拡張しやすくする
interface Normalizer { String apply(String s); }
final class TrimNormalizer implements Normalizer {
public String apply(String s) { return s == null ? "" : s.trim(); }
}
final class LowerNormalizer implements Normalizer {
public String apply(String s) { return s == null ? "" : s.toLowerCase(); }
}
final class Importer {
private final Normalizer normalizer;
Importer(Normalizer n) { this.normalizer = n; }
String run(String s) { return normalizer.apply(s); } // 戦略の委譲
}
Java継承せず差し替え可能。テストではダミー実装を渡すだけで挙動を制御できます。
例 3: 親のフック最小化で壊れにくくする
abstract class SecureService {
public final void execute(String user) {
auditStart(user);
doExecute(user); // 小さな拡張ポイントに限定
auditEnd(user);
}
protected abstract void doExecute(String user);
private void auditStart(String u) { /* ... */ }
private void auditEnd(String u) { /* ... */ }
}
Java親が骨格と安全策を固定し、子は必要最小限だけ上書き。影響範囲が限定されます。
使いどころの見極め(重要部分のまとめ)
継承のデメリットは「強い結合による壊れやすさ」「is-a 誤用での契約破綻」「カプセル化の崩壊」「テスト・保守の複雑化」「不要な API の相続」「初期化順序の罠」に集約されます。避けるために、次を徹底してください。
- 選択基準: is-a が厳密に成り立つ場合のみ継承。そうでなければ委譲+インターフェースへ。
- 親の設計: 骨格を final で固定し、拡張ポイントは小さく・検証付きに。protected 乱用を避ける。
- 階層管理: 階層は浅く、上書きは最小限。コンストラクタでオーバーライド可能メソッドを呼ばない。
- テスト容易性: 戦略の差し替え(委譲)を基本にし、継承は必要最小限に。
