Java | オブジェクト指向:継承 vs 委譲

Java Java
スポンサーリンク

継承と委譲の違い

継承は「is-a(〜は〜の一種)」の関係で、親の振る舞いを子が受け継ぎ、必要ならオーバーライドで差分を加える仕組みです。委譲(コンポジション)は「has-a(〜は〜を持つ)」の関係で、クラスが役割となるオブジェクトを“部品として持ち”、仕事をその部品へ任せます。継承は階層が固定されやすく親の設計に強く縛られますが、委譲は部品の差し替えで柔軟に振る舞いを変えられ、結合が弱く保てます。


継承を使うべき場面(重要)

継承は「本当に is-a が成立し、親の契約を破らない」場面で力を発揮します。テンプレートメソッドで流れを親に固定し、差し替えたいステップだけ子のオーバーライドに開くと、安全に拡張できます。

abstract class Importer {
    public final void run(String path) {
        var raw = load(path);            // 差し替え点
        var normalized = normalize(raw); // 共有
        save(normalized);                // 差し替え点
    }
    protected abstract String load(String path);
    protected String normalize(String s){ return s == null ? "" : s.trim(); }
    protected abstract void save(String data);
}

final class CsvImporter extends Importer {
    @Override protected String load(String path){ return "a,b,c"; }
    @Override protected void save(String data){ System.out.println("saved: " + data); }
}
Java

このように「流れ・不変条件は親」「やり方の違いは子」に分けると、監査や検証を親で統一でき、拡張の安全性が高まります。


委譲(コンポジション)を使うべき場面(重要)

委譲は「役割を組み合わせたい」「差し替えを簡単にしたい」「親の内部契約に縛られたくない」場面で最適です。戦略(ストラテジ)を注入して分岐を消し、デコレータで前後処理を重ねる拡張が得意です。

interface Formatter { String format(String raw); }

final class UpperFormatter implements Formatter {
    @Override public String format(String raw){ return raw == null ? "" : raw.toUpperCase(); }
}

final class NameService {                 // “持って任せる”
    private final Formatter formatter;    // 役割を部品として保持
    NameService(Formatter formatter){ this.formatter = formatter; }
    String render(String s){ return formatter.format(s); } // 委譲
}
Java

差し替えは注入で完了し、NameService のコードは不変です。テストではフェイク Formatter を渡すだけで外部依存なしに検証できます。


悪い継承を委譲で直す(深掘り)

継承で親の内部に踏み込み、順序や不変条件に依存すると「壊れやすい基底クラス問題」を招きます。差し替えたい振る舞いを役割として切り出し、“持つ”に変えるのが筋です。

// 悪い例:親の実装に縛られる
class Report {
    String build(String raw){ return "REPORT: " + raw; }
}
class FancyReport extends Report {
    @Override String build(String raw){
        // 親の都合に強く依存(将来の変更に弱い)
        return super.build(raw.toUpperCase());
    }
}

// 良い例:役割を分離して委譲
interface Title { String make(String raw); }

final class SimpleTitle implements Title {
    @Override public String make(String raw){ return "REPORT: " + raw; }
}

final class ReportService {
    private final Title title;
    ReportService(Title title){ this.title = title; }
    String build(String raw){ return title.make(raw); } // 委譲(差し替え自由)
}
Java

役割を小さく切るほど、差し替えの自由度が上がり、壊れにくくテストしやすい構造になります。


デコレータで“足し引き可能”な拡張をする

委譲はデコレータパターンと相性がよく、前後処理を安全に重ねられます。入れ子にしても衝突せず、機能を必要なだけ足し引きできます。

interface Storage { void put(String key, String value); }

final class MemoryStorage implements Storage {
    private final java.util.Map<String,String> map = new java.util.HashMap<>();
    @Override public void put(String key, String value){ map.put(key, value); }
}

final class LoggingStorage implements Storage {
    private final Storage inner;
    LoggingStorage(Storage inner){ this.inner = inner; }
    @Override public void put(String key, String value){
        System.out.println("[LOG] " + key + "=" + value);
        inner.put(key, value); // 委譲
    }
}

final class ValidatingStorage implements Storage {
    private final Storage inner;
    ValidatingStorage(Storage inner){ this.inner = inner; }
    @Override public void put(String key, String value){
        if (key == null || key.isBlank()) throw new IllegalArgumentException("key");
        inner.put(key, value); // 委譲
    }
}

// 組み合わせは自由
Storage st = new LoggingStorage(new ValidatingStorage(new MemoryStorage()));
st.put("a","1");
Java

この積み方なら、横断的関心(ログ、検証、監査など)を安全に追加・削除できます。


設計指針と落とし穴(重要部分のまとめ)

継承は is-a が成立し、親の契約やテンプレートを守る場面に限定するのが安全です。委譲は has-a による役割分解で、差し替え・拡張・テストに強い。必要な機能はインターフェースへ切り出して“持つ”に寄せ、具体の new は外へ追い出して注入する。巨大な親や深い階層は壊れやすく、委譲へリファクタすると依存が緩みます。動的に切り替えたい処理はインスタンスメソッドにし、骨格は抽象クラスで固定、差分は役割で委譲——この組み合わせが現場で一番強い型です。

タイトルとURLをコピーしました