ソフトウェア設計で普遍的に重要な設計アプローチ:関心の分離(SoC)は、高凝集設計、疎結合設計両方を要求します。具体的に、高凝集設計の推進で、コンポーネントの責務が関心ごとに集中している設計を実現します。疎結合設計の推進で、コンポーネントに割り当てられた関心ごとが構造的に分離されている設計を実現します。
この関心の分離は、抽象的な責務設計から詳細なコード実装まで、抽象・具体を横断的に工夫しながら推進する必要があります。コード実装なしに考えることができません。高凝集設計は契約による設計といった責務設計で推進できる一方で、疎結合設計は具体的なインターフェースやバウンダリの実装に依存しているためです。
今回はこのコード実装の工夫による疎結合設計の推進で、関心の分離を支えるアプローチを解説します。
対象の題材
今回は以下の仕様をC++で実装する場合を考えます。
- ある制御システムの通信処理が対象。サンプルコードのoperate()はその一つ。operate()含め通信処理は共通化して実装する。
- 通信処理の詳細はプラットフォームごとに異なる。プラットフォームはModel A、Model Bがある。今後も増える。プラットフォームごとに依存ライブラリが大きく異なる
- 通信処理の詳細は通信仕様バージョンごとにも差異がある。仕様バージョンはcom_v1、com_v2があり、今後も増える。
上記に対して、プラットフォーム、通信規格、それらを利用する通信処理実行者を関心事として関心の分離を実施し、疎結合にする方針をとります。
関心の分離が破綻した駄目なコード
関心の分離の点で悪い実装として、グローバルなフラグなどを観てDependency Lookupで処理を呼び出すパターンがあります。
void operate(int param) { if (comm_ver_ == PF_MODEL_A) { CommModelA::operate(param); } else if (comm_ver_ == PF_MODEL_B) { ...何かの特殊処理... CommMldelB::operate(param); } else if { ..フラグを使った分岐が続く.. }
こうした実装は疎結合設計が破綻しており、関心の分離に失敗しています。インターフェース共通化を行えず、プラットフォーム・通信規格の関心ごとがぐちゃぐちゃにまとめられていて、関心ごとに分けて考えられなくなっています。またフラグの分岐構造が複雑化するほか、この分岐構造のコピーが散在する形となり、プラットフォームや通信仕様の増加に対応する保守性が崩壊しています。
ダメなコード:継承を使った関心の分離
オブジェクト指向言語では、今回のプラットフォーム、通信仕様といった横断的なバリエーションがあるコードに対応する手段として、継承が使われる場合があります。
継承を使う場合、次のように抽象クラスでインターフェースを共通化します。
// 通信処理の共通IF class CommunicationBase { public: virtual ~CommunicationBase() = default; virtual void operate() const = 0; };
そしてバリエーションごとに派生クラスを定義し、処理部を共通化します。
class PFModelAComm : public CommunicationBase { public: void operate() const override { ... } ... }; class PFModelBComm : public CommunicationBase { public: void operate() const override { ... } ... }; ...
継承の導入により、通信処理の実行者視点では、バリエーションを共通記述で扱えるようになります。
std::unique_ptr<CommunicationBase> comm; ... //PF Model Aの制御を行う場合 comm = std::make_unique<PFModelAComm>(); ... comm->operate(); ...
ただこの継承依存の対応は問題が大きいです。例えば今回の例のようにプラットフォームに通信規格と、関心ごとの種類が複数ある場合、派生クラスが次のように爆発するか、保守性に問題ある多重継承に頼る形になります。
class PFModelA_V1_Comm : public CommunicationBase { ... }; class PFModelA_V2_Comm : public CommunicationBase { ... }; class PFModelB_V1_Comm : public CommunicationBase { ... }; class PFModelB_V2_Comm : public CommunicationBase { ... }; ...
これはバリエーションの増加に対する保守性が破綻しているほか、プラットフォーム、通信仕様と関心ごとの分離ができておらず、組み合わせが一体化しています。これは関心の分離が失敗している状態です。
疎結合設計、関心の分離に継承だけで対応するのは多くの場合でベストではありません。また抽象インターフェースの仮想関数は処理にオーバーヘッドがかかり、効率の点でもよくない手段と言えます。
良い例:BridgeパターンやStrategyバターンを使った関心の分離
今回の例のような、プラットフォーム、通信仕様と複数の関心がある場合で、関心の分離を行いつつ、呼び出し部を共通化して保守性を確保するのに有用なのが、BridgeパターンやStrategyパターンです。これらはバリエーションの組み合わせ対応をすべて継承で対応するのではなく、合成で対応します(いわゆる「継承でなくコンポジションの原則」)。
今回のサンプルの場合、次のような抽象化層を定義します。
class Communication { protected: std::unique_ptr<CommunicationImpl> pimpl_; public: Communication(std::unique_ptr<CommunicationImpl> impl) : pimpl_(std::move(impl)) {} virtual ~Communication() = default; void operate() const { if (pimpl_) { this->send_operation(); } } protected: virtual void send_operation() const = 0; };
バリエーションをすべて継承で対応せず、プラットフォーム対応はCommunicationImplクラスのメンバオブジェクトで対応します。プラットフォーム対応の実装は次ようなコードになります。
class CommunicationImpl { public: virtual ~CommunicationImpl() = default; virtual void send_data(const std::string& version) const = 0; }; class ModelAPlatform : public CommunicationImpl { public: void send_data(const std::string& version) const override { ...プラットフォームModel A固有の処理 } ... }; class ModelBPlatform : public CommunicationImpl { public: void send_data(const std::string& version) const override { ...プラットフォームModel B固有の処理 } ... };
通信仕様対応は次のような継承で対応します。
class ComV1 : public Communication { public: using Communication::Communication; protected: void send_operation() const override { ...V1固有処理 } }; class ComV2 : public Communication { public: using Communication::Communication; protected: void send_operation() const override { ...V2固有処理 } };
すると、例えばoperate()呼び出し部は次のように共通化して記述できるようになります。
std::unique_ptr<Communication> comm; ... // Model A、通信仕様V1の処理を行う場合 comm = std::make_unique<ComV1>(std::make_unique<ModelAPlatform>()); ... comm->operate(); ...
こうしたBrigeパターンやStrategyパターンの導入で、関心ごとに実装を分けて疎結合にできます。特に今回のパターンでは、プラットフォーム依存のコードが完全に分離されるため、依存ライブラリの分離が促進されます。これにより、関心の分離を実現しつつ、さらにバリエーション増加に対応するための保守性も確保できます。
モダンなC++での良い例:Type Erasure(型消去)パターンによる関心の分離
前述のBridgeパターンでは、保守性の確保と関心の分離の両立を実現できました。ただ関心ごとのバリエーション対応では、値セマンティクスに完全に対応し、例えばプラットフォームが異なるオブジェクトを一つのコンテナでまとめて管理、といった実装を行いたい場合があります。
これに対する対応策として、モダンなC++ではType Erasureパターンの導入で、C++でありながら実行時多態を実現しつつ、参照セマンティクスから値セマンティクスに移行させるアプローチがあります。
今回のサンプルでType Erasureパターンを導入する場合、次のType Erasureの抽象クラスを定義します。
class CommConcept { public: virtual ~CommConcept() = default; virtual void operate() const = 0; }; template <typename T> class CommModel final : public CommConcept { private: T object_; public: template <typename... Args> CommModel(Args&&... args) : object_(std::forward<Args>(args)...) {} void operate() const override { object_.operate(); } }; class AnyCommunication { private: std::unique_ptr<CommConcept> concept_; public: template <typename T> AnyCommunication(T object) : concept_(std::make_unique<CommModel<T>>(std::move(object))) {} AnyCommunication(const AnyCommunication& other) = delete; AnyCommunication& operator=(const AnyCommunication& other) = delete; AnyCommunication(AnyCommunication&&) = default; AnyCommunication& operator=(AnyCommunication&&) = default; void operate() const { if (concept_) { concept_->operate(); } } };
こうしたType Erasureパターンを導入すると、operate()の呼び出しを次のように実装できるようになります。
//様々なバリエーションのオブジェクトを同一のvectorに格納してしまう ComV1 comm_v1_a(std::make_unique<ModelAPlatform>()); ComV2 comm_v2_b(std::make_unique<ModelBPlatform>()); std::vector<AnyCommunication> any_comms; any_comms.push_back(std::move(comm_v1_a)); any_comms.push_back(std::move(comm_v2_b)); ... //コンテナに対する一括実行が可能になる for (const auto& comm : any_comms) { comm.operate(); }
こうした実装を行うと、プラットフォーム依存部、通信仕様依存部と利用者の結合をより疎にでき、より関心の分離を推進できます。