SOLID 原則を再び知る
ソフトウェアシステムを構築する際、SOLID 原則と呼ばれる設計の原則が挙げられることがある。この原則は、関数やデータ構造をどのようにクラス内に組み込むかであったり、それぞれがどのような関係で接続されるかを教えてくれる。
私自身 SOLID 原則は雰囲気だけで把握していたため、Clean Architecture 達人に学ぶソフトウェアの構造と設計
を読み学んだ内容をまとめる。
ちなみにコード例はインターフェースが出てくるので Java で書いているが、あまりなじみのない言語なので間違っている点があれば教えていただきたい。
SOLID とは
SOLID 原則は以下の 5 つの要素から構成される。
・単一責任の原則 (Single Responsibility Principle)
アクター(変更を加えたいステークホルダー)から見て、モジュールは単一の責務を負うべき
・オープン・クローズドの原則 (Open-Closed Principle)
既存の成果物を変更せずとも、新しいコードの追加によりシステムの拡張・変更できるような設計を行う
・リスコフの置換原則 (Liskov SubStitution Principle)
ポリモーフィズムにおいて、派生したパーツはそれぞれが交換可能となるような契約に従わなければならない
・インターフェース分離の原則 (Interface Segregation Principle)
使用しないものへの依存を回避し、インターフェースを最小限にする
・依存関係逆転の原則 (Dependency Inversion Principle)
上位レベルの概念は、下位レベルのコードに依存すべきではなく、詳細側が方針に依存すべき
単一責任の原則(SRP)
単一責任の原則は、「どのモジュールもたったひとつのことを行うべき」と受け取られがちだが、必ずしもそうではない。
巨大な関数を小さな関数に分割していく際、「ひとつの関数はたった一つのことを行うべき」という原則を適用する場合があるが、これは最下位のレベルのケースで、単一責任の原則とは別である。
この原則が意味するところは、
モジュールはたった一つのアクターに対して責務を負うべきである
という事である。
ここでいう「アクター」は、ソフトウェアを変更して満足させるべきユーザーやステークホルダーである。「モジュール」は、ソースファイルであり、クラスなどの凝集性のあるものだ。
例
Clean Architecture 本では、Employee
クラスを例にこの原則を説明している。
Employee
クラスでは、CFO、COO、CTO が利用するメソッドが存在する。このうちcalculatePay
とreportHours
は所定労働時間を算出するので、regularHours
に切り出した。
public class Employee {
public float calculatePay() {
// CFOが利用する
float hour = regularHours()
}
public float reportHours() {
// COOが利用する
float hour = regularHours()
}
public void save() {
// CTOが利用する
}
private float regularHours() {
// 所定労働時間を算出するメソッド
}
}
ここで、CFO チームでの所定労働時間の算出方法に手を加える必要が出てきたとする。COO チームでは変更を加える必要がない。
開発者はregularHours
に手を加え要件を満たしたが、reportHours
でもregularHours
が利用されていることに気が付かず、COO チームで確認できるデータにバグが生じてしまった。
この問題の原因には、別々のアクター(CFO, COO)のコードを一つにまとめてしまったことにある。単一責任の原則では、アクターの異なるコード、モジュールは分割すべき
ということを説明している。
オープン・クローズドの原則(OCP)
オープン・クローズドの原則とは、拡張に対して開いていて、修正に対して閉じていなければならない
という原則である。ちょっとした拡張のために大量の書き換えが必要になるようなシステムにならないよう、この原則が重要になる。
例
以下の例では、データを保存する処理を記述している。
public class SaveData {
public void save() {
new TextFile().save("Write to txt File")
}
}
このサンプルでは、TextFile
クラスのsave
メソッドを呼び出して保存しているが、SaveData クラスが TextFile クラスに依存してしまっている。
そのため、保存先を HTML ファイルや md ファイル、DB に保存しようとすると、save
メソッドを大きく書き換えていくことになるだろう。
オープン・クローズドの原則にあてはめた実装に変更していく。
Store
インターフェースを実装し、SaveData
クラスはインターフェースのsave
を呼び出すように変更する。
public interface Store {
void write(String str);
}
public class SaveData {
// Storeを実装しているクラスなら
// 変更を加える必要なく保存先を変更できる
public void save(Store store) {
store.save("Write to Store")
}
}
public class TextFile implements Store {
public void save(String str) {
// txtファイルに書き込み
}
}
こうしておけば、Store インターフェースを実装したクラスを追加することで保存先の変更が出来るようになった。
public class DataBase implements Store {
public void save(String str) {
// DBに書き込み
}
}
public class HTMLFile implements Store {
public void save(String str) {
// htmlファイルに書き込み
}
}
このように、モジュールの振る舞いを拡張でき、振る舞いの変更を行っても既存の部分には影響を与えない
ことをオープン・クローズドの原則と呼ぶ。
リスコフの置換原則(LSP)
リスコフの置換原則は、
SがTの派生型であれば、T型のオブジェクトo1を使って定義されたプログラムに対して、o1は全てS型のオブジェクトo2に置換できる
という事である。
逆に言うと、該当のオブジェクトが派生型と言うためには、利用されているプログラム中で全て置換可能でなくてはならないという事である。
例
- リスコフの置換原則を満たす例
このサンプルは、リスコフの置換原則を満たす。Billing
アプリケーションは二つの派生型、PersonalLicense
,BusinessLicense
に依存していないためである。どちらの派生型もLicense
型に置き換えられる。
public class Billing {
public float calculate(License license) {
return license.calcFee()
}
}
public interface License {
float calcFee();
}
public class PersonalLicense implements License {
public float calcFee() {
// 独自の計算アルゴリズム
}
}
public class BusinessLicense implements License {
public float calcFee() {
// 独自の計算アルゴリズム
}
}
- リスコフの置換原則を満たさない例
Clean Architecture 本では、リスコフの置換原則違反の例として、正方形・長方形問題を挙げている。
public class User {
public float calcluate() {
Rectangle r = //...
r.setW(5);
r.setH(2);
assert(r.area() == 10);
}
}
public class Rectangle {
// 長方形クラス
// 高さ・幅をそれぞれを独立して変更できる
public void setH(Integer height) {
// ...
}
public void setW(Integer Width) {
// ...
}
public float area() {
// ...
}
}
public class Square extends Rectangle {
// 正方形クラス
// 高さ、幅は同時に変わる
public void setSide(Integer number) {
// ...
}
}
このサンプルでは、User
はRectangle
(長方形)を期待しているが、r
の部分でSquare
(正方形)のインスタンスを作っていると、最後のアサーションは失敗するだろう。
Rectangle
は幅をそれぞれ変更できるが、Square
は高さと幅は常に同じなためである。(もちろん、アサーションの結果は実装による)
Rectangle
に置換可能ではないSquare
はRectangle
の正しい派生形とは言えない。この例から、リスコフの置換原則は継承の使い方の指針となることがわかるだろう。
インターフェース分離の原則(ISP)
インターフェース分離の原則の由来は、以下のようなサンプルで表される。
public class User1 {
public void main() {
new OPS().op1();
}
}
public class User2 {
public void main() {
new OPS().op2();
}
}
public class User3 {
public void main() {
new OPS().op3();
}
}
public class OPS {
public void op1() {
// User1 が利用
}
public void op2() {
// User2 が利用
}
public void op3() {
// User3 が利用
}
}
このサンプルでは、User1 は実際には使っていない op2 と op3 に意図せず依存している。
ここで言う依存は、op2 のコードを変更した際に、User1 の再コンパイルと再デプロイが必要になるという意味である。
この問題を解決するには、インターフェースに分離すればよい。そうすれば、意図しない依存を避けることができる。
public interface U1Ops {
public void op1();
}
public class User1 {
public void main(U1Ops ops) {
new ops().op1();
}
}
public class OPS implements U1Ops, U2Ops, U3Ops {
// 略
}
ただし、この原則はプログラミング言語に依存するところが大きい。Ruby や Python のような動的型付け言語では、再コンパイルや再デプロイを矯正するソースコードの依存性は存在しない。
アーキテクチャとの関係
上記の例はソースコード上での依存関係を示していたが、アーキテクチャレベルでもこの原則は適用され得る。
システム S を構築するにあたり、フレームワーク F を導入したいと考えた。この時、F の作者は特定のデータベース D のためだけに作っている。つまり S は F に依存し、F は D に依存している。
システムS -> フレームワークF -> データベースD
D の一部機能に何かしらの変更があると、F を再デプロイする必要がある。それはすなわち、S も再デプロイしなければならない。
さらに悪いことに、D の一部機能に障害が発生した場合、それが F や S の障害の原因となり得る。
無駄な依存を抱えていると、予期せぬトラブルに繋がるということである。
依存関係逆転の原則(DIP)
この原則が伝えている事は、「ソースコードの依存関係が(具象ではなく)抽象だけを参照しているものが、最も柔軟なシステムである。」という事である。
ソースコード上ではimport
,use
などで指定する参照先を、インターフェイスや抽象クラスなどのソースモジュールに限定し、なるべく具象モジュールを参照しないという事である。
安定したソフトウェアアーキテクチャは、変化しやすい具象への依存を避け、安定した抽象インターフェイスに依存すべきである。具体的には、
-
変化しやすい具象クラスを参照しない。
その代わり、抽象インターフェイスを参照する。オブジェクトの生成時にも大きな制約となり、一般的には、Abstract Factory パターンを利用する。 -
変化しやすい具象クラスを継承しない。
上のルールにも含まれる内容だが、継承も一種の依存関係である。 -
具象関数をオーバーライドしない。
依存管理のため、元の関数を抽象関数にして、それに対する複数の実装を用意しなければならない。 -
変化しやすい具象を名指しで参照しない。
Abstract Factory パターン
上記のルールに従おうとする際、具象オブジェクトの生成時に特別な処理が必要となってしまう。この望まない依存性を管理するため、Abstract Factoryパターン
を利用する。
public class Application {
public void run(ServiceFactory factory) {
// ConcreteImpl を使いたいが依存したくない
factory.makeSvc()
}
}
public interface Service {}
public interface ServiceFactory {
public Service makeSvc();
}
// ↑ 抽象
// ---- アーキテクチャの境界 ----
// ↓ 具象
public class ServiceFactoryImpl implements ServiceFactory {
public Service makeSvc() {
// 実際にサービスを作成する実装
return new ConcreteImpl();
}
}
public class ConcreteImpl implements Service {
// 実際のサービス
}
このパターンでは、具象と抽象に区切りができる。この境界を横切るソースコードの依存性は、全て具象側から抽象側(各インターフェース)へと向かっている。
抽象コンポーネントには、上位レベルのビジネスルールが含まれ、具象コンポーネントには、これらのビジネスルールが操作する実装の詳細が含まれる。
ソースコードの依存性と、処理の流れが逆向きになる。これが、「依存関係逆転の原則」と呼ばれる理由である。
例外
例外的に、依存関係逆転の原則を満たさない具象コンポーネントが少なくとも一つ存在する事になる。このコンポーネントはmain
コンポーネントと呼ばれることが多い。上記の例では、おそらくmain
関数がServiceFactoryImpl
インスタンスを生成し、Application
に渡すことになる。