開発プロセス●●●○○

SOLID原則

SOLID Principles ・ そりっどげんそく

変更に強いオブジェクト指向設計のための5原則。クラス設計の判断基準を与える。

✦ 30秒で試す — 航海「正方形は長方形の一種」が、プログラムでは嘘になるstretch に正方形 3×3 を渡すと、返る面積は?予想してみる

概要

SOLID原則は、オブジェクト指向設計のための5つの原則 — 単一責任の原則(SRP)、開放閉鎖の原則(OCP)、リスコフの置換原則(LSP)、インターフェース分離の原則(ISP)、依存性逆転の原則(DIP)— の頭文字を並べたものです。Robert C. Martin(通称 Uncle Bob)が1990年代後半から論文や記事で発表し、2000年の論文「Design Principles and Design Patterns」などでまとめた原則群です。SOLID という頭字語は Martin 自身ではなく、Michael Feathers が2004年頃に5つの頭文字を並べ替えて名付けたもので、この語呂とともに広まりました。

これらはアルゴリズムでもツールでもなく、クラス設計の場面で「どちらの分け方が良いか」を判断するための基準です。共通のゴールはひとつ、「変更に強い」構造を作ること。つまり仕様変更が起きたとき、修正が1か所に収まり、既存の動くコードを壊さずに済む設計です。その意味で SOLID は、結合度と凝集度という古典的な物差しを、オブジェクト指向のクラスとインターフェースの言葉で実践指針に翻訳したものだと言えます。

なぜ生まれたか

1990年代、オブジェクト指向言語は広く普及しましたが、クラスを使えば自動的に良い設計になるわけではないことも明らかになっていました。継承の乱用や巨大クラスによって、あらゆる変更が広範囲に波及する「硬いソフトウェア」、直すたびに予想外の場所が壊れる「もろいソフトウェア」、一部だけ再利用したくても引きはがせない「動かせないソフトウェア」— Martin はこうした設計腐敗の症状を診断し、それを防ぐための処方箋として原則群を整理しました。

ポイントは、ソフトウェアが腐敗する原因を「変更」に見定めたことです。要求は必ず変わる。変わったときに設計が壊れないためには、変わりやすい部分と変わりにくい部分を分離し、依存の向きを制御する必要がある — 個々の原則はすべて、この一点から導かれています。

詳細

5つの原則

単一責任の原則(SRP: Single Responsibility Principle) — クラスを変更する理由は1つでなければならない、という原則です。ここでいう「理由」は変更を要求してくる関係者(アクター)のことで、経理の都合と現場の都合で同じクラスが書き換えられるなら分割すべき、と読みます。凝集度の高さをクラス単位で言い直した原則です。

開放閉鎖の原則(OCP: Open-Closed Principle) — ソフトウェアの構成要素は「拡張に対して開き、修正に対して閉じている」べきだという原則です。新しい振る舞いを追加するとき、既存のコードを書き換えるのではなく、抽象(インターフェース)の新しい実装を足すだけで済む構造を目指します。

リスコフの置換原則(LSP: Liskov Substitution Principle) — サブタイプは、基底型が使われている場所にそのまま差し替えても、プログラムの正しさを壊してはならないという原則です。継承関係は「is-a に見えるか」ではなく「基底型の契約(事前条件・事後条件)を守れるか」で判断します。有名な反例が「正方形は長方形を継承できるか」問題です。数学では正方形は長方形の一種ですが、「幅と高さを別々に変えられる」長方形を継承した正方形は、幅を変えられると高さも連動させて辺を等しく保つしかありません。すると、長方形を受け取って幅だけを変える関数は、正方形を渡された途端に期待と違う面積を得ます。型検査は通るのに、契約が破れているのです。

手で確かめてください。下の部品では、長方形と正方形のカードを、2つの関数の引数の口へ差し込んで実行できます。正方形を「幅だけ変える」関数に差したときと、「面積を読むだけ」の関数に差したときの違いに注目してください。

⚡ 体験: 長方形の口に、正方形を差し込む
stretch(r: Rectangle)幅だけ 5 に変えるprintArea(s: Rectangle)面積を読むだけ↑ カードを関数の口へドラッグすると実行される実行すると、ここに図形が出るテスト幅が 5 になる: 未実行幅が 5 になる高さは 3 のまま: 未実行高さは 3 のまま面積が 15: 未実行面積が 15Rectangle 4×3implements ShapeSquare 3×3extends Rectangle
正方形の作り
正方形の定義と、呼び出し側の期待
  1. class Square extends Rectangle {
  2. setWidth(w) { this.w = w; this.h = w } // 辺を等しく保つ
  3. }
  4. function stretch(r: Rectangle) { const h = r.height; r.setWidth(5); assert(r.height === h) }

下の2枚のカードを、上に並ぶ関数の引数の口へドラッグして差し込むと、その関数が実行されます。まず長方形を「幅だけ変える」関数 stretch へ。

読むだけの関数では正方形も問題なく動いたように、LSP の違反はクラスの形だけでは決まらず、基底型を使う側が当てにしている契約(ここでは「幅を変えても高さは変わらない」という事後条件)で決まります。解決策は、正方形を長方形の子にするのをやめ、共通の性質(面積を持つ)だけを抽象として括り出すことです。そうすれば、誤った差し込みは実行前に型検査で止まります。

インターフェース分離の原則(ISP: Interface Segregation Principle) — 利用者に、使わないメソッドへの依存を強制してはならないという原則です。何でもできる太いインターフェースは、利用者を無関係な変更に巻き込みます。利用者ごとの小さなインターフェースに分割します。

依存性逆転の原則(DIP: Dependency Inversion Principle) — 上位モジュール(業務ロジック)は下位モジュール(DB アクセスやメール送信などの詳細)に依存してはならず、双方が抽象に依存すべきだという原則です。ソースコード上の依存の向きを、処理の流れとは逆向き(詳細→抽象)にできることが、オブジェクト指向の核心的な力です。そして、上位モジュールが抽象だけを知った状態で、実物(どの詳細を使うか)を外から渡して組み立てる実現手段が依存性注入です。

適用前適用後上位モジュール業務ロジック下位モジュールDB・メール送信などの詳細詳細の変更が業務ロジックまで波及する上位モジュール業務ロジック抽象インターフェース下位モジュール詳細は抽象の実装として従う矢印が逆転
依存性逆転の原則 — 抽象を挟むことで、依存の矢印が詳細の側から抽象へ向かうように逆転する

「変更に強い」とは何か

5原則がそれぞれ守っているものを並べると、共通の構図が見えてきます。SRP と ISP は「変更理由の異なるものを同居させない」— つまり高凝集を守ります。OCP・LSP・DIP は「変わりやすい詳細への依存を、安定した抽象への依存に置き換える」— つまり疎結合を守ります。合わせると、変更が起きたとき修正すべき場所が1つに定まり、その修正が抽象という防火壁で堰き止められる構造になります。

この構造はテストのしやすさとも直結します。抽象に依存するクラスは、テスト時に実装を偽物(モック)に差し替えられるからです。DIP を実際のコードで実現する定番の手法が依存性注入であり、デザインパターンの多く(Strategy、Observer、Template Method など)は SOLID を特定の場面に適用した具体例と読むことができます。

原則の暴走 — 過剰抽象化という落とし穴

SOLID の最大の落とし穴は、原則を「常に従うべきルール」と誤読することです。実装が1つしかないのに全クラスにインターフェースを用意する、起きるかどうか分からない変更に備えて抽象層を重ねる — こうした過剰抽象化は、間接参照だらけでコードを追えなくし、かえって変更を難しくします。抽象は1枚挟むごとに読解コストがかかる投資であり、変更が実際に見込まれる軸にだけ張るべきものです。

実務的な使い方は、最初から完璧な抽象を設計することではなく、変更要求が来て設計の痛み(同じ修正の繰り返し、波及する差分)を感じたときに、SOLID を診断名として使い、リファクタリングで構造を直すことです。原則は設計の完成図ではなく、コードレビューや設計判断の場で「なぜこの分け方が良いのか」を説明するための共通言語だと捉えるのが健全です。

試問

長方形と正方形のカードを、関数の引数の口(受け取る型が書いてある)へドラッグすると、その関数が実行されます。stretch は長方形を受け取って幅だけを5に変え、「高さは変わらない」ことを当てにします。printArea は面積を読むだけです。いまの正方形は Rectangle(長方形)を継承していて、正方形の作り方は下で切り替えられます。

✦ 命題挑戦中

正方形が、面積を読むだけの printArea では使えて、幅だけ変える stretch には実行する前に弾かれるようにせよ

  • 正方形を読むだけの printArea に差すと、正しく動く
  • 正方形を幅だけ変える stretch に差そうとすると、実行する前に弾かれる
⚡ 体験: 長方形の口に、正方形を差し込む
stretch(r: Rectangle)幅だけ 5 に変えるprintArea(s: Rectangle)面積を読むだけ↑ カードを関数の口へドラッグすると実行される実行すると、ここに図形が出るテスト幅が 5 になる: 未実行幅が 5 になる高さは 3 のまま: 未実行高さは 3 のまま面積が 15: 未実行面積が 15Rectangle 4×3implements ShapeSquare 3×3extends Rectangle
正方形の作り
正方形の定義と、呼び出し側の期待
  1. class Square extends Rectangle {
  2. setWidth(w) { this.w = w; this.h = w } // 辺を等しく保つ
  3. }
  4. function stretch(r: Rectangle) { const h = r.height; r.setWidth(5); assert(r.height === h) }

下の2枚のカードを、上に並ぶ関数の引数の口へドラッグして差し込むと、その関数が実行されます。まず長方形を「幅だけ変える」関数 stretch へ。