24
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケモンで学ぶオブジェクト指向〜SOLID原則編〜

Last updated at Posted at 2026-01-15

5つの原則で設計を磨く

1. はじめに:OO原則編の振り返り

前回、オブジェクト指向の3つの原則を学びました。

  • 継承よりコンポジションを好む:タイプを「持つ」設計に
  • 変化する部分をカプセル化する:技を独立したオブジェクトに
  • インターフェースに対してプログラミングするMoveインターフェースに依存

おかげで、複合タイプや個体ごとの技構成を柔軟に表現できるようになりました。

const gyarados = new Pokemon("ギャラドス", [waterType, flyingType], 30, 150);
gyarados.learnMove(new HydroPump());
gyarados.learnMove(new Hurricane());

でも、まだ改善の余地があります。前回の最後に触れた問題を覚えていますか?

「新しい技を追加するたびに、ダメージ計算のコードを毎回書くの?」

「でんじは(変化技)を作ろうとしたら、powerに何を入れればいい?」

これらの問題を解決するのが、今回学ぶSOLID原則です。

SOLIDとは、オブジェクト指向設計の5つの原則の頭文字を取ったものです。

頭文字 原則名 概要
S 単一責任原則 クラスの責任は1つに
O オープン・クローズド原則 拡張に開き、修正に閉じる
L リスコフの置換原則 子は親の代わりになれる
I インターフェース分離原則 不要な依存を強制しない
D 依存性逆転原則 具体ではなく抽象に依存

それぞれ、ポケモンの設計を題材に見ていきましょう。

2. S - 単一責任原則(Single Responsibility Principle)

「クラスを変更する理由は1つであるべき」

現在のPokemonクラスを見てみましょう。
機能を追加していくうちに、こんな風になっていませんか?

class Pokemon {
  // ステータス管理
  name: string;
  types: Type[];
  level: number;
  hp: number;
  moves: Move[];

  // バトル処理
  useMove(index: number, target: Pokemon): void { ... }
  receiveDamage(damage: number): void { ... }
  
  // 回復処理
  heal(amount: number): void { ... }
  cureStatus(): void { ... }
  
  // 図鑑表示
  showPokedexEntry(): void {
    console.log(`No.${this.pokedexNumber} ${this.name}`);
    console.log(`タイプ: ${this.types.map(t => t.name).join("/")}`);
    console.log(this.description);
  }
  
  // セーブデータ変換
  toSaveData(): string {
    return JSON.stringify({
      name: this.name,
      level: this.level,
      moves: this.moves.map(m => m.name)
    });
  }
  
  // 経験値計算
  gainExp(amount: number): void { ... }
  checkLevelUp(): void { ... }
}

このクラスには、いくつの「責任」があるでしょうか?

  • バトルでの戦闘処理
  • ポケモンセンターでの回復処理
  • 図鑑での表示
  • セーブデータへの変換
  • 経験値とレベルアップの管理

責任が多いということは、変更する理由が多いということです。

  • 図鑑の表示形式を変えたい → Pokemonクラスを修正
  • セーブデータの形式を変えたい → Pokemonクラスを修正
  • レベルアップの仕様を変えたい → Pokemonクラスを修正

全く関係ない変更なのに、同じクラスを触ることになります。
これでは修正のたびに他の機能を壊すリスクがあります。

解決策:責任ごとにクラスを分ける

// ポケモン本体:ステータスと基本的な振る舞いだけ
class Pokemon {
  constructor(
    public name: string,
    public types: Type[],
    public level: number,
    public hp: number,
    public moves: Move[]
  ) {}

  receiveDamage(damage: number): void {
    this.hp = Math.max(0, this.hp - damage);
  }
}

// 図鑑表示の責任
class PokedexRenderer {
  render(pokemon: Pokemon, entry: PokedexEntry): void {
    console.log(`No.${entry.number} ${pokemon.name}`);
    console.log(`タイプ: ${pokemon.types.map(t => t.name).join("/")}`);
    console.log(entry.description);
  }
}

