何となくクラス設計をしていませんか? ~目からウロコのSOLID原則~

カバー

[!] この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

そもそも私がSOLID原則を知った経緯は、オブジェクト指向に関数型言語の要素を取り入れた新しい言語を勉強していたことがきっかけになります。 最初は、なぜその言語が関数型言語の要素を取り入れたのかを知るために、オブジェクト指向を改めて調べる必要があると思い調査していました。 その際にSOLID原則という用語を発見しました。そして、SOLID原則について詳しく調べていくうちに、以下の利点があることに気づきました。

  • 無駄な処理をオブジェクトの利用者に書かせることがないようにクラスを構成できるようになります。
  • バグの発生箇所の特定が容易になります。
  • 機能提供後の機能追加やバグ修正をしやすくできます。

このことを知らないと大きな損になると思い、筆を執りました。

SOLID原則とは

ロバート・C・マーティンさんという方が論文で提唱した設計原則のうち5つを、同じソフトウェア技術者ミカエル・フェザーズさんが抜き出して世に広めました。 その5つの原則の頭文字つなげて提唱されたものがSOLID原則です。
各原則は以下の通りです。
S → Single Responsibility Principle: 単一責任の原則
O → Open-Closed Principle: 開放閉鎖の原則
L → Liskov Substitution Principle: リスコフの置換原則
I → Interface Segregation Principle: インタフェース分離の原則
D → Dependency Inversion Principle: 依存性逆転の原則

以下に各原則の簡単な説明を記載します。

図の凡例

この凡例は基本的にUML図のクラス図の記載方法に準拠しています。

説明図の凡例

単一責任の原則

モジュール、クラスまたは関数は、単一の機能について責任を持ち、  
その機能をカプセル化するべきである。

単一責任の原則は、「クラスには一つのアクター」という原則を守って設計することを提唱しています。
ここで言うアクターとは、開発するシステムに対する要求の出発点のことを指します。

各々のアクターから受ける要求をそれぞれの要求に対応したクラスが受けて処理を行うようにすること(局在化)によって、 処理の変更やバグ修正に対して安全に対応しやすくできます。

例として本原則に違反したクラス構成と則っている構成を以下に示します。 2つの図式の例では処理内容の異なる2種のアクターから依頼を受ける際に、1つのクラスで処理を受け付けるか別々のクラスで受け付けるかの違いがあります。 後から2つ目のアクターからの要求を受け付けるようにした場合を想定して説明しますと、
図1-1ですと、アクターBを受け付ける処理を追加した場合、元の処理(アクターAを受け付ける処理) の一部への影響が出るため、アクターA用の処理について試験を実施する手間がかかります。
※1つのクラスに処理を追加した場合、メンバ変数への影響が出ないかの確認も必要になります。
図1-2ではクラスを分離するため他の処理への影響が出難いというメリットがあります。 また共通化したい型やインタフェースは継承やインタフェース定義等で対応可能ですし、共通処理を設けるなら共通処理用のオブジェクトをインジェクションする方法を取れば試験時も安心です。

単一責任の原則-誤り例

図1-1 単一責任の原則に則ってないクラス図

単一責任の原則-正解例

図1-2 単一責任の原則に則っているクラス図

開放閉鎖の原則

ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、  
修正に対しては閉じているべきである。

と語られているが、この原則はソフトウェア要素を構成する際の心得として述べられてると考えています。

  • 拡張に関しては「開いている」
    ここで「開いている」とは、機能追加しやすくなっていることを指します。 機能を追加するために動作確認完了済みの既存処理へ修正することのないように構成しておくことを提唱しています。 そのための道具としてオブジェクト指向言語では継承やオーバーライド等の機能が用意されています。
  • 修正に関しては「閉じている」
    ここで「閉じている」とは、修正が必要となった際に影響度が少なくなるようになっていることを指します。
    ”影響度が少ない”
    →他の機能の処理で使用しているメンバ関数や変数へ修正や不意な変更を掛けないようにすることを指しています。

上記の原則に則る際には、まずは提供しようとしている機能を分析して分離を明確に行うことが必要になると考えます。

図1-3では複数のアクターの処理要求を1つのクラスで賄うようにしたため、他のアクターに対応するために受付用のメソッドに修正を加えることになってます。 処理アクターを追加するたびに既存処理ルートの試験が必要になり試験の手間が増えます。 これを防ぐためには図1-4の様にベースとなる上位クラスかインタフェースを設けて追加して対応クラスを設ける方が既存の処理への影響が非常に低くなります。
またバグが発生した際には問題個所が発見しやすく対処も容易となります。
すなわち、修正に関して”閉じる”場合は、機能単位でクラスを分離する方法が容易です。 機能追加に対して”開いた”状態にするには、まずはすでに正常に動作している処理への変更を避けることが重要であり、 この原則を念頭に置いて設計する必要があります。

開放閉鎖の原則-誤り例

図1-3 開放閉鎖の原則に則ってないクラス図

開放閉鎖の原則-正解例

図1-4 開放閉鎖の原則に則っているクラス図

リスコフの置換原則

型Bが型Aのサブタイプであるならば、プログラムで使用する型Aのオブジェクトを  
型Bのオブジェクトに置き換えても動作は変わらない  

