レガシー第4章:継承とポリモーフィズム(敵のバリエーションと多態性)

switch文の分岐地獄を打破する、オブジェクト指向最大の武器

通常インベーダーに加え、2回被弾が必要な装甲シールド敵(S)や、上空を高速横切るボーナスUFO(U)が登場!C言語の「巨大なswitch-case」による構造化設計の限界と破綻を暴き、C++の抽象基底クラス・純粋仮想関数・仮想関数テーブル(vtable)の驚異的な拡張性を徹底解説します。

← 前の章へ進む
次の章へ進む →

4.1 多態性の設計対比:あえてswitchを選ぶ理由と、vtableによる拡張性

ゲームに新しい敵キャラクターを追加するとき、C言語プログラマはどう書くでしょうか? enum で敵種別を定義し、switch-case 文で分岐させるのが典型です。実はこの手法、組込み現場では「極めて合理的な正解」として選ばれることもあります。それぞれの設計の必然性を解き明かします。

🐧 ペンギン先輩
シロクマ指導官!第3章で弾が当たった時の火花パーティクルが完成して、ゲームが凄く派手になりました! でも、敵が全部同じインベーダー(V)だけでちょっと単調です……!2回撃たないと壊れない頑丈な装甲敵とか、上空を猛スピードで横切るボーナスUFOとか、敵のバリエーションを増やしたいです!
🐻‍❄️ シロクマ先生
うむ!ゲームの奥深さは敵のバリエーションで決まる。ではペンギン通信士よ、C言語なら新しい敵を追加するとき、プログラムをどう設計するかの?
🐧 ペンギン先輩
えーと……`enum EnemyType { TYPE_NORMAL, TYPE_SHIELD, TYPE_UFO };` って定義して、敵の構造体に `type` を持たせます! そして移動処理関数の中で `switch(enemy->type)` して、描画関数の中でも `switch(enemy->type)` して、当たり判定の中でも `switch(enemy->type)` して分岐させます!
🐻‍❄️ シロクマ先生
ほう!実はな、組込み機器や極限のゲームエンジンにおいて、その `switch(enemy->type)` は**【あえて推奨されるレガシーの正解】**となる場合もあるのじゃ! なぜなら、仮想関数(vtable)を使うとインスタンス毎に4〜8バイトのポインタ(vptr)を消費し、関数ポインタの間接呼び出しでCPUキャッシュミス(Instruction Cache Miss)を誘発するからじゃ。switchならコンパイラが連続ジャンプテーブルに最適化できるしの。
🐧 ペンギン先輩
えっ!?switch文って悪者じゃないんですか!?じゃあ僕の設計で大正解じゃないですか!
🐻‍❄️ シロクマ先生
早合点するな!罪深いのはswitch文そのものではなく、**【同じ switch 分岐があちこちのファイル・関数に散らばること(多重散乱)】**なんじゃ! 移動、描画、当たり判定、被弾処理、スコア加算……10箇所に `switch(enemy->type)` が散乱したら、新しい敵を1体足すたびに10箇所すべてに `case TYPE_BOSS:` を書き足して回る羽目になる。1箇所でも忘れたらバグじゃ! そこで、C言語のままでも使える【関数ポインタテーブル】や、C++03の【抽象基底クラスとポリモーフィズム】という武器が必要になるのじゃ!

4.2 仮想関数テーブル(vtable)のメモリ構造と仮想デストラクタの絶対原則

「`virtual` を付けると、なぜ実行時に正しい関数が呼ばれるのか?」「なぜ基底クラスのデストラクタは `virtual` にしなければならないのか?」——C++プログラマが直面する最大の疑問を、メモリ(vtable / vptr)の内部構造から完全に解き明かします。

### ■ なぜ仮想デストラクタ(virtual ~Enemy)が絶対に必須なのか?

C++でクラスの継承を扱う際、最も重篤なメモリリークを引き起こす初心者の罠が「**仮想デストラクタの付け忘れ**」です。