// セーブデータ変換の責任
class PokemonSerializer {
  toSaveData(pokemon: Pokemon): string {
    return JSON.stringify({
      name: pokemon.name,
      level: pokemon.level,
      moves: pokemon.moves.map(m => m.name)
    });
  }
  
  fromSaveData(data: string): Pokemon {
    const parsed = JSON.parse(data);
    // 復元処理...
  }
}

// 経験値管理の責任
class ExperienceManager {
  gainExp(pokemon: Pokemon, amount: number): void {
    // 経験値計算とレベルアップ処理
  }
}

これで、各クラスは1つの責任だけを持つようになりました。

  • 図鑑の表示形式を変えたい → PokedexRendererだけ修正
  • セーブデータの形式を変えたい → PokemonSerializerだけ修正
  • レベルアップの仕様を変えたい → ExperienceManagerだけ修正

Pokemonクラスはシンプルになり、他の変更の影響を受けにくくなります。

3. O - オープン・クローズド原則(Open/Closed Principle)

「拡張に対して開いていて、修正に対して閉じている」

新しいタイプを追加するたびに、既存のコードを修正していませんか?

function getTypeEffectiveness(attackType: string, defenseType: string): number {
  if (attackType === "でんき") {
    if (defenseType === "みず") return 2.0;      // 効果ばつぐん
    if (defenseType === "じめん") return 0;      // 効果なし
    if (defenseType === "でんき") return 0.5;    // いまひとつ
    return 1.0;
  }
  
  if (attackType === "ほのお") {
    if (defenseType === "くさ") return 2.0;
    if (defenseType === "みず") return 0.5;
    if (defenseType === "ほのお") return 0.5;
    return 1.0;
  }
  
  if (attackType === "みず") {
    if (defenseType === "ほのお") return 2.0;
    if (defenseType === "くさ") return 0.5;
    if (defenseType === "みず") return 0.5;
    return 1.0;
  }
  
  // フェアリータイプを追加したい?
  // → ここに新しいif文を追加し、
  // → 他の全てのタイプにもフェアリーへの相性を追加する必要がある...
  
  return 1.0;
}

「フェアリー」タイプを追加したくなったら、この関数に新しい分岐を追加する必要があります。さらに、既存の全てのタイプの分岐にも「対フェアリー」の相性を追記しなければなりません。これは「修正に対して閉じていない」状態です。

解決策:タイプ自身が相性を知っている設計にする

class Type {
  private strengths: Set<Type> = new Set();    // 効果ばつぐん
  private weaknesses: Set<Type> = new Set();   // いまひとつ
  private immunities: Set<Type> = new Set();   // 効果なし

  constructor(readonly name: string) {}

  setStrongAgainst(...types: Type[]): this {
    types.forEach(t => this.strengths.add(t));
    return this;
  }

  setWeakAgainst(...types: Type[]): this {
    types.forEach(t => this.weaknesses.add(t));
    return this;
  }

  setNoEffectAgainst(...types: Type[]): this {
    types.forEach(t => this.immunities.add(t));
    return this;
  }

  getEffectiveness(defenseType: Type): number {
    if (this.immunities.has(defenseType)) return 0;
    if (this.strengths.has(defenseType)) return 2.0;
    if (this.weaknesses.has(defenseType)) return 0.5;
    return 1.0;
  }
}

タイプの定義と相性の設定は、データとして追加していきます。

const waterType = new Type("みず");
const fireType = new Type("ほのお");
const grassType = new Type("くさ");
const electricType = new Type("でんき");
const groundType = new Type("じめん");

electricType
  .setStrongAgainst(waterType)
  .setWeakAgainst(electricType, grassType)
  .setNoEffectAgainst(groundType);

fireType
  .setStrongAgainst(grassType)
  .setWeakAgainst(waterType, fireType);

