朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

オブジェクト指向を5年間理解できなかった人間がオブジェクト指向を説明する(第3回)

こんにちは。朝日ネット新卒社員のjiweenです。

前回は本連載の結論としてのオブジェクト指向の解釈を説明しました。今回からは、その視点を使ってGoFデザインパターンの仕組みを見ていきます。

  • 第1回 はじめに, 概要
  • 第2回 結論
  • 第3回 Adapter, State, Strategy, Abstract Factory
  • 第4回 Template Method, Factory Method, Bridge, Proxy, Composite, Interpreter, Decorator
  • 第5回 Visitor, Observer, Iterator, Facade, Mediator
  • 第6回 Builder, Singleton, Prototype, Flyweight, Chain of Responsibility, Command, Memento, まとめ

目次(第3回)

概要

前回述べた3つの設計ルールを反復しながら、それらのルールによってGoFデザインパターンが導かれる、あるいは合理的に説明されることを確認していきます。

  • 順番に読むことを想定しているので基礎的なことはより手前のパターン分析に書かれています。
  • 具体例については既存の情報がたくさんあるため、本記事では抽象的な分析を中心とします 。具体例や実装例 (コード) が見たい場合はwikipedia1 やGoF本などをご参照ください。 (もちろん「オブジェクト指向のこころ」もおすすめですよ!)
    • 深入りしないパターンについては、クラス図も省いています。
  • パターンは全てが有用という訳ではなく、明確な代替策が知られているものや、あまり汎用的でない特殊なケースを想定したものがあります。そのようなパターンについては適宜補足を行います。
  • 本記事ではパターンの分類方法がGoF本と異なります 。GoF本では23個のパターンを「生成に関するパターン」「振る舞いに関するパターン」「構造に関するパターン」と分類しています。このうち振る舞いと構造に関する分類は非常に違いが分かりづらく、文脈次第でどちらにも取れるパターンもあります。GoF本での分類は当時想定されていたパターンの応用例に着目したものだと思いますが、本記事ではパターンの構造や本質的な役割を分析するのが目的であるため、その特徴によって分類を行っています。
  • それぞれのデザインパターンは抽象的な小さいパターンなので、パターンを見るのが初めての方は実用性に不安を感じるかもしれません。実際の設計では単純なパターンをたくさん組み合わせるため、実践上はその組み合わせ方も重要となります。組み合わせ方は本記事の対象外ですが、パターンの役割を理解していれば実際に設計を行う中で自然と身につくのではないかと思います。

関連に関するパターン

  • Adapter

初めの一歩として、「関連」 を使う最も小さなパターンを説明します。

Adapterパターンは流動的要素をカプセル化する例としてシンプルかつ適度に具体的なので、最初に学ぶのに適していると思います。

Adapter

目的

Adapterパターンは、あるオブジェクトの素の振る舞いとは別の振る舞いを定義し、変換によって別のオブジェクトとして振る舞わせる (Adaptする) ことで実装を共有するパターンです。オブジェクトを使いやすくするために薄く包んでいるように見えるので、包んでいる側のオブジェクトを俗称として「ラッパー (wrapper)」と呼ぶこともあります。

構造

もし「オブジェクトを使いやすくするラッパー」という解釈を捨てて構造的な特徴に注目すれば、 Adapterは流動的要素の分割のために関連を導入するほぼ最小のパターンです。 なぜ最小かというと、1つのオブジェクトが1つのオブジェクトを関連で持っている、という構造だからです。

構造は単純ですが、ラッパーとしての使い方は初学者にとって一見の価値があると思います。本章の残りでは流動性についてイメージするためにラッパーとしての使い方を見ていきます。

導出

少し抽象的な言い方になってしまいますが、何らかのクラスAdapteeを考えてください。そのクラスはClientAクラスから利用されています。この時点ではなんの問題もありません。

しかし、ここでClientBクラスからも使いそうだということが分かったとします。

Adapteeに対するClientAとClientBの要求は似ているけれど一部の振る舞いが微妙に異なる、という場合を考えてください。

