如何理解 SOLID

如何理解 SOLID

在看過幾本架構面的書, 發現大量提到 SOLID 的觀念, 經過幾次反覆翻閱來整理一下自己所理解的觀念。

  • SRP: Single Responsibility Principle(單一權責原則)

  • OCP: Open-Closed Principle(開放關閉原則 )

  • LSP: Liskov Substitution Principle(里氏替換原則 )

  • ISP: Interface Segregation Principle(介面隔離原則)

  • DIP: Dependency Inversion Principle(依賴翻轉原則)

以上這五個原則所組成的第一個字就代表著 SOLID。

說明

一 . SRP: Single Responsibility Principle(單一權責原則)

SRP 從字面上意義來看, 就是一個人只負責一件事情, 當一個人同時負責太多事情就很容易出錯, 程式也是一樣, 如果一個類別裡面包含太多不同的功能, 一旦改動, 很容易造成邊際效應。

這邊常常會出現誤會就是一個類別只能寫一個方法或功能, 其實並不是這個意思, 因此單一權責原則代表的意思是負責所有該事項相關的功能, 總結一下說法就是: 一個類別應該只處理它應該處理的服務。

舉個例子來說。

class Person{
    void getMyName(){}
    void buildAHouse(){}
    void playBaseball(){}
    void playBasketball()
    void writeABook(){}
}

以上的類別做了太多事情了, 萬一房子設計/寫書規格/運動方式改變了, 此時就必須回過頭來調整一下 Person 類別。

所以我們應該要把類別細切開來, 並且將該類別進行歸類。

凝聚性

通常一個類別會產生許多變數, 每個變數都有個別的方法去使用它, 這樣會導致某些方法參考某幾個變數, 另外一些變數是由某幾個特定的方法才會參考它們, 那這樣就代表一個意義:

這些變數跟它所參考到的方法是同一個族群放一起就是凝聚性。

舉個例子來說:

class Person{
    private String houseName;
    private String bookName;
    private String myName;
    void getMyName(){}
    void buildAHouse(){}
    void playBaseball(){}
    void playBasketball()
    void writeABook(){}
}

每個方法都有自己所屬的成員變數, 經過分類整理以後就會變成下面的樣子。

class Person{
    private String myName;
    void getMyName(){}
}
class House{
    private String houseName;
    void getHouseName(){}
}
class Book{
    private String bookName;
    void getBookName(){}
}
class Build{
    public buildHouse(){}
}
class Write{
    public writeBook(){}
}
class Exercise{
    public playBaseball(){}
    public playBasketball(){}
}

Clean Code(p. 157) : 保持凝聚性會得到許多小型的類別

這些類別幾乎不需要花時間去理解, 就可以立刻知道它所對應的功能, 而且如果有必要調整某一個方法, 產生邊際效應的風險趨近於零, 從測試的角度來看, 只需要驗證邏輯正確性, 整體來說就是簡單到不行。

再來就是如果需要擴增方法, 沒有程式碼會因為新增方法而遭到破壞, 這樣的設計就符合單一職責原則也同時支援到開放封閉原則。

隔離修改

每次改變需求的時候, 我們的實作類別就會進行變動, 此時相依的類別就會被影響到, 進而產生變動後程式錯誤的風險。

舉個例子來說:

class Exercise{
    public playBaseball(){}
    public playBasketball(){}
}
class Person{
    private String myName;
    private Exercise exercise = new Exercise();
    void getMyName(){}
    void goExercise(){
        exercise.playBaseball();
    }
}

Person 類別相依於 Exercise 類別, 因此只要 Exercise 類別一變動或者 playBaseball 這個方法改壞掉了, 則 Person 類別就面臨出錯的風險。

那麼我們怎麼防堵這類的風險呢?其實也不難, 我們只需要從將 Exercise 類別的實例從外部注入(Injection), 就可以解決掉這個問題了。

class Exercise{
    public play(){}
}
class Person{
    private String myName;
    private Exercise exercise;
    public Person(Exercise exercise){
        this.exercise = exercise;
    }
    void getMyName(){}
    void goExercise(){
        exercise.playBaseball();
    }
}