waterType
  .setStrongAgainst(fireType)
  .setWeakAgainst(grassType, waterType);

これで、新しいタイプを追加するときは新しいインスタンスを作成するだけで済みます。

// フェアリータイプを追加!
const fairyType = new Type("フェアリー");
const dragonType = new Type("ドラゴン");

fairyType.setStrongAgainst(dragonType);
dragonType.setWeakAgainst(fairyType);

// Typeクラスも、既存のタイプ定義も、一切修正していない!

TypeクラスのgetEffectivenessメソッドには一切手を加えていません。

  • 拡張に対して開いている → 新しいタイプはインスタンス追加だけで実現
  • 修正に対して閉じている → 既存のTypeクラスや他のタイプ定義を変更する必要がない

この設計なら、ゲームに新しいタイプが追加されても(実際に第6世代でフェアリータイプが追加されました)、既存のコードを修正することなく対応できます。

4. L - リスコフの置換原則(Liskov Substitution Principle)

「子クラスは親クラスの代わりに使えるべき」

タマゴについて考えてみましょう。
タマゴはポケモンの一種...と言えるでしょうか?

手持ちに入れることができるので、「タマゴもポケモンの一種」として扱いたくなります。

class Pokemon {
  name: string;
  level: number;
  moves: Move[];

  useMove(index: number, target: Pokemon): void {
    const move = this.moves[index];
    move.execute(this, target);
  }

  gainExp(amount: number): void {
    // 経験値を得てレベルアップ
  }
}

class Egg extends Pokemon {
  constructor() {
    super("タマゴ", 0, []);
  }

  useMove(index: number, target: Pokemon): void {
    throw new Error("タマゴは技を使えません!");
  }

  gainExp(amount: number): void {
    // 何もしない...タマゴは経験値を得られない
  }
}

バトルシステムを考えてみましょう。

function battle(pokemon: Pokemon, opponent: Pokemon): void {
  console.log(`${pokemon.name}を繰り出した!`);
  pokemon.useMove(0, opponent);
}

const party: Pokemon[] = [pikachu, egg, charizard];

for (const pokemon of party) {
  battle(pokemon, wildPokemon);  // 💥 Eggのところでクラッシュ!
}

Pokemon型を受け取るコードは「useMoveを呼べば技を使ってくれる」と期待しています。
でもEggはその期待を裏切り、エラーを投げます。

これは「子クラスが親クラスの代わりに使えていない」状態です。

育て屋の経験値計算でも問題が起きます。

function addDayCareExp(party: Pokemon[]): void {
  for (const pokemon of party) {
    pokemon.gainExp(100);
    console.log(`${pokemon.name}の経験値が上がった!`);
  }
}

// タマゴに「経験値が上がった」と表示されるが、実際は何も起きていない

解決策:そもそもの継承関係を見直す

タマゴは本当に「ポケモン」でしょうか?

手持ちに入れられるという共通点はありますが、バトルに参加できない、経験値を得られないという点で、本質的に異なる存在です。

// 手持ちに入れられるもの
interface PartyMember {
  name: string;
  getPartyIcon(): string;
}

// バトルに参加できるポケモン
interface Battlable {
  useMove(index: number, target: Pokemon): void;
  receiveDamage(amount: number): void;
}

// 経験値を得られるポケモン
interface Trainable {
  gainExp(amount: number): void;
}

class Pokemon implements PartyMember, Battlable, Trainable {
  constructor(
    public name: string,
    public level: number,
    private moves: Move[]
  ) {}

  getPartyIcon(): string {
    return `${this.name} Lv.${this.level}`;
  }

  useMove(index: number, target: Pokemon): void {
    const move = this.moves[index];
    move.execute(this, target);
  }

  receiveDamage(amount: number): void {
    this.hp = Math.max(0, this.hp - amount);
  }

  gainExp(amount: number): void {
    this.exp += amount;
  }
}

