ホーム < ゲームつくろー! < オブジェクト指向設計編 <CS1:オブジェクト指向で作る「Hello World !」

その2 CS1:オブジェクト指向で作る「Hello World !」



 プログラマであれば「Hello World !」を知らないとは言わせません!どのような言語のプログラムでも、最初に行う「画面への出力」は「Hello World !」なんです!(エゴですよ、もちろん(笑))。ということで、オブジェクト指向設計論CS1(Case Study 1)は、Hello World !をオブジェクト指向に則って画面に出すプログラムを作成します。



@ オブジェクト指向設計の最初は問題意識

 オブジェクト指向プログラミングをしようとして、画面に向かってはいけません。95%は失敗します。その1で述べましたように、オブジェクト指向はプログラム技術ではなくて問題解決の技術です。問題解決は、別にプログラムを書かなくてもできますし、まずはそうするべきなんです。

 オブジェクト指向で大切なのは、「目の前にある問題を明文化すること」です。問題意識をしっかりもって、「解決すべき問題は何なのか」その問題領域を洗い出すことが重要なんです。

 そこで、今回のケーススタディの問題を明文化してみます。

CS1問題: 「Hello Word !」 という文字列を画面に表示する

これが解決すべき問題です。



A 仕様を決める

 問題意識(問題領域)はこれでばっちりです。次に、その問題をもう少し具体化します。これは「仕様を決める」作業でもあります。よく考えると、上の問題意識は抽象的なんです。画面に表示するというのは、コンソール?それとウィンドウ?Hello World !は実行した瞬間に出すの?それとも何かキーを押したら出すようにするの?こういうことを細かく決めるのが「仕様書」です。

 今回は出来るだけ簡単にしたいので、文字列はコンソールに出すことにしましょう。仕様は次のようになります。

・ コンソールアプリケーションを実行したら、「Hello World !」という文字列を画面に表示して終える。

 この作業がオブジェクト指向設計においてとても大切で、繰り返して訓練すべきことなんです。



B 仕様から「名詞」を抜き出す

 次にすることはオブジェクトの元になるクラスの設計なのですが、これには「コツ」があります。その1で説明したように、クラスは「名詞的」なものです。ですから例えば「表示する」というのは普通クラスになりません。これは「動詞」であり関数(処理)であるべきなんです。では、上の仕様内で使われている名詞を抽出してみます。 

クラス候補:
「コンソールアプリケーション」「Hello World」「文字列」「画面」

 何だかどれも微妙な感じですよね。でも、それで良いんです。これはあくまで候補でして、ここから本当に必要そうなものを吟味していくことになります。
 まず「コンソールアプリケーションクラス」というのはありでしょう。「え?コンソールってもとからもうあるじゃん」と思うかもしれません。しかし、例えばWindowsプログラムのゲームのある1シーンで「コンソールアプリケーションのような画面を出してHello Worldを表示させる」としたら、そのクラスの必要性が出てきます。そうでないとしても、アプリケーション全体を管理するクラスの存在が欲しく、このクラスはそれにふさわしいものです。先入観を捨てて、とりあえずコンソールアプリケーションクラスは採用することにしましょう。
 次の「HelloWorldクラス」はどうでしょう。実はこれも採用です。「はぁ?」と思われるのはごもっとも。しかし、HelloWorldオブジェクトに対して「君の文字列をちょうだい」のような命令をする様子を思い浮かべてみてください。オブジェクト指向っぽくありませんか?クラスはある程度具体化した物であるべきでして、今のところHelloWorldは立派な「物」と考えてよいでしょう。
 「文字列クラス」はどうか?これは迷いどころです。文字列と言うのはクラスというよりは「データの型」に近いものです。オブジェクト指向ではこれを「属性」と言います。クラスが持つデータの事です。世の中には文字列クラスはありますが、今回の問題を解決する上では、どちらかというとこれは「HelloWorldクラス」が持つべき属性に感じます。ですから、今回は文字列クラスは不採用とします。
 最後の「画面クラス」は採用です。一切の表示をこのクラスの機能を使って行うわけです。

 ということで、文字列以外はみんなクラスとして採用することにしました。作成するクラスは次の3つです。

・ ConsoleAppクラス (コンソールアプリケーションクラス)
・ HelloWorldクラス (HelloWorldクラス)
・ Screenクラス (画面クラス)