#### 1. 悲劇のコード(基底のデストラクタが非仮想の場合)
```cpp
class Enemy {
public:
~Enemy() { /* virtual が付いていない! */ }
};

class ShieldEnemy : public Enemy {
private:
int* m_shieldData; // 動的確保したリソース
public:
ShieldEnemy() { m_shieldData = new int[100]; }
~ShieldEnemy() { delete[] m_shieldData; } // 解放処理
};

// メイン処理
Enemy* e = new ShieldEnemy();
delete e; // 💥 大惨事発生!
```

#### 2. なぜ大惨事になるのか?
`delete e;` を実行した際、コンパイラはポインタの静的な型(`Enemy*`)しか見ていません。
デストラクタに `virtual` が付いていないと、コンパイラは「Enemyのデストラクタ」だけを静的に呼び出し、**`ShieldEnemy` のデストラクタを完全に無視してメモリを解放します**。
その結果、`ShieldEnemy` 内部で確保した `m_shieldData` のメモリは永遠に解放されず、プログラムは確実にメモリリークで死にます!

#### 3. 解決策:基底クラスには必ず virtual を付ける!
```cpp
class Enemy {
public:
virtual ~Enemy() {} // これ1行で完全解決!
};
```
デストラクタを `virtual` にすると、vtable経由で **派生クラスのデストラクタ(`~ShieldEnemy`)が最初に呼ばれ、その後に基底クラスのデストラクタ(`~Enemy`)が安全に逆順で呼ばれる** ようになります。

4.3 実装解説:BaseEnemyから広がる通常敵・シールド敵・UFOのクラス設計

実際に第4章のインベーダーゲームを動かしているC++(クラシックOOP)コードの全貌を公開します。1つの基底クラスから、いかにして多彩な敵キャラクターが生み出されるかを確認してください。

📄 Enemy.h
#pragma once

// 【第4章:抽象基底クラス Enemy】
// ポリモーフィズム(多態性)の根幹となるインターフェース定義
class Enemy {
protected:
    int m_x;
    int m_y;
    int m_hp;
    bool m_alive;

public:
    Enemy(int x, int y, int hp = 1)
        : m_x(x), m_y(y), m_hp(hp), m_alive(true) {}

    // 【重要:仮想デストラクタ】
    // 基底クラスポインタで破棄された際、派生クラスのデストラクタが必ず呼ばれるようにする
    virtual ~Enemy() {}

    // 【純粋仮想関数(= 0)】
    // 派生クラスに実装を強制する契約
    virtual void update(int dirX, bool moveDown) = 0;
    virtual char getGlyph() const = 0;
    virtual int getScore() const = 0;

    // 共通インターフェース
    virtual bool hit() {
        if (!m_alive) return false;
        m_hp--;
        if (m_hp <= 0) {
            m_alive = false;
        }
        return true;
    }

    bool isAlive() const { return m_alive; }
    int getX() const { return m_x; }
    int getY() const { return m_y; }
    int getHp() const { return m_hp; }
};
📄 NormalEnemy.h
#pragma once
#include "Enemy.h"

// 通常インベーダー:標準的な左右往復移動
class NormalEnemy : public Enemy {
public:
    NormalEnemy(int x, int y) : Enemy(x, y, 1) {}

    void update(int dirX, bool moveDown) {
        if (!m_alive) return;
        if (moveDown) {
            m_y += 1;
        } else {
            m_x += dirX;
        }
    }

    char getGlyph() const {
        return 'V';
    }

    int getScore() const {
        return 100;
    }
};
📄 ShieldEnemy.h
#pragma once
#include "Enemy.h"

// シールド敵:装甲耐久2。1発当たると外装が壊れて 's' になり、2発目で撃破!
class ShieldEnemy : public Enemy {
public:
    ShieldEnemy(int x, int y) : Enemy(x, y, 2) {}

    void update(int dirX, bool moveDown) {
        if (!m_alive) return;
        if (moveDown) {
            m_y += 1;
        } else {
            m_x += dirX;
        }
    }

    char getGlyph() const {
        // 耐久値に応じて見た目が変化(カプセル化された独自表現)
        return (m_hp > 1) ? 'S' : 's';
    }

    int getScore() const {
        return 200;
    }
};
📄 UfoEnemy.h
#pragma once
#include "Enemy.h"
#include "Common.h"