2つの要求がどちらも小さく不変的であればそれら全てに対応しても良いかもしれません。しかし2つの要求のどちらかが強く流動的である場合はどうでしょうか。例えばClientBクラスは開発中で、Adapteeクラスに対する要求を変更する可能性が高いとします。このときClientAクラスからの要求とClientBクラスからの要求は別の流動的要素となります。AdapteeクラスはClientBクラス由来の流動性を持っていますが、ClientAクラスの要求を満たすという全く別の流動性も持っているのです。2つの流動性をAdapteeクラスに持たせたままだと、ClientB側の流動的要素が仕様変更される度にClientA側の流動的要素が混じったクラスを変更することになります。変更の必要がないClientAクラスに関する情報が混じっているので、開発者は毎回 ClientA の変更に集中できませんし、そのせいでバグを埋め込んでしまう可能性もあります。流動性が混在する限りこのようなコストを支払い続けることになり、悲しい気持ちになってしまいます。

そこで2つの流動性を分割します。

ClientBクラスの要求を満たすAdapterクラスを実装し、Adapteeのオブジェクトを関連 (など) で持たせます。Adapterクラスは実質的な機能はAdapteeクラスに委譲していて、ClientBの要求に対して応答できるようにAdapteeクラスの機能を変換するラッパーです。

これでパターンはほとんど完成です。 Adapteeの適応的変換 (ラッピング) という流動性の強い要素がAdapterクラスにカプセル化されており、Adaptee自身が元から持っていた流動性の弱い要素はAdapteeクラスにカプセル化されたままであることが分かります

ここでは ClientB 側についての Adapter を導入しましたが、それはClientB が「新しく出てきたから」ではありません。ClientA と ClientB のどちらを Adapt するかは、ClientA 側を再設計して設計をシンプルにするか、再設計を回避してコストを節約するかという選択になります。 後から出てきた ClientB に対して Adapter を定義する方法はコストを抑えられるので多くの場合有効ですが、逆に ClientB に Adaptee を使わせた方が良い場合もあります。

例えば多くの依存を受けるような基幹的なレイヤーをバージョンアップしようとしているとして、ClientB側が新インターフェイス、ClientA側が旧インターフェイスだった場合を考えます (型としての "インターフェイス" を指しているわけではありません) 。ここで Adapter を ClientB 側に提供する方法のデメリットは、新インターフェイス側から見た設計が無駄に複雑になるということです。言い換えると、Adapter が Adaptee に依存するため、新インターフェイスの流動性に集中したい Adapter に対して旧インターフェイスの流動性が少し混じってしまいます。新インターフェイス側の設計を綺麗に保ちたいのであれば、ClientB 向きの洗練された実装をAdapteeとして書き直し、ClientA には互換性を担保するためのAdapterを提供する方が良い選択です。

また、 2つの要素がどちらも強い流動性 (変更可能性) を抱えている場合は2つのクラスに非対称に実装を分配するAdapterパターンを使うのではなく、3つのクラスを使う対称なパターン (共通部分を1つのクラスに切り出してそれ以外の2クラスに関連で持たせるパターン) を使うことになるでしょう。このパターンは構造的にはAdapterパターンの2回適用とみなすこともできますが、このような単なる関連の導入はあまりに普遍的なので多くの人が自然に使っていると思います。

冗長なインターフェイスについて

Adapterパターンのクラス図ではよくClientがAdapterクラスに直接依存するのではなく、インターフェイスに依存する形になっています。GoF本も例外ではなく、冒頭に挙げたクラス図はそれにならっています。こうするとAdapterクラスの詳細が隠蔽され、実装の詳細とインターフェイスがそれぞれ独立して変更できるようになります。

一方で、このようなインターフェイス (上図のTarget) は少々冗長に感じるかもしれません。私もそう感じます。多相性 (型のカプセル化) を実現しない、単に1クラスに対応するだけのインターフェイスをなぜパターンに組み込むのでしょうか?もちろんクラスの振る舞いが分かりやすくなる、将来的に振る舞いと実装を独立に変更することができるといった意味は確実にありますが、個人的にはそれだけの理由で冗長なインターフェイスを必ず定義するのは (言語と慣れにもよりますが) コストに見合わないと思います。意思疎通が容易でない複数人で開発していて実装と要求を明示的に分けたい場合や、クラスが複雑化している場合、大規模・長期的な開発で様々な変更に対して敏感な場合などはインターフェイスを定義しておくだけの価値があるかもしれません。しかし必要になった時にインターフェイスを差し込むのは容易なので、Adapterパターンのインターフェイス程度のものであればやはり後回しで良いように思います。

個人的な結論としては、冗長なインターフェイスを使う必要はありません。 Adapterパターンなどに登場する冗長なインターフェイスはパターンの一部としては覚えずに 、個々の文脈毎に判断すればよいと思います。

