【C++】オブザーバーパターンの使い方|通知でクラスの絡みをほどく

C++

今回は、C++でよく出てくる「オブザーバーパターンの使い方」について解説していきます。

「オブザーバーパターンって何?」
「HPが減ったらUIも更新したいけど、どう繋げばいい?」
「クラス同士が絡み合って、修正がしんどい…」

こんな疑問はありませんか?

ゲームを作っていると「プレイヤーがダメージを受けたら、HPバーも、実績も、サウンドも反応させたい」という場面が必ず出てきます。素直に書くと、プレイヤークラスがUIやサウンドを直接呼ぶことになり、どんどん絡まって手が付けられなくなります

オブザーバーパターンは、その絡まりをほどくための仕組みです。この記事を読み終えると、あなたはオブザーバーパターンの意味・実装方法・ゲームでの使いどころ・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。

オブザーバーパターンとは?

オブザーバーパターンとは、「何かが起きたよ」と知らせる側と、それを受け取る側を分ける仕組みです。日本語では「観察者パターン」と呼ばれます。

メールマガジンをイメージすると分かりやすいです。

  • 発信側(Subject)… 配信元。誰が読んでいるかは気にしない、ただ配信するだけ
  • 受信側(Observer)… 購読者。勝手に登録・解除できる

ポイントは、発信側が受信側のことを知らないこと。プレイヤーは「ダメージを受けた」と叫ぶだけで、それをUIが聞いているのか実績システムが聞いているのかを一切気にしません。ここが効きます。

なぜオブザーバーが必要?

まず、パターンを使わない書き方を見てください。

// 悪い例:プレイヤーが全部を直接知っている
class Player {
public:
    void TakeDamage(int dmg) {
        hp_ -= dmg;
        ui_.UpdateHpBar(hp_);        // UIを直接呼ぶ
        sound_.Play("damage.wav");   // サウンドも直接呼ぶ
        achievement_.Check(hp_);     // 実績も直接呼ぶ
    }
private:
    int hp_ = 100;
    UI ui_; Sound sound_; Achievement achievement_; // 全部抱え込んでいる
};

一見動きますが、問題だらけです。

  • 反応を1つ増やすたびにPlayer を書き換えることになる
  • Player をテストするのにUIもサウンドも必要になる
  • Player.hあらゆるヘッダを巻き込む

プレイヤーの仕事は「HPを減らすこと」であって、UIの更新ではありません。役割が混ざっているのが根本原因です。

オブザーバーパターンの実装

まず、受け取る側の共通の窓口を作ります。

#include <iostream>
#include <vector>
#include <algorithm>

// 受け取る側の共通インターフェース
class Observer {
public:
    virtual ~Observer() = default;
    virtual void OnDamaged(int hp) = 0; // 通知を受けたら呼ばれる
};

virtual を使って「窓口の形」だけ決めています。この仕組みはポリモーフィズムの記事で詳しく解説しています。

次に、知らせる側です。購読者リストを持ち、順番に声をかけるだけです。

// 知らせる側
class Subject {
public:
    void AddObserver(Observer* o) { observers_.push_back(o); }

    void RemoveObserver(Observer* o) {
        observers_.erase(
            std::remove(observers_.begin(), observers_.end(), o),
            observers_.end());
    }

protected:
    void Notify(int hp) {
        // コピーを回すと、通知中に登録解除されても安全
        auto snapshot = observers_;
        for (auto* o : snapshot) o->OnDamaged(hp);
    }

private:
    std::vector<Observer*> observers_;
};

そして Player は、UIもサウンドも知らなくなります

class Player : public Subject {
public:
    void TakeDamage(int dmg) {
        hp_ -= dmg;
        Notify(hp_); // 「ダメージ受けた」と叫ぶだけ
    }
private:
    int hp_ = 100;
};

// 受け取る側それぞれ
class HpBar : public Observer {
public:
    void OnDamaged(int hp) override {
        std::cout << "HPバー更新: " << hp << "n";
    }
};

class Achievement : public Observer {
public:
    void OnDamaged(int hp) override {
        if (hp <= 10) std::cout << "実績解除: 瀕死!n";
    }
};

int main() {
    Player player;
    HpBar       bar;
    Achievement ach;

    player.AddObserver(&bar);
    player.AddObserver(&ach);

    player.TakeDamage(50);
    player.TakeDamage(45);
    return 0;
}
HPバー更新: 50
HPバー更新: 5
実績解除: 瀕死!

注目すべきは、Player のコードに HpBarAchievement も一切出てこないことです。反応を増やしたければ Observer を継承したクラスを追加して AddObserver するだけ。Player は一行も触りません

【重要】私が実際にオブザーバーで困った体験談

個人開発でオブザーバーを導入したとき、シーン切り替えのたびにランダムでクラッシュするという、最悪に厄介なバグを出しました。

原因は登録解除のし忘れ。シーンを切り替えるとUIオブジェクトは破棄されるのに、Player の購読者リストにはその古いポインタが残ったままでした。次に Notify が走った瞬間、すでに死んでいるオブジェクトを呼びに行って落ちるわけです。

しかも毎回落ちるわけではないのが厄介でした。解放済みメモリがたまたま生きているように見えると、動いてしまうんです。デストラクタで必ず RemoveObserver を呼ぶようにしたら、ぴたりと止まりました。

もう一つ。Notify のループ中に RemoveObserver が呼ばれて、回している最中のリストが変わって壊れたこともあります。上のコードで snapshot にコピーしてから回しているのは、この対策です。

オブザーバー使用時のよくある失敗例と対処法

①登録解除のし忘れ

体験談のとおり、死んだポインタを呼んでクラッシュします。デストラクタで必ず解除するのを徹底しましょう。「登録したら解除する」をセットで書く癖をつけると安全です。

②通知中にリストを変更する

ループ中に登録・解除が起きるとイテレータが壊れます。コピーを取ってから回すのが簡単で確実な対策です。

③通知が連鎖して無限ループ

AがBに通知し、Bの処理がまたAに通知し…と循環すると止まらなくなります。通知を受けた側から元の相手に通知し返さない設計にしておきましょう。

注意点

  • 登録と解除は必ずセットで書く
  • 通知はコピーを回すと事故が減る
  • 通知先が1つしかないなら無理に導入しない(素直に呼べばよい)

まとめ

  • オブザーバーは知らせる側と受け取る側を切り離すパターン
  • 発信側は誰が聞いているか知らなくていい
  • 反応を増やしても発信側のコードを触らずに済む
  • 最大の罠は登録解除のし忘れによるクラッシュ

「1つの出来事に、複数のものを反応させたい」と思ったら、オブザーバーの出番です。まずはHPバーの更新あたりから試してみてください。

関連記事