はじめに
掲題のSOLID原則(オブジェクト指向の設計原則)は、オブジェクト指向の設計を行う上で重要な原則として広く周知されています。
SOLID原則とは、以下の守るべき原則の頭文字を取って名付けられました。
参考:https://qiita.com/hirokidaichi/items/d6c473d8011bd9330e63
-
S(Single responsibility principle:単一責務の原則)
"クラス(※)を変更する理由は1つでなければならない"
※:クラスと言われるが、モジュール、コンポーネント、サービスにも当てはまる。
https://postd.cc/solid-principles-every-developer-should-know/ -
O(Open/closed principle:開放閉鎖の原則)
"クラスは拡張に対して開き、修正に対して閉じていなければならない"
-
L(Liskov substitution principle:リスコフの置換原則)
"派生型はその基本型と置換可能でなければならない"
-
I(Interface segregation principle:インターフェース分離の原則)
"クライアントが利用しないメソッドへの依存を強制してはならない"
-
D(Dependency inversion principle:依存性逆転の原則)
"上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである。"
しかし、WEBの記事を参考にしようとすると、その原則の抽象度の高さ故に解釈が異なっていたり、その原則を守るための方針が異なっていたりしました。
上記の原則について、自分の理解を共有したいと思い、今回投稿しました。
S(Single responsibility principle:単一責務の原則)
"クラスを変更する理由は1つでなければならない"
「クラスを変更する理由」が「1つ」となるクラス分割とは何か
この原則を考える上で、極端な例を考えてみます。
- 全てのメソッド、公開メンバを一つのクラスにまとめる
- 各メソッド、各公開メンバごとにクラスを分割する
上記は、単一責務の原則を守れているでしょうか?
言い方によっては守れていると言えますが、おそらく全ての人がNoであると考えると思います。
では、それはなぜでしょうか。
その理由は、「凝集度」が低いためであると私は考えます。
凝集度とは、情報工学においてモジュール内のソースコードが特定の機能を提供すべく如何に協調しているかを表す度合いを意味します。凝集度は、次のような場合に低下すると記載されていました。
参考: https://ja.wikipedia.org/wiki/%E5%87%9D%E9%9B%86%E5%BA%A6
- クラスの責任範囲(メソッド群)に共通性がほとんどない。
- メソッドが様々なことを行い、しばしば粒度の粗いデータや全く関係のないデータ群を扱う。
上記の例はこの凝集度が低下しているために、守れていないと考えるのだと思います。
ゆえに、単一責務の原則を守ることとは、凝集度を高めることと同義であるといえます。
単一責務の原則を守る方法について
では、この凝集度を高め、単一責務の原則を守ることができる方法とは何でしょうか。
その方法として、良いと思われる方法が以下の参考サイトにありましたので共有します。
参考:https://code.tutsplus.com/tutorials/solid-part-1-the-single-responsibility-principle--net-36074
- Find and define the actors.(アクター(主体)を見つけ、定義する。)
- Identify the responsibilities that serve those actors.(それらのアクターに奉仕する責務を特定する。)
- Group our functions and classes so that each has only one allocated responsibility.(それぞれが割り当てられた責務をひとつだけ持つように、機能とクラスをグループ化する。)
上記の方法も非常に抽象度が高いのですが、ここで重要なことは、クラスを変更する理由はアクターに依存するということです。
想定するアクターが変われば、「クラスを変更する理由」が「1つ」になる分割は変わります。想定するアクターが曖昧であるほど責務が曖昧になってしまい、単一責務の原則を守ることが難しくなります。
よって、単一責務の原則を守るための第一歩として、まず初めにアクターがどう使うかを考えましょう。このプロセスの有無が、オブジェクト指向設計の初学者が責務の割り当てが、上手くいくようになる手始めとして重要になると私は思います。
O(Open/closed principle:開放閉鎖の原則)
"クラスは拡張に対して開き、修正に対して閉じていなければならない"
開放閉鎖の原則について考える前に
ある給与計算システムに対して、熟練者には給与を割り増して支払いたいという要求を受け取った場合を考えてみます(以下のURLには、実際のコードの例が掲載されています)。
参考:https://code-maze.com/open-closed-principle/
ここで、開発者は既存の給与計算システムを直接変更し、熟練者に給与を割り増せるように実現したとします。
これで要求については満たすことはできました。
しかし、既存システムの変更に対する影響についてはどうでしょうか。おそらく、既存システムの変更に対して多くの箇所に影響を受けることでしょう。影響がある箇所については、設計から見直さなければならないかもしれません。また、期待通り修正できていることを確認するためのテストも必要になるかもしれません。
上記のように、既存の動作しているシステムを変更するということは、非常に手間がかかり大変です。
しかし、このような状況はシステム開発においては頻繁に起きえます。
開放閉鎖の原則は、上記のような要求にうまく応えられる設計とするための原則になります。
「拡張に対して開く」とは何か、「修正に対して閉じている」とは何か
はじめに、「開く」「閉じている」の定義について、wikipediaの文章を日本語訳し、以下に引用します。
https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle
-
「開く」
これはモジュール(※)がまだ拡張可能な場合、そのモジュールは開いていると言われます。たとえば、フィールドに含まれるデータ構造にフィールドを追加したり、実行する一連の機能に新しい要素を追加したりすることができます。
※:繰り返しになりますが、ここでモジュールと言われているものはクラス、関数等にも当てはまります。
https://postd.cc/solid-principles-every-developer-should-know/ -
「閉じている」
他のモジュールが使用できる場合、そのモジュールは閉じられたと言われます。これは、モジュールが明確に定義された安定した記述(情報隠蔽という意味でのインターフェース)を与えられていることを前提としています。
上記の定義は抽象的であるためそのまま理解が難しいのですが、以下のURLを参照することで「拡張に対して開く」については理解できました。
参考:https://code-maze.com/open-closed-principle/
「拡張に対して開く」とは、既存クラスを変更するのではなく、既存クラスを継承した新規クラスを定義することで振る舞いを拡張できる状態である、ということです。既存クラスを直接変更すると、[開放閉鎖の原則について考える前に]で述べた通り影響範囲が広くなってしまい、考えることが多くなってしまいます。
一方で、既存クラスを継承した新規クラスを使用する場合、その影響範囲は新規クラスに限定されます。
このように、影響範囲を狭められることによって、振る舞いの拡張が容易になるというメリットがあります。
しかし、「修正に対して閉じている」については理解できませんでした。
その理由は、もしもインターフェースを実現するクラスを定義しても、そのクラスを他のクラスが使用することで依存関係が生まれ、影響してしまうと考えたためです。
例えば、インターフェースで定義したメソッドの入出力の型が合っていたとしても、戻り値が許容できない値であったなら期待通りに動作しない、と私は考えました。
私の理解が深められたのは、この原則がオブジェクト指向設計に向けたものであると認識し、「閉じている」の定義の以下の箇所について考えたときでした。
明確に定義された安定した記述(情報隠蔽という意味でのインターフェース)を与えられていること
すなわち、「明確に定義された安定した記述(情報隠蔽という意味でのインターフェース)」とは、プログラムで定義したインターフェースで実装されているか否かではなく、その外に現れる他の制約を含むものである、ということです。私の考えた具体例は、オブジェクト指向プログラミングによる考え方による例で、開放閉鎖の原則を満たせていなかったのです。
上記を整理しますと、「修正に対して閉じている」とは、設計上の定義や制約が定められているインターフェースを実現するクラスについて修正したときに他のクラスが使用できる状態である、ということです。
この定義が守られている場合、そのインターフェースを利用するクラスに影響を与えることがなくなります。その影響範囲は修正したクラスに限定されることになり、修正が容易になるというメリットがあります。
最後に、繰り返しになりますが「拡張に対して開く」とは何か、「修正に対して閉じている」とは何か、について私なりの解釈を以下にまとめます。
- 「拡張に対して開く」とは、既存クラスを変更するのではなく、既存クラスを継承した新規クラスを定義することで振る舞いを拡張できる状態である
- 「修正に対して閉じている」とは、設計上の定義や制約が定められているインターフェースを実現するクラスについて修正したときに他のクラスが使用できる状態である
蛇足になりますが、開放閉鎖の原則はオブジェクト指向設計に対する原則である、という背景を忘れたことで私は勘違いしてしまいました。なので、この原則に限らず、用語が生まれた背景が何なのかを忘れないように気を付けましょう。
L(Liskov substitution principle:リスコフの置換原則)
"派生型はその基本型と置換可能でなければならない"
「置換可能」であることによる嬉しさとは何か
「置換可能」であることによる嬉しさとは、派生型のクラスに関する知識を持つことなくそのクラスを利用できる、ということです。この記載は、以下の参考サイトに記載されている、リスコフの置換原則に反した場合の悪さの裏返しになります。
参考:https://ja.wikipedia.org/wiki/%E3%83%AA%E3%82%B9%E3%82%B3%E3%83%95%E3%81%AE%E7%BD%AE%E6%8F%9B%E5%8E%9F%E5%89%87
では、この原則に反した場合、すなわち、「派生型はその基本型と置換可能でない」に起きる問題について具体的に考えてみます。
例えば、図形を表す基本型としてShapeクラス、派生型としてTriangleクラスやRectangleクラスが定義されているとします。Calculatorクラスでは、CalculateArea()メソッドを公開しており、Shapeクラスを引数に受け取り、図形の面積を返すとします。上記までの派生型であるTriangleクラス、Rectangleクラスでは上手く動作すると想像できます。
しかし、その後に開発者が派生型としてLineクラスを追加で定義した場合はどうでしょうか。Lineクラスでは面積を持つことはありません。なので、例外を発生させる実装になっていたとします。
すると、開発者はLineクラスをCalculatorクラスに渡すと例外を発生してしまうということを意識しなければなりません。そうなると、各派生型に合わせて固有の実装が必要になる恐れがあり、影響範囲調査が困難になる可能性があります。
このような事態に陥るとオブジェクト指向設計の恩恵(例えば、ポリモーフィズム)を受けられなくなってしまいます。
※ポリモーフィズムの詳細:https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%82%BA%E3%83%A0
したがって、基本型と派生型を置き換えても問題ない設計とするよう意識しましょう。
I(Interface segregation principle:インターフェース分離の原則)
"クライアントが利用しないメソッドへの依存を強制してはならない"
「クライアントが利用しないメソッドへの依存」があるとどのような問題があるか
「クライアントが利用しないメソッドへの依存」を私の解釈で言い換えますと、あるインターフェース(I/F)が定義されており、そのI/Fを継承するクライアント(クラス)で不要であるはずのI/Fの公開メンバを定義しなければならない状態のことを指します。これに対して、不要な公開メンバを定義しても、何も処理をさせなければ実行自体は問題なく動きます。
しかし、真に問題となるのは、本来持つべきではない責務を担ってしまうI/Fは、その責務が本来の責務よりも広くなってしまうという点です。責務が広くなってしまうと継承するクラスが多くなると想定されます。そうなりますと、I/Fを変更したときの影響範囲がとても広いものとなってしまいます。
I/F分離の原則にしたがい細分化しておけば、あるI/Fに依存するクラスを最低限に抑えることができ、影響範囲を狭めることができます。プログラムの保守性を高めるためにも、I/Fを分割し、クライアントが利用しないメソッドへの依存をなくすよう意識しましょう。
参考URL:
- https://www.intertech.com/Blog/the-interface-segregation-principle-with-c-examples/
- http://objectclub.jp/technicaldoc/object-orientation/principle/principle06
- https://github.com/SunriseDigital/work-shop/wiki/%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%95%E3%82%A7%E3%82%A4%E3%82%B9%E5%88%86%E9%9B%A2%E3%81%AE%E5%8E%9F%E5%89%87
D(Dependency inversion principle:依存性逆転の原則)
"上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである。"
「抽象」に依存する嬉しさとは何か
「抽象」に依存するとは、上位のモジュールが下位のモジュールの詳細を知ることなく利用できると言い換えられます。この原則にしたがって実装することによる嬉しさとは何でしょうか。
もし、上位のモジュールが下位のモジュールの詳細を知らなければ利用できない場合、上位のモジュールは下位のモジュールの変更による影響を受けることになります。下位のモジュールに変更があれば、上位のモジュールも併せて修正しなければなりません。もし、「抽象」に依存していれば、上位のモジュールは下位のモジュールの変更による影響を受けなくて済みます。これはオブジェクト指向設計特有の嬉しさであり、手続き型言語では得られないものです。
オブジェクト指向設計をする上では、上位のモジュールも下位のモジュールも「抽象」に依存するようにし、実装への依存を減らすようにしましょう。
参考URL: