オブジェクト指向設計の基本
オブジェクト指向プログラミングの特徴
伝統的な手続き指向プログラミングでは、実行する一連のステップ、すなわちアルゴリズムを記述するものをプログラムと考えます。オブジェクト指向プログラミングでは、プログラムはオブジェクトの相互作用システムをして記述します。一時的に必要となる領域、すなわちテンポラリ・バッファはプログラムのいたるところで用いられます。C++を厳格な手続き型言語として使うこともできますが、オブジェクト指向のアプローチをとることでC++の長所を生かすことができます。
オブジェクト指向プログラミングは、キーとなる概念をいくつか持っています。
- ■ 抽象化の概念
- 最も基本的な概念で、大規模なプログラムをより単純に記述できるようにします。
- ■ カプセル化の概念
- プログラムの変更とメンテナンスを簡単にします。
- ■ クラス階層の概念
- プログラムを簡単に拡張できるようにします。
以上の概念はどの言語にも適用できますが、明示的にサポートしているのはオブジェクト指向言語だけです。
抽象化
"抽象化"とは、プログラムのデータ構造や動作から、あまり重要でない詳細部分にとらわれることなく、共通な特徴を見つけて1つの概念を作り出すことです。プログラミング言語は、高度の抽象化をサポートしているほど、より"高水準"であると考えられます。例えば、同じ仕事を実行する2つのプログラムが、片方はアセンブリ言語、他方はC言語でそれぞれ書かれているときを考えてください。アセンブリ言語プログラムは、その仕事を実行するためにコンピュータが行うことを詳細に記述しています。一方、Cプログラムではコンピュータが行なうことをより抽象化して記述できます。抽象化によってプログラムが簡潔になり、理解しやすくなるのです。
伝統的な言語も抽象化をサポートしてきましたが、オブジェクト指向言語はより強力な抽象化メカニズムを提供しています。このことを理解するために、異なる種類の抽象化を考えてみましょう。
プロシージャの抽象化
最も一般的な形式の抽象化は、共通な手続きまたは動作をまとめた "プロシージャの抽象化"です。
プロシージャの抽象化には多くの段階があります。例えば、C言語で書かれたプログラムは、アセンブリ言語でかかれたプログラムより、簡潔にわかりやすく記述することができます。これはC言語がアセンブリ言語を抽象化したものであるためです。また、アプリケーションプログラムのマクロ言語で書かれたプログラムでは、与えられた仕事をC言語で書いたプログラムよりもより高水準に記述できます。
ある言語でプログラムを書くとき、その言語自身が提供している抽象化のレベルを使わなくてはならない理由はありません。ほとんどの言語では、ユーザー定義関数(プロシージャ、またはサブルーチンとしても知られています)をサポートすることで、より高水準のプロシージャの抽象化を使ってプログラムを書けるようになっています。ユーザー独自の関数を書くことで、プログラムが行うことを表現する新しい術語を定義していることになるのです。
プロシージャの抽象化の単純な例として、2つの文字列が等しいかどうかを大文字小文字を区別せずに判定する、次のようなプログラムを考えてみます。
while ( *s != '¥0' ) { if ( ( *s == *t ) || ( ( *s >= 'A' ) && ( *s <= 'Z' ) && ( ( *s + 32 ) == *t ) ) || ( ( *t >= 'A' ) && ( *s <= 'Z' ) && ( ( *t + 32 ) == *s ) ) ) { s++; t++; } else break; } if ( *s == '¥0' ) printf("equal ¥n"); else printf("not equal ¥n");
プログラムをこのような方法で書くと、2つの文字列が等しいかどうかを判定するために行なう個々の比較をつねに頭に置いておく必要があります。このプログラムを抽象化するには、次のように文字列比較に当たる部分を関数にしてしまうことです。
if ( !_stricmp(s, t) ) printf("equal ¥n"); else printf("not equal ¥n");
_stricmp
を定義することで、プログラムの行数を減らせるだけでなく、わかりやすいプログラムになります。関数が定義されていない前の例では、混乱をまねく可能性があります。また特に重要なのは、この関数が実行するステップの詳細ではなく、文字列比較(大文字小文字を区別しない)を行なうという"動作" です。
関数を使うことによって、プログラミング言語をステートメントのレベルではなく、論理的な操作のレベルで考えられるようになるため、大規模なプログラムを設計しやすくなります。
データの抽象化
もう1つの種類の抽象化は、データ型の表現形式に対する "データの抽象化" です。
例えば、すべてのコンピュータ データは16進数または2進数として見ることができます。しかし、ほとんどのプログラマは10進数で考えた方がわかりやすいので、ほとんどの言語は整数と浮動小数点数の両方をサポートしています。16進数を何バイトか入力するかわりに、単に "3.1416" と入力できるのです。同様に、Basicでは文字列データ型をサポートしており、文字列の表現形式を知らなくても文字列操作を直感的に実行できるようになっています。一方、Cは文字列の抽象化をサポートしていません。Cでは、連続したメモリ位置を占める文字の連なりとして文字列を操作しなければなりません。
データの抽象化は、つねに何らかのプロシージャの抽象化も含んでいます。与えられたデータ型の変数に対する操作を実行するとき、ユーザーはそのデータの形式やその内部形式に対してどのような操作が行なわれるかについて知る必要はありません。Cプログラマであれば、浮動小数点演算が2進数でどのように実行されるかについて心配しなくてもいいのです。
ほとんどの言語は、プロシージャの抽象化をサポートしていますが、データの抽象化としてできることは非常にかぎられています。Cでは、ユーザー定義のデータ型を構造体とtypede
fを通してサポートしています。しかし、ほとんどのプログラムでは、構造体を変数の集合をしてしか使っていません。 次に例を示します。
struct PersonInfo { char name[30]; long phone; char address1[30]; char address2[30]; };
このようなユーザー定義の型は、いくつかの情報を個別に扱うかわりに、1つの単位として操作できるようになるためたいへん便利です。
しかし、この型には概念的な利点は何もありません。
この構造体が持つ4つの情報について考えずに、この構造体を考えても意味がありません。
データの抽象化のよい例は、stdio.h
で定義されているFILE
型です。
typedef struct _iobuf { char* _ptr; int _cnt; char* _base; char _flag; char _fille; } FILE;
FILE
構造体は、概念的に含むフィールド以上のものを表現することができます。ユーザーはFILE
がどのように表現されているかを知らなくても良いのです。ユーザーは単にFILE
へのポインタを多くのライブラリ関数とともに使うことで、この構造体のフィールドの取り扱いを任せることができます。
ある構造体を使うのに必要な関数を宣言せずに、その構造体を宣言できるということに注意してください。Cでは、データの抽象化とプロシージャの抽象化とが実際には完全にリンクされているにもかかわらず、その2つを別個の技術として取り扱うようになっているのです。
クラス
ここからがオブジェクト指向プログラミングの出番です。オブジェクト指向言語は、プロシージャの抽象化とデータの抽象化とをクラスと呼ばれる形式で結合します。クラスを定義するときは、プロシージャの抽象化とデータの抽象化の両方を一度に記述します。そのクラスのオブジェクトを使うときには、そのクラスに含まれているデータとそれらを操作するプロシージャとを一体のものと考えます。
単純なクラスとして多角形を考えます。このとき、多角形を連続した点の集まりとして考え、頂点の座標の集まりとして表現することもできます。しかし、多角形の概念を頂点の集合だけとして良いのでしょうか。実際の多角形にはその他の性質として、周辺の長さ、面積、形を持っています。そして、プログラミングをするうえで、多角形を移動させたり、回転させたり、鏡像反転させたりしたいこともあるでしょう。また、2つの多角形が与えられたときに、それらの交わりまたは結びを求めたり、2つの形が等しいかどうかを調べたいこともあるでしょう。これらの性質や操作のすべてを実現するには、多角形オブジェクトを定義します。多角形を1つのオブジェクトとしてとらえることで、構成する低水準の要素を参照したりする必要はなく、また多角形を操作するための手順がより明確になります。
クラスのデータ抽象化とプロシージャ抽象化によって、ユーザープログラムとコンピュータとを分ける新たな階層を簡単に作成でき、また長くて複雑なアプリケーションをより簡単に書けるようになります。
クラスは、通常のデータ型と異なるバイナリツリーも表現できます。このときのクラスの各オブジェクトは、Cで構造体を使ったときのようにツリーの中のノードでなく、それ自体がツリーとなります。1つのバイナリツリーを作るのとまったく同じ簡単さで、複数のバイナリツリーを作れます。さらに重要なことに、ユーザーはバイナリツリーという概念でとらえることができ柔軟性を持ったクラスとして使うことができます。これは、バイナリツリーを必要としたときに、処理するデータ構造が自由に決められるということです。つまり、いつでも、バイナリツリーの特徴である、ある項目を素早く検索、追加、削除できる性質や、全ての項目をソートした順番で列挙する能力など、データ構造にとらわれずに利用できるのです。ここで処理したいデータ構造は、ノードとポインタを使ってインプリメントするツリーか、配列を使ってインプリメントするツリーか、ユーザーが知らない何らかのデータ構造であるかは自由に決めることができます。
このようなクラスを BinaryTree
と呼ぶべきではありません。その名前は特定のインプリメントを意味しているからです。実際のクラスには、それらに対して実行できる操作にもとづいて、SortedList
などといった名前をつけるべきです。
組み込み型から構成されるデータ構造を使わずに、独自の集合を持つ抽象的な要素を基本としてプログラムを設計することによって、プログラムはそのインプリメントの独立性を高めることができます。これは、オブジェクト指向プログラミングのもう1つの特徴であるカプセル化につながります。
カプセル化
"カプセル化" は、抽象化をサポートしたり強化するためにクラス内部の動作を隠す方法です。これは、クラスの中で、パブリックな可視性を持つ "インターフェイス" と、プライベートな可視性を持つ"インプリメンテーション" を明確に分けるものです。クラスのインターフェイスはそのクラスが実行できることを記述し、クラスのインプリメンテーションはどのようにしてそれを実行するかを記述します。この区別によって、クラスの性質を公開でき、よりオブジェクトの抽象化を高めています。
カプセル化を、関数とデータの組み合わせとして定義することもありますが、これは誤解を生じる可能性があります。関数とデータを1つのクラスにまとめて、全てのメンバをパブリックにすることができます。しかし、これはカプセル化の例とはなりません。完全にカプセル化されたクラスとは、そのデータを関数で隠し、ユーザーが関数を呼び出すことでしかそのデータにアクセスできないようにすることです。
カプセル化はオブジェクト指向プログラミング特有のものではありません。伝統的な構造化プログラミングでの "データ隠蔽"の原理は、クラスではなくモジュールに適用されるものですが、カプセル化はそれと同じアイデアに基づいたものです。大きなプログラムをモジュールに分解して、それぞれに明確に定義された関数のインターフェイスを持たせ、他のモジュールから使えるようにする手法はよく行なわれます。データ隠蔽の目的は、各モジュールを他のモジュールからできる限り独立させることです。理想的には、モジュールは他のモジュールで使われるデータ構造について何も知らず、他のモジュールをそのインターフェイスを通してしか参照できないようにします。モジュールが他のモジュールに影響を与えることを少なくするためには、グローバル変数やデータ構造を直接使うことは最小限に抑えなければなりません。
例えば、ある情報のテーブルを維持する必要があるプログラムを考えます。このテーブルに対して動作するすべての関数を1つのモジュール、すなわちファイルtable.c
の中で定義し、それらのプロトタイプを table.h
というファイルで次のように宣言します。
/* table.h */ #include "record.h" /* RECORD 型の定義を得ます */ void add_item(RECORD* new_item); RECORD* search_item(char* key); ....
プログラム内の関数でこのテーブルを使う必要が生じたときは、table.h
で定義された関数の1つを呼び出します。table.c
モジュールはこのテーブルを配列としてインプリメントしているかもしれませんが、他のモジュールはそれについて何も知りません。その配列が static
として宣言されれば、実際に table.c
の外ではアクセスできなくなります。インタフェースだけが見えて、そのインプリメンテーションは完全に隠されます。
データ隠蔽は多くの利点をもたらします。 1つは、先にも述べた抽象化です。ユーザーはモジュールがどのように動作するかを考えずにそのモジュールを使えます。 もう1つは "ローカル性"です。 これは、プログラムの一部に変更があっても他の部分を変更しなくてもよいことを意味します。ローカル性の低いプログラムはすべてが互いに依存しあっているために、ある部分を修正すると他の部分が動作しなくなる可能性があります。ローカル性の高いプログラムは安定していて、維持するのが簡単です。変更の影響はプログラムの小さな部分だけに制限されます。 例えば、table.c
の配列をリンクリストまたは他のデータ構造に変更しても、そのテーブルを使う他のモジュールを書き直す必要はありません。
データをモジュールに隠すことには制限があります。先の例では、table
モジュールはプログラム中に複数の情報テーブルを持つことを許していません。また、特定の関数にローカルなテーブルを持つことも許してません。これらを克服するには、構造体とポインタを使うことです。例えば、次のようにして、ポインタをテーブルへのハンドルとして使い、テーブルポインタをパラメータとして持つような関数を書くことができます。
/* table.h */ #include "record.h" /* typedef を使って TABLE を定義します */ TABLE* create_table(); void add_item(TABLE* handle, RECORD* new_item); RECORD* search_item(TABLE* handle, char* key); void destroy_table(TABLE* handle);
この手法は、先の例で使われたものよりもかなり強力です。一度に複数のテーブルを使うことも、異なる関数で別個のテーブルを持つこともできます。しかし、このモジュールが提供するTABLE
型は、組み込み型と同じように簡単に使えません。例えば、ローカルなテーブルは関数終了時に自動的に消滅することはありません。これらのテーブルを適切に使うには、動的に割り当てられた変数のような記述が必要です。
C++では次のように書き換えることができます。
// table.h #include "record.h" class Table { public: Table(); void addItem(Record* newitem); record* searchItem(char key); ‾Table(); private: // ... }; // prog.cpp #include "table.h" void func() { Table first, second; // ... }
このクラスでは、Cでテーブル ハンドルを使う手法と比べて2つの長所を持っています。1つ目は、先にも述べたように使いやすいことです。ユーザーは、整数や浮動小数点数を宣言するのと同じ方法で、Table
のインスタンスを宣言でき、それらのすべてに同じスコープ規則が適用されます。
さらに重要な2つ目の長所は、クラスがカプセル化を強化するということです。テーブルポインタを使った手法では、プログラマがテーブルハンドルの後ろにあるものをアクセスしないということは単なる約束事にしか過ぎません。多くのプログラマがその関数のインターフェイスを回避して、テーブルを直接操作することを選ぶかもしれません。テーブルのインプリメンテーションを変更したときに、それにともなう変更箇所をソースプログラムから特定することは非常に時間がかかります。このようなエラーはコンパイラによっては検出されず、実行時に、例えばnullポインタで参照してプログラムが停止するまでわからないこともあります。このように、インプリメンテーションのわずかな変更でも、このような問題を起こす可能性があります。バグを修正しようと変更したのに、他の関数が特定のインプリメンテーションに依存していたために新しいバグを引き起こしてしまうことさえあります。
これとは対照的に、Table
をクラスとして宣言すると、ユーザーはインプリメンテーションを隠すことができ、データ構造に対するアクセスを制限することができます。例えば、Table
オブジェクトのプライベート データにはアクセスすることはできません。このことによって、さらにローカル性が高まります。
ローカル性を無視してデータ構造に直接アクセスすることがありますが、これはインターフェイスの関数だけを使っていたのでは煩わしくなる操作が、簡単に実行できるようになるからです。うまく設計されたクラスインターフェイスでは、そのクラスの重要な特徴を反映していればこの問題を最小限に抑えることができます。すべての可能な操作を便利にするようなインターフェイスは存在しませんが、たとえいくらか非効率的な部分ができるとしても、通常はクラス内部のデータ構造へのアクセスを禁止することがいちばんよい方法です。便利さが少し失われることよりも、カプセル化によってもたらされるプログラムの維持のしやすさの方が大きな利点となります。カプセル化を行うことで、大規模なプログラムの変更にともなうモジュールの修正を少なくし、新しいシステムを開発したり既存のものを更新したりするのに必要な時間と労力を大幅に削減できます。
また、将来クラスインターフェイスが変更されたときでも、アクセス可能なデータ構造よりもカプセル化されたクラスを使うほうが良い方法です。インターフェイスの変更のほとんどは、上位互換性を保つために、既存のインターフェイスに対する追加だけですみます。このようなときでも、古いインターフェイスを使っていたプログラムは正しく動作します。プログラムを再コンパイルしなければなりませんが、コンピュータがその時間を必要とするだけでプログラマの時間は必要としません。
C++では、カプセル化は完全に安全性を保証するわけではないことに注意してください。クラスのプライベート データは、いつでも &
演算子と*
演算子を使ってそれらへアクセスできてしまいます。カプセル化は単にクラスの内部表現を "不用意に" 使うことを防止しているだけです。
クラス階層
プロシージャ指向のプログラミングではまったく見られない、オブジェクト指向プログラミングの特長の1つに、型の階層を定義する機能があります。C++では、あるクラスを他のクラスから派生させることで、その特殊型、または特殊な種類として定義することができます。また、多くのクラスすべてを1つの基本クラスから派生させることで、クラス間の類似性を表現したり、1つの一般的な分類の下位区分として定義できます。それとは対照的に、Cではすべての型を完全に独立なものとして扱います。
いくつかのクラスに、共通の基本クラスを指定することは抽象化の方法の1つです。それらのクラスをより高水準の抽象度で見たものが基本クラスとなります。基本クラスには、その派生クラスが共通に持つものを指定しますから、個々の特長よりも共通の特長に着目し定義します。このような抽象化は、ユーザーが多くの要素を直接見るかわりに、小数のカテゴリーとして見ることができるようにします。例えば日常では、 "ライオン、虎、熊…" などのかわりに "哺乳動物"と考える方が簡単ですし、"秋田犬、土佐犬、柴犬…" などよりも "犬"と考えるほうが簡単です。
基本クラスがいくつかのクラスの "一般化" であるのに対して、派生クラスは、他のクラスの "特殊化"です。 派生クラスは、それ以前に定義されている型の特殊型として指定し、特長を追加して記述しているのです。例えば、ライオンは哺乳動物ですが、すべての哺乳動物が持っているわけではない特長もいくつか持っています。
クラス階層を定義することによって、2つの大きな利益を受けられます。派生クラスは基本クラスのプログラムを共有でき、基本クラスのインターフェイスも共有できます。プログラムの再利用を目的として設計した階層と、共通のインターフェイスを持つように設計した階層とは、ふつう異なる特長を持ちますが、これらは排他的なものではありません。
プログラムの継承
C++では、クラスを記述しているときに既存のクラスの機能を組み入れようとするときは、単に既存のクラスから派生させます。これによって、継承によるプログラムの再利用ができます。
特長を共有するいくつかのクラスを一度にインプリメントするときなどは、クラス階層を利用することで冗長なプログラミングを避けることができます。各派生クラス出それらの共通の特長を繰り返し記述するかわりに、基本クラスで一度だけ記述し、インプリメントすればよいのです。
例えば、ユーザーが画面上のフィールドにデータを入力していくようなデータ入力フォームの設計をするプログラムを考えます。このプログラムでは、名前を入力するフィールド、日付を入力するフィールド、金銭値を入力するフィールドなどをフォームに含めることができます。各フィールドには適切なデータの型だけが入力できるとします。各フィールドの型を独立したクラスとして、Namefield
、Datefield
、moneyField
といった名前で、それらが入力を確認する独自の基準を持つように作ることもできます。しかし、すべてのフィールドがいくつかの機能を共有していることに注意してください。各フィールドにはユーザーが何を入力すべきかを伝えるメッセージが伴っていて、その説明を定義して表示するプロシージャはすべてのフィールドに必要なものです。その結果、setPrompt
、displayPrompt
などのまったく同じインプリメンテーションを持つことになります。
これらの関数をインプリメントする、Field
という基本クラスを定義することで、プログラムサイズを小さくすることができ、ユーザー自身の労力も削減することができます。Namefield
、DateField
、およびMoneyField
クラスはField
から派生させ、それらの関数をそのまま継承します。このようなクラス階層は、変更を1箇所だけで行なえるため、バグを修正したり機能を追加したりすることも容易です。
プログラム共有用に設計されたクラス階層は、そのプログラムのほとんどをいくつかの基本クラス(階層の最上位付近)に持ちます。このようにすると、そのプログラムは多くのクラスで再利用できます。派生クラスは、これらの基本クラスの特殊化したバージョン、または拡張したバージョンを表現します。
インターフェイスの継承
もうひとつの継承では、派生クラスが基本クラスのメンバ関数のプログラムは継承せずに、名前だけを継承します。派生クラスがこれらの関数の独自コードを提供します。このようにすると、基本クラスと同じインターフェイスは持ちますが、同じ関数で基本クラスとは異なることを実行する派生クラスが作れます。
この方法では、異なるクラスが同じインターフェイスを使えます。その結果、その動作において高水準の類似性を持つようにすることができます。
データ入力フォームの例では、Field
はgetValue
というメンバ関数を持ちますが、その関数は有益なことは何一つしません。NameField
はそのメンバ関数を継承し、入力が正しい名前であるかを確認するプログラムを提供します。DateField
と MoneyField
でも同じですが、その関数に異なるプログラムを提供します。このように、個々のフィールドオブジェクトは、さまざまな型を持ち、しかも異なる動作をするかも知れませんが、それらすべてが同じインターフェイスを共有し、Field
オブジェクトとして扱えます。
データ入力フォームは単にField
オブジェクトのリストを維持し、フィールドの型による依存を無視しています。すべてのフィールドに値を読み込むには、フォームはそのField
のリスト要素それぞれについてgetValue
を繰り返し呼び出します。その際、各フィールドに何の型が定義されているかさえ知る必要はありません。個々のフィールドは、自動的に独自のバージョンのgetValue
を使って入力を取り込みます。
このデータ入力フォームの例は、プログラムの共有をインターフェイスの共有の両方のために継承を使っています。しかし、抽象基本クラスを書くことで、厳密にインターフェイス共有のためだけのクラスを設計することもできます。
インターフェイス共有用に設計されたクラス階層では、そのプログラムのほとんどをいくつかの派生クラス(階層の最下位付近)が持ちます。派生クラスは、基本クラスによって定義される抽象モデルでかつ動作するバージョンを表現しています。
まとめると、クラスは抽象化、カプセル化、および階層のサポートを提供します。クラスは、抽象データ型とそれにともなう操作をいっしょにして定義するための機構です。クラスはカプセル化することができ、ユーザーのプログラムを区画化してローカル性を高めます。最後に、クラスは階層にまとめあげ、冗長なプログラミングを最小にしつつお互いの関係を強調することができます。
オブジェクト指向システムの設計
トップダウンの構造化プログラミングでは、設計の最初のステップはプログラムの目的とする機能を指定することです。"このプログラムは何をするのか" という問いに答える必要があります。最初にそのプログラムが実行するおもなステップを高水準の擬似プログラムやフローチャートを使って記述します。そして、次におもなステップをより小さいステップに分解して、その記述を洗練していきます。 この手法は"プロシージャ分解" として知られています。プロシージャ分解ではプログラムを処理の記述として扱い、それを下位の処理に分解していきます。
オブジェクト指向設計では、これとはまったく異なる手法を取ります。オブジェクト指向設計では、問題をタスクや処理として解析することはしません。データを用いて記述することもしません。 "このプログラムはどんなデータに対して動作するのか"という問いから始めたりはしません。 そのかわりに、問題をオブジェクトの相互作用システムとして解析します。最初の質問は、"何がオブジェクトか" または "このプログラムで何が動的な要素か" です。
オブジェクト指向設計は、プロシージャ分解とは異なる前提から始まるだけでなく、薦め方も異なります。プロシージャ分解は、プログラムの抽象的な見方から始まり詳細な見方で終わる、"トップダウン"のアプローチです。 しかし、オブジェクト指向設計はトップダウンの技術ではありません。最初に大きなクラスを指定して、それを小さいクラスに分解していくようなことはしません。また、小さなクラスから始めてそれらを使って構築していくようなボトムアップである必要も(クラスライブラリを使って、この種のアプローチをとることもできますが)ありません。オブジェクト指向設計では、すべての段階で高水準と低水準の両方の抽象化を用いて作業します。
オブジェクト指向設計では、次の手順に従う必要があります。
- クラスを指定します
- 属性と動作を割り当てます
- クラス間の関係について考えます
- クラスの階層を整理します
オブジェクト指向設計は、つねに"繰り返し"既存のクラスを利用するという手法であることをわすれないでください。
新しいステップでは、既存のステップを利用し、違う部分のみをインプリメントするということです。特に初期設計である基本クラスが正しく設計されることがより望ましく、後の開発に掛かる時間が大幅に短縮されます。また、つねに改訂が起こる可能性があることに注意し、もしあれば設計過程を通して、クラス記述をさらに洗練してください。
クラスの指定
最初のステップは、プログラムが必要とするクラスを見つけることです。これは、プログラムの基本的な関数を指定することよりもむずっかしいことです。単に問題のプロシージャ分解を行なって、結果としての構造体型やデータ構造をクラスにまとめることはできません。クラスは、プログラムの中心であり、アクティブな要素でなければなりません。
クラスを指定する1つの手法として、プログラムが目的とする記述を書き、その記述中のすべての名詞をリストし、そのリストからクラスを選ぶという方法があります。これは極度に単純化されたアプローチで、うまくいくかどうかは元の記述がいかに正確に書かれているかに依存します。しかし、オブジェクト指向設計になれていない間は有効な方法の1つでしょう。
物理的なオブジェクトをモデル化したプログラムでは、クラスを指定するのが最も簡単になります。例えば、ユーザーのプログラムが飛行機の座席予約を扱うものであれば、おそらく Airplane
クラスと Passenger
クラスが必要になるでしょう。プログラムがオペレーティング・システムであれば、ディスク・ドライブやプリンタを表現するための Device
クラスが指定するクラスの候補となります。
しかし、多くのプログラムは物理的な要素をモデル化してはいません。このような時は、プログラムが扱う概念的な要素を指定する必要があります。この良い例としては、図形処理プログラムで使われる Rectangle
クラスと Circle
クラスなどがあります。しかし、それ以外のときはこれほど明確になっていません。 例えば、コンパイラは SyntaxTree
クラスを必要とするかもしれませんし、 オペレーティング・システムは Process
クラスを必要とするかもしれません。
さらに明白でないクラスの候補は、イベント(オブジェクトに起こること)と相互作用(オブジェクト間で起こること)です。例えば、Transaction
クラスは、銀行のプログラムで貸し付け、預金、または資金移動といったものを表現し、Command
クラスは、プログラム中でユーザーが実行した動作を表現するものとします。
ユーザーのクラスの候補を階層から見つけることができるかもしれません。BinaryFile
と TextFile
をクラス候補として指定しているなら、それらを File
という基本クラスから派生させることができます。しかし、どのような階層が適切であるかが常に明白であるとはかぎりません。 例えば、銀行のプログラムは1つのTransaction
クラスを使うこともできたり、Transaction
クラスから派生する、Loan
、Deposit
、Transfer
といった別々のクラスを使うこともできます。クラス自身についてと同様に、この段階で指定したどのような階層も、後の設計過程で洗練したり放棄したりするための候補でしかありません。
上記のすべての候補クラスは、問題の要素をモデル化することを目標としています。プログラムによっては、他の種類の候補クラスが必要かもしれません。 例えば、ユーザーが以前に書いたSortedList
クラスを使ってインプリメントできるクラスを指定することもあります。このようなときは、ユーザーのプログラム記述がソートされたリスト(sorted list)を明示的に使っていなくても、SortedList
が候補クラスになります。一般には、この段階で各クラスをどのようにインプリメントするかを考えるのは早すぎます。しかし、既存のクラス・ライブラリを用いてクラスを構築する方法を探すのは適切なことです。
属性と動作の割り当て
いったんクラスを指定すると、次の仕事はそのクラスがどのような役割を持つかを決定することです。クラスの役割は次の2種類に分けることができます。
- そのクラスのオブジェクトが維持する情報 ("このクラスのオブジェクトは何を知っているのか")
- オブジェクトが実行できる操作、またはそのオブジェクトに対して実行できる操作 ("このオブジェクトは何ができるのか")
すべてのクラスは、特長としての "属性" を持っています。例えば、Rectangle
クラスは高さと幅の属性を持つことができ、GraphCursor
クラスは形状(矢印、十字、など)を持つことができ、Fileクラスは名前、アクセスモード、および現在位置を持つことができます。 クラスのインスタンスは、"状態"を持たなければなりません。 あるオブジェクトの状態は、そのすべての属性の現在値から成ります。例えば、File
オブジェクトは、名前 foo.txt 、アクセスモード"読み出し専用"、そして現在位置は "ファイルの先頭から12バイト目" という状態を持ちます。属性の値はデータメンバとして格納し、必要に応じて計算しなおしてもかまいません。
属性とクラスとを混同しないことが重要です。属性を記述するためにクラスを定義することは控えるべきです。 Rectangle
クラスは有用ですが、Height
や Width
といったクラスはおそらく無用でしょう。 Shape
クラスを持つべきかどうかを決定することはそれほど簡単ではありません。形状(shape)がカーソルの状態を記述するためだけに使われているのなら、それは属性です。形状が、異なる値を持つ可能性のある属性と、それに対して実行できる操作の集合を持つときは、それ自身クラスとする必要があります。さらに、プログラムが Shape
クラスを必要としているときでさえも、他のクラスが形状を属性として持つ可能性もあります。プログラムが操作する Shape
オブジェクトと、GraphCursor
オブジェクトの形状とには関連はありません。
各クラスは、オブジェクトが他のオブジェクトとどのように作用するか、また、相互作用中に状態がどのように変化するかを示す"動作" も持っています。 クラスの動作には非常に多くの種類があります。例えば、Time
オブジェクトは現在の状態を変更せずに表示できます。Stack
オブジェクトでは、要素をプッシュしたりポップしたりすることで、内部状態が変化します。Polygon
オブジェクトは、他と交差して、3つめの Polygon
オブジェクトを生成します。
クラスが何を知っているべきかと何ができるのかを決定するときには、ユーザーはそれをプログラムの内容から調べる必要があります。プログラムの内容は、クラスの状態やクラスが実行する動作を作り上げる情報を持っています。そこから、これらの役割のすべてをいずれかのクラスに割り当てる必要があります。どのクラスも保持していない情報や、どのクラスも実行しない操作が残っているときは、おそらく新しいクラスが必要です。また、プログラムの仕事がクラス間でほぼ平均して分担されていることも重要です。もし1つのクラスがプログラムのほとんどを含んでいるときは、おそらくそのクラスを分割する必要があるでしょう。逆に、あるクラスが何もしていないときは、おそらくそのクラスを破棄する必要があるでしょう。
属性と動作を割り当てる作業を行うことで、何が有用なクラスを構成するかがより明郭になります。あるクラスに役割を割り当てることが困難なときは、おそらくプログラム中でよく定義さえた要素が正しく表現されていません。最初のステップで見つけたクラスの多くがこのステップの後で放棄されるかもしれません。特定の属性や動作が多くのクラスに繰り返し現れるときは、以前には気がつかなかった有用な抽象化ができるかもしれません。他のクラスが使うために、これらの特長だけを持つ新しいクラスを作る必要があるかもしれません。
オブジェクト指向プログラミングに慣れていないプログラマがよく間違えるのは、カプセル化した処理以上の何物でもないクラスを設計することです。そうして設計されたクラスは、オブジェクトの型を表現するかわりに、プロシージャ分解で見つかった関数を表現しています。こうした誤ったクラスは、属性がないために設計の段階で見つけることができます。このようなクラスは状態情報を保持せず、動作だけを持っています。クラスの役割を記述するときに、"このクラスは整数を取り、2乗して、その結果を返す"のように記述しているのであれば、それはクラスではなく関数です。このようなクラスのもう1つの特長は、ただ1つのメンバ関数からなるインターフェイスを持つことです。
一度クラスの属性と動作を指定してしまうと、そのクラスのインターフェイスとなるメンバ関数を考えます。ユーザーが指定した動作は、通常メンバ関数を意味しています。状態を問い合わせたり設定するのにメンバ関数を必要とする属性もあります。それ以外の属性はクラスの中でのみわかります。
特定のメンバ関数とそれらのパラメータ、および戻り値の型は、設計過程の最後に決定します。また、インプリメンテーションの問題は、この段階では余り設計に影響を与えません。この設計段階で定まらない要素には、属性を格納しておくのか計算するのか、どの種類の表現を使うのか、そしてメンバ関数をどのようにインプリメントするのか、などが含まれます。
クラス間の関係について考える
前のステップの拡張として各クラスの機能を決定することは、クラスがどのようにして他のクラスの機能を使うかを決定することでもあります。ユーザーが指定したクラス内のいくつかは孤立したまま存在することもありますが、多くはそうではありません。クラスは他のクラスの上に構築され、他のクラスを利用するのが普通です。
あるクラスが、別のクラスに依存していることがしばしばあります。これは、あるクラスが他のクラスのメンバ関数を呼び出しているときなどです。例えば、Time
クラスは、String
オブジェクトとの間の変換を提供する変換関数をもつことができます。このような関数は、String
クラスのコンストラクタを必要とし、またアクセス関数を呼び出さなければなりません。
クラスが他のクラスに依存するもう1つの場合は、他のクラスを埋め込んでいる、つまり他のクラスのオブジェクトをメンバとして含んでいるときです。例えば、Circle
オブジェクトは、その中心を表す Point
オブジェクトを、半径を表す整数と同様に持つことができます。
この種の関係を "包含関係" といいます。 他のクラスを含むクラスは "集合" または"複合クラス" です。 他のクラスをメンバに含む "コンポジション" はときどき継承と混同されます。これら2つの違いについては次の節で説明します。
クラス間の関係で問題になるほとんどの場合は、あるクラスのインターフェイスが他のクラスに依存しているために起こります。例えば、Circle
クラスは、Point
オブジェクトを返すgetCenter
関数を持つことができます。 すると、ユーザーはCircle
のインターフェイスを使うために Point
について知っていなければなりません。また、あるクラスのインプリメンテーションが、他のクラスに依存していることもあります。例えば、AddressBook
を、SortedList
クラスのプライベートメンバオブジェクトを持つように設計したときなどです。このときは、インターフェイスではなく、データの表現として他のクラスを利用しているので、AddressBook
を使うユーザーは SortedList
について何も知る必要はありません。AddressBook
のインターフェイスについてだけを知っていればよいのです。これは、AddressBook
のインプリメンテーションを、インターフェイスを変更せずに変更できるため、カプセル化を高めていることになります。
このように、あるクラスから別のクラスを使うときは、特に慎重な設計が必要です。具体的には、他のクラスが保持している情報について知る必要があるのか、他のクラスの動作を使うのか、逆に他のクラスはこのクラスの情報や動作を使う必要があるのか、といった点について考える必要があります。
クラス間の関係をより完全に定義していくと、以前のステップで行なった決定のいくつかを修正することになるかもしれません。以前あるクラスに割り当てていた情報や動作が、他に割り当てた方がより適切であることがあります。また、オブジェクトにそのクラスの関係についての情報を与えすぎないようにしてください。例えば、Book
クラスと、Book
オブジェクトを格納するLibrary
クラスを持っているとします。 Book
オブジェクトが、どの Library
に保持されているかを知っている必要はありません。Library
オブジェクトがすでにその情報を持っているからです。このように、クラス間の関係を明郭にすることによって、クラスはより洗練されていきます。
あるクラスが他のクラスについての特別な知識を必要とするときに、フレンドクラスを使う誘惑に駆られるかもしれません。しかし、C++のフレンド機構はクラスのカプセル化を壊してしまうため、その使用に当たっては十分慎重でなければなりません。あるクラスを修正すると、そのフレンドクラスすべてを書きなおす必要が生じるかもしれません。
あるクラスをもとにクラスを定義すると、そのクラスのインターフェイスは元のクラスのインターフェイスに近いものになっています。ユーザーは、どのメンバ関数がどの属性を設定するか、またどの属性から問い合わせを行なうのかを知っています。また、クラスの最適な動作を定義するうえで、大変見通しの良いものとなります。
クラスを階層化する
クラス階層を作ることは、クラスを指定する最初のステップの拡張です。しかし、それは2番目と3番目のステップで得られた情報を必要とします。クラスに属性と動作を割り当てることで、それらの類似性と違いをより明確に認識できるはずです。また、クラス間の関係を認識することで、どのクラスが他のクラスの機能を組み入れる必要があるかがわかります。
ある階層が適切かどうかを示す1つの目安は、オブジェクトの型に対してswitch
ステートメントを使っているかどうかです。例えば、預金が当座預金か普通預金かを決定するデータメンバを持つ Account
クラスを設計したとします。このような設計では、そのクラスのメンバ関数は預金の種類によって次のように異なる動作を実行する必要があるかもしれません。
class Account { public: int withdraw(int amount); // ... private: int accountType; // ... }; int Account::withdraw(int amount) { switch ( accountType ) { case CHECKING: // 当座預金特有の処理 break; case SAVINGS: // 普通預金特有の処理 break; // ... } }
通常、このような switch
ステートメントは、ポリモーフィズムを持ったクラス階層が適切であることを示しています。ポリモーフィズムは、仮想関数を使うことで、ユーザーが正確な型を指定せずに適切なメンバ関数を呼び出せるようにします。
上の例では、Account
クラスは2つの派生クラス、Savings
と Checking
とを持つ抽象基本クラスにすることができます。 withdraw
関数を仮想関数として宣言し、2つの派生クラスがその同時のバージョンをインプリメントします。すると、ユーザーはオブジェクトの正確な型を指定しなくても適切な withdraw
を呼び出すことができます。
もう一方で、あるクラスの異なるカテゴリーを指定できるというだけで階層が必要になるわけではありません。例えば、Car
の派生クラスとして Sedan
とVan
を持つ必要は必ずしもありません。ユーザーがすべての種類の車について同じ処理を実行するとき、階層は不要です。このとき、車の種類を格納するには、データメンバを使うのが適切です。
コンポジションと継承
コンポジションと継承とは、ともにクラスが他のクラスの機能を再利用できるようにします。しかし、これらは異なる関係を意味しています。多くのプログラマは、既存のクラスの機能を借りたいときに、継承が新しいクラスと既存のクラスとの間の関係を正確に記述しているかを考えずに、自動的に継承を使ってしまいます。あるクラスが他のクラスを "持っている(has)"ときにはコンポジションを使う必要があり、あるクラスが他のクラスの "一種である(is a kind of)" ときには継承を使う必要があります。 例えば、円は点の一種ではありません。 円は点を"持っている" のです。逆に、数値データフィールドは一般的なデータフィールドを持っているわけではありません。数値データフィルドはデータフィールドの "一種である" のです。
継承とコンポジションのどちらが定説であるのかを決定するのがむずかしいこともあります。例えば、スタックは特別な操作の集合を持ったリストの一種でしょうか、それともスタックはリストを含んでいるのでしょうか。ウィンドウはそれ自身を表示できるテキストバッファの一種でしょうか、それともウィンドウがテキストバッファを含んでいるのでしょうか。このようなときには、設計においてそのクラスが他のクラスにどのように適合するかを調べる必要があります。
継承が適切な関係であることを示す1つの目安は、ポリモーフィズムを使いたいときです。継承を使えば、派生したオブジェクトを、その基本クラスへのポインタを使って参照し、仮想関数を呼び出すことができます。しかし、コンポジションでは、複合オブジェクトのメンバのクラスへのポインタを使って複合オブジェクトを参照することはできません。また、仮想関数を呼び出すこともできません。
他のクラスの機能を複数回使いたいときは、おそらくコンポジションのほうが適切です。例えば、FileStamp
クラスを書いていて、各オブジェクトがファイルの作成日、最終変更日時、および最終読み出し日時を格納するようにしたいときは、明らかに継承よりもコンポジションのほうが適切です。複雑な多重継承を用いるよりも、3つの Date
オブジェクトをメンバとして含める方がはるかに簡単です。
継承用クラスの設計
クラス階層を構築していると、以前に指定したクラスを修正したり廃棄したりすることはもちろん、新しいクラスを作ることもあります。最初のステップで指定したクラスのほとんどは、おそらくインスタンスを作ることを目的としたものでしょう。しかし、いくつかのクラスに共通な特長を分離してクラスにしたときは、その共通な特長となったクラスのインスタンスを作ることはほとんどありません。このクラスは "継承用" であり、実際のインスタンスはこのクラスから継承したクラスから作ります。継承されたクラスは、共通な特長を引き継ぎ、独自の特長を付け加えたものになっています。このように、階層の構成中にユーザーが作る新しいクラスは、インスタンスを持たない抽象クラスにすることが多くなります。
抽象クラスを追加することは、クラスの再利用の能力を高めます。例えば、5つのクラスが直接に継承する1つの抽象クラスを作ります。しかし、それらの派生クラスの2つが、それ以外の3つの派生クラスは持たない特長を共有していても、その特長は基本クラスに置くことはできません。その結果、その特長を持たせてたい各クラスでインプリメントする必要があります。これを防ぐために、基本クラスから派生し、新しい特長を追加する、中間的な抽象クラスをつくることができます。特長を共有する2つのクラスは、その共通の特長をこの抽象クラスから継承します。また、後で階層を拡張するときにより柔軟性を高める働きもあります。
しかし、抽象クラスをやたらに作ることは控えるべきです。極端な例として、各クラスが他のクラスから継承し、ただ1つだけ新しい関数を追加するような抽象クラスの列を作ることもできます。理論的にはこれは再利用性を高めますが、実際には非常にぎこちない階層となります。
再利用性を最大にするためには、共通の特長を階層のできるだけ高位の部分に置くことが望ましいのです。しかし他方では、ほとんどの派生クラスが利用しないような特長で基本クラスの負担を増やすべきではありません。基本クラスからクラスを派生させ、見通しのよい階層となるように設計してください。
継承は、クラス階層の設計に影響を与えるだけでなく、独立したクラスの設計にも影響を与えます。ユーザーが書くすべてのクラスは、あとで他のプログラマによって基本クラスとして利用される可能性があり、継承されるクラスのインターフェイスとインプリメンテーションに影響を与えます。クラスは、そのクラスを使うクラスまたは関数と、そのクラスから継承する派生クラスとを持ちます。クラスを設計するときには、これらに異なるインターフェイスを定義したいのかどうかを決定しなければなりません。キーワードprotected
は、メンバを派生クラスからは見えるようにしますが、ユーザーから見えるようにはなりません。このようにすれば、クラスについての情報をユーザーに与えるよりも派生クラスにより多く与えることができます。
protected
のインターフェイスを使うと、クラスのインプリメンテーションについての情報を公開することができます。しかし、これはカプセル化の原理に反します。クラスのprotected
部分を修正することは、すべての派生クラスも修正する必要があるということを意味します。それゆえに、キーワードprotected
は注意して使う必要があります。