class Egg implements PartyMember {
  name = "タマゴ";
  private steps: number = 0;

  getPartyIcon(): string {
    return "タマゴ(あと少し...)";
  }

  // Battlable, Trainableは実装しない
  // バトルや経験値の機能を持たないことが型で明示される

  walk(): void {
    this.steps++;
  }
}

これで、バトルシステムはBattlableを要求するように変わります。

function battle(pokemon: Battlable, opponent: Pokemon): void {
  pokemon.useMove(0, opponent);
}

const pikachu = new Pokemon("ピカチュウ", 25, [thunderbolt]);
const egg = new Egg();

battle(pikachu, wildPokemon);  // ✅ OK
battle(egg, wildPokemon);      // ❌ コンパイルエラー!Eggは渡せない

手持ち表示はPartyMemberを使います。

function showParty(members: PartyMember[]): void {
  members.forEach((member, i) => {
    console.log(`${i + 1}: ${member.getPartyIcon()}`);
  });
}

const party: PartyMember[] = [pikachu, egg, charizard];
showParty(party);  // ✅ タマゴも含めて表示できる
// 1: ピカチュウ Lv.25
// 2: タマゴ(あと少し...)
// 3: リザードン Lv.36

リスコフの置換原則のポイントは、「親クラスを使うコードが期待する振る舞いを、子クラスも守る」ということです。

守れないなら、そもそも継承関係が間違っている可能性があります。
「タマゴはポケモンの一種」という素朴な発想を捨て、「手持ちに入れられるもの」「バトルできるもの」という役割で分類し直すことで、問題を解決できました。

5. I - インターフェース分離原則(Interface Segregation Principle)

「使わないメソッドへの依存を強制しない」

前回の予告で触れた問題です。
現在のMoveインターフェースを見てみましょう。

interface Move {
  name: string;
  power: number;
  type: Type;
  execute(user: Pokemon, target: Pokemon): void;
}

「でんじは」という技を作ろうとすると、困ったことが起きます。

class ThunderWave implements Move {
  name = "でんじは";
  power = 0;  // 変化技にpowerは不要なのに...何を入れる?
  type = electricType;

  execute(user: Pokemon, target: Pokemon): void {
    console.log(`${target.name}はまひしてしまった!`);
  }
}

powerは攻撃技には必要ですが、変化技には意味がありません。でもMoveインターフェースを実装するには、powerを定義するしかありません。

「つるぎのまい」のような自分を対象にする技はどうでしょう?

class SwordsDance implements Move {
  name = "つるぎのまい";
  power = 0;  // 不要
  type = normalType;

  execute(user: Pokemon, target: Pokemon): void {
    // targetは使わない...自分を強化する技なので
    console.log(`${user.name}のこうげきがぐーんと上がった!`);
  }
}

target引数も使いません。
不要なものに依存させられている状態です。

解決策:インターフェースを分離する

// 全ての技に共通する部分
interface Move {
  name: string;
  type: Type;
}

// 攻撃技用のインターフェース
interface AttackMove extends Move {
  power: number;
  execute(user: Pokemon, target: Pokemon): void;
}

// 変化技用のインターフェース(対象あり)
interface StatusMove extends Move {
  execute(user: Pokemon, target: Pokemon): void;
}

// 変化技用のインターフェース(自分対象)
interface SelfMove extends Move {
  execute(user: Pokemon): void;
}

各技は必要なインターフェースだけを実装します。

class Thunderbolt implements AttackMove {
  name = "10まんボルト";
  power = 90;
  type = electricType;

  execute(user: Pokemon, target: Pokemon): void {
    console.log(`${user.name}の10まんボルト!`);
    // ダメージ計算...
  }
}

class ThunderWave implements StatusMove {
  name = "でんじは";
  type = electricType;
  // powerは不要なので定義しない!

  execute(user: Pokemon, target: Pokemon): void {
    console.log(`${target.name}はまひしてしまった!`);
  }
}

