Open-Closed Principle とデザインパターン
Open-Closed Principle とデザインパターン
さて,このセクションではデザインパターンを統一的に理解するために,「 Open-Closed Principle (OCP) 」 という設計ルールに基づいてパターンを眺めてみることにします.まず OCP の意味と解説を行い,その後デザインパターンを OCP の観点から見てみます.実は,デザインパターンのうちの多くは OCP を満たすために用意されたものと考えることができるのです.このセクションでは, OCP を理解し,数あるデザインパターンの中からどういう場合にどのパターンを使うのが一番効果的なのかを考えます.
GoF のデザインパターンは,全部で 23 個ものパターンがあります.このデザインパターンは,多くの局面で繰り返し現れる設計を抽出したものですから,オブジェクト指向のエッセンスを集めたものだと言えるでしょう.オブジェクト指向には,カプセル化,継承,ポリモルフィズムといった数少ない道具しかありません.では,なぜ 23 個もの多くのパターンになってしまったのでしょうか? このことは,デザインパターンの中に何かかくされた原理というべきものが存在するということを暗示しています.それが今回紹介する OCP です.
それではまず,優れたソフトウェアとは何かということから始めましょう.
優れたソフトウェア
まず,ソフトウェアの仕様変更とそれにかかるコストという観点から考えます.コストの観点から見て優れたソフトウェアとは,仕様の変更とソフトウェアに対する修正が対応しているものだといえるでしょう.つまり,仕様の変更が小さければソフトウェアの修正も小さく,大きければ修正も大きくなる自然なソフトウェアです.しかし,ソフトウェアによっては,少しの修正に対して大幅な修正を引き起こしてしまう場合もあります.こうした状況を「仕様変更に対してそのコストが連続していない(不連続)」といいます.言うまでもなく,コスト的に優れたソフトウェアとは仕様変更に対して連続性を持つことを意味します.
よくある例としては,ユーザの考えている仕様変更と実際のプログラムの仕様変更のギャップです.ユーザが考えている仕様変更が単純に見えても実際そうでないことが往々にしてありますが,そうしたギャップを最小限に抑えたソフトウェアにするべきでしょう.したがって,仕様変更や拡張性への感覚を鋭くすることが優れたソフトウェアを設計するためのカギになります.つまり,ある仕様変更,機能追加という変化に対してソフトウェアはどういう影響をうけるのか,ほとんど修正がなく安定しているのか,それとも大幅な修正が必要でかなり不安定なのか……このことを絶えず意識することで,そのソフトウェアの長所と短所ははっきりし,仕様変更に強くメンテナンスしやすいものになります.これは先のセクションで述べたホットスポットを考慮することにつながります.しかし現実には,すべての仕様変更に強いソフトウェアはあり得ないでしょう.したがってもっとも可能性の高い仕様変更を予想し,その仕様変更に対して十分安定したソフトウェアを作ることが必要となります.つまり「ソフトウェアがさまざまな仕様変更でどういう修正が加わるのかを常に考える」ことが,優れたソフトウェアをつくるための第一歩なのです.
それでは,仕様変更に対してどう対応できれば一番よいのでしょうか? バグを減らすためには,なるべく修正個所を抑える必要があります.修正個所が広範囲にわたると,それだけでバグの可能性が多くなり,修正コストも大きくなってしまいます.可能な限り,修正個所は 1 個所に絞りたいところです.共通する部分をうまく共有しているプログラムなら,これを実現するのはさほど難しくはありません.
仕様変更への別の対応方法としては,コードの追加があります.コードの修正ではなく,コードの追加だけで対応できれば,バグを産む可能性はかなり減ります.さらに,コードを修正する必要がなくなったモジュールは,再利用できるというメリットもあります.コードの修正ではなく,コードの追加で変化に対応するという方法は,従来の構造化プログラミングでは簡単には実現できなかったことですが,オブジェクト技術の道具である継承とポリモルフィズムを使えば,これが可能になります.この考え方が,次に述べる OCP に従ったソフトウェアのもつ最大の特徴なのです.
Open-Closed Principle
Bertrand Meyer 氏によれば,「 Open-Closed Principle (OCP) 」とは次のことを意味します.
「モジュールは拡張に対して開いて (Open) おり,修正に対して閉じて (Closed) いなければならない」
Open-Closed Principle は,オブジェクト指向設計を考える際,その設計が正しいかどうかの指針を与えてくれるもっとも基本的な原理です.
あるモジュールの機能が拡張可能なとき,そのモジュールは「開いている」といいます.開いているモジュールは,機能追加や仕様変更に応じて異なった振る舞いをするようにできます.モジュールは将来どのように拡張されるかは予想できません.したがってそのモジュールは柔軟性,つまり「開いている」ことが求められます.また,あるモジュールが他のモジュールから利用されていて,そのソースコードを修正することが許されないとき,そのモジュールは「閉じている」といいます.モジュールが頻繁に修正されると,そのモジュールに依存しているほかのモジュールはその度に更新されることになります.ソフトウェアが安定するためには,修正に対して閉じていることが求められます.「開いているのに閉じている」これはどうやったら実現できるのでしょうか?
ここでは,OCP の例として,次のような単純な音楽ソフトを考えましょう.このツールには,音符をあらわす Note クラス,四分音符をあらわす Quater クラス,二分音符をあわらす Half クラスがあるとします.
class Note { public: virtual void Play() = 0; }; class Quarter : public Note { public: virtual void Play(); }; class Half : public Note { public: virtual void Play(); };
これらの音符を集めて演奏する楽譜クラス Staff は次のようになっているとします.
class Staff { public: void AddNote(Note*); void Play(); private: typedef std::vectorNotes; Notes mNotes; }; void Staff::Play() { Notes::iterator i; for (i = mNotes.begin(); i != mNotes.end(); ++i) (*i)->Play(); }
楽譜クラス Staff は, AddNote メソッドで音符を追加していき登録された音符を Play メソッドを使って演奏します.さて,この Staff::Play メソッドは単純ですが,次の 2 つの特徴を持っています.
- 開いている (Open)
八分音符や四分休符などの音符を追加するという機能追加に対して拡張性がある.
- 閉じている (Closed)
八分音符や四分休符などの音符を追加するという機能追加に対して修正する必要がまったくない.
このように, Staff::Play の振る舞いを拡張するには,コードの修正ではなくコードの追加で十分です.つまり, Staff::Play は OCP を満たしているわけです.
一方,同じ音楽ソフトを C 言語を使って実装してみましょう. C 言語の構造体を使って音符オブジェクトを表し,列挙型 NoteType でどんな種類の音符なのか判断できるようにします.
enum NoteType { Quarter, Half }; typedef struct { NoteType mNoteType; double mPitch; //音程 }Note; void Quarter_Play(Note* self); void Half_Play(Note* self);
同様に,楽譜オブジェクトも構造体で表します.
typedef struct { Note** mNotes; int mNoteCount; }Staff; void Staff_AddNote(Staff* self, Note* aNote); void Staff_Play(Staff* self) { int i; Note* aNote; for (i = 0; i < self->mNoteCount; ++i) { aNote = self->mNotes[i]; switch(aNote->mNoteType) { case Quarter: Quarter_Play(aNote); break; case Half: Half_Play(aNote); break; } } }
関数 Staff_AddNote で音符を追加し,Staff_Play で演奏をします.関数 Staff_Play は,明らかに OCP を満たしていません.つまり,八分音符や四分休符などの音符オブジェクトを追加したいときに,コードを修正する必要があります.この例での修正は簡単ですが,ある程度規模が大きいプログラムではこのような関数がプログラムのあちこちに現われる可能性があります.修正個所が分散し,ある箇所を修正するたびに,また別の箇所も更新する必要がある状況では,バグを生む可能性はかなり高くなります.またこの例では,なぜ switch 文がオブジェクト指向プログラミングで好まれないのかということも示唆しています.これは, switch 文の分岐を変更するような要求に対して, OCP がみたされていないからなのです.
オブジェクト指向と OCP
ここでは, OCP をもう少し深く見るために,図 A のような単純なモデルを考えます.このクラス図では, ClientA と ClientB は Server を使って,それぞれ Server にメッセージを送っています.
図 A :単純なモデル
開発が進むにつれ, ClientB クラスはそれまでの Server クラスでは要求が満たされないことがわかりました.つまり, Server' クラスという Server と大部分が同じで振る舞いが異なるクラスが必要になってしまいました.これに対応するため,例えば図 B のような設計をします.この設計では,バージョンが異なる 2 つの Server クラスがあります.これはコピー&ペーストプログラミングの典型例です.このプログラミングスタイルがよくないのは明らかでしょう.つまり,微妙に異なる Server への仕様変更に対して,上のモデルは OCP を破っていることになるのです.
図 B :単純なモデル -- 変更後
次に, Client と Server の間にクラスをはさんで考えてみましょう(図 C ).AbstractServer クラスは抽象クラスで, Server クラスのスーパークラスになっています.再び Server' クラスが必要になった場合を考えましょう.今度は図 D のようなモデルになります.このとき, ClientA クラスと AbstractServer はまったく修正されていません.また, Server' への機能追加(あるいは変更)をコードの修正ではなくコードの追加によって実現しています.したがってこのモデルは OCP を守っているといえるでしょう.以上から,オブジェクト指向で OCP に対応するためには,
- 変更の可能性のあるところ(ホットスポット)を見つける.
- その部分を抽象化してクラスにしてしまう.
- 抽象クラスのサブクラスで変化に対応する.
とすればよいことが分かります.これが OCP を満たすオブジェクト指向ソフトウェアの戦略だといえます.
図 C :継承モデル
図 D :継承モデル -- 変更後
デザインパターンと OCP
OCP に沿った設計では,まず予想される仕様変更に注目する必要があります.そこで変更の観点からデザインパターンを見てみると,多くのデザインパターンが変更に対して柔軟に対応するために用意されていることに気づきます.表 A のように,各デザインパターンは何に対して柔軟性を持たせるかが明確になっています.さらに多くのパターンの名前は,柔軟性を持たせるために導入されたクラスの名前に対応しています.
変更箇所 | パターン |
---|---|
アルゴリズム | Strategy, Visitor |
オブジェクトの状態 | State |
オブジェクトの振る舞い | Decorator |
インターフェイス | Adapter |
実装方法 | Bridge |
オブジェクト間の通信方法 | Mediator |
コンテナクラスのアクセス方法 | Iterator |
オブジェクト指向の利点は現実世界の「もの」に対応したオブジェクトを作ることが可能で,したがってモデル化しやすい,とよく言われます.しかし,もう 1 つの利点である「抽象化」にはあまりフォーカスが当たりません.上記のような変更部分そのものには,現実世界に対応する「もの」が存在しませんが,抽象化された概念をそのままコードに埋め込んでプログラムできるという特徴は非常に重要です.
ただし,抽象的な概念というのは理解しにくいのが普通です.例えば,数学ではそういった概念に明確な名前を与えて,考えやすくするようにしています.これと同じく(先にのべたように),デザインパターンにも「概念に名前を付ける」という意義があり,抽象化設計をより具体的に進めるための道具となります.デザインパターンには,何も新しいことはありません.デザインパターンの中で良いパターンの条件とは,
- 古いパターン
- 何度も使われているパターン
- シンプルなパターン
なのです.デザインパターンは新しい技術ではなく「新しいものごとのとらえ方」なのです.抽象的な概念をデザインパターンで捉えることにより,普通ならなかなか思い付かないクラス設計をすることができるようになるのです.
このように,どんな修正に対して柔軟性をもたせるのかに注目することで,デザインパターンはかなり分かりやすくなります.デザインパターンは抽象的すぎてよく分からないといった方は,こういった OCP の視点からデザインパターンを眺めてみることをお勧めします.
デザインパターンの例: Iterator
ここでは,OCP がデザインパターンにどう関わっているかを見るため, Iterator パターンを取り上げます.リンクリスト,動的配列などのデータ構造を集めたコンテナクラスライブラリには,必ずといっていいほどイテレータというクラスが存在します.このイテレータはどうして必要になったのでしょうか? OCP の立場から考えてみましょう.
まず,出発点としてリンクリストが与えられ,これをシーケンシャルにアクセスしたいと考えたとします.一番最初の設計では,カプセル化を考えて次のようなクラスになるでしょう.
class Item; class LinkList { public: void AddItem(Item*); void RemoveItem(Item*); // シーケンシャルアクセスのためのメソッド Item* FirstItem(); Item* NextItem(); bool More() const; };
この LinkList クラスを利用した関数は,例えば次のようになります.
void TraverseList(LinkList& aLinkList) { for (Item* i = aLinkList.FirstItem(); i = aLinkList.NextItem(); aLinkList.More()) { cout << i->Name() << endl; } }
その後,このリンクリスト内のオブジェクトをランダムアクセスする必要がでてきました.このランダムアクセスを実現するため,配列にしたいという仕様変更が発生したのです.このとき関数 TraverseList は OCP を満たしていません.つまり,コンテナクラスの種類を変更するということに関して拡張性がなかったのです.そこで次のように修正しました.
class Item; class Container { public: void AddItem(Item*); void RemoveItem(Item*); Item* FirstItem(); Item* NextItem(); bool More() const; }; class LinkList : public Container {...}; class Vector : public Container {...}; void TraverseContainer(Container& aContainer) { for (Item* i = aContainer.FirstItem(); i = aContainer.NextItem; aContainer.More()) { cout << i->Name() << endl; } }
このように, Container クラスという変更部分を抽出したクラスを作ることで,あとはどんな種類のコンテナクラスがきても拡張はコードの追加で実現できるようになりました.動的配列は, Container クラスのサブクラスとして追加すれば,関数 TraverseContainer はまったく修正する必要がありません.コンテナクラスの変更という仕様変更に対して OCP を満たしています.
さて,また仕様変更が生じました.今度は TraverseContainer を使うときに,逆方向にスキャンしたり,順方向にスキャンしたりしてコンテナクラスのアクセス方法をいろいろ変えたいと考えました.これを次の様に修正してしまってよいのでしょうか?
class Container { public: ... void SetForward(); //順方向にスキャンする void SetBackward(); //逆方向にスキャンする ... };
これは,明らかにアクセス方法という仕様変更について OCP を満たしていません.今度は,こういうアクセス方法を抽象化してイテレータというクラスに分離すればよいわけです.
class Iterator { public: Iterator(Container&); Item* FirstItem(); Item* NextItem(); bool More() const; }; class FowardIterator : public Iterator {...} class BackwardIterator : public Iterator {...}
このようにアクセス方法を別クラスにすることで,いろいろなアクセス方法に対応できるようになりました.実際には,各イテレータを LinkList や Vector に対応するためもう少し複雑になりますが,大体の趣旨は分かっていただけたかと思います.このように, OCP を満たすべく設計を進化させた結果をデザインパターンと見ることができるわけです.
デザインパターンの選択: Observer と Mediator
オブジェクト指向モデルは,複数のオブジェクトが互いにメッセージを送って協調しあうものですが,これを素直にうけとめてモデル化すると,あちこちで処理が分散し,結果的に分かりにくくメンテナンスしにくいモデルになってしまいます.つまり各オブジェクトが他のオブジェクトと密接に関連しあう複雑なモデルになってしまうわけです.これではそもそも何のためにクラスやオブジェクトに分けたのか,わからなくなってしまいます.こうしたモデルを簡略化したものを 図 E に示します.
図 E :オブジェクト間の通信
デザインパターンには,そうしたオブジェクト間のやりとりをうまく扱うものがあります.代表的なものは Mediator と Observer でしょう.ここでは,どういうときにどんなパターンを使えばいいのかを, Observer と Mediator パターンを比較して考えてみます.
Mediator パターンは,オブジェクト間のやりとりそのものをカプセル化してクラスにしてしまったものです.このことによってオブジェクト間のやりとりに秩序ができ,メンテナンスしやすいものになります(図 F ). Mediator パターンが採用されたシステムでは,各オブジェクトが個別に通信せずに, Mediator を経由してメッセージ通信を行います.こうすることで,システム全体の見通しが良くなります.それではこのパターンは, OCP を満たしているのでしょうか? 図 F のモデルにおいて変更の可能性があるのは協調しているオブジェクト間のやりとりと,協調オブジェクトの追加・削除です. Mediator パターンは,こういった変更に対して OCP を満たしていません.特に協調オブジェクトの追加と削除には, Mediator クラスの内部を修正することでしか変化に対応できないのです.では,このパターンは価値のないパターンなのでしょうか? 一概にそうは言い切れません.少なくともこうした修正箇所を 1 箇所に閉じ込めるという利点があります.あえていうなら, 弱い OCP を満たしているといえるでしょう.その意味で Mediator パターンはあまりオブジェクト指向的であるとはいえないですが,妥協点としては良いパターンになります.
図 F : Mediator パターン
次に Observer パターンを見てみましょう(図 G ).このパターンは,オブジェクト間に 1 対多の依存関係がある場合に使われます.オブジェクト Obaservable に依存しているオブジェクト Observer を自動的に更新するような処理がある場合によく使われるパターンです.再びこのパターンが OCP を満たしているかどうか見てみましょう.ここでは, Observable に対して Observer を新しく追加する仕様変更を考えます.このとき, Observable と他の Observer はまったく影響をうけません.つまり, Observer を追加するという仕様変更に対しては OCP を満たしているといえるわけです.
図 H :Observer パターン
さて, Observer と Mediator パターンの特徴がわかったところで,フォントダイアログを作る例を考えましょう.このダイアログにはコンポーネントが並んでいて,フォントの種類を指定するフォントリストボックス,フォントの大きさを指定するフォントサイズリストボックス,ユーザにフォントがどんなものになるのか表示するフォントのサンプル表示ビューなどがあります.フォントリストボックスの中にあるフォントを選択したら,フォントサイズリストをそのフォントに対応するフォントサイズが並ぶよう変更し,フォントのサンプル表示をそのフォントが表示されるように更新して……という具合に処理を行うとします(画面 A ).このとき,設計の第一案としては,フォントリストボックスを Observable ,フォントサイズリストボックスとフォントサンプル表示ビューをその Observer にするということが考えられます.つまり,フォントが変わったときフォントサイズの種類とフォントのサンプル表示が変わらないといけないので, Observer のパターンを応用するわけです.この場合,まず各コンポーネントに Observer としての機能を持たせる必要があります.また,サンプル表示ビューはフォントサイズにも依存しているので,この 2 つのコンポーネント間にも Observer パターンを使うことになってしまいます.少し考えただけでもこれではずいぶん複雑です.
画面 A :フォントダイアログ
この場合は, Mediator パターンにしてしまった方がシンプルでメンテナンスもしやすくなります.その理由は簡単です.この例では, Observable としてのフォントリストボックスよりも 1 対多の依存関係のほうが変化しやすいからです.コンポーネント間の依存関係が将来変更されやすいのに,現在の仕様で各コンポーネントが Observer - Observable の関係があるからといって安易に Observer を使ってはいけません.この場合はコンポーネント間の依存関係が変化しやすいので,それに対応した Mediator パターンを使うのがよいでしょう.Observer だから良い,というような発想ではなく,それぞれのケースで将来の変更に対応できるよう柔軟に設計するのが第一なのです.
このようにデザインパターンを効果的に使うためには次のように考えるのが間違った使い方をしないための指針となるでしょう.
- 変更の可能性があるのはどこか? 柔軟性を持たせなければならないところはどこか?
例えば,柔軟性を持たせなければならないのがアルゴリズムなら Strategy のパターン,オブジェクトの状態ならば State のパターンなどを検討してみます.
- そのパターンを使うことで柔軟性を失うのはどこか?
例えば, AbstractFactory パターンを使うと,生成するオブジェクトの集合を変えることが可能ですが,その集合に新しい種類のオブジェクトを追加するにはかなりの修正が必要です.つまりこのパターンは,オブジェクトの種類を変更することについて OCP を満たしていないことが分かります.
このように,「 OCP が最も効果的に成り立つようにデザインパターンを使う 」 ことが重要です.個々のデザインパターンには長所と短所の両方あり,そのトレードオフを見極めて使わなければなりません.自分が好きなパターンだからといって何度も同じパターンを使うのは単なるワンパターンに過ぎません.また, 1 つのシステムにより多くのデザインパターンを適用することが良いことでもありません.こういった意味で,デザインパターンは従来のアルゴリズムとデータ構造に似ているでしょう.アルゴリズムとデータ構造にはそれぞれ長所と短所があります.例えば,あるアルゴリズムは高速に処理できるがその分メモリをたくさん消費する.また,別のデータ構造を使うと検索は速いが更新は遅いということがあります.これと同様に,すべての局面で優れた設計を提供するようなパターンはありません.どういう状況で,何を目的にして使うのか判断することが重要で,逆にそうしたことを念頭においてデザインパターンを習得する必要があるでしょう.