C# SOLID:物件導向程式設計五大原則

2020-07-16 10:04:47
下面簡單介紹一下物件導向程式設計的五大原則,即 SOLID,其中,每個字母代表一條原則:
  • 單一職責原則(Simple responsibility principle)
  • 開閉原則(Open/closed principle)
  • 里氏代換原則(Liskov Substitution principle)
  • 介面隔離原則(Interface segregation principle)
  • 依賴倒轉原則(Dependency inversion principle)

適當的應用這些原則,會使得程式碼擁有良好的擴充套件性,並易於測試和多人開發。因此,SOLID 廣泛運用於測試驅動開發和敏捷開發中。

單一職責原則

單一職責原則(Simple responsibility principle)可能是五大原則中最容易理解的,它希望型別應當只具有一種功能或表示一種概念,這裡應將功能理解為改變的原因。

例如,資料庫管理類應當只包括對資料庫進行 CRUD 動作的方法,不應該包括其他方法,例如許可權判斷等。

單一職責原則既可以用於型別(類、結構、介面),也可以用於方法。在撰寫方法時,如果每個方法只專注於一件事,那麼它的命名也就很簡單,單元測試非常方便,其他開發者也可以很容易地理解這個方法內部的程式碼。

開閉原則

開閉原則(Open/closed principle):軟體實體(類、模組、函數等)應該對擴充套件開放,對修改關閉。

通俗來講,它意味著你應當能在不修改類的前提下擴充套件一個類的行為,就好像不需要改變體型就可以穿上不同的衣服,即能夠按照你的意願穿上不同的衣服來改變面貌,而不用改造身體。

對擴充套件開放,對修改關閉了(除非去做手術)。

在物件導向設計中,對擴充套件開放意味著類或模組的行為能夠改變,在需求變化時我們能以新的、不同的方式讓模組改變,或者在新的應用中滿足需求。

通過繼承介面,可以實現遵循開閉原則的型別。簡單工廠進化到工廠方法模式就是一個十分經典的例子。

1) 簡單工廠

簡單工廠(Simple Factory)模式中,可以根據引數的不同返回不同類的範例(這通過 switch 判斷實現)。

簡單工廠模式專門定義一個具體工廠類來負責建立其他類的範例,被建立的範例具有共同的父類別。

簡單工廠模式包含如下角色:
  • Factory:工廠角色:工廠角色負責實現建立所有範例的內部邏輯。
  • Product:抽象產品角色:抽象產品角色是所建立的所有物件的父類別,負責描述所有範例所共有的公共介面。
  • ConcreteProduct:具體產品角色:具體產品角色是建立目標,所有建立的物件都充當這個角色的某個具體類的範例。

其精髓如下:
  • 抽象基礎類別定義所有子類共有的方法。
  • 子類各自定義方法的具體不同實現。
  • 具體工廠類用於建立抽象基礎類別的一個範例物件(生產產品)。生產哪個由傳入的值確定。要進行 switch 判斷,而這也是簡單工廠不符合開閉原則的根源。

【範例】借用艦隊 Collection 的台詞,指定族產品為戰列艦,實現兩個具體產品長門和陸奧。

抽象產品型別程式碼如下:
//一個抽象產品
public class BattleShip
{
}

//若干具體產品
public class Nagato : BattleShip
{
    public Nagato()
    {
        Console.WriteLine("我是戰列艦長門。請多指教。和敵戰列艦的戰鬥就交給我吧。");
    }
}

public class Mutsu : BattleShip
{
    public Mutsu()
    {
        Console.WriteLine("長門級戰艦二號艦的陸奧喲。請多關照。");
    }
}
工廠型別程式碼如下:
//一個具體工廠
public class Factory
{
    //根據傳入值製造產品
    public BattleShip CreateBattleShip(string type)
    {
        //這是簡單工廠的問題所在:擴充套件性差
        switch (type)
        {
            case "mutsu":
                return new Mutsu();
            case "nagato":
                return new Nagato();
            default:
                throw new ArgumentException("該型號的戰列艦不可用");
        }
    }
}

