コーヒーを題材にSOLID原則を学ぶ

2024/07/05に公開

SOLID原則とは

オブジェクト指向プログラミングにおいて、簡素かつ柔軟に保守することを目的とした設計原則の1つである。
「Clean Architecture 達人に学ぶソフトウェアの構造と設計」などの著者として知られるロバート・C. マーチンが提唱した数々の設計原則の中から、ミカエル・フェザーズが5原則の頭文字を取りSOLID原則として普及させた。

5つの原則

  • S : Single responsibility principle(単一責任の原則)
  • O : Open/closed principle(オープン・クローズドの原則)
  • L : Liskov substitution principle(リスコフの置換原則)
  • I : Interface segregation principle(インターフェース分離の原則)
  • D : Dependency inversion principle(依存関係逆転の原則)

単語だけ聞いて具体的なイメージが湧かないので、身近なコーヒーを題材に例えてみました。
コード例をkotlinベースに記載していますが、便宜上省略している箇所があるため、そのまま実行できません。

単一責任の原則

「クラスを変更する理由は、ひとつだけであるべきである」

プログラミングに関する原則であり、モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきである。モジュール、クラスまたは関数が提供するサービスは、その責任と一致している必要がある[1]

https://ja.wikipedia.org/wiki/単一責任の原則

悪いコード

CoffeeProcessorクラスにおいて、収穫と焙煎のメソッドを持っています。
もし、収穫や焙煎の手法が変わればこのクラスを修正しなければならず、他の責任に影響を与えます。

// コーヒーの製造するクラス
class CoffeeProcessor() {
    fun harvest(): String{
        return "収穫する"
    }
    fun roast(): {
        return "焙煎する"
    }
}

良いコード

  • 単一の責任: 収穫方法が変わればCoffeeHarvestを、焙煎方法が変わればCoffeeRoastを変更するため影響箇所が限定的になります。
// コーヒーの収穫を担当するクラス
class CoffeeHarvest() {
    fun harvest(): {
        return "収穫する"
    }
}

// コーヒーの焙煎を担当するクラス
class CoffeeRoast() {
    fun roast(): {
        return "焙煎する"
    }
}

オープン・クローズドの原則