インターフェイスに関するパターン

  • State
  • Strategy
  • Abstract Factory

これらのパターンは、インターフェイスを使用した型レベルのカプセル化、つまり多相性 (ポリモーフィズム) の基本例となっています。インターフェイスを使って型の流動性を生み出すことでどのような設計が可能になるのか見ていきます。

特にStateパターンは「インターフェイスという非流動的要素によって」「流動的要素をカプセル化する」という設計の基本的な形になっています。残りのパターンはその応用や亜種です。

State

目的

State (状態) パターンは あるオブジェクトの状態と、状態によって切り替わるような処理を切り出すパターン です。流動的要素は状態のセット (どんな状態があるか) と状態ごとの処理です。

構造

具体例を見ないとState感は感じにくいかもしれませんが、Stateパターンは構造的に素直なパターンです。(具体例は適宜調べていただきたいです)

導出

Clientクラスのオブジェクトに何らかの状態変化があり、それによって処理が部分的に変化する場合を考えてください。ここで状態と状態によって変化する処理が流動的要素となります。流動的要素が分かっているので「非流動的要素によってカプセル化」することを考えます。

「カプセル化には関連とインターフェイスを積極的に使う」のが良いので、インターフェイスでカプセル化できるよう、「状態と状態によって変化する処理」を別のオブジェクトとして切り出します。「状態と状態によって変化する処理」は何種類もあるので状態の数だけクラスが発生します (StateX) が、それらのクラスをStateインターフェイスでカプセル化することによって外側からは複数のクラスがあることは (初期化時以外) 隠蔽されます。このようにしてカプセル化されたStateオブジェクトをClientクラスに関連で所持させ、状態を切り替えたり状態依存の処理にアクセスすることを可能にします。

Clientは状態依存の処理にアクセスする時 State インターフェイスを使用するため、State型のオブジェクトが実際にどのStateXクラスなのかということを知りません。つまり、状態依存の処理を行う時の「今どの状態で、どの処理をしなければならないか」という流動性はStateインターフェイスにカプセル化され、代わりにClientはStateインターフェイスというより非流動的な構造にアクセスすることができます。 (なお、状態遷移を行う時は初期化のためStateXクラスにアクセスする必要があります。状態遷移が本質的に個々の状態に依存したものである場合はこれは適切な設計です)

クラス図を見ると、「状態と状態に依存した処理」という流動的要素がインターフェイスによってカプセル化されており、流動的要素の各具体実装がStateXに対応することが分かると思います。Stateパターンは列挙的な状態を取り扱うという想定目的があるためStateパターンと名付けられていますが、やっていることは自然な型のカプセル化です。

直和型・Visitorパターンとの関係

Stateパターンを語るときによく対抗勢力として挙げられるアンチパターンがあります。特定の状態変数 (典型的には列挙型 = enumerated type やそれに準ずるもの) によってswitchするようなパターンです。このアンチパターンを使うと状態変数による処理の切り替え (switch文・if文) が、複数箇所に、状態の仕様とは無関係に散らばってしまいます。そして状態の仕様に変更を加えたときに関係する全ての分岐をコンパイラ以外の何者かがチェックしなければいけなくなります。これは流動的要素が不必要に分離されている悪いパターンです。このswitch地獄を解決する方法は私が知っている限りStateパターンを含め3種類あります。

  • Stateパターン
    • 状態と状態ごとの処理とを一体化して切り出しても差し支えない場合、一般的なオブジェクト指向言語の構文ではStateパターンが最も簡潔で自然です。
    • 状態と状態毎の処理とを一体化すると困る場合、次の2つを使います。
  • Visitorパターン (第5回で扱います)
    • Stateパターンより制約が緩く、状態と状態ごとの処理すらも分離したままにできます。代わりに少し複雑なので、必要に応じて使用します。
  • 直和型
    • (言語のサポートが十分であれば) Visitorパターンと同じことが簡潔に実現できます。
    • 必要最小限の仕事をする、汎用的な解決策です。

詳細についてはVisitorパターンの分析の際に述べます。

Stateパターンでは「状態」と「状態に依存した処理」を同じオブジェクトへ閉じ込めなければいけませんが、その制限を外したい場合は直和型かVisitorパターンを使用します。

状態遷移の管理