C 処理を考える

 次に行うことは、クラスの処理、つまりメンバ関数の決定です。データではなくて最初に処理の方から考えるのが「コツ」です。というのは、オブジェクト指向というのはお互いのクラスの関数を使って何かをするという考え方に基づいているからです。各クラスの接点は「処理(関数)」なわけでして、関係を考える方が属性を決めることよりも優先順位が高いのです。各クラスが処理として何をするかは、実はすべて仕様書に書かれています。逆に言えば、仕様書に書かれていない不必要な処理を考えることはやめた方が無難です(載っていなくて必要な処理は考えるべきです)。

 ConsoleAppクラスの処理は「実行する」と「終える」でしょうね。「表示する」というのもまぁあって悪くないかもしれませんが、これはどちらかと言うと画面クラスの役目に思えます。処理の決め方のコツは、そのクラスの役目をしっかりと考えることです。アプリケーションを管理するクラスというのは、例えば必要なオブジェクトを生成するとか、終了処理をするとか、アプリケーション全体にかかわる警告を発するなど、アプリケーションの動きそのものを管理する役目を成します。画面の無いアプリケーションがこの世に沢山あることからして、画面に表示すると言うのはアプリケーションの主たる動作として今一な感じがします。
 HelloWorldクラスの処理は何でしょう。仕様を見ると…う〜ん、らしい動作はありません。「表示する」というのもありかなという気もしますが、ここは迷いどころです。もし「表示する」という処理を加えると、それはHelloWorldクラスが表示の方法を知っていると言うことになります。はたしてそうでしょうか?画面の表示方法というのは世の中に様々あります。その専門家はやはり画面クラスであるわけで、HelloWorldクラスでは無い気がします。よって、「表示する」という処理はこのクラスに入れません。ではこのクラスの役目は何かと言うと「文字列を渡す」という事ではないでしょうか。つまり、「お前の持っている文字列を渡せ!」と命令されるとそれを惜しげもなく差し出すというわけです。多分、これだけでしょうね。
 最後のScreenクラスのメイン処理は「画面に表示する」です。表示する物を設定する関数も必要になりそうです。そこで今回は「HelloWorldオブジェクトを設定する」という関数も設けます。

 現在まで考えられる処理の候補をまとめます。

・ ConsoleAppクラス
  → 実行する
  → 終える

・ HelloWorldクラス
  → 文字列を渡す

・ Screenクラス
  → HelloWorldオブジェクトを設定する
  → 画面に表示する

 今回のクラス設定やメンバ関数について、ちょっとプログラムを知っている人は「HelloWorldクラスなんておかしい」とか思うかもしれません。また「HelloWorldクラスに表示関数を仮想関数として作れば、多態性が利用できて柔軟な表示が可能になる」などと思いませんでしたか?それはある意味正解です。オブジェクト指向による問題解決には「答えが複数」あります。よって、皆さんが設計すれば、もっと違う形になるかもしれません。要は、問題さえ解決できれば何だって正解といえるわけです。今回の問題は、画面に「Hello World !」を出力すると言うことだけです。であれば、上のクラス設定もありなんです。あまり懲りすぎたクラスというのは、得てして使いにくいもので、気がつけばオブジェクトの機能がわけわからなくなっていたりします。それは、凄いクラスだけれども、良いクラスではないでしょう。

<ちょっと余談>
 多態性に関して余談になりますが、オブジェクトに表示関数を仮想関数として持たせるというのは、オブジェクト指向の入門書などに良く掲載されています。これ、多態性の例としてとてもわかりやすいんですよね。「世の中には丸や四角のオブジェクトがあって、それがどう表示されるかはわからないから、Objectという親クラスにDraw仮想関数を設けて、その描画方法は派生したCircleクラスやRectangleクラスにまかせる。」というやつです。プログラムのでは、多態性の機能を利用して、

for( i=0; i<num; i++)
   Obj[i]->Draw();