// ボーナスUFO:最上段を高速直進し、画面端で自滅する特別敵
class UfoEnemy : public Enemy {
private:
    int m_speed;

public:
    UfoEnemy(int x, int y)
        : Enemy(x, y, 1), m_speed(1) {}

    // 通常の編隊移動(dirXやmoveDown)を無視し、独自の飛行アルゴリズムを実行!
    void update(int /*dirX*/, bool /*moveDown*/) {
        if (!m_alive) return;
        m_x += m_speed;
        if (m_x >= SCREEN_WIDTH - 2) {
            m_alive = false; // 画面外離脱
        }
    }

    char getGlyph() const {
        return 'U';
    }

    int getScore() const {
        return 500;
    }
};
📄 Game.h
#pragma once
#include <vector>
#include "Player.h"
#include "Enemy.h"
#include "Bullet.h"
#include "Particle.h"

class Game {
private:
    Player m_player;
    // 【重要:基底クラスのポインタを管理するコンテナ】
    // NormalEnemy, ShieldEnemy, UfoEnemy すべてを多態的に一括保持!
    std::vector<Enemy*> m_enemies;
    std::vector<Bullet> m_bullets;
    std::vector<Particle> m_particles;

    int m_score;
    bool m_gameOver;
    bool m_gameClear;
    int m_enemyDir;
    int m_enemyMoveTimer;
    int m_ufoTimer;

    void processInput();
    void update();
    void render();
    void spawnExplosion(int x, int y, bool isUfo = false);

public:
    Game();
    ~Game(); // 動的確保した敵オブジェクトを一括解放
    void run();
};
📄 Game.cpp
#include "Game.h"
#include "NormalEnemy.h"
#include "ShieldEnemy.h"
#include "UfoEnemy.h"
#include "Common.h"
#include <iostream>

Game::Game() : m_player(SCREEN_WIDTH / 2 - 1, SCREEN_HEIGHT - 2), m_score(0) {
    // 異なる種類の敵を同じベクターに追加!
    for (int col = 0; col < 6; col++) {
        int x = 4 + col * 4;
        if (col == 2 || col == 3) {
            m_enemies.push_back(new ShieldEnemy(x, 2));
        } else {
            m_enemies.push_back(new NormalEnemy(x, 2));
        }
    }
}

Game::~Game() {
    // 【重要:仮想デストラクタによる安全な解放】
    // Enemy* 経由で delete しても、仮想デストラクタにより各派生クラスが正しく破棄される
    for (size_t i = 0; i < m_enemies.size(); ++i) {
        delete m_enemies[i];
    }
}

void Game::update() {
    // 【多態的ディスパッチ】
    // 敵の種類を意識せず、基底ポインタ経由で update() を呼ぶだけで
    // 各敵固有の実装が自動実行される!
    for (size_t i = 0; i < m_enemies.size(); ++i) {
        if (m_enemies[i]->isAlive()) {
            m_enemies[i]->update(m_enemyDir, hitWall);
        }
    }
}
💡 このセクションの重要ポイント
  • 【レガシーOOPの正解】開放閉鎖原則(OCP)と抽象基底クラス: 既存のGameクラスのループコードに一切手を触れずに、新しい敵クラスを追加するだけで機能拡張できる古典的オブジェクト指向の真骨頂。
  • 仮想デストラクタの絶対原則: 基底クラスポインタ経由で派生オブジェクトを安全にdeleteするために、基底クラスのデストラクタは必ず virtual にする。
  • 動的ディスパッチ(vtable / vptr)のコスト意識: インスタンス毎に4〜8バイトの__vptrを消費し間接呼び出しコストが生じるため、極限の省リソースでは「局所化されたswitch」や「関数ポインタテーブル」と適材適所で使い分ける。

📝 理解度チェッククイズ

