楽水

人々の創造が自由に表現できる舞台づくり

アプリケーション システム開発 デザインパターン

ソフトウェアの設計原則①SOLIDの原則

投稿日:


昨今の特徴を端的に表すと

  • 個の時代
    組織から個人へ。
  • 心の時代
    「もの」から「こと」へ。

ということになるのではないでしょうか。
激しく変化する多様な嗜好に合わせて、

  • ビジネスの変化が加速化
  • ビジネスとITが密着化

する。
このような時代をVUCAの時代と呼ぶ人もいます。
VUCAとは、以下の4つの頭文字を合わせた言葉です。

  • Volatility:変動性
  • Uncertainty:不確実性
  • Complexity:複雑性
  • Ambiguity:曖昧性

先行き不透明で、予測困難な時代、ソフトウェアも変化に強い構造にする必要があります。
つまり、
いかに変化に強いソフトウェアを設計できるか
が重要な鍵になっているのです。
変化に強いソフトウェアの代表的な特徴は、

  • 保守性が高いこと
    修正箇所が局所化され、他の部分に影響しないこと(リスクの局所化)。
  • 再利用性が高いこと
    ソフトウェアが部品化され、それを再利用することができる。
  • 拡張性が高いこと
    ソフトウェアを構成する要素同士を疎結合することで、ソフトウェアを様々な実装で拡張することができる。

です。
今回は、この3つの特徴を実現する、ソフトウェアの設計原則の一つ、SOLIDについて、以下の観点で解説します。

SOLIDとは何か

SOLIDとは、アメリカのソフトウェアエンジニアでインストラクタでもあるロバート・マーチン(Robert C. Martin)により提唱されたソフトウェアの設計原則のうち、以下の原則の頭文字をとった言葉です。

  • 単一責任の原則:Single Responsibility Principle(SRP)
  • 開放/閉鎖の原則:Open/Closed Principle(OCP)
  • リスコフの置換原則:Liskov Substitution Principle(LSP)
  • インターフェース分離の原則:Interface Segregation Principle(ISP)
  • 依存性逆転の原則:Dependency Inversion Principle(DIP)

一つ一つ見ていきましょう。

単一責任の原則(SRP)

まず、単一責任の原則(SPR)ですが、これは、
1つのソフトウェア構成要素(サブシステムやモジュール、クラス、関数など)に、変更する理由が2つ以上あるようではいけない
という原則です。
既にあるコードを修正するというのは、以下の点でそれなりにコストのかかる行為です。

  • そのコードを修正することでどこに影響があるか調査しなければいけない
  • 修正の影響で、動作に支障が出ないか確認しなければいけない

なので、変更による影響を最小限に抑えるようソフトウェアを設計する必要があります。
そのための解の一つが単一責任の原則(SRP)です。
単一責任の原則(SRP)を適用することで、ソフトウェア構成要素が持つ単一の責務が変更されたときのみ、それが変更されるよにすることで、変更による影響を最小限に抑えることができます。
複数の変更理由があるソフトウェア構成要素は、変更頻度が高いため非常に不安定で、その要素に依存する全ての要素が変更による影響を受けるので、保守性が低いソフトウェアになってしまいます。
例をあげて説明します。
以下は、ビジネスルール、レポート、データベースアクセスに関わるメソッドを持つクラスです。

public class Employee {
  public Money calculatePay(){、、、}
  public String reportHours() {、、、}
  public void save(){、、、}
}

クラスというのは、同じ変数を操作するメソッドの集まりなのだから、これで良いと考えるエンジニアもいると思いいます。
しかし、このクラスの場合、以下の3つの変更理由が存在します。

  • calculatePayメソッドは、給与計算に関わるビジネスルールが変わる度に、変更を加える必要がある
  • reportHoursメソッドは、レポートのフォーマットが変わる度に、変更を加える必要がある
  • saveメソッドは、DBAがデータベーススキーマを変更する度に、変更を加える必要がある

