コンポジション(委譲)とは
コンポジションは「クラスが“持つ(has-a)”関係で他のオブジェクトを部品として組み合わせ、仕事をその部品へ任せる(委譲する)」設計です。継承(is-a)と違い、親の内部契約や振る舞いに縛られません。必要な役割を小さく分けて持ち、呼び出しを委譲することで、依存が柔らかく保たれ、差し替え・テスト・拡張が容易になります。
なぜ継承よりコンポジションなのか(重要)
継承は「親の設計に強く結びつく」ため、親の変更が子へ波及し、拡張で破綻しやすい弱点があります。コンポジションは「役割を部品として持つ」だけなので、部品の差し替えで振る舞いを変えられ、壊れにくいです。単一責務の部品を明確化すれば、組み合わせで多様な振る舞いを安全に作れます。テストでは部品をフェイクに置き換えるだけで、外部依存なしの検証ができます。
基本パターン:役割を持って委譲する
コンポジションの最小形は「フィールドで役割(インターフェース)を持ち、メソッドでその役割へ処理を委譲する」ことです。
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この形なら、UpperFormatter を SnakeFormatter に差し替えるだけで振る舞いが変わります。NameService 自体のコードは不変で、拡張時の波及がありません。
継承との対比:悪い継承をコンポジションで直す(重要な深掘り)
継承で「親のメソッドを上書きし、内部に踏み込む」設計は壊れやすいです。差し替えたい振る舞いを“持つ”へ変更しましょう。
// 悪い例:親の実装に縛られる継承
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役割を Title に切り出して持つだけなら、Fancy な作りへ自由に差し替え可能です。親子関係の制約から解放され、変更に強い構造になります。
デコレータで“足し引き可能”な拡張をする
コンポジションはデコレータパターンと相性が抜群です。元の役割を包み、前後に処理を追加できます。入れ子にすれば、機能を足し引き自在に組み替えられます。
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追加機能をクラス階層に積まず、包んで重ねることで、衝突せず拡張できます。テストでは各層を単独で検証可能です。
戦略の差し替えと委譲の相乗効果
ストラテジパターン(“やり方の違い”をインターフェースで表す)と委譲を組み合わせると、呼び出し側の分岐を消し、柔軟さが最大化します。
interface Discount { int apply(int price); }
final class NoDiscount implements Discount {
@Override public int apply(int price) { return price; }
}
final class RateDiscount implements Discount {
private final int percent;
RateDiscount(int percent) { this.percent = percent; }
@Override public int apply(int price) { return Math.max(0, price - price * percent / 100); }
}
final class Cart {
private final Discount discount; // “持って任せる”
Cart(Discount discount) { this.discount = discount; }
int checkout(int price) { return discount.apply(price); } // 委譲
}
Java割引の種類追加は Discount の実装を増やすだけ。Cart は不変で、切り替えは注入で完了します。
実務での指針と落とし穴(重要な深掘り)
コンポジションは「部品の責務を小さく明確に切る」ほど効きます。巨大なインターフェースに依存すると結局強い結合になります。役割を分解し、最小の契約に依存してください。委譲先の選定はコンストラクタ注入にし、クラス内部で new して固定しないこと。外部資源(ファイル・DB・ネット)はポート(インターフェース)として持ち、アダプタ(具体)は境界の外で作るとテスト容易性が上がります。
一方で、委譲の鎖を深くしすぎると読解が難しくなります。階層は浅く、名前で意図を明確にし、ログや監査などの“横断的関心事”はデコレータで一段ずつ足すのが安全です。また、状態共有が必要なら抽象クラスで骨格を固定し、差し替えは役割で委譲する“併用”が効きます。
まとめと設計の勘所
コンポジション(委譲)は「クラスが役割を持ち、その役割へ仕事を任せる」設計です。継承より結合が弱く、差し替え・テスト・拡張に圧倒的に強い。デコレータで前後処理を重ね、ストラテジで“やり方”を差し替える。依存は抽象へ、注入で固定、責務は小さく——この型を守ると、読みやすく壊れにくい設計が自然に育ちます。
