設計模式的原則與種類(下)

上一篇中,我們介紹了 SOLID 原則中的 SRP 還有 OCP 原則。

在這一篇,我們會繼續介紹剩下的三個原則,也就是 LSP、ISP、DIP。

讓我們用正方形與長方形做比喻。在數學上來說,正方形算是長方形的一種,只是他的長與寬剛好一樣。那今天我們在程式碼中創造一個叫做 Square 類別,並繼承一個 Rectangle 類別,這樣會發生什麼事呢?

class Rectangle
{
protected:
    int width, height;
public:
    Rectangle(const int width, const int height)
        : width{width}, height{height} { }

    int get_width() const { return width; }
    virtual void set_width(const int width) { this->width = width; }
    int get_height() const { return height; }
    virtual void set_height(const int height) { this->height = height; }

    int area() const { return width * height; }
};

class Square : public Rectangle
{
public:
    Square(int size): Rectangle(size, size) {}
    void set_width(const int width) override {
        this->width = height = width;
    }
    void set_height(const int height) override {
        this->height = width = height;
    }
};

這樣看似合理,但當我們寫出以下這段程式碼時,我們就會發現這樣是不可行的

void process(Rectangle& r)
{
    int w = r.get_width();
    r.set_height(10);

    std::cout << "expected area = " << (w * 10) 
        << ", got " << r.area() << std::endl;
}

int main()
{
    Square s{ 5 };
    process(s);

    return 0;
}

這樣的程式碼會輸出:

expected area = 50, got 100

會有這樣的錯誤結果就是因為子類別沒有遵從父類別的行為規則。換句話說,LSP 所聲明的,就是如果一個類別在一個程式中可以正常運作,那麼即使我們將這個類別替換為其他的衍生類別,那麼程式也應該不會出錯。

有一個方法可以修改上面例子中的錯誤,那就是在 Rectangle 裡面再設立一個 flag,這個 flag 會告訴我們目前的類別是正方形還是長方形,讓 process() 函數去偵測就行了!

但這樣的缺點就是未來沒有擴展性,使用者也必須知道太多的細節,這對程式設計絕對不是一件好事。

所以其實,LSP 告訴我們:除非你確定子類別遵從父類別的行為規則,否則大多時候我們都不該用繼承!

那什麼叫做子類別遵從父類別的行為規則?

我們可以觀察下面這幾個條件:

1. 子類別的先決條件必須比父類別的更為鬆散

什麼是「先決條件」?比如說當父類別中的某個函數的輸入數字的要求為「必須在 1 ~ 10 之間」,那麼子類別中的同一個函數的要求就不能為「必須在 2 ~ 7 之間」,但可以為「必須在 0 ~ 20 之間」。這樣就可以確保在所有可以執行父類別函數的地方,都可以執行對應的子類別函數。

2. 子類別的後置條件必須比父類別的更為嚴謹

什麼是「後置條件」?前面講的先決條件著重在輸入,這邊的後置條件當然就著重在輸出啦!比如說當父類別中的函數的輸出被規定為某個特定類別比如說 int,那麼子類別的同一個函數就不能輸出除了 int 以外的東西。我認為這個是相當直覺的,因為你總不會希望你寫的程式碼突然輸出不在你意料內的東西吧!

3. 子類別必須保留父類別中的法則

什麼是「法則」?法則指的是不管任何時刻都不會變的條件。比如說正方形的法則就是長寬相等。

現在我們回頭來看為什麼一開始的 Square 會是錯誤的。

我們看到 RectangleSquare 兩個都沒有先決條件和後置條件,因此我們不需要理前兩個條件。但有趣的來了,在 Rectangle 中,我們的「法則」是「在更動長的時候不能動到寬,反之亦然」。但在 Square 中,我們只要動到一個,就會同時動到另外一個。所以 LSP 告訴我們,RectangleSquare 彼此是不能有繼承關係的!

總結就是:繼承不要隨意使用!除非我們確定兩者的關係符合 LSP 條件,不然在大型的軟體應用中,子類別很容易做出不符合預期的結果!

介面隔離原則 ISP

讓我們來看下面這個例子

struct IMachine
{
    virtual void print(std::string& doc) = 0;
    virtual void scan(std::string& doc) = 0;
};

struct Emoloyee : IMachine
{
    void print(std::string& doc) override;
    void scan(std::string& doc) override;
};

在這個例子中,我們可以知道這個 Employee 擁有 print()scan() 兩個能力。但今天我們想要讓一個員工只負責印東西,另一個員工只負責掃描東西,這樣可以做到嗎?