簡單工廠模式的主要問題:擴充套件性差。

一個簡單的工廠模式的程式碼寫完了,但是以上程式碼還是有問題。它違背了開閉原則。

如果某天需求變更,突然要加一種新的戰列艦比如“大和”,我們的做法是:
  • 新增一個具體產品型別,並繼承抽象產品型別。
  • 在工廠類加一條分支語句(這一操作違背了開放封閉原則)。

在實際應用中,產品很可能是一個多層次的樹狀結構。但簡單工廠模式中只有一個工廠類來對應這些產品。

正如前面提到的,簡單工廠模式適用於業務簡單的情況或者具體產品很少增加的情況。而對於複雜的業務環境可能就不太適應了。這就應該由工廠方法模式岀場了。

2) 工廠方法模式

工廠方法模式(Factory Method Pattern)定義了一個建立物件的介面,但由子類決定要範例化的類是哪一個。工廠方法讓類把範例化推遲到子類。

和簡單工廠一樣,工廠方法模式也會面對一族產品,所以也會有一個抽象的產品類和若干具體的子類,子類實現父類別的抽象方法。

工廠和簡單工廠的區別:
  • 簡單工廠只有 1 個工廠,而工廠方法模式有 1 個抽象的工廠,和多個具體的工廠。具體的每個工廠對應每一個子產品的建造。這使得擴充套件的時候,我們不需要增加 switch 分支作為判斷語句,而是增加工廠。
  • 工程方法模式符合開閉原則,簡單工廠不符合。

在簡單工廠中,只有一個具體的工廠(它可以生產所有東西),範例化都是在那一個工廠實現的(通過條件判斷)。

工廠方法中有若干個具體工廠,它們對應著所有的產品,每個工廠只能生產對應的一種產品。

範例化是在具體工廠中實現的(即“子類決定要範例化的類”,“推遲到子類”)。

工廠方法模式中,抽象工廠不負責生產,它只是一個介面,它的生產方法是一個抽象方法,對應虛的產品。它是建立物件的“介面”。

工廠方法模式包含如下角色:
  • Product:抽象產品。
  • ConcreteProduct:具體產品。
  • Factory:抽象工廠,這是簡單工廠沒有的。
  • ConcreteFactory:具體工廠。

程式碼實現

【範例】沿襲簡單工廠的程式碼,抽象產品類和具體產品類不需要改動。首先,增加一個抽象工廠類。然後,多個具體工廠類繼承它,由於不再需要通過 switch討論,程式碼如下:
public interface AbstractFactory
{
    //抽象工廠類提供簽名
    BattleShip Create();
}

//多個具體工廠
public class MutsuFactory : AbstractFactory
{
    public BattleShip Create()
    {
        return new Mutsu();
    }
}

public class NagatoFactory : AbstractFactory
{
    public BattleShip Create()
    {
        return new Nagato();
    }
}
呼叫方:
class Program
{
    static void Main(string[] args)
    {
        var f = new NagatoFactory();
        f.Create();

        Console.ReadKey();
    }
}

擴充套件性

假設增加了一個戰列艦。此時我們要做的事情如下:
  • 抽象產品類無需改動。
  • 增加一個具體的產品類,繼承抽象產品類。
  • 抽象工廠類無需改動。
  • 增加一個具體的工廠,繼承抽象工廠類,生產該新產品。

也就是說,我們不用修改,只需新增。所以工廠方法模式符合開閉原則!

由於使用了物件導向的多型性,工廠方法模式保持了簡單工廠模式的優點,而且克服了它的缺點。

在工廠方法模式中,核心的工廠類不再負責所有產品的建立,而是將具體建立工作交給子類去做。

這個核心類僅僅負責給出具體工廠必須實現的介面,而不負責哪一個產品類被範例化這種細節,這使得工廠方法模式可以允許系統在不修改工廠角色的情況下引進新產品。

用工廠方法模式足以應付我們可能遇到的大部分業務需求。但當產品有多於一族時,這種情況下就可使用抽象工廠模式了。

里氏代換原則