3つも変更理由があるEmployeeクラスは、非常に不安定な存在と言えます。
3つの理由のうちいずれかがあれば変更されてしまうからです。
さらに問題なのは、Employeeに依存するクラスがすべて、Employeeの変更に影響されるということです。
単一責任の原則(SRP)を適用することで、リスクが局所化され、ソフトウェアの保守性を高くすることができます。

開放/閉鎖の原則(OCP)

次に、開放/閉鎖の原則(OCP)ですが、これは、
システムの構成要素は、
ソフトウェアの構成要素は拡張のために開いていて、修正のために閉じていなければならない
という原則です。
これは、ソフトウェアの構成要素に機能拡張が発生した場合、既存のコードには修正を加えずに(閉じている)、新しくコードを追加するだけで対応できる(開いている)ようにする、ということです。
※既存のコードは、バグがあった場合のみ修正する。
先の例で考えてみましょう。
Employeeクラスを単一責任の原則(SRP)に従って次のようにしたとしましょう。

public class Employee {
  public Money calculatePay(){、、、}
}

さて、給与計算ですが、従業員の就業日数や、携わっている役職の種類など様々な変数を使って、様々な方法で計算することができます。
なので、将来、条件によって給与計算方法を変えたくなった場合、条件によって計算方法を変えられるようにコードを修正する必要があります。

public class Employee {
  public Money calculatePay(条件){
    条件=Aの場合、、、、
    条件=Bの場合、、、、
}
}

さらに、新しい条件が追加されるされる都度(機能拡張の都度)、コードを修正する必要があります。

public class Employee {
  public Money calculatePay(条件){
    条件=Aの場合、、、、
    条件=Bの場合、、、、
    条件=Cの場合、、、、
    、、、、
}
}

このように機能拡張の都度、コードを修正しなければならない場合、修正によるリスクと、それを軽減するためのコストが発生します。
どうすれば良いのでしょうか。
開放/閉鎖の原則(OCP)では、修正によるリスクとコストを避けるため、一度、記述したコードの修正はエラー対応のみとし、機能拡張の場合、既存コードは修正せず、新たなコードを追加するようにするという立場をとります。
例えば、下図のように、既存のcalculatePayメソッドには手を入れず、それを利用したcalculatePayByA、calculatePayByBという新たなメソッドを追加します。

Employeeオブジェクトを利用するAppServiceは、必要に応じてcalculatePayByA、calculatePayByBなど拡張機能を利用すればよいわけです。
このように、開放/閉鎖の原則(OCP)を適用することで保守性を担保して機能拡張することができます。

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

次に、インターフェースという観点から考えてみましょう。
インターフェースとは、要素を利用する側に提供する仕様です。
メソッドでいうと、メソッド名、パラメータ、戻り値の型など、そのメソッドは「何をするのか(What)」を示す部分がインターフェース(メソッドのシグニチャといいます)で、それを「どう実現するのか(How)」であるメソッドの実装部分は含みません。
上図のモデルでは、calculatePayByA、calculatePayByBなど拡張されたメソッドのインターフェースが複数、AppServiceに公開されています。
なので、calculatePayByAを必要としているAppServiceAにとって、calculatePayByBやcalculatePayByCは余計なインターフェースということになります。
あるいは、calculatePayByBを必要としているAppServiceBにとって、calculatePayByAやcalculatePayByCは余計なインターフェースということになります。
利用者にとって不要なインターフェースに依存させてはいけない
という立場をとるのがインターフェース分離の原則(ISP)です。
なぜならば、利用者にとって不要なインターフェースがあると、それだけ余計なリスクとコストが発生し、保守性を落とすことになるからです。
ではどうするか。
例えば、下図のように、Employeeクラスを継承して、EmployeeAクラス、EmployeeBクラス、EmployeeCクラスを定義し、それぞれ、calculatePayByA、calculatePayByB、calculatePayByCメソッドを実装し、インターフェースを分離します。
この場合、親クラスであるEmployeeのcalculatePayメソッドが、子クラスであるEmployeeA、EmployeeB、EmployeeCから再利用されます。