のように書けるというわけです。多態性の柔軟さを言う例としては、動物犬猫鳥の例と並ぶほどわかりやすいものです。

 そういう実装は実際に行います。でも、私はこれ、何となくどうかなぁと感じているんです。なぜなら、オブジェクトが描画方法を知っていると言うことは、オブジェクトがその機種を知っていると言うことにつながるからです。PS2で動いているオブジェクトをXBOX360に移植しようと思った時、描画の規格が異なるために、このクラスをそのまま移植できません。ですから、Obj_XBOXのようなクラスを派生させてXBOX360用のDrawクラスをオーバーライドすることになります。つまり、扱うオブジェクトすべての描画関数の派生を行うわけです。まぁ確かに行えば良いのですが、何か気持ちが悪い。どうしてだろうと考えたら、本来物として存在するオブジェクトが描画まで担当するという不釣合いに違和感を感じているのがわかりました。本来書き換えるべきは「オブジェクトのデータを用いてそれを描画する方法を知っているやつ」じゃないでしょうか?上の例だとそれはScreenクラスに相当します。ですから、XBOX360にHelloWorldと表示させたいとき、HelloWorld::Draw関数をオーバーロードするのではなくて、画面を表すScreenクラスの文字を描画する関数を書き換えた方が素直なんじゃないかなという気がします。もちろん、そうしないといけないと言うわけではありません。オブジェクト指向の正解は複数ありますし、他のプラットフォームに移植する可能性が無いことがわかっているなら、オブジェクトに描画部分を実装するのもありです。