Q1. 基底クラス Enemy のデストラクタに「virtual」を付けずに、Enemy* ポインタ経由で派生クラス(ShieldEnemy)のインスタンスを delete した場合、何が起こるでしょうか?
  • 1. コンパイルエラーになり、プログラムをビルドできない。
  • 2. ShieldEnemy のデストラクタが呼ばれず、Enemy のデストラクタしか実行されないため、メモリリーク等を引き起こす。
  • 3. C++が自動的に検知して、常に派生クラスのデストラクタを安全に呼び出してくれる。
  • 4. プログラム実行時にセグメンテーション違反で即座にクラッシュする。
正解と解説を見る
正解:ShieldEnemy のデストラクタが呼ばれず、Enemy のデストラクタしか実行されないため、メモリリーク等を引き起こす。
基底クラスのデストラクタが非仮想の場合、静的な型(Enemy*)に基づいて基底クラスのデストラクタしか呼ばれません。派生クラスで確保したリソースの解放処理がスキップされて深刻なメモリリークになるため、基底クラスのデストラクタには必ず virtual を付けるのが絶対鉄則です。
Q2. C++において「純粋仮想関数(Pure Virtual Function)」を1つ以上持つクラスに関する説明として、最も正確なものはどれですか?
  • 1. そのクラスは抽象クラスとなり、単体でインスタンス化(new や変数宣言)することができなくなる。
  • 2. すべてのメンバ変数が private から protected に自動変更される。
  • 3. 派生クラスでその関数をオーバーライドしてはならない。
  • 4. 実行速度が通常の関数より100倍以上遅くなる。
正解と解説を見る
正解:そのクラスは抽象クラスとなり、単体でインスタンス化(new や変数宣言)することができなくなる。
「virtual void update() = 0;」のように末尾に「= 0」を付けた純粋仮想関数を持つクラスは「抽象クラス(Abstract Class)」と呼ばれ、直接インスタンスを生成できなくなります。派生クラスに対して「必ずこの関数を実装せよ」という厳格な契約を強制する役割を果たします。
Q3. C++の仮想関数呼び出し(動的ディスパッチ)を裏側で支えている仕組みとして正しい組み合わせはどれですか?
  • 1. mallocテーブル と freeスタック
  • 2. 仮想関数テーブル(vtable)と 各インスタンスが持つテーブルポインタ(__vptr)
  • 3. RTTI(実行時型情報)による文字列一致検索
  • 4. switch-case 文による自動コード生成
正解と解説を見る
正解:仮想関数テーブル(vtable)と 各インスタンスが持つテーブルポインタ(__vptr)
仮想関数を持つクラスをコンパイルすると、クラスごとに仮想関数のアドレスを並べた「vtable(仮想関数テーブル)」が静的に生成されます。また、各インスタンスには先頭に隠しポインタ「__vptr」が埋め込まれ、実行時には __vptr を辿って vtable から該当関数を高速に呼び出します。
Q4. オブジェクト指向設計の重要な原則である「開放閉鎖原則(Open-Closed Principle: OCP)」の意味として正しいものはどれですか?
  • 1. ファイルは常に open() で開き、終了時に必ず close() で閉じなければならないという原則。
  • 2. すべてのメンバ変数は private(閉鎖)にし、メンバ関数は public(開放)にしなければならないという原則。
  • 3. ソフトウェアの構成要素は、機能拡張に対して開かれており(Open)、既存コードの修正に対して閉じている(Closed)べきであるという原則。
  • 4. ソースコードはオープンソースとして公開しなければならないという原則。
正解と解説を見る
正解:ソフトウェアの構成要素は、機能拡張に対して開かれており(Open)、既存コードの修正に対して閉じている(Closed)べきであるという原則。
開放閉鎖原則(OCP)とは、「新機能を追加するとき、既存のテスト済みコードを書き換えることなく、新しいクラスを追加するだけで拡張できるように設計すべきである」という原則です。本章の Enemy 継承設計は、Game クラスのループに手を触れずに新敵を追加できるため、OCP の典型例です。
← 前の章へ進む
次の章へ進む →

🐻‍❄️ シロクマC++ラボ インタラクティブ学習システム

ブラウザでJavaScriptを有効にすると、ブラウザ内インベーダーゲームエミュレータ、メモリマップ可視化、UMLクラス図、対話型解説、理解度クイズが起動します。

インタラクティブ学習を開始する →