class SwordsDance implements SelfMove {
  name = "つるぎのまい";
  type = normalType;

  execute(user: Pokemon): void {
    console.log(`${user.name}のこうげきがぐーんと上がった!`);
  }
}

各クラスは、本当に必要なものだけを持つようになりました。

6. D - 依存性逆転原則(Dependency Inversion Principle)

「具体ではなく抽象に依存する」

これはOO原則編で学んだ「インターフェースに対してプログラミングする」の発展形です。

バトルシステムを作ることを考えましょう。
ダメージ計算が必要です。

// ダメージ計算クラス
class Pokemon {
  attack: number;
  defense: number;
  // ...
}

class SimpleDamageCalculator {
  calculate(move: Thunderbolt, attacker: Pokemon, defender: Pokemon): number {
    return move.power * (attacker.attack / defender.defense);
  }
}

このSimpleDamageCalculatorThunderboltという具体的なクラスに依存しています。
他の技でダメージ計算をしたい場合はどうすればいいでしょうか?

// ❌ 技ごとにメソッドを追加する?
class SimpleDamageCalculator {
  calculateThunderbolt(move: Thunderbolt, attacker: Pokemon, defender: Pokemon): number { ... }
  calculateFlamethrower(move: Flamethrower, attacker: Pokemon, defender: Pokemon): number { ... }
  calculateIceBeam(move: IceBeam, attacker: Pokemon, defender: Pokemon): number { ... }
  // 技が増えるたびにメソッドも増える...
}

これでは新しい技を追加するたびにSimpleDamageCalculatorを修正する必要があります。

解決策:抽象(インターフェース)に依存する

// ✅ AttackMoveインターフェースに依存
class DamageCalculator {
  calculate(move: AttackMove, attacker: Pokemon, defender: Pokemon): number {
    const typeEffectiveness = this.getTypeEffectiveness(move.type, defender.getTypes());
    return Math.floor(move.power * (attacker.attack / defender.defense) * typeEffectiveness);
  }

  private getTypeEffectiveness(moveType: Type, defenderTypes: Type[]): number {
    let effectiveness = 1.0;
    for (const defType of defenderTypes) {
      effectiveness *= moveType.getEffectiveness(defType);
    }
    return effectiveness;
  }
}

DamageCalculatorAttackMoveというインターフェースに依存しています。
具体的なThunderboltFlamethrowerのことは知りません。

const calculator = new DamageCalculator();

const thunderbolt = new Thunderbolt();
const flamethrower = new Flamethrower();
const iceBeam = new IceBeam();

// どんな技でも同じメソッドで計算できる
calculator.calculate(thunderbolt, pikachu, gyarados);
calculator.calculate(flamethrower, charizard, venusaur);
calculator.calculate(iceBeam, lapras, dragonite);

さらに、バトルシステム全体で考えてみましょう。

// ❌ 具体的なクラスに依存
class BattleSystem {
  private calculator = new DamageCalculator();  // 具体的なクラスを直接生成

  executeTurn(attacker: Pokemon, move: AttackMove, defender: Pokemon): void {
    const damage = this.calculator.calculate(move, attacker, defender);
    defender.receiveDamage(damage);
  }
}

一見問題なさそうですが、この設計だとダメージ計算のロジックを差し替えられません。
例えば、テスト時に固定ダメージを返したい場合や、ダブルバトル用の計算式に切り替えたい場合に困ります。

// ダメージ計算のインターフェース
interface DamageCalculator {
  calculate(move: AttackMove, attacker: Pokemon, defender: Pokemon): number;
}

// 通常のダメージ計算
class StandardDamageCalculator implements DamageCalculator {
  calculate(move: AttackMove, attacker: Pokemon, defender: Pokemon): number {
    const typeEffectiveness = this.getTypeEffectiveness(move.type, defender.getTypes());
    return Math.floor(move.power * (attacker.attack / defender.defense) * typeEffectiveness);
  }