リスコフの置換原則は、基底型からの派生型の定義に際して、以下の項目の順守を提唱している。

  1. 事前条件(preconditions)を、派生型で強めることはできない。派生型では同じか弱められる。
  2. 事後条件(postconditions)を、派生型で弱めることはできない。派生型では同じか強められる。
  3. 不変条件(invaritants)は、派生型でも保護されねばならない。派生型でそのまま維持される。
  4. 基底型の例外(exception)から派生した例外を除いては、派生型で独自の例外を投げてはならない。

条件を強めるとはプロセス上の状態の取り得る範囲を狭くすることであり、条件を弱めるとはプロセス上の状態の取り得る範囲を広くすることである。これは実装視点では様々なアサーションの複合になる。例えば型の汎化/特化、数値 の範囲、データ構造の内容範囲、コードディスパッチ先の選択範囲といった様々な事柄を含む。

引用元:リスコフの置換原則 - Wikipedia、ウィキペディア (Wikipedia): フリー百科事典より

私が、リスコフの置換原則で疑問だった点は継承クラスを作成する際にメソッドをオーバーライドして処理を変更しても成り立つのかといった点でした。 実際にプログラミングして確かめましたが成り立つようでした。
オーバーライドすると言うことは、スーパータイプ(=クラス)の振る舞いを変更する訳ではないということで納得しました。
※リスコフの置換原則には「サブタイプ(=クラス)はスーパータイプの全ての振る舞いを持つこと」とあります。
本原則はあくまでも型の構成に関しての事であり、オーバーライドする際にはメソッドのパラメータ数とリターン値の型の変更は許されておらず、又パラメータの型についても弱められる方向でしか変更できません。 なので、リスコフの原則はオーバーライドを許容していると判断できました。

リスコフの置換原則-誤り例

図1-5 リスコフの置換原則に則ってないクラス図

リスコフの置換原則-正解例

図1-6 リスコフの置換原則に則っているクラス図

インタフェース分離の原則

汎用なインタフェースが一つあるよりも、  
各クライアントに特化したインタフェースがたくさんあった方がよい

この原則に則らないと、本来ならば見せなくてよいインタフェースまで見せてしまうことで
無駄な処理が含まれてしまうことが考えられ、思わぬ問題が発生してしまうことになりかねません。 またオブジェクトの利用者に不要なインタフェースの実装を強要してしまいます。
この原則に則るなら、オブジェクトの利用者に必要なインタフェースのみを見せるようにします。
合わせて、オブジェクトの利用者毎にインタフェースを分離します。
そうすることでオブジェクトの利用者側に不要なインタフェースの対応を強要しません。

インタフェース分離の原則-誤り例

図1-7 インタフェース分離の原則に則ってないクラス図

インタフェース分離の原則-正解例

図1-8 インタフェース分離の原則に則っているクラス図

依存性逆転の原則

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。  
双方とも具象ではなく、抽象(インタフェースなど)に依存するべきである。

例としてクラスAとクラスBというクラスがあるものと考えます。
クラスAはクラスBのメソッドを使用したいものとします。
安直に考えるとAが持つ処理内でクラスBを生成してメソッドを実行するだけです。
これはクラスAがクラスBに依存してることになります。もしAがBの上位階層なら上位が下位に依存することになります(この原則に合致しません)。
そこでBのインタフェース定義をA側で行ってB側でそのインタフェースを使って定義すると依存性が逆転することになります。
クラスAが使用するクラスをDI(依存性の注入)すれば尚良いことになります。
これは依存関係が混在すれば処理構成が複雑になりその結果、バグの温床になります。
また依存性の注入は依存するクラスのオブジェクトを使用するクラスの コンストラクタで渡すようにすればよく、図1-10によればクラスAの単体試験を実施する際にクラスBに引きずられることが無くなります。

依存性逆転の原則-誤り例

図1-9 依存性逆転に則ってないクラス図

依存性逆転の原則-正解例

図1-10 依存性逆転に則っているクラス図

おわりに

まずこの原則を知った際に感じたことは無知とは恐ろしく損をするんだなってことでした。
私が知ってるオブジェクト指向とはデータとそのデータに対応した処理を一つのパッケージにまとめたものであるということくらいで、 最初はオブジェクト指向のクラスとはCの構造体のようなものだなと理解していたときもありました。 また「ポリモーフィズム」と言っても”何の意味があるの?”くらいにしか思っていませんでした。
それが、この原則の内容を読んでみて様々な気づきがありました。

  • この原則で述べられているオブジェクトの設計方法は、アジャイル開発や分散開発にも役立ちます。
  • クラスの設計によって、後工程のコーディングや試験にかかる工数に大きく影響します。
  • クラスを構成する際には、プログラムでやりたい作業内容の内容分析を十分に行うことが必要になります。
  • 便利だからと言って、継承上の親クラスに後から共通関数を足すような拡張はすべきでないことです。
  • 処理に対する拡張やバグ修正のやりやすさに継承やオーバーライドは役に立つということです。
  • クラスも型なんだなぁということです。

皆さんは今までクラスを設計する際に、何となくクラスを設計していませんでしたか?
SOLID原則を学ぶことで機能追加や修正、試験に苦労しない設計を行うことができるようになります。
またオブジェクト指向プログラムで開発を既に行っている方も、もしこの原則を知らないのであれば一度は目を通していただきたいと思います。

参考サイト


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。