<ちょっと余談2> 2007. 6. 20追記
 オブジェクトに描画部分を実装する仕様にした場合、描画の内部でミドルウェアを使うという手があります。他のプラットフォームへの移植を考えた時、ゲームの内容に近い部分はそのままに、ミドルウェアだけをそのプラットフォームに対応させると、割りと移植が簡単になります(割とですが(^-^;)。DirectXのようにバージョンアップが甚だしいライブラリは、ミドルウェアを1枚かませるとObj[i]->Draw()が生きてくると思います。



D 関連性を決める静的分析


 続いて各クラスがどう関連するかを決める静的分析を決めます。「属性は?」と思うかもしれませんが、属性ってある意味どうでも良いもの何です。また、関連が決まって初めて属性が見えてくるというのもあります。オブジェクトがあくまでも相手のメンバ関数でシステムを作っていくと言うことを忘れてはいけません。

 関連性には「関連」と「集約」があります。これらはともにクラスの関係を表す用語で非常に似ており、よく混乱します。実はその使い分けは簡単なんです。まずは集約ですが、これはあるクラスのオブジェクトが、他のクラスのオブジェクトと運命共同体である場合を言います。オブジェクトには寿命がありますが、集約関係にあるオブジェクトは、それを使うオブジェクトが消滅すると自身もなくなってしまうのです。昔はこれをa-part-ofとか言っていましたが、最近は言わなくなりました。ちなみに、UML2において集約というのはコンポジションという物に含まれてしまいました。意味合いは似たようなものです。一方の関連の方は、片方のオブジェクトが消滅しても、関係するもう一方のオブジェクトがシステム内に存在しなければいけない関係です。運命共同体ではないわけです。「知っている」と言うくらいの関係です。

 一般に、集約の場合、関係するオブジェクトはクラスの内部で生成され、その実体を持ちます。一方で関連の場合、オブジェクトはクラスの外部で生成され、他のクラスに関連を作ってもらう事になります。実はこれが大切でして、関連の場合、双方のクラスの他にもう1つのクラスが存在していることになるわけです。関連で忘れがちになるのはこの「関係性を作るクラス」の存在です。

 さて、関連性の記述方法を紹介しておきましょう。

 
この関係図(クラス図)をいつも見るたびに、「あれ、どっちがとっちだっけ」と迷うのですが、「根元が先を知っている」と考えます。関連の場合、「ClassBはClassAを知っている」と考えます。もちろんClassAはClassBのことなど微塵も知りません。コンポジションの場合、「ClassBはClassAをコンポジットしている」とでも言うかもしれませんが、イメージし辛い良いでしたら「ClassBはClassAを所持している」とでも考えれば良いでしょう。ClassAはやっぱりClassBを知りません。

 このクラス図を使って「Hello World表示」に存在する3つのクラスの関係を記述してみましょう。まずConsoleAppクラスとHelloWorldクラスの関係はどうなるでしょうか。ConsoleAppクラスはHelloWorldクラスを知っているべきでしょうから、矢印の方向は決まっています。問題は関連かコンポジションかです。その決め手は運命共同体か否かで考えます。使われるHelloWorldオブジェクトは、ConsoleAppオブジェクトが無くなれば必要ないものになってしまいます。ですから、これは「コンポジション」の関係です。よって、ConsoleAppクラスのメンバ変数として保持します。

 ではScreenクラスとConsoleAppクラスの関係はどうでしょう。これもHelloWordと同じコンポジションの関係でしょうね。

 最後にScreenクラスとHelloWorldクラスの関係はどうなるか?これは良く考える必要があります。決め手はScreenクラスに設けた「HelloWorldオブジェクトを設定する」という処理です。これは明らかにScreenクラスがHelloWorldクラスのことを知っている前提で考えられています。よって、矢印の向きはScreenクラスからHelloWorldクラスに向けられますね。では関連かコンポジションか?Screenオブジェクトが無くなった時にHelloWorldオブジェクトも一緒に消滅する必要があるかどうかがポイントです。これは、たぶんその必要は無いですよね。つまり、関係性は「関連」ということになります。関連で気をつけるのは「誰が関係を作ってくれるのか」です。これは、双方の使用者であるConsoleAppクラスに担ってもらいましょう。

 以上からクラス図を以下のように作成しました。


 属性名が入っていませんが、実際これだけでもう関連は十分に説明できます。オブジェクト指向は「オブジェクトが持っている属性をあまり気にしない」というのがポイントであり重要な考え方でもあります。



E 属性の決定

 上のように関係性も決めましたので、それぞれのクラスに必要と思われる属性を決めましょう。
 まずConsoleAppクラスはHelloWorldクラスとScreenクラスの両方を支配していますから、双方の実体を持つことにします。ちなみに、クラスの中にクラスのオブジェクトを持つ場合、ポインタとして保持しておくのが普通です。どうせ内部で作成するので、ポインタだろうと実体だろうと変わりは無いのですが、ポインタの良いところは取替えが利くと言う点で、実体よりも自由度があるのですから、ポインタにしておきましょう。

 HelloWoridクラスは当然文字列を内部に保持します。これは生成と同時に作成してしまってよいでしょう。上の関連図からわかるように、HelloWorldクラスは他者を誰も知らないのですから、他に属性は必要なさそうです。

 ScreenクラスはHelloWorldクラスと関連を持っています。一般に「関連」がある場合は、その関連するクラスのポインタを保持します。さらに、そのポインタにオブジェクトを登録するための関数を必ず持ちます。よって、Screenクラス内にはHelloWorldオブジェクトへのポインタを持ちます。

 このクラス間の関係を記述した時点で「静的設計」は完了します。次は、クラス同士がどのようにメッセージを送り合って動いていくかという「動的設計」に移ります。



F 動的設計(シーケンス図)

 クラスのつながりがわかったところで、クラス間のメッセージのやり取りを記述していきます。これを動的設計と言います。この記述には着目する項目に合わせた様々な方法が考案されていますが、ここではシーケンス図を使うことにしました。これについても微妙な違いがあるんですが、大筋はみんな同じなので、あまり形式的にならずに気楽に描きます。

 シーケンス図の目的はただ1つ。「メッセージを発して終わるまでを記述する」。それだけです。簡単ですよね。例えば、ConsoleAppオブジェクトがScreenオブジェクトにHelloWorldオブジェクトを登録する様子を次のように表します。


 スタートは左上のConsoleAppで、少しずつ下に下っていきます。横方向に矢印がついていますが、これはメッセージの送り先を指定します。その上にメッセージの内容がかかれます。実際のプログラムではメッセージを飛ばすことは難しいので、代わりに相手の関数を呼び出すことになります。命令を受けたScreenオブジェクトは、自身のポインタに渡されたHelloWorldのポインタを代入し「保持」します。自己完結のメッセージ(もしくは処理)は自分に戻ってくるような矢印で書きます。わかりやすいですよね。

 さて、次は表示に関するのシーケンス図です。

 ConsoleAppオブジェクトがScreenオブジェクトに描画を指示すると、Screenオブジェクトは自分が保持しているHelloWorldオブジェクトから文字列を取得し、それを描画します。シーケンス図は縦方向に時系列になっていて、上から読んでいけばメッセージがどのように伝播していくのかが良くわかります。

 今回の例はきわめて簡単なので、わざわざシーケンス図を描く必要は無いかもしれませんが、ある程度複雑なプロセスである場合、この図を描くことでメッセージ動きが目でわかるようになります。実際、メッセージのやり取りは殆どプログラムの流れなので、もう殆ど実装は終わったようなものです。一般に、シーケンス図は大切なメッセージの発信についてだけ描き、全てのメッセージについて描くことはあまりありません。全部作っていては大変すぎます。



G さて実装だ!

 ここまでできれば、実装も問題ないでしょう。ちゃっちゃとやってしまいます!
 以下のプログラムは空のコンソールアプリケーションを作成して上書きでコピペすることで完全に動きますので、オブジェクト指向の動きを見たい方は是非お試し下さい。


スクリーンショット
// CS1:オブジェクト指向で作るHello World !


#include "stdafx.h"
#include <iostream>

using namespace std;


// HelloWorldクラス
class HelloWorld
{
protected:
        const char* m_csHelloWorld;

public:
        HelloWorld()
        {
                // Hello World !を持つ
                m_csHelloWorld = "Hello World !";
        }

        const char* GetStr(){ return m_csHelloWorld; }
};



// Screenクラス
class Screen
{
protected:
        HelloWorld *m_pHW;

public:
        Screen(){ m_pHW = NULL; };
        void SetHelloWorld( HelloWorld* pHW){ m_pHW = pHW; }
        void Draw()
        {
                // Hello World !を描画する
                if(m_pHW){
                        cout << m_pHW->GetStr() << endl;
                }
        }
};


// ConsoleAppクラス
class ConsoleApp
{
protected:
        HelloWorld *m_pHW;
        Screen *m_pScreen;

public:
        ConsoleApp()
        {       
                // コンポーネントオブジェクトを作成
                m_pHW = new HelloWorld;
                m_pScreen = new Screen;
        }

        ~ConsoleApp()
        {
                // コンポーネントを消去
                delete m_pHW;
                delete m_pScreen;
        }

        void Start()
        {
                // スクリーンにHelloWorldを関連付け
                m_pScreen->SetHelloWorld(m_pHW);

                // 描画指示
                m_pScreen->Draw();
        }

        void End(){};
};


// メイン関数
int _tmain(int argc, _TCHAR* argv[])
{
        ConsoleApp CA;
        CA.Start();

        return 0;
}


 メイン関数ではたった2行の事しかしていません。コンソールアプリケーションオブジェクトを宣言して、そのStart関数を読んでいるだけです。ConsoleApp::Start関数では、静的及び動的解析で行った通り、スクリーンオブジェクトとHelloWorldオブジェクトを関連付け、スクリーンオブジェクトに描画を指示しています。



 今回は通常の構造化プログラムだと実は1行ですむ「Hello World !」の表示をあえてオブジェクト指向で作成してみました。このプログラム自体に使い道は正直あまりありませんが、今回行った事はオブジェクト指向設計のプロトタイプなのです。すなわち、問題意識(問題領域)、仕様書作成、クラス抽出、処理の決定、関連決定、属性決定、動的解析(シーケンス図)そして実装というオブジェクト指向のプロセスをしっかりと踏んだんです。複雑な問題になっても、基本的にこの流れは変わりません。

 オブジェクト指向の良いところは、このプロセスの途中で何か気がついたことがあったときに、振り返ってやり直したり追加したりすことが出来る点にあります。例えば上の実装で、実はConsoleApp::End関数は使っていません。これはシーケンス図辺りでもう消してよいと気がついていたんですが、この説明にちょうど良いと思いまして、あえて残しておきました。大切なのは、そのような変更を行ったときに、処理の決定以降で該当する部分をちゃんと修正しておくと言うことです。ドキュメントと実装に食い違いがあると、後で困ることになります。

 今回クラス図とシーケンス図が出てきましたが、これは最新のUML2に準拠していません。しかし、根本は間違っていません。この「表記法」というのはあくまでも分析のためのツールであり、「こうしないとだめだ」というものでは決してありません。がっちがちにUML2に従っていたら、方法論に結果が埋没してしまいます。大切なのは、問題をしっかりと解決できる能力を身に付けることであり、正しく表記できることは必須では無いです。もちろん、共通認識が正しく出来るように、独自の表記は控えるべきだとは思います。