這條原則大概是 SOLID 中最難理解的。里氏代換原則 (Liskov Substitution principle) 通俗的來說就是子型別必須能夠替換它們的基本類型(在任何地方、任何時候)。

但通常來說,未必一定能夠做到,例如:
  • 鳥類可以定義飛行的方法,但鴕鳥雖然是鳥卻不能飛行。
  • 鴨子可以定義吃的方法,但玩具鴨雖然是鴨卻不能吃。

我們本希望子類可以做父類別所有的事情,並且還能做父類別不能做的事情。但是,現實生活之中,父類別能做的事情,未必子類也都可以做。

因此,上面的兩個例子都違反了里氏代換原則。

解決的方法有兩個(以第一個例子為例):
  • 加入判斷語句,使得當鳥等於鴕鳥時就不呼叫飛行的方法。不過,這樣做是違背開閉原則的。
  • 使用介面,令所有會飛的鳥繼承介面 IFlyBird,該介面再繼承自 IBird,而鴕鳥直接繼承自 IBird。

介面隔離原則

介面隔離原則(Interface segregation principle):用戶端不應該依賴它不需要的介面。或者多個專門的介面好於一個通用的介面。

在 .NET 中,所有的集合都繼承自 IEnumerable 或它的泛型版本。

如果我們將所有集合可用到的方法都編寫在 IEnumerable 中,那麼 IEnumerable 將會成為一個非常龐大的胖介面。

例如,某些集合可以從中間插入,或者從中間刪除,那麼,我們可以將 Add 和 Remove 方法寫在 IEnumerable 中。

但是,某些集合,例如佇列和棧,它們是不能隨便插入刪除的。所以,如果佇列和棧的實現 Stack 和 Queue 繼承自 IEnumerable,就會出現問題。

因此,在 .NET 中,微軟將這些不那麼通用的方法放到了 IEnumerable 的子類 ICollection 的子類 IList 中,並令那些擁有隨便插入刪除能力的集合(例如 List)繼承自 IList,而 Stack 和 Queue 繼承自 ICollection。

總的來說,如果介面過胖,那麼解決方式是將一些不那麼通用的方法放到介面的子介面中,並令用戶端選擇性地繼承父介面(功能較少的類)或者子介面(功能較多的類),從而保證介面隔離。

依賴倒轉原則

依賴倒轉原則(Dependency inversion principle)的主要內容是:較為抽象的類定義一組介面,具體的實現類必須遵循這些介面(細節應該依賴於抽象),也可解讀為:高層不應該依賴於底層,兩者皆應當依賴於抽象(一組介面)。

假設我們要定義一個汽車,它擁有著一系列部件,例如引擎、車輪等。那麼,我們應該令所有的引擎繼承自 IEngine 介面(例如,ToyotaEngine, BenzEngine 等)。然後,令所有的車輪繼承自 IWheel 介面。

這樣一來,在建構函式中,我們的豐田汽車就可以由傳入一個 ToyotaEngine 和 ToyotaWheel 來組裝而成。而奔馳汽車可以由傳入一個 BenzEngine 和 BenzWheel 組裝而成。

在這個例子中,高層(汽車)依賴於一組抽象:

Car(IEngine engine, IWheel wheel)

而底層(引擎和車輪)直接依賴於對應的介面。因此,兩者皆依賴於抽象。而將具體型別的引擎和車輪通過建構函式傳遞給一個具體型別的汽車這個動作,就叫做(建構函式)依賴注入(Dependency Injection)。

在設計模式中,對應的模式是策略模式。如果你的汽車的定義依賴於具體的引擎和車輪,那麼,你就無法更改引擎和車輪的型別。

依賴注入使得我們的系統靈活性更高,可以更好地應付使用者需求的改變。在需求改變時,大幅降低改程式碼的風險。

依賴注入降低模組之間的耦合程度,方便單元測試。在實際開發中,有很多使用依賴注入的動機,例如對不同型別的資料庫進行處理時,在建構函式中可以傳入繼承自一組介面的不同型別資料庫的範例。

可以使用 AutoFac、structureMap 等工具來管理依賴注入。