ホーム < ゲームつくろー! < オブジェクト指向設計編 < オブジェクトポインタを受け渡すリスク
その4 オブジェクトポインタを受け渡すリスク
オブジェクト指向はクラスの関連の塊です。クラスがお互いにメッセージをやりとりし、その結果何らかの成果が達成されます。ところで、次の例をご覧下さい。
CShip pShip = new CShip;
CEnemy pEnemy = new CEnemy;
pEnemy->SetTarget( pShip );
void CEnemy::SetTarget( CShip* tgt)
{
m_pTarget = tgt;
}
これはSTGで敵オブジェクトにターゲットである自機オブジェクトを登録しているソースコードです。一見すると真っ当な感じがします。しかし、これは本当に正しい設計なのでしょうか?
ここで言う「正しさ」とは何か?それを含め、この章ではオブジェクトポインタを受け渡す意味とリスクについて考えてみます。
@ 関連か依存か、それとも…
例えば、仕様書につぎのような記述があったとします。
・ 敵は自機の位置に向かって弾を発射する。
これより、敵は自機を知っていると解釈するかもしれません。それをクラス図で書くとこのような感じです。
これはUML2で言うところの「関連」です(CEnemyクラスがCShipクラスを保持する)。これを元に、CShipを保持する冒頭の関数を定義したわけです。では、その3で登場したOCP(Open Closed Principle:開放閉鎖原則」をこのクラス図及びその関数は満たしているでしょうか?
例えば「自機の能力の変化によって攻撃力を変える」と仕様を変更するとします。こうなると、敵は自機の位置だけでなく能力も知る必要が出てきます。しかし、現状のCEnemyクラスは、能力取得インターフェイスを持っていないCShipしか扱えないので、それを知る術がありません。そこで、この仕様変更を満たすために、CEnemyを修正するのではなくて、派生させることを考えます(OCPを守るんです)。
CVariableEnemyクラスは自分の能力を変化させるメンバ変数に対応するように、自身を変化させます。CShipもその派生クラス(CAbilityShip)で能力値を取得する関数を新設します。
今回は自機の能力値を敵が得るだけの関係に過ぎないので、相手を保持する必要はありません。つまり、CVariableEnemyクラスとCAbilityShipクラスは「依存」の関係になります。
UML2では依存を点線で表します。
さて、これで仕様を満たすことができましたが、上のクラス図、微妙な気持ち悪さがありませんでしょうか?「CEnemyとCShip」の実線あたりが…ねぇ(-_-;
A OCPを満たすレベルは、関連 < 依存 < 関連連結クラス
仕様書を見て、「CEnemyはCShipを知っているんだろう」と判断し、一番最初に示したクラス図ができました。このクラス設計は決して悪いわけではありません。しかし、仕様書をもっとよく見ると、「敵は自機の位置に向かって弾を発射する」とあります。自機にではなくて「位置」になんです。ということは、CEnemyクラスに自機から位置情報を取得するメンバ関数を持たせる、仕様は満たされるのではないでしょうか。つまり、こういう関数です。
void CEnemy::SetTargetPos( CShip* pship )
{
m_Pos = pship->GetPos();
}
これは、CShipオブジェクトへのポインタを保持しないので「依存」の関係です。これにより、クラス図はちょっと変更になりました。
相手のポインタを保持しない分、先程よりも関係がゆるくなりました。しかしですよ、仕様書を今一度よく読みます。
・ 敵は自機の位置に向かって弾を発射する。
これをより最小限に解釈するならば、CEnemyクラスにターゲットの位置を保持する関数を定義して、その関数を使って自機の位置を教えるクラス(関連連結クラス)を挟めば、それも仕様を満たすのではないでしょうか?
void CEnemy::SetTargetPos( CPOSITION pos )
{
m_Pos = pos;
}
void CPosRelation::Connect()
{
m_Enemy->SetTargetPos( m_Ship.GetPos() );
}
クラス図はこうなります。
こうなると、もうCEnemyクラスとCShipクラスの間に直接的な関係は無くなってしまいます。これは、お互いの存在を全く知らない状態です。
CPosRelationクラスは、CShipオブジェクトの位置をCEnemyオブジェクトに伝える伝言クラスです。こういう関連連結クラス(ちなみに、これは私が勝手にそう呼んでいるだけです)の存在は、最大限のOCPを与えてくれます。例えば、先程の「自機の能力の変化によって攻撃力を変える」という仕様変更に対しても、
という風にCAbilityRelationという関連連結クラスを挟む事で対処できます。仕様の変更によりCEnemyクラスやCShipクラスがどのように突飛にサブクラス化しても、関連連結クラスにより両者の依存関係は完全に閉じ込められ、外部に影響を及ぼすことは殆どなくなります。
ここまでの具体的な話をUML2の仕様で考えて見ます。UML2では、クラス間の関係を大きく「関連」と「依存」に分けています。関連は相手のクラスのポインタを保持します。依存は保持しません。そこが両者の決定的な違いです。「関連」はさらに「集約」と「合成」に分かれます。「集約」は一時的に相手を保持しますが、消去は行いません。一般には関連とほぼ同じ意味合いで使われます。一方、合成(コンポジション)は一度相手を持つと、その相手をずっと持ち続け、自分がいなくなる時に相手も一緒に消してしまいます。これは大変に強い関係です。
関係 矢印(AはBを知っている関係) 意味 依存 AはBの情報を拝借しますが、Bのポインタは保持しません。 関連
概念的にAはBを知っていますよというための記法。実装向きではないです。
集約 AはBのポインタを一時的に保持します。しかし、Bのポインタ先を消したりすることは基本的にしません。実装向きな記法です。 合成(=コンポジション) AはBを完全に支配します。Bを消す権限もありますし、Aが消えるとBも消える。BはAと運命共同体です。こちらも実装向き。
これらをOCPの自由度という観点でで見てみると、こうなります。
私が思いますに、表記や意味もそうですが、OCPの観点から見たこの序列。これを意識することがクラス設計において物凄い大切です!!関連連結クラスを使う以外は、お互い(もしくはどちらか)にオブジェクトポインタを渡します。それが入るだけで、OCPの自由度は格段に下がってしまう!これが、この章の結論です!
C 「どの関係を使うのか」をどう判断するか?
将来起こるであろうどのような変化にも絶えうる仕様にすることは、クラスの設計の最重要点です。その最大の候補が関連連結クラスであることは上で示しました。では、いつでもどこでも関連連結クラスを使えば良いのでしょうか?それは、まぁそれでも良いのですが、さすがに疲れてしまいます。クラスが関係するたびに、その間に連結クラスを挟んでいては、クラスパッケージが連結クラスであふれてしまいます。そうなると、利用する方が大変です。適切な基底クラス(まず変更が無いと考えられるクラス)を用いれば、依存や関連で十分なことも結構あります。
どの関係を使えば良いかの判断基準は、ずばり「仕様変更があるかどうか」です。仕様が変わらないだろうと思われる固い部分は、「関連」や「合成」を使っても良いでしょう。仕様変更が多分無くて、相手を持つ必要が無い(=相手から情報を拝借する)場合は「依存」です。仕様変更の予定がある、もしくは、将来的に仕様変更が起こると考えられる関係にあるならば、関連連結クラスを入れておいた方が無難です。
関連連結クラスの良いところは、将来的にもう仕様変更が無いだろうという段階で、関連連結をはずせる点にあります。2つのクラスが十分に固まったのですから、お互いを知って依存や集約で存分に連結してしまえばよいんです。その段階で、そのクラスは揺るぎ無い何らかの振る舞いを提供するはずです。もちろん、依存以下関連・合成で相手を知る状態にしてしまうと、その関係を切ることはもう出来ません。それは、それ以上の関係を望めない、言い換えれば仕様を限定した状態です。つまり、依存以下の関係を作ると言うのは仕様変更をせばめるリスクがあり、その使用には結構な覚悟が必要なんです。これを忘れると、その設計は失敗してしまうでしょう。
この章では、オブジェクトのポインタを受け渡す具体例を挙げながら、そのリスクを説明してきました。簡単な実装に見える関係も、実はこれだけ奥深い。だからこそ、オブジェクト指向は難しく、また魅力的なのかもしれません。