ソフトウェア設計

[SOLID #1] SOLIDの5文字を暗記せず理解する(SRP・OCP・LSP)

面接で「SOLID原則を説明してください」と聞かれたことは、皆さん一度はあるのではないでしょうか。

読了 4 分
[SOLID #1] SOLIDの5文字を暗記せず理解する(SRP・OCP・LSP)のカバー画像

面接で「SOLID原則を説明してください」と聞かれたことは、皆さん一度はあるのではないでしょうか。

しかし正直なところ、5つを順番に暗唱して答えられても、「では自分のコードにどう使うのか」まで話せる人は多くありません。

今回はSOLIDを教科書的な定義ではなく、実務の場面に置き換えて説明します。分量が多いため、2回に分けます。今日は前半の3文字、SRP・OCP・LSPです。

SOLIDは、Robert Martin(Uncle Bob)が整理したオブジェクト指向設計の5原則の頭文字です。

  • SRP — 単一責任の原則
  • OCP — 開放・閉鎖の原則
  • LSP — リスコフの置換原則
  • ISP — インターフェース分離の原則
  • DIP — 依存性逆転の原則

5つはそれぞれ別の話に見えますが、共通するメッセージは1つです。変更が起きたとき、修正が必要なコードの範囲を小さくすることです。


SRP — 単一責任の原則

教科書的な定義は「クラスはただ1つの責務だけを持つべき」ですが、Uncle Bob自身が後に、よりよい表現へ言い換えました。

モジュールは、ただ1つのアクターに対してのみ責務を負うべきである。

ここでいうアクターとは、そのコードの変更を要求する人や部署です。たとえば、次のようなクラスがあるとします。

class Employee {
    func calculatePay() { ... }   // 経理チームから変更依頼
    func reportHours() { ... }    // 人事チームから変更依頼
    func save() { ... }           // 開発チーム(DBA)から変更依頼
}

3つのメソッドは、それぞれ所有者が異なります。経理チームの要求でcalculatePayを修正する際に共通ロジックへ触れると、人事チームのレポートがひそかに間違ってしまいます。実際にこうした事故が起きると、原因を見つけるのも困難です。

SRPはこれを「変更を求める人が異なるなら、コードも分離する」と整理してくれます。給与計算、勤務レポート、保存をそれぞれ別のクラスに分ければ、経理チームの要求が人事機能に影響することはありません。


OCP — 開放・閉鎖の原則

定義は「拡張に対して開かれ、修正に対して閉じていなければならない」です。最初は禅問答のように聞こえますが、コードで見ると明快です。

// 決済手段が増えるたびにこの関数を修正する必要があるなら
func pay(method: String, amount: Int) {
    if method == "card" { ... }
    else if method == "kakaopay" { ... }
    else if method == "naverpay" { ... }
    // 新しい決済手段 =  else if = 既存コードを修正
}

この構造では、Toss Payを追加するために、正常に動いている関数を開いて修正しなければなりません。修正のたびに、既存の決済手段を壊すリスクを負うことになります。

// 新しい決済手段を「追加」するだけで済む構造
let payments: [String: Payment] = [
    "card": CardPayment(),
    "kakaopay": KakaoPayment(),
    "naverpay": NaverPayment(),
    // Toss Payの追加 = 新しいクラスを登録する1行
]

func pay(method: String, amount: Int) {
    payments[method]?.process(amount: amount)
}

既存コードには触れず(修正に対して閉じ)、新しいクラスを追加するだけで機能が増えます(拡張に対して開く)。頻繁に変わる箇所にだけこの構造を用意するのがコツです。すべてのコードに適用すると、前回説明したYAGNI違反になります。

Toss Payの追加はクラスを1つ足すだけで完了
Toss Payの追加はクラスを1つ足すだけで完了
修正ではなく追加で対応するのがOCPです
修正ではなく追加で対応するのがOCPです

LSP — リスコフの置換原則

名前は最も恐ろしく見えますが、意味は簡単です。

子クラスは、親クラスが使われる場所にそのまま置き換えても、プログラムを壊してはなりません。

有名な反例が、長方形と正方形の問題です。「正方形は長方形である」という数学的常識どおりに継承すると、次のようなことが起きます。

class Rectangle {
    var width = 0
    var height = 0
    func setWidth(_ w: Int) { width = w }
    func setHeight(_ h: Int) { height = h }
}

class Square: Rectangle {
    override func setWidth(_ w: Int) { width = w; height = w }  // 正方形だから
    override func setHeight(_ h: Int) { width = h; height = h }
}

// 長方形を期待するコード
func test(_ rect: Rectangle) {
    rect.setWidth(5)
    rect.setHeight(4)
    print(rect.width * rect.height) // 20を期待するが Squareなら 16
}

Squareを入れた瞬間、「幅5、高さ4なら面積20」という当然の期待が崩れます。is-a関係が成り立っているように見えても、振る舞いの契約を壊すなら継承してはいけない、というのがLSPです。

実務上のサインはこれです。子クラスで親メソッドをオーバーライドし、例外を投げたり空実装にしたりしているなら、その継承はLSP違反である可能性が高いでしょう。

正方形は長方形の場所には入れないんですね
正方形は長方形の場所には入れないんですね

前編を終えて

今日扱った3つの原則を、1行ずつ残しておきます。

  • SRP — 変更を求める人が異なるなら、コードも分離する。
  • OCP — 頻繁に変わる箇所は、修正ではなく追加で対応できるようにする。
  • LSP — 子クラスは親クラスの契約を壊さない。壊す必要があるなら、継承が間違っている。

次回は残りの2文字、ISP(インターフェース分離)とDIP(依存性逆転)を扱います。特にDIPは、最近のフレームワークがなぜどれも依存性注入を使うのかと直接つながる話なので、きっと面白いはずです。