如此一來, 我們的 Exercise 類別即便變動, 比如說多了一個 playPingPong 的方法, 或者把 playBaseball 方法改壞掉了, 我們都不用再去爬 Person 類別調整, 只需要針對 Exercise 類別進行修復或新增即可。

二. OCP: Open-Closed Principle(開放封閉原則 )

開放封閉原則意思就是程式要容易變動而且不被修改, 當初我看到這句話的時候, 想到這句話不就是互相矛盾?但是後來查了很多資料, 發現它的意思其實應該是:

程式盡量不被修改且容易擴充。

危險行為

變動是程式裡面最常見而且最危險的一種行為。

所以如果我們要避免危險的行為就是不要去做它!那為什麼還是得去改變程式碼?主要來自幾個原因:

  • 修 Bug

  • 新增功能

  • 重構

是不是很眼熟?這幾個動作都是常見的危險行為, 所以要避免危險就是不要去做它, 馬上寫好辭職信放到直屬長官桌上(X)。

抽象化

其實只要把注入改成抽象類別或介面, 就可以達到擴充的功能了。

舉個例子:

abstract class Exercise{
    void play();
}
class Baseball extends Exercise{
    @Override
    void play(){
        System.out.println("play baseball");
    }
}
class Basketball extends Exercise{
    @Override
    void play(){
        System.out.println("play basketball");
    }
}
class Person{
    private String myName;
    private Exercise exercise;
    public Person(Exercise exercise){
        this.exercise = exercise;
    }
    String getMyName(){
        return myName;
    }
    void goExercise(){
        exercise.play();
    }
}
public void main(){
    Exercise baseball = new Baseball();
    Exercise basketball = new Basketball();
    Person john = new Person(baseball);
    Person tom = new Person(basketball);
}

沒想到稍微修改一下, 這樣就讓 Person 類別跟 Exercise 的子類別脫鉤了, 如此一來無論未來要增加多少種運動, 都可以擴充且不需要更動到程式碼了。

三. LSP: Liskov Substitution Principle(里氏替換原則 )

這個原則主要是應用在替換這個字, 里氏最早提出來的這個原則, 意思是說只要你把傳入的類別抽象化起來, 那麼就可以透過更換的方式把目標無痛轉換, 這段話到底在說什麼?

訂定合約

對於某個類別來說, 它接收到的合約是可以執行某個規格, 所以只要你是根據該規格所生產出來的物件, 該類別都可以執行它。

舉個例子, 以剛剛 OCP 原則的例子來改寫解釋:

interface Exercise{
    void play();
}
class Baseball implements Exercise{
    @Override
    void play(){
        System.out.println("play baseball");
    }
}
class Basketball implements Exercise{
    @Override
    void play(){        
        System.out.println("play basketball");
    }
}

上面完成個別類別的實作, 接著我們會來寫兩段 Person 的實作, 一段是比較沒有彈性的實作, 另外一段是抽象過後比較有彈性的實作。

  • 實作一. 比較沒彈性的實作
class Person{
    private String myName;
    private Baseball baseball = new Baseball();
    void play(){
        exercise.play();
    }
    void getMyName(){}
    void playBaseball(){
        baseball.play();
    }
}
public void main(){
    Person tom = new Person();
    tom.playBaseball();
}

可以看到如果我們要讓 Tom 去打棒球, 只需要產生 Baseball 類別的實體, 接著呼叫 playBaseball 方法就可以了, 問題來了, 今天如果需要請 Tom 再去打籃球, 此時, 原本的 Person 類別以及 main 操作方法都必須要再進行調整, 而原本已經正確的程式, 就因此被變動到, 進而產生可能錯誤的風險, 所以讓我們來試看看另外一種比較有彈性的作法。

  • 實作二. 比較彈性的實作
class Person{
    private String myName;
    private Exercise exercise;
    public void setExercise(Exercise exercise){
        this.exercise = exercise;
    }
    void play(){
        exercise.play();
    }
    void getMyName(){}
    void goExercise(){
        exercise.play();
    }
}
public void main(){
    Exercise baseball = new Baseball();
    Exercise basketball = new Basketball();
    Person tom = new Person();
    tom.setExercise(baseball);
    tom.play();
    tom.setExercise(basketball);
    tom.play();
}

