2009年05月25日

型情報を使わずに派生クラスを特定する

ひさしぶりに『More Effective C++』を読んでいたら、面白いコードを発見したので紹介しておきます。

オブジェクト指向では、様々なオブジェクトを共通の基底クラスから派生させて、それらを基底クラスのポインタのリストで管理するというのはよく使うテクニックです。管理クラスは管理対象が具体的に何のクラスなのかは意識する必要がなく、基底クラスに用意された共通のインターフェースで指示を出せば、指示を出された側が自分で判断してふるまいを決めます。この性質をポリモーフィズムと呼びます。

More Effective C++の例を借用すると、宇宙船(SpaceShip)、宇宙ステーション(SpaceStation)、小惑星(Asteroid)を共通の基底クラスGameObjectから派生させると次のようになります。

ClassDiagram1

class GameObject { ... };
class SpaceShip : public GameObject { ... };
class SpaceStation : public GameObject { ... };
class Asteroid : public GameObject { ... };

宇宙船も宇宙ステーションも小惑星も全てGameObjectの一種なので、GameObject*のリストに登録できます。

list<GameObject*> objects;

objects.push_back(new SpaceShip);
objects.push_back(new SpaceStation);
objects.push_back(new Asteroid);

この仕組みは便利で様々なところで使われていますが(特にGUIフレームワーク)、時にはポインタの指しているオブジェクトが具体的に何のクラスなのか知りたい場合があります。

例えばゲームでは、あるオブジェクトが別のオブジェクトに衝突した際に、衝突した相手によって動作が変わるので相手の正体を知る必要があります。しかし、ここでは全てのオブジェクトをGameObject型のポインタで管理しているので、相手の型が何なのか分かりません。

そこで、C++では実行時型情報(RTTI)というシステムで、ポインタからそのオブジェクトの型を知る術を提供しています。typeid演算子とdynamic_cast演算子がそのための演算子となっています。

素直にRTTIを利用して衝突処理を作ると次のようになります。

void SpaceShip::collide(GameObject* otherObject)
{
    if (SpaceShip* ss = dynamic_cast<SpaceShip*>(otherObject)) {
        // SpaceShip-SpaceShipの衝突
    }
    else if (SpaceStation* ss = dynamic_cast<SpaceStation*>(otherObject)) {
        // SpaceShip-SpaceStationの衝突
    }
    else if (Asteroid* a = dynamic_cast<Asteroid*>(otherObject)) {
        // SpaceShip-Asteroidの衝突
    }
    else {
        // 未知との遭遇
    }
}

これはGameObject基底クラスにcollide()というメソッドを用意しておいて、衝突される側のcollide()に衝突する側のオブジェクトのポインタを渡すという方法で実装されています。ここではSpaceShipのcollide()しか書いていませんが、SpaceStationとAsteroidにも同じ内容のコードを書く必要があります。

このアプローチには色々と問題があります。

まず一番問題なのが、判定するクラスの数が増えるほどifの数が爆発的に増えていきます。n個のクラスがあれば、ifはnの2乗もの数が必要になってしまいます。これを全て正確に管理するというのは大変な作業です。ifの書き忘れがあってもコンパイルエラーは出ず、実行時にその組み合わせの衝突が起こるまで分かりません。

また、ifの順番に関する問題も秘めています。例えば、Asteroidクラスから更に継承してSmallAsteroidクラスを作ったとすると、SmallAsteroidオブジェクトに対するdynamic_cast<Asteroid*>は成功してしまいます。正しく判定するには、必ず派生クラスを先にチェックしないといけません。

dynamic_castの代わりにtypeidを使ったアプローチではifの順番の問題は無くなりますが、今度は派生クラスで基底クラスと同じ動作で構わないという場合でも個別に処理を書く必要があり、融通が利かなくなります。

さて、前置きが長くなりましたが、この問題に対するMore Effective C++に載っていた面白いコードとは次のようなものです。

class GameObject {
public:
    virtual void collide(GameObject& otherObject) = 0;
    virtual void collide(SpaceShip& otherObject) = 0;
    virtual void collide(SpaceStation& otherObject) = 0;
    virtual void collide(Asteroid& otherObject) = 0;
};

class SpaceShip : public GameObject {
public:
    virtual void collide(GameObject& otherObject);
    virtual void collide(SpaceShip& otherObject);
    virtual void collide(SpaceStation& otherObject);
    virtual void collide(Asteroid& otherObject);
};

void SpaceShip::collide(GameObject& otherObject)
{
    otherObject.collide(*this);
}

見ての通りSpaceShip::collide(GameObject&)では、引数とthisを入れ替えて再びcollide()を呼んでいるだけです。(他のバージョンのcollideには具体的な衝突時のコードを書きます)

一見するとこれは無限に再帰呼び出しをしてスタックを食いつぶしてしまいそうですが、オーバーロードされた各種collide()に秘密があります。

まず、何だか分からないオブジェクトo1(正体はSpaceShip), o2に対してo1->collide(*o2)を呼ぶと、SpaceShip::collide(GameObject&)が呼ばれます。ここで引数を入れ替えてもう一度collideを呼ぶと、SpaceShip::collide()の中では*thisの型はSpaceShipと分かっているので、SpaceShip&を引数に取るバージョンのcollide()が自動的に呼ばれます。

typeidやdynamic_castに頼らずに、2つの基底クラスのポインタからそれぞれの派生クラスの特定に見事成功しています。面白いですね。

ところで、このコードでは各クラスに対応したcollideの特別バージョンも全て同じ名前で定義されていて引数の違いだけで区別されていますが、必ずしも同じ名前の関数にする必要はなく、collideSpaceShip()、collideSpaceStation()、collideAsteroid()のように別の名前を付けることもできます。多分、thisの型によって自動的に振り分けられるという動作や、再帰呼び出しのように見える点が面白くて、Scott Meyersはこのように書いたんだと思います。

このアプローチの欠点は、新しいクラスを作るたびにそのクラス用のcollide()を基底クラスに追加する必要があるという点です。

また、コードの再利用を考えてフレームワーク化した場合、通常はフレームワーク利用者はコアとなるGameObjectクラスにメンバを追加することはできないので、この方法を使うことはできません。

この問題に対するより良い解法は、『More Effective C++』や『Modern C++ Design』等で詳しく書かれています。(私はこれらの本に載っている方法は、あまり好きではありませんがw)

この種の問題(2つの変数に依存して呼ぶ関数が決まる)は「ダブルディスパッチ」とか「二重ディスパッチ」と呼ばれているので、興味がある人はそれらをキーワードに検索してみるといいでしょう。

タグ:C++
2009年05月25日 【プログラミング】 | コメント(0) |

この記事へのトラックバック
この記事へのコメント

コメントを書く
お名前: [必須入力]

メールアドレス:

ホームページアドレス:

コメント: [必須入力]

認証コード: [必須入力]


※画像の中の文字を半角で入力してください。
×

この広告は1年以上新しい記事の投稿がないブログに表示されております。