SOLID原則:パッと見で分かる(思い出せる)よう図解しとく


はじめに

今更ながらSOLID原則についてちゃんと押さえておくべく。(日頃無意識でやれてる所とやれてない所あると思い)

文章とかコード書いといても、後で見返した時に”読まないといけない”のは個人的に辛いので、パッと見で思い出せるように簡単な例をクラス図で描いておきたかったので記す。

SOLID原則

保守性を高めて管理しやすいコードを書くための知見と言う名の原則

S (SRP:単一責任の原則)

  • クラス(やモジュールやメソッド等)に任せる仕事(負わせる責任)は1つに限定すべし
    • 単一の相手(アクター)に対する責任だけ負わせる(ように適切に分割)
    • それにより、相手(アクター)に対して露出する知識を適切に絞り、変更時の影響範囲を最小限に抑える
    • 絞ることで、仕様変更によりクラスを修正する際、変更理由は常に同じになる(1つに限定される)
    • 変更理由が2つ以上になるなら機能を分けるべし

Example1

※この原則で言いたいことと多少ずれているが大きく外れているわけでもないためあえて残す

  • とあるサービスのアクターとアクターが行える操作が
    • ユーザ(サービス利用者)
      • データの参照:readData
    • オペレータ(サービス運用者)
      • データの参照:readData
      • データの更新:updateData

だったとして
※あくまで登場人物と操作にのみ着目。それ以外も意識すると簡潔な例で済まないので除外

NG例

  • ユーザに updateData は許可してないのに、外から見たら許容してるように見える
  • ユーザの情報管理、オペレータの情報管理、データ操作 と複数の仕事を担う = 変更理由も対応して複数になる

OK例

  • (Utilレベルの共通コードを除いて)単一の相手(アクター)に対する責任に

Example2

  • アクター:各アクターが使用するメソッド
    • 個人:calculateAmount
    • 企業/組織:report
    • 管理者:fixedAmount

だったとして

NG例

  • アルゴリズムの共有はNG
    • report()から行われる一連の処理を修正する必要がなく、calculateAmout()から行われる一連の処理修正のために、calculateHour()を修正すると、report()に影響が出る(NG)

OK例1

Facadeパターンでアクター別に処理を分けつつ委譲する。

OK例2

O (OCP:オープン/クローズドの原則)

主に機能追加に関する原則

  • 機能追加の為に(クラス等の)責任を修正すべきではない(クローズド)
  • 機能追加を(クラス等の)修正で行うのではなく拡張で行うべき(オープン)

Example

  • 修正前:csvファイルの読み書きのみ行っていた
  • 修正後:jsonファイルの読み書きもできるようにしたい

修正前が以下として

NG例

OK例

※他原則を考慮すると、実はこれでOKとは言い難いので
  ⇒ 良い例: 「D (DIP:依存性逆転の原則)」参照されたし

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

  • とあるクラスの派生型であるサブクラス(もしくはインターフェースの実装クラス)を使用する際、型はスーパークラス(もしくはインターフェース)で置換可能でなければならない。
    • 継承したクラスは、継承元クラスと同じ動作をしなければならない
  • サブクラス(もしくはインターフェース実装クラス)の実体を使う側では意識するような作り込みはNG
    • コード上でクラスの型チェックをしているなら、この原則に違反している

Example1

NG例

継承したサブクラスと継承クラスでメソッドの戻り値の型が異なるとか。
(静的型付けだと、そもそも起きないが)

参考記事参照されたし:SOLID原則について簡単に書く - リスコフの置換原則

Example2

NG例

function test() {
  const processor = new Printer();
  processor.run();
  processor.print();
}

↓ 型をスーパークラスで置換できない

function test() {
  const processor: Processor = new Printer();
  processor.run();
  // processor.print(); エラーになる

  // 以下のように無理やり変更できるが、OCP違反にもなる
  if (processor instanceof Printer) {
    processor.print();
  }
}

だからと言って、サブクラスが持つメソッドをスーパークラスに持たせるのもNG。次節のISPに違反する

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

ここでのインタフェース:抽象クラス、基底クラス、ダックタイピング的なものを全て含めたもの

  • インターフェースを複雑にしない(1つのインターフェースには最小限のものだけを定義)
  • 使わないもの(不要なメソッド等)に依存することを強いてはならない、分離できるものは分離すべし

Example

(LSP違反でもあるけど)以下のようなものがあって、これを正しく修正するならば

NG例

OK例

補足:

  • 共通処理の扱いには注意されたし、継承にすべきか委譲にすべきか

継承と委譲の使い分けと、インターフェースの重要性について

D (DIP:依存性逆転の原則)

  • 抽象化されたもの(インターフェース等)に依存させ、詳細(実装)に依存させるべからず
  • 上記を意識し依存関係を考えて実装しようとすると大体逆方向っぽく見えるようになる

望ましい依存

  • 双方向依存しない
  • 依存関係が循環しない
  • 必要以上の情報(詳細)を受け取らず(抽象に対して依存させ)疎結合にする

実現方法

  • FactoryMethodパターン
  • DI etc.

Example1

FactoryMethodパターン

before

after

Example2

DI

before

after

まとめ

不要な知識の漏洩は密結合の主原因

  • S:1つの役割だけにすることで、持たせる(露出する)知識を限定し
  • O:機能追加時は既に持たせている知識を破壊せず、拡張することで元々知識を使っていた側に影響を与えず
  • L:使う側には詳細(サブクラスやインターフェースの実装クラス)の知識には触れさせず、知識の露出を抑え
  • I:インターフェース(抽象クラス、基底クラス等を含む)には利用用途に応じた最小限のものだけ定義し、不要な知識を持たせず露出せず
  • D:詳細(サブクラスやインターフェースの実装クラス)には依存させず、抽象(インターフェース)に依存させ、蜜結合を避けることで、複雑さを下げる

抽象に依存させると、コードが動いた時に使われる実態はどのクラスのオブジェクトか(初見だと特に)掴みにくいというデメリットはあると思うが、
(初見でも)コード読み込んでいけば把握できるし、その後の改修等を考えると、抽象と詳細どちらに依存させるのが良いかはきっと自明だろう。

改修/拡張のしやすいコードを書く者足れ

参考文献