如此可以看到 Tom 可以同時操作 baseball 以及 basketball 物件, 並且執行它, 也就是說即便我新增再多個 Exercise 的子類別, 也可以無痛的替換掉執行的物件。

對於 Tom 這個物件而言, 無論如何都只會遵守 Exercise 所訂定出來的合約操作。

只要是 Exercise 的子類別, Person 類別都可以執行它。

四. ISP: Interface Segregation Principle(介面隔離原則)

跟第一個 SRP 原則雷同, 只是換成介面宣告太多方法, 讓實作的類別非得實作一些沒用到的方法。

拿前面既有的例子來增加功能:

interface Exercise{
    void play();
    void drink();
    void rest();
    void useEquipment();
}

我們新增了喝、休息、操作器材的功能, 但是其實有些運動其實不需要使用器材這個項目, 因此我們需要把這個介面進行縮減。

interface Exercise{
    void play();
    void drink();
    void rest();
}
interface Equipment{
    void use();
}

如此一來我們就可以把使用設備這個界面獨立出來, 有需要的運動可以自己去實作它, 配合 Person 來進行操作。

五. DIP: Dependency Inversion Principle(依賴反轉原則)

最初我們在宣告一個類別可能會是長這樣。

class Person{
    private Baseball baseball = new Baseball();
    void goExercise(){
        baseball.play();
    }
}

看起來一切都是正常的, 可是這樣會出現一個嚴重的問題, 就是當 Baseball 類別進行修改, 而且不小心改壞掉會連帶 Person 類別一起壞掉, 因此我們可以說:

Person 依賴著 Baseball

所以我們要透過抽象的方式, 讓 Person 從 Baseball 那邊解開依賴, 怎麼做呢?

首先定義一個虛擬類別:

abstract class Exercise{
    void play();
}

讓 Baseball 去繼承這個類別:

public class Baseball extends Exercise{
    @Override
    void play() {
        System.out.println("play baseball");
    }
}

這樣一來就把 Person 類別從 Baseball 類別的魔爪逃走了, 因為 Person 遵循的是 Exercise 的規範, 而 Exercise 只定義出你必須遵守的規則, 其餘的由子類別自己自由發揮, 也就是說 Person 只要確保我呼叫 play 可以正確玩到我要的運動, 其餘的該運動規則會是由 Baseball 類別自己定義好。

操作行為

class Person{
    private String myName;
    private Exercise exercise;
    public void setExercise(Exercise exercise){
        this.exercise = exercise;
    }
    void play(){
        exercise.play();
    }
    void getMyName(){}
    void goExercise(){
        exercise.play();
    }
}
public void main(){
    Exercise baseball = new Baseball();
    Exercise basketball = new Basketball();
    Person tom = new Person();
    tom.setExercise(baseball);
    tom.play();
    tom.setExercise(basketball);
    tom.play();
}

這樣的好處就顯而易見了, 某一天我需要把 Baseball 換成 Basketball , 我就可以開一個 Basketball 類別並且繼承 Exercise 類別, 如此一來, Person 類別也不需要調整可以直接吃掉新開類別所產生的物件, 因為它只認 Exercise所定義的規格。

這樣就符合依賴反轉原則的定義

高階模組不依賴低階模組,兩者都必須要依賴抽象。 抽象不能依賴細節,細節必須依賴抽象。

結論

其實這五個原則都是在討論一件事情, 就是程式變動後, 要怎麼讓原本的程式不受影響, 而這些原則會感覺解決方法其實非常雷同, 透過這些原則所產生的解決方法可能會互相參雜彼此的概念, 但是目的其實很清楚, 就是要寫出一個乾淨、有彈性且好測試的程式。

參考書籍

  • Clean Code : Clean Architecture 整潔的軟體設計與架構篇

  • Clean Code : 敏捷軟體開發技巧守則

  • 大話重構

  • Kent Beck 的實作模式

  • 搞笑談軟工 http://teddy-chen-tw.blogspot.com/