  private getTypeEffectiveness(moveType: Type, defenderTypes: Type[]): number {
    // ...
  }
}

// テスト用の固定ダメージ
class FixedDamageCalculator implements DamageCalculator {
  constructor(private fixedDamage: number) {}

  calculate(move: AttackMove, attacker: Pokemon, defender: Pokemon): number {
    return this.fixedDamage;
  }
}

// ✅ インターフェースに依存
class BattleSystem {
  constructor(private calculator: DamageCalculator) {}  // 外から注入

  executeTurn(attacker: Pokemon, move: AttackMove, defender: Pokemon): void {
    const damage = this.calculator.calculate(move, attacker, defender);
    defender.receiveDamage(damage);
  }
}
// 本番環境
const battleSystem = new BattleSystem(new StandardDamageCalculator());

// テスト環境(常に50ダメージ)
const testBattleSystem = new BattleSystem(new FixedDamageCalculator(50));

// ダブルバトル用(将来の拡張)
const doubleBattleSystem = new BattleSystem(new DoubleBattleDamageCalculator());

これが依存性逆転原則です。

  • BattleSystem(上位モジュール)はDamageCalculator(インターフェース)に依存
  • StandardDamageCalculator(下位モジュール)もDamageCalculator(インターフェース)に依存(実装)
  • 両者が抽象に依存することで、実装を自由に差し替えられる
❌ Before: BattleSystem → StandardDamageCalculator
           (上位が下位の具体クラスに直接依存)

✅ After:  BattleSystem → DamageCalculator ← StandardDamageCalculator
                          (インターフェース) ← FixedDamageCalculator
           (上位は抽象に依存、下位は抽象を実装)

7. まとめ

SOLID原則を振り返りましょう。

原則 ポイント ポケモンでの例
S - 単一責任 変更理由は1つに Pokemonから図鑑表示やセーブ機能を分離
O - オープン・クローズド 拡張は簡単、修正は不要 基底クラスで共通のダメージ計算をまとめる
L - リスコフの置換 子は親の代わりになれる タマゴはポケモンとして扱えない → 継承関係を見直す
I - インターフェース分離 不要な依存を強制しない 攻撃技と変化技でインターフェースを分ける
D - 依存性逆転 具体より抽象に依存 BattleSystemDamageCalculatorインターフェースに依存

これらの原則は、個別に覚えるものではなく、相互に関連しています。

  • 単一責任を守ると、自然とクラスが小さくなる
  • 小さいクラスは、インターフェースも小さくなる(インターフェース分離)
  • インターフェースに依存すると、拡張が楽になる(オープン・クローズド、依存性逆転)

次回予告

ここまでで、オブジェクト指向の基礎・OO原則・SOLID原則を学んできました。

実は、OO原則編で作った「技」の設計を覚えていますか?

class Pokemon {
  private moves: Move[] = [];
  
  learnMove(move: Move): void { ... }
  useMove(index: number, target: Pokemon): void { ... }
}

技を独立したオブジェクトとして持ち、実行時に切り替える。
これはStrategyパターンと呼ばれる有名なデザインパターンです。

次回は「デザインパターン編」として、私たちがすでに使っていたパターンの名前と、他の便利なパターンを学んでいきます。

  • Strategy → 技の切り替え(もう使ってた!)
  • Factory → ポケモンの生成(タマゴ孵化、野生エンカウント)
  • Observer → 状態異常の処理(毎ターン毒ダメージ)
  • Decorator → アイテム効果(こだわりハチマキ装備)

お楽しみに!

シリーズ目次

本記事は「ポケモンで学ぶオブジェクト指向」シリーズの一部です。

  1. 基礎編:ピカチュウをコードで表現してみよう
  2. OO原則編:継承だけでは表現できない世界
  3. SOLID原則編:5つの原則で設計を磨く
  4. デザインパターン編:名前を知れば会話ができる
24
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?