ホーム < ゲームつくろー! < オブジェクト指向設計編 < 拡張できて修正不要の原則 : OCP
その3 拡張できて修正不要の原則 : OCP
オブジェクト指向には幾つか「原則」と呼ばれるものが存在しています。原則(Principle)というのは約束(Promise)や指針(Guideline)よりもはるかに強い戒めです。オブジェクト指向における原則は、よっぽどの理由が無い限り破る事は許されないものばかりです。沢山ある原則の中で、ここではオブジェクト指向の基本原則である「Open-Closed Principle : OCP」について見ていくことにします。尚、今回の話は「まさーるのページ」(石井様作成)にあるオブジェクト指向に関する素晴らしいお話の影響を多大に受けております。このページをご覧になりますと、オブジェクト指向の本が読みたいと熱望してしまうこと間違いなしです。私も思わず関連本を買ってしまいました(笑)
@ OCPって何だ?
Open-Closed Principle : OCP(開放閉鎖原則)という厳めしい名前が付いたこの原則は、1988年にBertrand Meyerが提唱したオブジェクト指向の最も基本的且つ大切な原理にあたります。この法則は、ぶっちゃげて言いますと、題目の通りで「拡張できて修正不要の原則」です。
クラス、もしくはそれを組み合わせたシステムというのは、個人・企業に関わらず機能拡張の要求が良く出されます。その時、既存のクラスやシステムが機能拡張可能である状態を「Open:開いている」、その時に修正を要しない状態を「Close:閉じている」と言います。
「機能を拡張するのに修正しないなんてありえるのか?」と思ってしまいます。そのカラクリは簡単です。機能拡張の方法には2つあります。1つはコードを修正する方法、もう1つはコードを追加する方法です。既存のコードを変更することなく、コードを追加することで機能拡張できれば、その修正はまず他に影響しないでしょう。つまり、OCPとは作成したクラスやシステムがどれだけ安全に、他人に迷惑をかけることなく機能拡張できるかを示す指針であるわけです。
とは言うものの、これは中々実感できないものです。そこで、幾つかの例を挙げて、システムがOCPを満たしているか否かを判定してみることにします。習うより慣れろです。
A 肌で感じるOCPその1 : 画面に絵を表示する
ゲーム製作の基本は画面に絵を出すことです。そのために、まず絵を管理するクラスを適当に作ります。
class Picture
{
protected:
BITMAP m_bmp; // ビットマップ
public:
void Draw(); // 描画関数
void Load( char* filename ) // ファイルから読み込み
{
// ファイルからビットマップの情報を読み込んでm_bmpに格納する作業
}
};
さらに、描画を管理するクラスも適当に作成します。
class Screen
{
protected:
list<Picture*> m_PictList; // 絵のリスト
public:
void AddPicture( Picture* pPict);
void Draw()
{
list<Picture*>::iterator it = m_PictList.begin();
for(;it=m_PictList.end(); it++)
(*it)->Draw();
}
};
このクラスの使い方は至って簡単で、Picture::Load関数でファイルからビットマップを読み込んでPictureオブジェクトを生成し、ScreenオブジェクトにPictureを登録してScreen::Draw関数を呼ぶだけです。
この簡単な描画システムに対して、「JPGも扱えるようにしたい」という要求が追加されました。さて、この要求に対してこの描画システムはOCPを満たしているでしょうか。それを判断してみます。
○ Pictureクラス
要求を満たす拡張に対して 開いている 現状はBMPだけを扱える仕様です。このクラスがJPGも扱えるようになるには、Picture::Load関数でJPGファイルを解析して最終的にBITMAPに落とせば良いわけです。また、描画部分はビットマップ専用の描画方法が実装されているはずですが、ここもJPGを扱えるように拡張します。ということで、このクラスは既存の機能を損なうことなく拡張ができるため開いていると言えます。
修正に対して 閉じていない 上の変更は全てコードの書き換え、つまり修正が必要になります。よって、このクラスは修正に対して閉じていません。
以上から、PictureクラスはOCPを満たしていません。
○ Screenクラス
要求を満たす拡張に対して 開いている AddPicture関数によってPictureオブジェクトを登録し、それが持つPicture::Draw()関数を呼ぶことで描画を実現しています。このクラス自体はBMPのみを描画することを想定しているのではなくて、Pictureオブジェクトという絵を管理するオブジェクトが持つ描画関数を呼ぶ役目をしています。よって、PictureクラスがJPGを扱えるのであれば、自ずと要求通りの機能拡張を満たすことになります。このことから、このクラスは拡張に対して開いています。
修正に対して 閉じている あくまでも絵を管理するPictureクラスが持つDraw関数を呼ぶことで描画をしようと考えているので、JPG用にソースコードを一切変更する必要がありません。つまり、このクラスは修正に対して完全に閉じています。
以上から、ScreenクラスはOCPを満たしています。
ということで、JPGにも対応できるようにすることは可能ですが、そのためにPictureクラスを修正しなければならない使用になっていることがわかりました。これはシステム全体としてはOCPを満たしていない、強いていうなら「オブジェクト指向の原則に反している」システムと言えます。そこで、PictureクラスがOCP満たすように考え直す必要があります。
Pictureクラスが良くない理由は、BMPを表すBITMAPを内包してしまっているからです。これにより、このクラスは「BMP専用」になってしまています。いっその事、このクラスを「絵を読み込んで描画するクラス」として抽象化してしまい、派生クラスでBMPやJPGなどに対応すれば、要求を満たすことができます。
class Picture
{
public:
virtual void Load( char* filename ) = 0; // 画像ファイルの読み込み
virtual void Draw() = 0; // 描画
};
class BMPPicture : public Picture
{
public:
BITMAP m_BMP;
void Load( char* filename ){
// ビットマップを読み込んでBITMAPを生成
}
void Draw(){
// ビットマップ描画を行う
}
};
class JPGPicture : public Picture
{
public:
JPEG m_JPG; // JPEGオブジェクト(適当です)
void Load( char* filename ){
// JPGを読み込んでJPGオブジェクトを生成
}
void Draw(){
// JPG描画を行う
}
}
新しく生成したPictureクラスは、派生クラスを設ける(コードを追加する)事で元のコードを変えることなくBMPやJPG、それどころかどのような描画ファイルにでも対応できるようになります。つまり、完全にOCPを満たしたことになります。
このように、作成したクラスやクラス内のメンバ関数についてOCPが満たされているかどうかを判定して改良するだけで、クラスは格段に汎用性を保つようになります。
B 肌で感じるOCPその2 : アイテム登録クラス
RPGではアイテムを登録する作業が必須です。物を買ったり使ったり捨てたりするたびに、アイテムは登録されたり削除されたりします。アイテムは色々ありますので、Itemというクラスで抽象化しておきます。一方、初期の仕様でアイテムはポインタ配列で管理されるとします。これらを踏まえてアイテム管理クラスを作ります。
class Item
{
protected:
char* m_Name;
int m_iItemID;
public:
void SetItem( char* name, int ID);
};
class ItemMng
{
protected:
Item* m_pItemAry; // アイテム配列
public:
virtual void AddItem( Item *pitem ); // アイテム追加
virtual Item* GetItem( int elem ); // アイテム取得
virtual int RemoveItem( int elem ); // アイテム消去
};
ある新しいRPGを製作するにあたり、このアイテム登録システムに変更要求が来ました。アイテムの個数を登録できるように変更したいという依頼です。さて、上のクラスはこの仕様変更に耐えうる仕様になっているでしょうか?
○ Itemクラス
要求を満たす拡張に対して 開いている 現状はアイテムの名前とそのIDを登録する仕組みになっています。ここにアイテムの個数を登録するには、このクラスの機能を継承したクラスItemCounterクラスを作成すれば解決しそうです。よって、派生という形で既存のクラスに追加するだけで変更要求を満たすため、このクラスは拡張に対して開いていると言えます。
修正に対して 閉じている アイテムの数を保持するクラスを派生させれば良いので、このクラス自体に修正は必要ありません。よって、このクラスは修正に対して閉じています。
このことから、ItemクラスはOCPを満たしています。
○ ItemMngクラス
要求を満たす拡張に対して 開いている 新しくアイテムの個数を保持するには、派生クラスItemMngにおいてメンバ変数にItemCounterポインタ配列を保持するようにし、各メンバ関数を現在のアイテム数を増減させるようオーバライドします。既存のItemオブジェクトへのポインタ配列は使用しなくなりますが、「これを使っているアプリケーション」がある限り、絶対に消せません。ちょっとだけ無駄が出てしまいますが、拡張に対してこのクラスは何とか開いているといえます。
修正に対して 閉じている ItemCounterポインタ配列を用いることで、派生だけで要求を満たすことができます。よって、このクラスは修正に対して閉じています。
ということで、どうやらこのシステムは修正をせずに追加だけで機能拡張ができそうです。
OCPに気を付けるようになると、安易にクラスを修正してしまうのではなくて、派生を用いて問題に対処するようになります。これが既存クラスに対して安全であることは言うまでもありません。
C OCPに頑健なクラス設計の基本
OCPは仕様の変更内容に対して頑健(ロバスト:ゆるぎないこと、強いこと)である事を言う原理です。仕様とOCPはいつも対で考えられます。それを考えますと、クラスを設計するにあたって「クラス内でどこが変更されやすいか?」をあらかじめ予想しておくことが重要なポイントとなります。Aの例では絵のフォーマットという非常に変わりやすい点を抽象クラスで抽象化することによって、OCPを満たす事ができました。別の例として、例えばWindowsとPS2ではそのプラットフォームの仕様が驚くほど異なります。ゲームをWindowsからPS2に移植(またはその逆)することを想定している場合、クラスの設計は双方のプラットフォームの違い、つまり「変わりやすいところ」について大変シビアに考えなければならなくなります。各クラスが持つ描画関数をはじめとする多くの関数は、ことごとく純粋仮想関数(インターフェイス)としなければならないでしょうし、DirectXですらプラットフォーム依存になるので、すべてラッパインターフェイスをかぶせてクラスを設計することになるはずです。そういう変更されやすい箇所のことを「ホットスポット(hot spot)」と呼んでいます。ホットスポットを見極める、それがオブジェクト指向を扱う人に必要な隠れたセンスかもしれません。
OCPを考え出すと、安易にパソコンに向かってクラスを作るのが怖くなってしまうかもしれません。それは、正しい反応だと思います。クラスは長く使われるものですから、その仕様については十分に吟味する必要があります。また、1つのクラスを作り終わった後、すぐに本番の設計に組み込むのではなくて「リファクタリング(refactoring)」を行うことが極めて大切です。リファクタリングとは一度作成されたクラスやシステムの仕様を変えることなく、より柔軟で頑健になるようクラスを作りかえる作業のことです。AとBで挙げた2つのクラスは、正にリファクタリングの作業をしたようなものです。リファクタリング作業により、クラスは美しく抽象化され、またそれにより頑健になることを目の当たりにしたと思います。考えうる変更に頑健になるようにリファクタリングをして、初めてそのクラスは実用に耐えうる物になると、私は思います。