ホーム < ゲームつくろー! < クラス構築編 < ちょっと反則だけど最強に使える荒業タスククラスの実装
ちょっと反則だけど最強に使える荒業タスククラスの実装
「タスク」というのは「小さな仕事」という意味で、ゲーム製作ではしばし使われる技法の一つです。タスクはあるメッセージを受けてゲームのパラメータを変えたりキャラクタを動かしたりと、ゲームに変化を与える仕事をなします。
タスクについて良く知らない方のために、タスクの雰囲気をつかんでもらう実装をまずはご紹介します。その昔C言語の時代には、タスクは関数ポインタの組み換えで行っていました:
C言語時代のタスクの実装例 #define MSG_LEFT 0
#define MSG_RIGHT 1
// 左移動タスク
void LeftTask(int* pVal){ (*pVal)-=1; }
// 右移動タスク
void RightTask(int* pVal){ (*pVal)+=1; }
int _tmain(int argc, _TCHAR* argv[])
{
// タスク関数配列
void (*TaskFunc[])(int*) = {LeftTask, RightTask};
// 左左右と動かす
int Pos = 0, i;
int Msg[] = {MSG_LEFT, MSG_LEFT, MSG_RIGHT}; // 左、左、右
for(i=0; i<3; i++)
TaskFunc[Msg[i]](&Pos);
return 0;
}
void (*TaskFunc[])(int*)
というのがint*型を引数に取る関数ポインタの配列です。TaskFuncというのが変数名なんです。凄い定義の仕方をしますが、これはちゃんとしたC言語に準拠した書き方です。
グローバルエリアに定義されたLeftTask関数とRightTask関数は、コンパイル時に静的なアドレスを与えられます。TaskFunc関数ポインタ配列にはその静的アドレスを格納しているわけです。ですから、これはコンパイルエラーにはなりません。メイン関数内では「左、左、右」という移動メッセージをMsg配列に格納して、後はforループでマクロ定数を順番に与え、対応する関数を呼び出してPosを変化させています。C言語でのタスクのエッセンスはこんな感じです。随分とややこしい事をしていたもんです。
これがC++版になると、クラスを用いて少しスマートな書き方に変わります。
C++時代のタスクの実装例 #define MSG_LEFT 0
#define MSG_RIGHT 1
class TaskBase{
public:
virtual void Func( int* pVal ) = 0; // タスク仮想関数
};
class LeftTask : public TaskBase{
public:
virtual void Func( int* pVal ){ (*pVal)-=1; }; // 左移動
};
class RightTask : public TaskBase{
public:
virtual void Func( int* pVal ){ (*pVal)+=1; }; // 右移動
};
int _tmain(int argc, _TCHAR* argv[])
{
// タスククラス配列
TaskBase *TaskObj[] = {new LeftTask, new RightTask};
// 左左右と動かす
int Pos = 0, i;
int Msg[] = {MSG_LEFT, MSG_LEFT, MSG_RIGHT}; // 左、左、右
for(i=0; i<3; i++)
TaskObj[Msg[i]]->Func(&Pos);
return 0;
}
TaskBase親クラスにTask純粋仮想関数を設け、LeftTaskクラス及びRightTaskクラスを派生させています。メイン関数内では、親クラスのポインタ配列(TaskObj)内にLeftTaskオブジェクト及びRightTaskオブジェクトのポインタを格納し、後はそれぞれのFuncメソッドをループ内で呼び出しています。ポリモーフィズムをうまく利用しているわけですね。概念としてこちらの方がずっと分かりやすいと思います。
さて、物凄い長い前振りでしたが、このタスクの考え方はウィンドウプロシージャのような「メッセージ駆動型アプリケーション」で大いに利用されるようになりました。グローバルメソッドではなくて、メッセージに対応するメンバメソッドを呼び出すスタイルにすることで、隠蔽とカプセル化を獲得したわけです。まずは、その辺りから見てみる事にしましょう。ちなみに、私が言いたい事はず〜〜っと後に出てきます。そうせざるを得ないんです。ごめんなさい(T_T)
@ クラスの内部を操作するメッセージプロシージャ
ある大きなクラスにメッセージを渡して、内部でそのメッセージに合わせた動作をさせているとしましょう。例えば、こんな感じです。
動作オブジェクトクラスのメッセージプロシージャ bool MoveObject::MsgProc( int msg )
{
switch(msg)
{
case MSG_LEFT: // 左に動く
MoveLeft();
break;
case MSG_RIGHT: // 右に動く
MoveRight();
break;
default:
return false;
}
return true;
}
MoveObjectクラスは移動するメソッドを幾つか持つクラスです。また外部のメッセージを受信する能力があり、ここでは引数のメッセージに合わせて左や右へ移動するメソッドを呼び出しています。こういう仕組みはウィンドウズのメッセージプロシージャでおなじみで、メッセージを行動に変える部分は冒頭のタスクとやっている事は全く一緒です。
このswitch〜case文は、やりたい事(メッセージの種類)が多くなる程長く長くなります。それは別に悪い事ではありません。頑張って実装すればいいんです。しかし、やりたい事が増えるたびにこのクラスを再度コンパイルし直さなければならないという点は大いに問題です。例えば、MSG_LEFTTOPというメッセージを受けたら左上に移動するようにしたいと思うと、MsgProcメソッドにcase文を追加する必要が出てきます。さらに右下移動とか、元の位置に戻るとか、移動幅を変えるとか、やりたいアイデアが沢山出てきたらどうでしょうか?その度にこのクラスを再コンパイル事になりますし、またMoveObject::MsgProcメソッドの肥大が甚だしくなるでしょう。2度と使わない実装なども出てくる可能性が大です。
そこで、「switch〜case文を消したいなぁ」と発想するようになります。これは、GoFのデザインパターンの1つである「ストラテジパターン(Strategy)」がベストチョイスなんです(詳しい説明はリンクにあります)。ストラテジパターンを簡単に言うと、やりたい事を外に出して入れ替え差し替えできるようにしてしまおうというパターンです。ストラテジパターンを使うと上の実装はどうなるか?次の単元をご覧下さい。
A クラス内部を操作するストラテジパターンの導入
上のプログラムをストラテジパターンで書き直すと、次のようになります:
動作オブジェクトクラスのメッセージプロシージャ(ストラテジパターン版) bool MoveObject::MsgProc( int msg )
{
map<int,MoveObjectTask*>::iterator it; // メッセージ→タスクハッシュのイテレータ
it = m_Msg_Task_HashTable.find( msg );
if( it != m_Msg_Task_HashTable.end() ){
(*it).second->Task( this );
return true;
}
return fasle;
}
すこしごちゃごちゃしていますので説明します。まずm_Msg_Task_HashTableというメンバ変数は、引数のメッセージからMoveObjectTaskタスクオブジェクトというタスク(やりたい事)を取り出すハッシュテーブルで、STLのmapを用いています。map::findメソッドでメッセージに対応するタスクオブジェクトを取得し、そのタスクがもつTaskメソッドを実行します。これ、冒頭のタスク処理そのものですよね。実際に左や右へ行くタスクは次のように実装されます:
動作オブジェクトクラス用のタスククラス class MoveObjectTask{
public:
virtual Task( MoveObject* ptr ) = 0; // 引数のMoveObjectクラスを操作する関数
};
// 左へ移動タスク
class MO_LeftTask : public MoveObjectTask{
public:
virtual Task( MoveObject* ptr ){ ptr->MoveLeft(); // 左へ移動 }
};
// 右へ移動タスク
class MO_LeftTask : public MoveObjectTask{
public:
virtual Task( MoveObject* ptr ){ ptr->Moveright(); // 右へ移動 }
};
そしてこれらのタスククラスを登録するメソッドをMoveObjectクラスに設ければ、ストラテジパターンによるクラス内部タスクの出来上がりです。
登録メソッド void MoveObject::RegistTask( MoveObjectTask* pTaskObj, int msgID )
{
// タスクを登録
m_Msg_Task_HashTable.insert( pair(msgID, pTaskObj) );
}
この方法の驚異的に最強の利点は、何と言ってもやりたい事をいくらでも外付けできるという点です。右上(MO_RightTop)、桂馬飛び(MO_KnightMove)、元に戻る(MO_ResetPos)など、好きな動きをさせるタスククラスをMoveObjectTaskクラスから派生させて、固有メッセージIDと一緒にMoveObjectオブジェクトにバンバン登録しちゃえばいいんです。そうすれば、後はメッセージプロシージャにメッセージを送るだけで、対応する行動をいくらでも好きなだけ定義する事が可能になります。取り外しも思いのままですから、無駄も省けます。何とも凄いですよね!ストラテジパターンでswitch〜case文が消えた時には、私なんぞ「こりゃーすげー!やべー!」と大いに感動したものです。
ところが、時が経ちスキルが上がってくると、この仕様に1つ不満が出てきたんです。
B 専用タスクなのにクラスの公開メソッドでしか操作できないのが不満だー!
題目がそのまんまなのですが、上の実装だとタスククラスが出来る事はMoveObjectクラスがもつ公開メソッドでできる操作範囲に限られてしまいます。例えば、MoveObjectクラスのMoveLeftメソッドやMoveRightメソッドが非公開だったらどうでしょう?これらメソッドを内部で内密に使おうと、派生クラスにのみ仕様許可を与えるためprotected宣言する事は良くあるでしょう。そうなると、MoveObjectTaskタスククラス群はもはやMoveObjectオブジェクトをメッセージによってまったく動かせなくなってしまいます。せっかくのストラテジタスクも台無しというわけです。
では、せめて気心の知れたクラスにだけは非公開メソッドを公開し、自分を自由に操ってもらうのはどうでしょう。これを可能にする嬉しい機能がC++にはあります。それは「フレンドクラス」です。例えば、次のようにすると、タスククラスは非公開メソッドにアクセスできるようになります:
タスククラスをお友達にする(フレンドクラス定義) class MoveObject
{
public:
// フレンドクラス
// これらのクラスには非公開メソッドを公開します
friend class MO_LeftTask;
friend class MO_RightTask;
friend class MO_RightTopTask;
friend class MO_KnightMove;
friend class MO_ResetPos;
// 非公開メソッド
protected:
void MoveLeft();
void MoveRight();
void ResetPos();
}
MoveObjectクラスの中にフレンドクラスを太文字のように定義すると、これらのクラスは非公開メソッドに自由にアクセスできるようになります。なんだ、それじゃ一件落着だね・・・じゃないんです!
こうしてしまうと、新しいタスククラスを作成する度にMoveObjectクラスの宣言部(上記)を書き換えなければなりません。あるゲームでのみ使用する特別なタスククラスがあったとしても、このクラスをフレンド宣言しないといけないので、MoveObjectクラスはその稀有なタスククラスと一蓮托生、一生付き合っていかなければならないんです。これじゃ不満を通り越して怒髪天を衝くってもんです!!
つまり、上のようにfriendを使う方法は、ストラテジタスクの実装として全然駄目なんです。さて、困りました・・・。
C ちょっと反則だけど、とびっきりの解決策!
ストラテジタスクは物凄い便利で使いたいのに、friend宣言ではまるでセンスの無い実装になってしまいます。「何とかMoveObjectクラスを触る権利を持ったクラスにだけ内部公開をする方法は無いものだろうか?」と、相当あれこれうんうん悩みまして、ちょっと反則ではありますが、これしかないという解決策をついに見つけました!!
ポイントはコンポジットパターン(Composite)の応用です。詳しい説明はリンクをどうぞ。このパターンは「自分のクラスの中に自分と同じ型のポインタを持つ」という構造を持っています。ここでは、この構造を利用します。
まず、MoveObjectクラス内にストラテジタスクとなるハッシュテーブル及びタスク実行する仮想関数メソッド(Taskメソッド)を次のように追加します:
MoveObjectクラスにTaskメソッドを追加 class MoveObject
{
protected:
map<int,MoveObject*> m_Msg_Task_HashTable; // メッセージ→タスクハッシュテーブル
protected:
virtual void Task( MoveObject* ptr ){}
void MoveLeft();
void MoveRight();
void ResetPos();
public:
void RegistTask( MoveObject* ptr, int msgID );
};
このハッシュテーブルの与え方がコンポジットパターンに近いものです。自分自身の方のポインタを持っていますよね。そして、このクラスの中に定義したタスクメソッド(Taskメソッド)。これが最大の注目点です。どうして操作される側にタスクメソッドがあるのか?それは、このクラスの派生クラスをタスククラスとして振舞わせるためなんです。
MoveObjectクラスを操作するタスククラスを、大胆にもMoveObjectクラス自体から派生させます。そして、Taskメソッドをオーバーライドするわけですが、ここで更に大胆にも、Taskメソッド内で引数の親クラスをダウンキャストしてしまいます!
MoveObjectクラスからタスククラスを派生 class MO_LeftTask : public MoveObject
{
virtual void Task( MoveObject* ptr ){
MO_LeftTask *p = (MO_LeftTask*)ptr; // ダウンキャスト
// 親クラスの非公開メソッドを仕様
p->MoveLeft();
}
};
こうすると、引数のptrは子クラス(つまりMO_LeftTaskクラス)として振舞えるようになります。そして、ここが最大のポイントなのですが、キャストしたポインタ変数pはMO_LeftTaskクラス自身の型であるため、p->MoveLeftメソッドの呼び出しは許されます!だって、自分自身が持つMoveLeftメソッドを呼び出しているだけなのですから。でも、自分はこのメソッドを再定義していないので、この呼び出しによって呼ばれるのは親クラスのMoveLeftメソッドです。よって、これは実質親クラスの非公開メソッドを呼び出している事になります!しかも、このキャストによって親クラスのメンバ変数も触り放題です。これも、至って合法です。だって、MO_LeftTaskクラスは親クラスを余すことなく引き継いでいるわけで、親クラスのメンバ変数を知っていて当然なんですから。さらに素晴らしい事に、親クラスでprivate宣言されたメソッドやメンバ変数は、この変換では触る事はできません。
つまり、ちょっと危険なダウンキャストに目を瞑り、このクラスが親クラスのメソッドを用いる限り、オブジェクト指向でいう「カプセル化」も崩していないんです。親クラスの全てを触れ無いという点では真のタスクにはなりませんが、これによりprotected宣言の壁を乗り越えられるというのは、一つの事件です(^-^)。
このテクニックを使えば、実質親クラスは必要なメンバ変数とタスク関数だけ持っていれば良く、多くのメソッドはタスククラスを新規に作成することでいくらでも動的に追加できることになります。これは、ちょっとした革命です!もちろんクラスにはちゃんとしたメソッドを定義した方が良いです。公開しているメソッドを使った方が高速で、具体的な引数も渡せますし、他のクラスとの連携も取りやすくなるからです。でも、一度決めた静的なメソッドだけでなく、実行時に能力を追加していける仕様は、やっぱり革命です。
D 安全性について
このテクニックは工夫次第できっと物凄い効果をもたらしてくれると思いますが、使用に関していくつか注意点があります。
まず、上のような反則くさいダウンキャストをサポートしていない言語では使えません。親クラスに新機能を付けた派生クラスを新設した場合(例:MoveObjectExクラスなど)、親クラスにあるMoveObjectを引数に持つTaskメソッドではその新機能を使えません。その機能も使ったタスククラスを作る場合、「実行時型判定」を用いてTaskメソッドの引数に渡された実体がMoveObjectExクラスである確証を得てからメソッドを呼び出す必要があります。でも、そういう事を実現可能であることは間違いありません。タスククラスは全てを知っているのですから、ダウンキャスト大歓迎で良いのではないでしょうか。
最後に、タスクメソッド内で引数のポインタのキャスト型pを通して子クラスのメソッドを用いては絶対にいけません。それは、引数の実体は親クラスなのですから、その親クラスの関数テーブル内には子クラスのメソッドが含まれないためです。間違って呼び出すと、多分間髪を入れずにメモリが破壊されます。タスククラスを実装する時はくれぐれもご注意下さい。ちなみに、Taskメソッド内で、自分自身のメンバ変数やメソッドはいつものように使っていっこうに構いません。そりゃそうです(笑)。
ちょっとした注意を払えば、このテクニックはウルトラ柔軟な仕様を提供してくれますので、怖がらずにどしどし使ってみましょう〜。