AppServiceは、必要に応じてEmployeeAクラス、EmployeeBクラス、EmployeeCクラスのいずれかを利用します。
EmployeeAクラス、EmployeeBクラス、EmployeeCクラスは、AppServiceに対して必要なインターフェースしか提供しないので、無駄なリストとコストが発生することはありません。
このように、利用者にとって必要なインターフェースだけに限定することによって保守性を担保するのがインターフェース分離の原則(ISP)です。
つまり、余計な贅肉は削ぎ落としてスリムにしましょうということです。
それから、不要なインターフェースは、それを使う側が、使う必要があるのかどうか確認するためのコストを発生させます。
これは要素の複雑性を増加させ(見通しが悪くなる:透過性が落ちる)、要素の勝手を悪くさせ、結果的に要素の再利用性を落とす(再利用しにくくなる)ことになります。

リスコフの置換原則(LSP)

次に、リスコフの置換原則(LSP)について見ていきましょう。
リスコフの置換原則(LSP)は、
もし、SがTの派生型であれば、プログラム内でT型のオブジェクトが使われている箇所は全てS型のオブジェクトで置換可能にする
という原則です。
上図のモデルで考えてみましょう。
EmployeeAクラスを利用しているAppServiceでは、次のようにしてEmployeeAクラスを利用するでしょう。
Employee emp = new EmployeeA();
Money money = emp.calculatePayByA();
もし、このモデルがリスコフの置換原則(LSP)に則っているのであれば、EmployeeBもEmployeeの派生クラスなので、EmployeeAとEmployeeBが置換可能になっているはずです。
Employee emp = new EmployeeA();
のEmployeeAをEmployeeBに置換すると次のようになります。
Employee emp = new EmployeeB();
Money money = emp.calculatePayByA();
しかし、EmployeeBクラスは、calculatePayByAメソッドを持っていないのでエラーになります。
なので、次のようにcalculatePayByAメソッドをcalculatePayByBメソッドに切り替える必要があります。
Employee emp = new EmployeeB();
Money money = emp.calculatePayByB();
つまり、上図のモデルは、リスコフの置換原則(LSP)に則っていないということになります。
ではどうするか。
例えば、下図のように、EmployeeAクラス、EmployeeBクラス、EmployeeCクラスのインターフェースをすべてcalculatePayに統一します。

すると、以下のようにEmployeeAをEmployeeBで置き換えても、インターフェースの部分を切り替える必要がなくなります。
Employee emp = new EmployeeA();
置換→
Employee emp = new EmployeeB();
Money money = emp.calculatePay();
リスコフの置換原則(LSP)が適用されたわけです。
ここで、リスコフの置換原則(LSP)を適用することで、どのような効果があったか考えてみましょう。
リスコフの置換原則(LSP)を適用する前まで、AppServiceは、EmployeeAクラス、EmployeeBクラス、EmployeeCクラス、それぞれの独自インターフェースを意識する必要がありました。
しかし、リスコフの置換原則(LSP)を適用後、EmployeeAクラス、EmployeeBクラス、EmployeeCクラスのインターフェースが統一されたので、EmployeeAクラス、EmployeeBクラス、EmployeeCクラスの違いがなくなり、それらが置換可能になりました。
これは、AppServiceがEmployeeAクラス、EmployeeBクラス、EmployeeCクラスに依存する度合いが減ったことを意味します。
なので、将来、EmployeeDクラス、EmployeeEクラスなど機能を拡張しやすくなります。
つまり、リスコフの置換原則(LSP)を適用し、利用者に対するインターフェースを統一し置換可能にすることで拡張性を上げることができるのです。
ここで、もう少し考えてみましょう。
上図のモデルの場合、Employeeを継承してEmployeeAクラス、EmployeeBクラス、EmployeeCクラスが定義されています。
Employeeは、拡張される給与計算メソッドcalculatePayだけでなく、従業員のスキルや等級を取得するメソッドなども持っていると考えられます。
ということは、Employeeを継承してEmployeeEクラス、EmployeeDクラスを定義する都度、特に拡張する必要のないcalculatePay以外のメソッドも継承されることになります。
これは、拡張する必要のないメソッドの実装を増やすことになり、その分、間違った修正によるリスクや、それを検査するためのコストを上げ、保守性を下げることになります。
そこで、拡張されるべき給与計算メソッドcalculatePayだけに限定して拡張できるように考えます。
例えば、下図のように、PayCalculatorという給与計算を専門に担うクラスを定義し、給与計算処理を委譲するようにすればどうでしょうか。

