はじめに
オブジェクト指向設計において、クラス間の関係を表現する方法として 「is-a(〜は〜である)」 と 「has-a(〜は〜を持つ)」 という2つの考え方があります。
この記事では、それぞれの関係の意味と、どのような場合に継承を使い、どのような場合にコンポジション(合成)やインターフェースを使うべきかを、TypeScript のコード例を交えながら解説します。
is-a 関係とは
is-a 関係は「AはBの一種である」という関係を表します。この関係は 継承(inheritance) で表現します。
犬 is-a 動物 → 「犬は動物の一種である」
猫 is-a 動物 → 「猫は動物の一種である」
正社員 is-a 社員 → 「正社員は社員の一種である」
class Animal {
breathe() {
console.log("呼吸する");
}
}
class Dog extends Animal {
bark() {
console.log("ワン!");
}
}
class Cat extends Animal {
meow() {
console.log("ニャー!");
}
}
const dog = new Dog();
dog.breathe(); // 呼吸する(Animal から継承)
dog.bark(); // ワン!
Dog は Animal の一種なので、Animal のすべての振る舞い(breathe)を引き継ぎます。
has-a 関係とは
has-a 関係は「AはBを持っている」という関係を表します。この関係は コンポジション(composition / 合成) で表現します。
車 has-a エンジン → 「車はエンジンを持っている」
人 has-a 住所 → 「人は住所を持っている」
会社 has-a 社員 → 「会社は社員を持っている」
class Engine {
start() {
console.log("エンジン始動");
}
}
class Car {
constructor(private engine: Engine) {} // 車はエンジンを「持っている」
drive() {
this.engine.start();
console.log("走行開始");
}
}
const car = new Car(new Engine());
car.drive();
// エンジン始動
// 走行開始
Car は Engine を「持っている」だけであり、Engine の一種ではありません。
継承を使うべきとき
以下の条件を すべて 満たす場合に、継承の使用を検討します。
- 真の「is-a」関係がある: 子クラスが親クラスの「一種」であることが自然に成り立つ
- 振る舞いの互換性がある: 親クラスの代わりに子クラスを使っても、プログラムが正しく動作する(リスコフの置換原則)
- 共通の振る舞いを再利用したい: 親クラスのメソッドをそのまま使える
継承が適切な例: ログの出力形式
abstract class Logger {
abstract formatMessage(message: string): string;
log(message: string) {
console.log(this.formatMessage(message));
}
}
class TextLogger extends Logger {
formatMessage(message: string): string {
return `[TEXT] ${message}`;
}
}
class JsonLogger extends Logger {
formatMessage(message: string): string {
return JSON.stringify({ log: message });
}
}
function writeLog(logger: Logger, message: string) {
logger.log(message);
}
writeLog(new TextLogger(), "処理完了"); // [TEXT] 処理完了
writeLog(new JsonLogger(), "処理完了"); // {"log":"処理完了"}
TextLogger も JsonLogger も「Logger の一種」であり、Logger を期待する箇所でどちらを使っても正しく動作します。これが継承の正しい使い方です。
継承のメリット
- 共通機能を親クラスにまとめて再利用できる
- 多態性(ポリモーフィズム)により柔軟な拡張が可能
継承を使うべきでないとき
以下のような場合は、継承ではなく別のアプローチを使うべきです。
ケース1: 「is-a」に見えるが振る舞いが異なる(LSP 違反)
Rectangle / Square 問題 が典型例です。
// 悪い例: 正方形は長方形の一種...のはずが、振る舞いが異なる
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // 親クラスの契約を破っている
}
setHeight(height: number) {
this.width = height;
this.height = height;
}
}
function printArea(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(4);
console.log(rect.getArea()); // Rectangle なら 20 を期待
}
printArea(new Rectangle(0, 0)); // 20
printArea(new Square(0, 0)); // 16 — 期待と異なる!
数学的には「正方形 is-a 長方形」ですが、プログラム上では setWidth と setHeight の振る舞いが異なるため、リスコフの置換原則に違反します。
ケース2: 機能の再利用だけが目的
「コードを再利用したいから」という理由だけで継承を使うのは危険です。継承は構造的に「強い依存関係」を作るため、親クラスの変更が子クラスに波及しやすく、保守性が下がります。
// 悪い例: 「print メソッドがあるから」という理由だけで継承
class Printer {
print() {
console.log("印刷します");
}
}
class Report extends Printer {
create() {
console.log("レポート作成中...");
}
}
const report = new Report();
report.create();
report.print(); // 一見便利だが、Report は「Printer ではない」(is-a が成立しない)
「レポートはプリンターではない」ため is-a 関係が破綻しています。
// 良い例: コンポジション(has-a)で設計
class Printer {
print() {
console.log("印刷します");
}
}
class Report {
private printer: Printer;
constructor() {
this.printer = new Printer();
}
create() {
console.log("レポート作成中...");
}
output() {
this.printer.print(); // 委譲
}
}
Report は Printer を 持つ(has-a) 関係にすることで、自然な設計になります。
もう1つの典型例として、Stack と ArrayList の関係があります。
// 悪い例: Stack は ArrayList の「一種」ではない
class Stack<T> extends ArrayList<T> {
push(item: T) { this.add(item); }
pop(): T { return this.removeLast(); }
}
// 問題: add(), get(), insert() なども使えてしまう
// 良い例: コンポジションで実装
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
isEmpty(): boolean { return this.items.length === 0; }
}
ケース3: 「is-a」でも振る舞いの差が大きすぎる
「A is B」であっても、振る舞いの差が大きすぎる場合は継承ではなくインターフェースを使う方が適しています。
// 悪い例: ペンギンは鳥だが、飛べない
class Bird {
fly() {
console.log("空を飛ぶ");
}
}
class Penguin extends Bird {
fly() {
throw new Error("ペンギンは飛べない"); // LSP 違反
}
}
Penguin is a Bird(ペンギンは鳥である)は成り立ちますが、「鳥=飛べる」という前提が崩れており、継承による不整合が起きています。
// 良い例: インターフェースによる抽象化
interface Animal {
move(): void;
}
class Bird implements Animal {
move() {
console.log("空を飛ぶ");
}
}
class Penguin implements Animal {
move() {
console.log("泳ぐ");
}
}
「飛ぶ」ではなく「移動する」という抽象的な概念に変更し、インターフェースで共通の契約を定義しています。これにより、各クラスが自分に適した振る舞いを実装でき、LSP 違反を回避できます。
補足: この修正例はコンポジションや委譲ではなく、インターフェースによるポリモーフィズムです。「振る舞いの契約を共有するが、構造的には独立」という設計手法です。
ケース4: 複数の機能を組み合わせたい
TypeScript はクラスの多重継承をサポートしていないため、複数の親クラスから機能を引き継ぐことができません。このような場合はコンポジションが適しています。
class Mailer {
send(to: string, body: string) {
console.log(`メール送信: ${to} - ${body}`);
}
}
class Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
class UserRegistrationService {
constructor(
private mailer: Mailer,
private logger: Logger
) {}
register(name: string, email: string) {
// ユーザー登録処理...
this.logger.log(`${name} を登録しました`);
this.mailer.send(email, "登録完了のお知らせ");
}
}
UserRegistrationService はメール送信機能とログ機能の「一種」ではなく、それらを「持っている」だけです。
コンポジション + 委譲(デリゲーション)
コンポジションと委譲を組み合わせることで、継承よりも柔軟な設計が可能になります。
interface Movement {
move(): void;
}
class FlyMovement implements Movement {
move() {
console.log("空を飛ぶ");
}
}
class SwimMovement implements Movement {
move() {
console.log("泳ぐ");
}
}
class WalkMovement implements Movement {
move() {
console.log("歩く");
}
}
class Animal {
constructor(
private name: string,
private movement: Movement // コンポジション: 移動方法を「持つ」
) {}
move() {
console.log(`${this.name}:`);
this.movement.move(); // 委譲: 移動処理を Movement に任せる
}
}
const bird = new Animal("鳥", new FlyMovement());
const penguin = new Animal("ペンギン", new SwimMovement());
const dog = new Animal("犬", new WalkMovement());
bird.move(); // 鳥: 空を飛ぶ
penguin.move(); // ペンギン: 泳ぐ
dog.move(); // 犬: 歩く
この設計では、以下が実現されています。
-
AnimalがMovementを持つ(has-a) → コンポジション -
Animalのmove()がMovementのmove()を呼び出す → 委譲(デリゲーション) - 実行時に
Movementを差し替えることも可能 → 柔軟性
継承階層を作るよりも、振る舞いをオブジェクトとして切り出す方が拡張性に優れています。
4つのクラス間関係の整理
is-a と has-a に加えて、実務では以下のような関係も意識すると設計の精度が上がります。
| 判定基準 | 関係 | 設計手法 | 解説 |
|---|---|---|---|
| A is B(〜は〜である) | is-a | 継承 | 構造的・意味的に上位概念で分類できるとき |
| A has B(〜は〜を持つ) | has-a | コンポジション | A の内部で B を部品として利用する場合 |
| A uses B(〜が〜を使う) | uses | 依存関係注入(DI) | 処理の一部として B を呼び出すが、構造的には別物 |
| A behaves like B(〜のように振る舞う) | behaves-like | インターフェース実装 | 行動の契約を共有するが、構造的には別物 |
「Composition over Inheritance」の原則
GoF(Gang of Four)の名著 Design Patterns(1994年)では、以下の原則が提唱されています。
「クラスの継承よりもオブジェクトのコンポジションを多用せよ」
この原則が生まれた背景には、継承が持つ以下の問題点があります。
| 観点 | 継承 | コンポジション |
|---|---|---|
| 結合度 | 密結合(親の変更が子に波及) | 疎結合(インターフェースを介する) |
| 柔軟性 | コンパイル時に固定 | 実行時に差し替え可能 |
| テスト | 親クラスごとテストが必要 | モックに差し替えやすい |
| 再利用 | 継承階層に縛られる | 自由に組み合わせ可能 |
GoF のデザインパターンの多く(Strategy、Decorator、Adapter など)は、継承ではなくコンポジションを活用してポリモーフィズムを実現しています。
判断のフローチャート
継承とコンポジションの使い分けに迷ったとき、以下の順序で判断してみてください。
-
「AはBの一種である」と自然に言えるか?
- 言えない → コンポジション
- 言える → 次へ
-
子クラスを親クラスの代わりに使っても正しく動作するか?(LSP)
- 動作しない → コンポジション or インターフェース
- 動作する → 次へ
-
親クラスの公開メソッドをすべて子クラスで使うか?
- 使わない(一部を隠したい) → コンポジション
- すべて使う → 継承を検討
迷ったらコンポジションを選ぶのが安全です。コンポジションで実装した後に、必要があれば継承に変更することは容易ですが、その逆は困難です。
SOLID原則との関連
is-a / has-a の判断は、SOLID原則と密接に関連しています。
- SRP(単一責務の原則): 継承で複数の責務を引き継ぐと SRP に違反しやすくなります。コンポジションなら責務ごとにクラスを分離できます
- OCP(開放閉鎖の原則): コンポジション + インターフェースにより、既存コードを修正せずに拡張できます
- LSP(リスコフの置換原則): 継承を使う場合、子クラスが親クラスの代わりに使えることを保証する必要があります
- ISP(インターフェース分離の原則): コンポジションで小さなインターフェースを組み合わせることで ISP に準拠しやすくなります
- DIP(依存性逆転の原則): コンポジション + インターフェースにより、抽象への依存が自然に実現できます
各原則の詳細は SOLID原則のまとめ をご参照ください。
まとめ
| 関係 | 意味 | 実現方法 | 使いどころ |
|---|---|---|---|
| is-a | 「AはBの一種」 | 継承 | 真の分類関係があり、LSP を満たす場合 |
| has-a | 「AはBを持つ」 | コンポジション | 機能の再利用、柔軟な組み合わせが必要な場合 |
| behaves-like | 「Aは B のように振る舞う」 | インターフェース | 行動の契約を共有するが、構造は独立の場合 |
継承は「構造を共有するため」ではなく「概念を共有するため」に使うものです。「機能を使いたいだけ」の場合は、委譲・コンポジションの方が柔軟です。
設計判断では、常に 「is-a or has-a?」 を問い直すこと。そして 迷ったらコンポジション を選びましょう。
問題集
この記事の内容についての問題集はこちらで公開しています。理解度の確認にご活用ください。