名前を知れば会話ができる
1. はじめに:なぜデザインパターンを学ぶのか
ここまで、オブジェクト指向の基礎・OO原則・SOLID原則を学んできました。
実は、このシリーズの中で、すでにいくつかの「デザインパターン」を使っていたことをご存知でしょうか?
デザインパターンとは、先人たちが発見した「よくある問題への解決策」に名前をつけたものです。
OO原則編で作った技の設計を覚えていますか?
class Pokemon {
private moves: Move[] = [];
learnMove(move: Move): void { ... }
useMove(index: number, target: Pokemon): void {
const move = this.moves[index];
move.execute(this, target);
}
}
技を独立したオブジェクトとして持ち、実行時に切り替える。
この設計にはStrategyパターンという名前がついています。
デザインパターンを学ぶメリットは何でしょうか?
- 車輪の再発明を避けられる:よくある問題には、すでに良い解決策がある
- チームでの共通言語になる:「ここはStrategyで」の一言で設計意図が伝わる
- コードの意図が明確になる:パターン名がドキュメント代わりになる
今回は、ポケモンの設計を題材に、4つの代表的なパターンを学んでいきましょう。
2. Strategy パターン:技の切り替え
「アルゴリズムをカプセル化し、実行時に切り替え可能にする」
まずは、すでに使っていたStrategyパターンから始めましょう。
OO原則編で、技をメソッドからオブジェクトに切り出しました。
// Before: 技がメソッドとして固定
class Pikachu extends Pokemon {
thunderbolt(): void {
console.log("10まんボルト!");
}
}
// After: 技が独立したオブジェクト
interface Move {
name: string;
execute(user: Pokemon, target: Pokemon): void;
}
class Thunderbolt implements Move {
name = "10まんボルト";
execute(user: Pokemon, target: Pokemon): void {
console.log(`${user.name}の10まんボルト!`);
}
}
そして、ポケモンは技を「持ち」、実行時に選択します。
class Pokemon {
private moves: Move[] = [];
learnMove(move: Move): void {
this.moves.push(move);
}
useMove(index: number, target: Pokemon): void {
const move = this.moves[index];
move.execute(this, target);
}
}
const pikachu = new Pokemon("ピカチュウ", [electricType], 25, 100);
pikachu.learnMove(new Thunderbolt());
pikachu.learnMove(new QuickAttack());
pikachu.learnMove(new IronTail());
// 実行時に技を選択
pikachu.useMove(0, opponent); // 10まんボルト!
pikachu.useMove(1, opponent); // でんこうせっか!
pikachu.useMove(2, opponent); // アイアンテール!
これがStrategyパターンです。
-
Strategy(戦略):
Moveインターフェース -
ConcreteStrategy(具体的な戦略):
Thunderbolt、QuickAttackなど -
Context(利用者):
Pokemonクラス
Strategyパターンを使うと、アルゴリズム(技の効果)を独立させて、実行時に切り替えることができます。
新しい技を追加してもPokemonクラスを変更する必要がありません。
OO原則編で「変化する部分をカプセル化する」と学びましたが、それを実現するための具体的な設計パターンがStrategyだったのです。
3. Factory パターン:ポケモンの生成
「オブジェクトの生成を専用のクラスに任せる」
ポケモンの生成方法はいくつかあります。
- 野生でエンカウント(レベルや技がランダム)
- タマゴから孵化(遺伝技、個体値の遺伝)
- 化石から復元(特定の種族のみ)
それぞれ生成ロジックが異なります。
これを呼び出し側で毎回書いていると、どうなるでしょうか?
// ❌ 生成ロジックがあちこちに散らばる
// 野生ポケモンを生成(草むらの処理)
const pokemon1 = new Pokemon("ピカチュウ", [electricType], 0, 0);
pokemon1.level = Math.floor(Math.random() * 5) + 3; // Lv.3〜7
pokemon1.hp = 35 + pokemon1.level;
pokemon1.learnMove(new ThunderShock());
// 野生ポケモンを生成(別の場所でも同じようなコード...)
const pokemon2 = new Pokemon("ピカチュウ", [electricType], 0, 0);
pokemon2.level = Math.floor(Math.random() * 5) + 3;
pokemon2.hp = 35 + pokemon2.level;
pokemon2.learnMove(new ThunderShock());
// タマゴから孵化
const pokemon3 = new Pokemon("ピチュー", [electricType], 1, 36);
pokemon3.learnMove(new ThunderShock());
pokemon3.learnMove(new Charm()); // 遺伝技
同じような生成ロジックがあちこちにコピーされています。
レベルの範囲を変えたくなったら、全ての箇所を修正する必要があります。
解決策:Factoryに生成を任せる
class PokemonFactory {
// 野生ポケモンを生成
static createWild(
species: string,
types: Type[],
minLevel: number,
maxLevel: number
): Pokemon {
const level = Math.floor(Math.random() * (maxLevel - minLevel + 1)) + minLevel;
const pokemon = new Pokemon(species, types, level, 35 + level);
// レベルに応じた技を覚えさせる
const moves = this.getMovesForLevel(species, level);
moves.forEach(move => pokemon.learnMove(move));
return pokemon;
}
// タマゴから孵化
static hatchFromEgg(egg: Egg, inheritedMoves: Move[]): Pokemon {
const pokemon = new Pokemon(egg.species, egg.types, 1, 36);
// 基本技
pokemon.learnMove(new ThunderShock());
// 遺伝技
inheritedMoves.forEach(move => pokemon.learnMove(move));
return pokemon;
}
private static getMovesForLevel(species: string, level: number): Move[] {
// 種族とレベルに応じた技リストを返す
}
}
使う側はシンプルになります。
// 野生ポケモン
const wildPikachu = PokemonFactory.createWild("ピカチュウ", [electricType], 3, 7);
// タマゴから孵化
const pichu = PokemonFactory.hatchFromEgg(egg, [new Charm()]);
生成ロジックがPokemonFactoryに集約されたことで、修正が必要なときは1箇所を変えるだけで済みます。
また、生成方法の違いがメソッド名で明確になります。
createWildなのかhatchFromEggなのか、コードを読むだけで意図がわかります。
4. Observer パターン:状態異常の処理
「状態の変化を、関心のあるオブジェクトに通知する」
バトルでは、毎ターン終了時にさまざまな処理が発生します。
- 毒状態 → HPが減る
- やけど状態 → HPが減る
- たべのこし(持ち物)→ HPが回復する
- 天候が「すなあらし」→ 一部のタイプ以外はダメージ
これを素直に実装すると、どうなるでしょうか?
// ❌ BattleSystemが全ての状態を知っている
class BattleSystem {
onTurnEnd(pokemon: Pokemon): void {
// 状態異常のチェック
if (pokemon.status === "poison") {
const damage = Math.floor(pokemon.maxHp / 8);
pokemon.receiveDamage(damage);
console.log("毒のダメージを受けた!");
}
if (pokemon.status === "burn") {
const damage = Math.floor(pokemon.maxHp / 16);
pokemon.receiveDamage(damage);
console.log("やけどのダメージを受けた!");
}
// 持ち物のチェック
if (pokemon.item === "たべのこし") {
const heal = Math.floor(pokemon.maxHp / 16);
pokemon.heal(heal);
console.log("たべのこしでHPが回復した!");
}
// 天候のチェック
if (this.weather === "sandstorm") {
if (!pokemon.types.includes(rockType) &&
!pokemon.types.includes(groundType) &&
!pokemon.types.includes(steelType)) {
const damage = Math.floor(pokemon.maxHp / 16);
pokemon.receiveDamage(damage);
console.log("すなあらしのダメージを受けた!");
}
}
// 新しい状態異常や持ち物が増えるたびにここを修正...
}
}
BattleSystemがあらゆる状態異常、持ち物、天候について知っている必要があります。
新しい要素が増えるたびに、この巨大なif文を修正しなければなりません。
解決策:Observerパターンで通知する
「ターン終了」というイベントを、関心のあるオブジェクトに通知する設計に変えましょう。
// ターン終了時に呼ばれるインターフェース
interface TurnEndObserver {
onTurnEnd(pokemon: Pokemon): void;
}
各状態異常や持ち物が、このインターフェースを実装します。
// 毒状態
class PoisonStatus implements TurnEndObserver {
onTurnEnd(pokemon: Pokemon): void {
const damage = Math.floor(pokemon.maxHp / 8);
pokemon.receiveDamage(damage);
console.log("毒のダメージを受けた!");
}
}
// やけど状態
class BurnStatus implements TurnEndObserver {
onTurnEnd(pokemon: Pokemon): void {
const damage = Math.floor(pokemon.maxHp / 16);
pokemon.receiveDamage(damage);
console.log("やけどのダメージを受けた!");
}
}
// たべのこし
class Leftovers implements TurnEndObserver {
onTurnEnd(pokemon: Pokemon): void {
const heal = Math.floor(pokemon.maxHp / 16);
pokemon.heal(heal);
console.log("たべのこしでHPが回復した!");
}
}
// すなあらし
class SandstormWeather implements TurnEndObserver {
onTurnEnd(pokemon: Pokemon): void {
const immuneTypes = [rockType, groundType, steelType];
const isImmune = pokemon.getTypes().some(t => immuneTypes.includes(t));
if (!isImmune) {
const damage = Math.floor(pokemon.maxHp / 16);
pokemon.receiveDamage(damage);
console.log("すなあらしのダメージを受けた!");
}
}
}
ポケモンは、自分に適用されているObserverを保持します。
class Pokemon {
private turnEndObservers: TurnEndObserver[] = [];
addTurnEndObserver(observer: TurnEndObserver): void {
this.turnEndObservers.push(observer);
}
removeTurnEndObserver(observer: TurnEndObserver): void {
this.turnEndObservers = this.turnEndObservers.filter(o => o !== observer);
}
// ターン終了時に全てのObserverに通知
notifyTurnEnd(): void {
for (const observer of this.turnEndObservers) {
observer.onTurnEnd(this);
}
}
}
BattleSystemはシンプルになります。
class BattleSystem {
onTurnEnd(pokemon: Pokemon): void {
// 通知するだけ!
pokemon.notifyTurnEnd();
}
}
// 使用例
const pikachu = new Pokemon("ピカチュウ", [electricType], 25, 100);
// 毒状態になった
pikachu.addTurnEndObserver(new PoisonStatus());
// たべのこしを持たせた
pikachu.addTurnEndObserver(new Leftovers());
// ターン終了
pikachu.notifyTurnEnd();
// → 毒のダメージを受けた!
// → たべのこしでHPが回復した!
新しい状態異常や持ち物を追加しても、BattleSystemを修正する必要はありません。
TurnEndObserverを実装したクラスを作るだけです。
5. Decorator パターン:持ち物による強化
「オブジェクトに動的に機能を追加する」
ポケモンの持ち物には、ステータスに影響を与えるものがあります。
- こだわりハチマキ → 攻撃1.5倍
- こだわりメガネ → 特攻1.5倍
- しんかのきせき → 防御・特防1.5倍(進化前のみ)
これを素直に実装すると、どうなるでしょうか?
// ❌ Pokemonクラスに全アイテム効果を書く
class Pokemon {
private item: string | null = null;
getAttack(): number {
let attack = this.baseAttack;
if (this.item === "こだわりハチマキ") {
attack = Math.floor(attack * 1.5);
}
if (this.item === "いのちのたま") {
attack = Math.floor(attack * 1.3);
}
if (this.item === "ちからのハチマキ") {
attack = Math.floor(attack * 1.1);
}
// 新しいアイテムが増えるたびにここを修正...
return attack;
}
getDefense(): number {
let defense = this.baseDefense;
if (this.item === "しんかのきせき" && this.canEvolve) {
defense = Math.floor(defense * 1.5);
}
// 新しいアイテムが増えるたびにここも修正...
return defense;
}
}
持ち物が増えるたびにPokemonクラスの複数のメソッドを修正する必要があります。
単一責任原則にも違反しています。
解決策:Decoratorでステータスをラップする
まず、ステータスを提供するインターフェースを定義します。
interface StatsProvider {
getAttack(): number;
getDefense(): number;
getSpecialAttack(): number;
getSpecialDefense(): number;
getSpeed(): number;
}
Pokemonクラスはこのインターフェースを実装し、素のステータスを返します。
class Pokemon implements StatsProvider {
constructor(
public name: string,
private baseAttack: number,
private baseDefense: number,
private baseSpecialAttack: number,
private baseSpecialDefense: number,
private baseSpeed: number
) {}
getAttack(): number {
return this.baseAttack;
}
getDefense(): number {
return this.baseDefense;
}
getSpecialAttack(): number {
return this.baseSpecialAttack;
}
getSpecialDefense(): number {
return this.baseSpecialDefense;
}
getSpeed(): number {
return this.baseSpeed;
}
}
持ち物はStatsProviderをラップして、ステータスを加工します。
// こだわりハチマキ:攻撃1.5倍
class ChoiceBandDecorator implements StatsProvider {
constructor(private pokemon: StatsProvider) {}
getAttack(): number {
return Math.floor(this.pokemon.getAttack() * 1.5);
}
// 他のステータスはそのまま委譲
getDefense(): number {
return this.pokemon.getDefense();
}
getSpecialAttack(): number {
return this.pokemon.getSpecialAttack();
}
getSpecialDefense(): number {
return this.pokemon.getSpecialDefense();
}
getSpeed(): number {
return this.pokemon.getSpeed();
}
}
// こだわりメガネ:特攻1.5倍
class ChoiceSpecsDecorator implements StatsProvider {
constructor(private pokemon: StatsProvider) {}
getAttack(): number {
return this.pokemon.getAttack();
}
getDefense(): number {
return this.pokemon.getDefense();
}
getSpecialAttack(): number {
return Math.floor(this.pokemon.getSpecialAttack() * 1.5);
}
getSpecialDefense(): number {
return this.pokemon.getSpecialDefense();
}
getSpeed(): number {
return this.pokemon.getSpeed();
}
}
// こだわりスカーフ:素早さ1.5倍
class ChoiceScarfDecorator implements StatsProvider {
constructor(private pokemon: StatsProvider) {}
getAttack(): number {
return this.pokemon.getAttack();
}
getDefense(): number {
return this.pokemon.getDefense();
}
getSpecialAttack(): number {
return this.pokemon.getSpecialAttack();
}
getSpecialDefense(): number {
return this.pokemon.getSpecialDefense();
}
getSpeed(): number {
return Math.floor(this.pokemon.getSpeed() * 1.5);
}
}
// 素のピカチュウ
const pikachu = new Pokemon("ピカチュウ", 55, 40, 50, 50, 90);
console.log(pikachu.getAttack()); // 55
console.log(pikachu.getSpeed()); // 90
// こだわりハチマキを持たせる
const pikachuWithBand: StatsProvider = new ChoiceBandDecorator(pikachu);
console.log(pikachuWithBand.getAttack()); // 82(1.5倍)
console.log(pikachuWithBand.getSpeed()); // 90(変わらず)
// こだわりスカーフを持たせる
const pikachuWithScarf: StatsProvider = new ChoiceScarfDecorator(pikachu);
console.log(pikachuWithScarf.getAttack()); // 55(変わらず)
console.log(pikachuWithScarf.getSpeed()); // 135(1.5倍)
Decoratorパターンのポイントは、元のオブジェクトを変更せずに機能を追加できることです。
Pokemonクラスは素のステータスを返すだけ。
持ち物の効果は各Decoratorクラスが担当します。
新しい持ち物を追加しても、Pokemonクラスを修正する必要はありません。
6. まとめ
今回学んだ4つのパターンを振り返りましょう。
| パターン | 目的 | ポケモンでの例 |
|---|---|---|
| Strategy | アルゴリズムを切り替え可能に | 技を独立したオブジェクトとして持つ |
| Factory | 生成ロジックを一元管理 | 野生エンカウント、タマゴ孵化、化石復元 |
| Observer | イベントを関心のあるオブジェクトに通知 | ターン終了時の毒ダメージ、持ち物効果 |
| Decorator | 動的に機能を追加 | 持ち物によるステータス強化 |
デザインパターンは「目的」ではなく「手段」です。
パターンを使うこと自体が目的になってはいけません。
大切なのは、問題を認識し、適切な解決策を選べるようになることです。
今回学んだパターンの名前を知っていれば、同じような問題に出会ったときに「これはStrategyで解決できそうだ」と気づくことができます。
7. シリーズ全体の振り返り
全4回のシリーズを通じて、オブジェクト指向の考え方を学んできました。
| 編 | 学んだこと |
|---|---|
| 基礎編 | クラス、インスタンス、カプセル化、継承、ポリモーフィズム |
| OO原則編 | 継承よりコンポジション、変化のカプセル化、インターフェース指向 |
| SOLID原則編 | 単一責任、オープン・クローズド、リスコフの置換、インターフェース分離、依存性逆転 |
| デザインパターン編 | Strategy、Factory、Observer、Decorator |
基礎編で学んだ概念が、OO原則編で「なぜそうするのか」という原則につながり、SOLID原則編でより具体的な指針になり、デザインパターン編で「こう実装する」という形になりました。
オブジェクト指向は、一度学んで終わりではありません。
実際のコードを書きながら、「この設計で変更に強いか?」「責任が集中していないか?」と問い続けることで、少しずつ身についていきます。
このシリーズが、皆さんの設計力向上の一助となれば幸いです。
シリーズ目次
本記事は「ポケモンで学ぶオブジェクト指向」シリーズの一部です。