継承だけでは表現できない世界
1.はじめに:基礎編の設計を振り返る
前回、オブジェクト指向の基礎として、クラス・インスタンス・カプセル化・継承・ポリモーフィズムを学びました。
abstract class Pokemon {
constructor(
public name: string,
protected level: number,
protected hp: number
) {}
abstract cry(): void;
}
class Pikachu extends Pokemon {
constructor(level: number) {
super("ピカチュウ", level, 35 + level);
}
cry(): void {
console.log("ピカチュウ!");
}
thunderbolt(): void {
console.log("10まんボルト!");
}
}
一見よさそうですが、この設計にはいくつか問題が隠れています。
前回の最後に触れた「タイプ」と「技」の問題を、改めて考えてみましょう。
2. 継承の限界:タイプをどう表現する?
ポケモンにはタイプがあります。
ピカチュウはでんきタイプ、コイキングはみずタイプ。
これを継承で表現しようとすると、どうなるでしょうか?
// タイプごとに親クラスを作る?
class ElectricPokemon extends Pokemon {}
class WaterPokemon extends Pokemon {}
class FlyingPokemon extends Pokemon {}
class Pikachu extends ElectricPokemon {} // ピカチュウは電気タイプ
class Koiking extends WaterPokemon {} // コイキングは水タイプ
単一タイプならうまくいきそうです。
ではギャラドス(みず・ひこう)はどうでしょうか?
// 多重継承? → TypeScriptではできない
class Gyarados extends WaterPokemon, FlyingPokemon {} // ❌ エラー!
// 複合タイプ専用のクラスを作る?
class WaterFlyingPokemon extends Pokemon {}
class Gyarados extends WaterFlyingPokemon {} // 動くけど...
複合タイプ用のクラスを作れば動きます。
でも、タイプの組み合わせは100通り以上。
FireFlyingPokemon、WaterGroundPokemon、GrassPoisonPokemon...全部作りますか?
これが継承の限界です。
原則①:継承よりコンポジションを好む
ここで役立つのが「継承よりコンポジション」という原則です。
コンポジションとは何でしょうか?
継承は「親クラスを拡張する」という関係です。
一方、コンポジションは「他のオブジェクトを部品として持つ」という関係です。
// 継承:ピカチュウ「は」電気タイプポケモン「である」
class Pikachu extends ElectricPokemon {}
// コンポジション:ピカチュウ「は」電気タイプ「を持っている」
class Pikachu {
private type: Type = electricType;
}
継承が「is-a(〜である)」の関係なら、コンポジションは「has-a(〜を持っている)」の関係です。
タイプの問題を、コンポジションで解決してみましょう。
まず、タイプを独立したオブジェクトとして定義します。
interface Type {
name: string;
// 相性計算(将来の拡張用)
getEffectiveness(target: Type): number;
}
// 各タイプを作る
const electricType: Type = {
name: "でんき",
getEffectiveness(target: Type): number {
if (target.name === "みず") return 2.0; // 効果バツグン
if (target.name === "でんき") return 0.5; // いまひとつ
return 1.0;
}
};
const waterType: Type = {
name: "みず",
getEffectiveness(target: Type): number {
if (target.name === "ほのお") return 2.0;
if (target.name === "みず") return 0.5;
return 1.0;
}
};
const flyingType: Type = {
name: "ひこう",
getEffectiveness(target: Type): number {
if (target.name === "くさ") return 2.0;
if (target.name === "でんき") return 0.5;
return 1.0;
}
};
次に、ポケモンがタイプを「持つ」設計に変えます。
class Pokemon {
private types: Type[];
constructor(
public name: string,
types: Type[],
protected level: number,
protected hp: number
) {
this.types = types;
}
getTypes(): Type[] {
return [...this.types];
}
}
これで複合タイプも自由自在です。
// 単一タイプ
const pikachu = new Pokemon("ピカチュウ", [electricType], 25, 100);
// 複合タイプも同じように作れる!
const gyarados = new Pokemon("ギャラドス", [waterType, flyingType], 30, 150);
const charizard = new Pokemon("リザードン", [fireType, flyingType], 36, 140);
継承で100個以上のクラスを作る必要はなくなりました!
3. 変化する部分をカプセル化する:技の設計
次は技の問題です。
基礎編では、技をメソッドとして定義していました。
class Pikachu extends Pokemon {
thunderbolt(): void {
console.log("10まんボルト!");
}
}
この設計の問題点は何でしょうか?
実際のゲームでは、同じピカチュウでも個体によって覚えている技は違います。
サトシのピカチュウは「10まんボルト」「アイアンテール」「でんこうせっか」を覚えていますが、野生のピカチュウは「でんきショック」しか使えないかもしれません。
メソッドとして定義すると、全てのピカチュウが同じ技を持つことになってしまいます。
原則②:変化する部分をカプセル化する
「変化する部分」を見つけて、それを独立させましょう。
ここで変化するのは「技」です。
技を独立したオブジェクトとして切り出します。
interface Move {
name: string;
power: number;
type: Type;
execute(user: Pokemon, target: Pokemon): void;
}
具体的な技は、このインターフェースを実装して作ります。
class Thunderbolt implements Move {
name = "10まんボルト";
power = 90;
type = electricType;
execute(user: Pokemon, target: Pokemon): void {
console.log(`${user.name}の10まんボルト!`);
// ダメージ計算ロジック...
}
}
class QuickAttack implements Move {
name = "でんこうせっか";
power = 40;
type = normalType;
execute(user: Pokemon, target: Pokemon): void {
console.log(`${user.name}のでんこうせっか!`);
}
}
class ThunderShock implements Move {
name = "でんきショック";
power = 40;
type = electricType;
execute(user: Pokemon, target: Pokemon): void {
console.log(`${user.name}のでんきショック!`);
}
}
ポケモンは技を「持つ」ようになります。
class Pokemon {
private types: Type[];
private moves: Move[] = [];
constructor(
public name: string,
types: Type[],
protected level: number,
protected hp: number
) {
this.types = types;
}
// 技を覚える
learnMove(move: Move): void {
if (this.moves.length >= 4) {
console.log("技は4つまでしか覚えられない!");
return;
}
this.moves.push(move);
console.log(`${this.name}は${move.name}を覚えた!`);
}
// 技を使う
useMove(index: number, target: Pokemon): void {
const move = this.moves[index];
if (!move) {
console.log("その技は覚えていない!");
return;
}
move.execute(this, target);
}
// 覚えている技を確認
getMoves(): Move[] {
return [...this.moves];
}
}
これで、個体ごとに異なる技構成が実現できます。
// サトシのピカチュウ
const satoshiPikachu = new Pokemon("ピカチュウ", [electricType], 50, 120);
satoshiPikachu.learnMove(new Thunderbolt());
satoshiPikachu.learnMove(new QuickAttack());
satoshiPikachu.learnMove(new IronTail());
// 野生のピカチュウ(弱い技だけ)
const wildPikachu = new Pokemon("ピカチュウ", [electricType], 5, 35);
wildPikachu.learnMove(new ThunderShock());
4. インターフェースに対してプログラミングする
タイプと技の設計を通じて、もう一つの重要な原則が見えてきます。
原則③:インターフェースに対してプログラミングする
PokemonクラスのuseMoveメソッドをもう一度見てみましょう。
class Pokemon {
private moves: Move[] = [];
// 技を使う
useMove(index: number, target: Pokemon): void {
const move = this.moves[index];
if (!move) {
console.log("その技は覚えていない!");
return;
}
move.execute(this, target);
}
}
このメソッドは、技が「10まんボルト」なのか「でんこうせっか」なのかを知りません。
知っているのは「Moveインターフェースにexecuteメソッドがある」ということだけです。
// Pokemonクラスは具体的な技クラスを知らない
move.execute(this, target); // Thunderbolt? QuickAttack? 知らなくても動く
これが「インターフェースに対してプログラミングする」ということです。
具体的なThunderboltクラスやQuickAttackクラスではなく、Moveインターフェースに依存することで、新しい技を追加してもPokemonクラスを変更する必要がありません。
// 新しい技を追加
class VoltTackle implements Move {
name = "ボルテッカー";
power = 120;
type = electricType;
execute(user: Pokemon, target: Pokemon): void {
console.log(`${user.name}のボルテッカー!`);
// 反動ダメージの計算...
}
}
// Pokemonクラスは一切変更なしで新しい技が使える
satoshiPikachu.learnMove(new VoltTackle());
satoshiPikachu.useMove(3, gyarados); // ボルテッカー!
もしPokemonクラスが具体的な技クラスに依存していたらどうなるでしょうか?
// ❌ 悪い例:具体的なクラスに依存
class Pokemon {
useMove(move: Thunderbolt | QuickAttack | VoltTackle, target: Pokemon): void {
if (move instanceof Thunderbolt) {
console.log("10まんボルト!");
// Thunderbolt固有の処理...
} else if (move instanceof QuickAttack) {
console.log("でんこうせっか!");
// QuickAttack固有の処理...
} else if (move instanceof VoltTackle) {
console.log("ボルテッカー!");
// VoltTackle固有の処理...
}
// 新しい技を追加するたびにここを修正...
}
}
技が増えるたびにPokemonクラスを修正することになります。
これでは拡張性がありません。
インターフェースに依存することで、Pokemonクラスは「技の使い方」という抽象的な知識だけを持ち、具体的な技の詳細はMove実装クラスに任せることができます。
5. Before/After比較
今回の変更を振り返ってみましょう。
| 観点 | Before(基礎編) | After(OO原則適用後) |
|---|---|---|
| タイプ | 継承で表現 → 複合タイプで破綻 | コンポジションで柔軟に表現 |
| 技 | クラスのメソッドとして固定 | 独立したオブジェクトで個体ごとに設定可能 |
| 拡張性 | 新タイプ・新技追加でクラス修正が必要 | 既存コード変更なしで追加可能 |
6. まとめ
今回学んだ3つの原則を整理します。
継承よりコンポジションを好む
「is-a(〜である)」より「has-a(〜を持っている)」で考える。
タイプを継承ではなく、ポケモンが「持つ」ものとして設計することで、複合タイプを柔軟に表現できました。
変化する部分をカプセル化する
変化しやすい部分を見つけて独立させる。
技をメソッドからオブジェクトに切り出すことで、個体ごとに異なる技構成を実現できました。
インターフェースに対してプログラミングする
具体的なクラスではなく、インターフェースに依存する。
Moveインターフェースを使うことで、新しい技を追加しても既存コードを変更せずに済みました。
次回予告
今回の設計、だいぶ良くなりました。
でも、まだ改善の余地があります。
「新しい技を追加するたびに、ダメージ計算のコードを毎回書くの?」
「でんじは(変化技)を作ろうとしたら、
powerに何を入れればいい?攻撃技と変化技、同じインターフェースで本当にいいの?」
次回は「SOLID原則編」を通じて、さらに堅牢で拡張しやすい設計を目指します。
お楽しみに!
シリーズ目次
本記事は「ポケモンで学ぶオブジェクト指向」シリーズの一部です。