按照上面的例子其實是可以的,就是負責印東西的員工永遠只呼叫 print() 這個函數,負責掃描的員工永遠只呼叫 scan() 的函數。但這樣其實不是一個好的解決方式,因為我們沒辦法保證會不會哪一天負責印東西的員工突然出錯就呼叫了 scan() 的函數。

那我們要怎麼樣可以更好的解決呢?ISP 告訴我們應該建立多個介面:

struct IPrinter
{
    virtual void print(std::string& doc) = 0;
};

struct IScanner
{
    virtual void scan(std::string& doc) = 0;
};

struct Printer : IPrinter
{
    void print(std::string& doc) override;
};

struct Scanner : IScanner
{
    void scan(std::string& doc) override;
};

struct Machine : IPrinter, IScanner
{
    IPrinter& printer;
    IScanner& scanner;

    Machine(IPrinter& printer, IScanner& scanner)
    : printer{printer}, scanner{scanner}
    {
    }

    void print(std::string& doc) override;
    void scan(std::string& doc) override;
};

經過改良後,我們建立了兩個介面 IPrinterIScanner,兩個介面負責不同的工作。這麼一來,我們就可以很簡單的創造只會印東西和只會掃描東西的員工了!

如果未來我們希望創造一個同時會兩個功能的怎麼辦呢?這邊我們新增一個 Machine 的類別,這個類別則同時繼承了 IPrinterIScanner,這麼一來,我們就可以透過 Machine 的類別達到目的了!有沒有發現這也和前一章所講到的 OCP 原則相呼應呢!也就是當我們想要新增功能的時候,我們不必去更動原本就已經存在的兩個介面,而是利用他們,去創造一個新的類別,以完成新增的功能!

依賴反向原則 DIP

這麼做有助於解耦程式中的模組,提升程式碼的的擴展性,還有讓程式碼更容易維護。

通過抽象介面,我們可以更輕易的更改類別中的實作,同時不用太去擔心會影響到高層代碼。

這邊我們來看一個例子:

enum class Relationship
{
    parent,
    child,
    sibling
};

struct Person
{
    string name;
};

struct Relationships
{
    vector<tuple<Person, Relationship, Person>> relations;

    void add_parent_and_child(const Person& parent, const Person& child)
    {
        relations.push_back({parent, Relationship::parent, child});
        relations.push_back({child, Relationship::child, parent});
    }
};

struct Research    // 高層代碼
{
    Research(const Relationships& relationships)
    {
        auto& relations = relationships.relations;
        for (auto&& [first, rel, second] : relations)
        {
            if (first.name == "John" && rel == Relationship::parent)
            {
                cout << "John has a child called " << second.name << endl;
            }
        }
    }
};

在這個例子中,我們有兩個主要類別,RelationshipsResearchRelationships 負責紀錄人與人的關係,Research 則是將特定的人的小孩列出來。

這種應用方式在一般的程式碼是非常常見的,也就是說讓 Research 這個類別直接利用 Relationships。這樣會有什麼問題呢?這就是高度耦合的程式碼,也就是說當以後如果我們想要更動到 Relationships 時,Research 高機率也會需要更動。

比如說如果我們以後不想用 vector 這個資料結構儲存關係資料,那麼在 Research 中的迴圈也需要重寫。

那麼我們應該怎麼寫呢?DIP 告訴我們需要寫一個抽象介面:

enum class Relationship
{
    parent,
    child,
    sibling
};

struct Person
{
    string name;
};

struct RelationshipBrowser    // 抽象介面
{
    virtual vector<Person> find_all_children_of(const string& name) = 0;
};

struct Relationships
{
    vector<tuple<Person, Relationship, Person>> relations;

    void add_parent_and_child(const Person& parent, const Person& child)
    {
        relations.push_back({parent, Relationship::parent, child});
        relations.push_back({child, Relationship::child, parent});
    }

    vector<Person> find_all_children_of(const string &name) override
    {
        vector<Person> result;
        for (auto&& [first, rel, second] : relations)
        {
            if (first.name == name && rel == Relationship::parent)
            {
                result.push_back(second);
            }
        }
        return result;
    }
};

struct Research
{
    Research(RelationshipBrowser& browser)
    {
        for (auto& child : browser.find_all_children_of("John"))
        {
            cout << "John has a child called " << child.name << endl;
        }
    }
};

更好的一個程式碼是建立一個抽象介面,這裡我們叫做 RelationshipBrowser,然後讓 RelationshipsResearch 都透過這個介面來和彼此互動。這麼一來,原本高度耦合的程式碼現在就被我們完美的解耦合了!

我們只需要確保 find_all_children_of() 這個函數有被正確實作出來,並且讓 Research 呼叫這個函數就可以了!如果以後想要更動,我們也只需要對單一個類別做更動,並不需要擔心會影響到其他模組。這就是 DIP 所要倡導的觀念!