このように、拡張部分をPayCalculatorに絞って修正箇所およびリスクを局所化することで、つまり部品化することで、さらに保守性を上げることができます。

依存性逆転の原則(DIP)

最後に、依存性逆転の原則(DIP)について見ていきましょう。
これは、
抽象(上位要素)は詳細(下位要素)に依存してはならない。両方とも抽象に依存すべきである
という原則です。
上図のモデルを見てみましょう。
Employeeが上位要素で、その部品であるPayCalculatorが下位要素だとすると、上位要素が下位要素に依存していることになります。
なので、PayCalculatorのcalculatePayを間違って修正した場合、それがEmployeeに影響します。
それでは、上位要素と下位要素を疎結合にし、その間の依存度を下げるにはどうすればよいでしょうか。
例えば、下図のように、PayCalculatorのcalculatePayから実装を除きインターフェースだけにし、実装は、インターフェースを実現するEmployeeAクラス、EmployeeBクラス、EmployeeCクラスに任せるようにすればどうでしょうか。

そうすると、PayCalculatorが抽象化されるので、EmployeeはPayCalculatorの実装に依存しなくなります。
さらに、インターフェースを実現するEmployeeAクラス、EmployeeBクラス、EmployeeCクラスも抽象化されたPayCalculatorの実装に依存しなくなります。
つまり、依存性逆転の原則(DIP)を適用することによって、上位要素と下位要素の結合度が下がったことになります。
上位要素と下位要素の結合度を下げることで、互いの依存度が下がるので、下位要素を拡張しやすくすることができるとともに、下位要素のモジュール性、再利用性を高めることができます。

以上、今回は、SOLIDについて解説しました。

-アプリケーション, システム開発, デザインパターン

執筆者:


  1. […] ソフトウェアの設計原則①SOLIDの原則 […]

  2. […] ソフトウェアの設計原則①:SOLIDの原則という記事で、変化に強いソフトウェアの代表的な特徴として以下をあげ、それを実現する、ソフトウェアの設計原則の一つ、SOLIDについて解説 […]

  3. […] ソフトウェアの設計原則①:SOLIDの原則という記事で、変化に強いソフトウェアの代表的な特徴 […]

  4. […] SOLIDの依存関係逆転の原則を適用した場合、次のようになります。 […]

  5. […] 依存関係、物理構造から論理構造への依存関係は、ソフトウェアの設計原則の一つ依存性逆転の原則(DIP)と同じ考え方です。 ここでは、MDAをさらに一般化して次のようにビジネスのレ […]

  6. […] ムは変える必要はないのです。 このシステムから業務への依存関係、物理構造から論理構造への依存関係は、ソフトウェアの設計原則の一つ依存性逆転の原則(DIP)と同じ考え方です。 […]

関連記事

クラス図を使った概念モデルの作り方

ここでは、UMLのクラス図を使& …

ドメイン駆動設計入門【DDDをわかりやすく解説】

突然ですが、エンジニアの&#30 …

オブジェクトの性質【同一性、等価性、不変性、参照透過性】

ここでは、オブジェクトの&#24 …

【実践!DX】基幹システムの構築

DX戦略の考え方という記事で&# …

変化に強いシステムを創る

ここでは、環境の変化に柔&#36 …