ソフトウェア要素(クラスモジュール関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。

https://ja.wikipedia.org/wiki/開放/閉鎖原則

悪いコード

CoffeeMachineクラスが異なる種類のコーヒーを作るためのロジックをすべて持っています。新しいコーヒーの種類を追加するたびに、クラスを修正する必要があります。

class CoffeeMachine {
    fun brew(coffeeType: String): String {
        return when (coffeeType) {
            "Espresso" -> "高圧・短時間で抽出したコーヒー"
            "Latte" -> "エスプレッソ+牛乳"
            "Cappuccino" -> "エスプレッソ+泡立てた牛乳"
        }
    }
}

良いコード

新しいコーヒの種類を増やしたい場合

  • 拡張に対して開いている: Coffeeインターフェースを作成し、それを実装することで新しいコーヒーの種類を追加できます。
  • 修正に対して閉じている: CoffeeMachineクラスは変更せずに、新しいコーヒーの種類を追加できるように設計されています。
// コーヒーの共通インターフェース
interface Coffee {
    fun brew(): String
}

// 各コーヒーの種類ごとのクラス
class Espresso : Coffee {
    override fun brew(): String {
        return "高圧・短時間で抽出したコーヒー"
    }
}

class Latte : Coffee {
    override fun brew(): String {
        return "エスプレッソ+牛乳"
    }
}

class Cappuccino : Coffee {
    override fun brew(): String {
        return "エスプレッソ+泡立てた牛乳"
    }
}

// コーヒーマシンはコーヒーの種類に依存しない
class CoffeeMachine {
    fun brewCoffee(coffee: Coffee): String {
        return coffee.brew()
    }
}

リスコフの置換原則

サブタイプのオブジェクトはスーパータイプのオブジェクトの仕様に従わなければならない

https://ja.wikipedia.org/wiki/リスコフの置換原則

基底クラス(親クラス)のオブジェクトは派生クラス(子クラス)のオブジェクトに置き換えても、プログラムの正しさが保たれるべきであるという原則です。

悪いコード

バナナジュースは具材を挽いたり、抽出して作りません。
Coffee クラスを継承したMixJuice クラスは親子関係で振る舞いが異なるため、原則に違反しています。

open class Coffee {
    open fun grind(): String {
        return "豆を挽く"
    }

    open fun brew(): String {
        return "コーヒーを抽出する"
    }
}

class BananaJuice : Coffee() {
    override fun grind(): String {
        return "存在しない振る舞い" 
    }
    override fun brew(): String {
	    return "存在しない振る舞い"
    }
}

良いコード

  • リスコフの置換原則を守る: Coffee クラスを継承したEspresso クラスはgrind メソッドやbrew メソッドを適切にオーバーライドしています。
open class Coffee {
    open fun grind(): String {
        return "豆を挽く"
    }
		
    open fun brew(): String {
        return "コーヒーを抽出する"
    }
}

class Espresso : Coffee() {
    override fun grind(): String {
        return "豆を挽く"
    }
		
    override fun brew(): String {
        return "コーヒーを抽出する"
    }
}

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

he interface segregation principle (ISP) states that no code should be forced to depend on methods it does not use.[1] ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.

https://en.wikipedia.org/wiki/Interface_segregation_principle

英語版しかなかったので、補足説明

自分が利用するインターフェースにのみ依存するべきという原則です。大きなインターフェースを複数の小さなインターフェースに分割することで、特定のモジュールが必要とするメソッドのみを含むインターフェースを作成してください。

悪いコード

インターフェースが大きすぎて、Coffee クラスを継承したEspressoクラスが必要ないメソッドを持つ必要があります。

// コーヒーの共通インターフェース
interface Coffee {
    fun grind(): String
    fun brew(): String
    fun pourMilk(): String
}

class Latte : Coffee {
    override fun grind(): String {
        return "豆を挽く"
    }
		
    override fun brew(): String {
        return "コーヒーを抽出する"
    }
    
    override fun pourMilk(): String {
        return "ミルクを入れる"
    }
}

class Espresso : Coffee {
    override fun grind(): String {
        return "豆を挽く"
    }
		
    override fun brew(): String {
        return "コーヒーを抽出する"
    }
    
    override fun pourMilk(): String {
        return "不必要"
    }
}

良いコード

  • 必要なインターフェースにのみ依存: インターフェースを分けることにより、Espresso クラスは不要なメソッドを実装する必要がありません。
// コーヒーの共通インターフェース
interface Coffee {
    fun grind(): String
    fun brew(): String
}

// 牛乳インターフェースを分ける
interface Milk {
    fun pourMilk(): String
}

class Latte : Coffee, Milk {
    override fun grind(): String {
        return "豆を挽く"
    }
		
    override fun brew(): String {
        return "コーヒーを抽出する"
    }
    
    override fun pourMilk(): String {
        return "ミルクを入れる"
    }
}

class Espresso : Coffee {
    override fun grind(): String {
        return "豆を挽く"
    }
		
    override fun brew(): String {
        return "コーヒーを抽出する"
    }
}

依存関係逆転の原則

  1. 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
  2. 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。

https://ja.wikipedia.org/wiki/依存性逆転の原則

悪いコード

上位モジュールCoffeeMachine クラスが下位モジュールであるEspressoMachine クラスに依存しています。他のコーヒーの種類を追加すると、CoffeeMachine クラスに影響が出てしまいます。

class EspressoMachine {
    fun brewEspresso(): String {
        return "エスプレッソを抽出する"
    }
}

class CoffeeMachine {
    private val espressoMachine = EspressoMachine()

    fun makeCoffee(): String {
        return espressoMachine.brewEspresso()
    }
}

良いコード

  • 上位モジュール が下位モジュールに依存しない: CoffeeMachineクラスが抽象化されたインターフェース CoffeeMaker に依存しています。
  • 柔軟性と再利用性の向上: 新しい種類のコーヒーマシンを追加する場合、既存のコードに影響を与えることなく新しい実装を提供できます。
// コーヒーメーカーの抽象インターフェース
interface CoffeeMaker {
    fun brew(): String
}

// エスプレッソマシンの実装
class EspressoMachine : CoffeeMaker {
    override fun brew(): String {
        return "Brewing espresso"
    }
}

// ドリップコーヒーマシンの実装
class DripCoffeeMachine : CoffeeMaker {
    override fun brew(): String {
        return "Brewing drip coffee"
    }
}

// 高レベルモジュールが抽象に依存
class CoffeeMachine(private val coffeeMaker: CoffeeMaker) {
    fun makeCoffee(): String {
        return coffeeMaker.brew()
    }
}

まとめ

説明を読んでいるだけだと、流してしまいがちな原則をコーヒーで例えることで、自分の言葉で説明でき、実は当たり前の原則だということが分かりました。

SOLID原則は数ある設計手法の内の一つであり、全てを適用する必要はないですが、当然知っているものとして会話が進むことがあるので、知識として頭に入れておくと役に立つ場面があります。理解した上で、使う・使わないの線引きができることが重要と改めて学びました。

Discussion