Stateパターンは状態と状態に依存する処理を切り出しますが、状態遷移処理はClientクラスに残したままにします。複雑な制限と流動性のもとで状態遷移管理をしなければならない場合 (例えばゲームのシーン管理など) は状態間の遷移を管理する専用のクラスが必要になる可能性がありますが、それはStateパターンの範囲外になります。状態遷移の管理はオブジェクト間関係のオブジェクト化であり、第5回で扱うMediatorパターンの適用とみなせます。

Strategy

目的

本連載で提案している設計指針の上では Stateパターンと同一 とみなせます。つまり 「状態によって切り替わる処理をカプセル化」します。

構造

Stateパターンと同一です。

State と Strategy の違い

本連載の視点からはStateとStrategyは同じように適用できるのですが、GoFの分類によれば適用対象が異なります。 Stateが移り変わる状態を扱うパターンであるのに対して、Strategyはオブジェクトが一度生成されると変化しないような実行時の流動性を伴わない特徴を表現します。 つまり、静的な特徴に対してパターンを適用するのか、動的な特徴に対してパターンを適用するのかによって呼び名が使い分けられています。

これは、恐らくパターンが発想された経緯に由来します。Stateは状態に対するswitchを無くす薬として考案されたのに対し、Strategyは継承による振る舞いの入れ替えを無くす薬として考案されたのだと思います。前者の切り替えは動的で、後者の切り替えは静的です。

State と Strategy の使い分け

StateとStrategyの間には「状態遷移が動的か静的か」という違いがありますが、それによってパターンのための実装は全く変化しません。状態遷移をどうするかはパターンとは無関係に決められます。例えば、State パターンの Client オブジェクトが初期化時にしか state フィールドを設定しないのであれば、それはそのまま Strategy パターンとなります。

そして、2つのパターンは設計レベルでも同じものとみなせます。流動性に着目した設計では、どちらのパターンも「切り替わる処理を流動的要素とみなしてカプセル化する」ものです。他の違いは、本連載で推奨している設計指針とは別の視点から見えるものです。

よって (意思疎通の問題を除けば) State/Strategyを意識して使い分ける必要は無い と思います。

Abstract Factory

目的

複数の Strategy (State) パターンを更に Strategy (State) パターンでまとめます。一般に知られている使い方としては、"流動的に複数のオブジェクトを生成するオブジェクト" を更に流動的に切り替えることができます。

構造

Abstract Factoryパターンは 構造的には二重のStrategy (State) パターン で、Stateパターンで説明した流れと同じような変形を忠実に行えば導出できます。しかしオブジェクトの生成で頻出するため (?) パターン化されています。

導出

二重にStrategyを適用することでAbstract Factoryパターンが導かれることを見ていきます。

あるオブジェクトの実装の切り替えが必要なことが分かり、切り替えたい実装を洗い出してみると複数種類の切り替えがあったという場合を考えてください。これは複数のStrategy (State) パターンとして設計できます。

Clientでは、各StrategyNについて具体的なインスタンスを選択することになります。ここで、一連のインスタンスの選択が、何らかの状況によって切り替わるとします。例えば、「ある状況ではStrategy1AとStrategy2A、またある状況ではStrategy1BとStrategy2A、…」という具合です。つまり、Strategyパターンのインスタンス選択に関するさらなるStrategyパターンを考えることができます。ここで新しく発生するStrategyのように、オブジェクトの生成を担うオブジェクトは Factory (工場) と呼ばれます。

上図ではClientはStrategyNを関連で使用していますが依存で使う場合もあるかもしれません。

Abstract Factoryという名前は、Factoryが具体的なオブジェクトではなく抽象的 (abstract) なオブジェクト (StratetgyNインターフェイス) を生成するように見えることから来ています。(FactoryがAbstractだからではありません)

次回予告

Abstract Factoryパターンが二重のStrategy (State) パターンとして導出されるというのが今回のサビでした。次回は、クラス継承を安全に扱うためのBridgeパターンや、再帰的構造を扱うCompositeパターンを分析します。次回以降は少し難しくなりますが、基本的には第2回で示した設計方針しか使いません。特に流動性に着目した考察はあらゆるプログラム設計に通じる考え方だと信じているので、ぜひ体感して頂きたいです。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。

新卒採用 キャリア採用|株式会社朝日ネット


  1. 英語を読みたくないとしても、デザインパターンのコードやクラス図に関しては確実に英語版の方のwikipediaを見るべきです。英語版は具体例まで丁寧に記述されています。