不同于设计模式,在面向对象软件设计当中,还有 SOLID 代码设计原则,本文将对其进行介绍和举例。

1、什么是设计模式

设计模式是软件设计当中通用的、可复用的解决方案,它是关于如何在不同情况下如何解决问题的描述或模版,通常是最佳实践。

设计模式使得代码变得可维护的、可扩展的、解耦的,开发者为每个解决某种类型问题的方案起名,整理成各种通用的设计模式。

注意,软件架构和设计模式是不同的概念,软件架构描述的是要实现什么样的功能和在什么地方来实施,而设计模式是描述怎样来实现。

设计模式不是要编写解决方案,它更像是描述解决方案应该长什么样。在设计模式当中,问题及其解决方案是密不可分的。

我们需要设计模式,这样代码会更加清晰、直观、占用更少的内存空间、性能高效,也更加方便我们后续进行代码的修改。

设计模式主要包含 6 条规则:

  1. 经过验证和实践的(Proven Solutions)

  2. 可以简单地进行复用(Easily Reusable)

  3. 直观的(Expressive)

  4. 易于交流的(Ease Communication)

  5. 预防重构的需要(Prevent the Need for Refactoring Code)

  6. 最小化代码(Lower the Size of the Codebase)

当设计一个完整的软件应用时,我们需要考虑得很多:

  • 创建(Creational Design Patterns):如何创建或者实例化对象?(Factory、Builder、Prototype、Singleton)

  • 结构化(Structural Design Patterns):各个对象如何结合成一个大的实体,如何兼容未来的需要?(Adapter、Bridge、Composite、Decorator、Facade、Flyweight、Proxy)

  • 行为(Behavioural Design Patterns):对象之间如何联系,如何避免后续改动的影响以及减少副作用?(Chain of responsibility、Command、Interpreter、Iterator、Mediator、Memento、Observer、State、Strategy、Template Method、Visitor)

2、SOLID 设计原则

SOLID 是面向对象软件设计当中较为出名的一系列设计原则,包含有以下 5 个原则:

  • SRP – Single Responsibility Principle

  • OCP – Open/Closed Principle

  • LSP – Liskov Substitution Principle

  • ISP – Interface Segregation Principle

  • DIP – Dependency Inversion Principle

注:设计模式(Pattern)和设计原则(Principle) 是两个不同的概念。

2.1、Single Responsibility Principle (SRP)

SRP (单一功能原则),主要思想是一个类只能有一个被修改的原因(A class should have only one reason to change)。

也就是说每个类都应该有一个单一的功能,并且该功能应该由这个类完全封装起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Journal {
    string          m_title;
    vector<string>  m_entries;

public:
    explicit Journal(const string &title) : m_title{title} {} 
    void add_entries(const string &entry) {
        static uint32_t count = 1;
        m_entries.push_back(to_string(count++) + ": " + entry);
    } 
    auto get_entries() const { return m_entries; }

    //void save(const string &filename)
    //{
    //    ofstream ofs(filename); 
    //    for (auto &s : m_entries) ofs << s << endl;
    //}
};

struct SavingManager {
    static void save(const Journal &j, const string &filename) {
        ofstream ofs(filename);
        for (auto &s : j.get_entries())
            ofs << s << endl;
    }
};

SavingManager::save(journal, "diary.txt");

在上面的代码案例中,Journal 类如果同时存在两个功能(two reason to change),关联事件(add_entries)和保存(save),那么会存在以下问题:

  • 如果要添加 BookFile 等类,需要为他们也实现各自的保存函数

  • 当要修改或维护保存函数时,需要去每个类实现当中去做判断

所以根据 SRP 设计原则,把保存函数抽离,封装成另一个类,这样 Journal 类只需要关联事件,而 SavingManager 类只负责保存,这样就可以应付后面的改动,提高可维护性、更直观、解耦、可复用。

但也会存在缺点,各个类之间可能会存在联系,导致上百个类关联到一起,实际应该整合成一个类。

SRP 原则是为了减少改动的影响,所以整合功能以相同的原因(same reason)做修改,分离功能是因为以不同的原因(different reason)做修改。在做代码重构时,非常有帮助。

2.2、Open Closed Principle (OCP)

OCP (开闭原则),主要思想是软件中的对象应该对于扩展是开放的,但是对于修改是封闭的(classes should be open for extension, closed for modification)。

也就是说对一个类只能做扩展,而不能被修改。

2.2.1、反例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
enum class COLOR { RED, GREEN, BLUE };
enum class SIZE { SMALL, MEDIUM, LARGE };

struct Product {
    string  m_name;
    COLOR   m_color;
    SIZE    m_size;
};

using Items = vector<Product*>;
#define ALL(C)  begin(C), end(C)

struct ProductFilter {
    static Items by_color(Items items, const COLOR e_color) {
        Items result;
        for (auto &i : items)
            if (i->m_color == e_color)
                result.push_back(i);
        return result;
    }
    static Items by_size(Items items, const SIZE e_size) {
        Items result;
        for (auto &i : items)
            if (i->m_size == e_size)
                result.push_back(i);
        return result;
    }
    static Items by_size_and_color(Items items, const SIZE e_size, const COLOR e_color) {
        Items result;
        for (auto &i : items)
            if (i->m_size == e_size && i->m_color == e_color)
                result.push_back(i);
        return result;
    }
};

int main() {
    const Items all{
        new Product{"Apple", COLOR::GREEN, SIZE::SMALL},
        new Product{"Tree", COLOR::GREEN, SIZE::LARGE},
        new Product{"House", COLOR::BLUE, SIZE::LARGE},
    };

    for (auto &p : ProductFilter::by_color(all, COLOR::GREEN))
        cout << p->m_name << " is green\n";

    for (auto &p : ProductFilter::by_size_and_color(all, SIZE::LARGE, COLOR::GREEN))
        cout << p->m_name << " is green & large\n";

    return EXIT_SUCCESS;
}

一个产品(Product)可能有多个属性,然后我们需要过滤出特定属性的产品。

但按照上面代码的实现,存在下面几个问题:

  • 对于暴露出去的接口,如果要修改或新增属性,需要修改类和增加新的过滤函数

  • 需要频繁地修改类的实现

2.2.2、使用模版实现扩展

要实现 OCP 原则的方式,可以采用多态和模版。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template <typename T>
struct Specification {
    virtual ~Specification() = default;
    virtual bool is_satisfied(T *item) const = 0;
};

struct ColorSpecification : Specification<Product> {
    COLOR e_color;
    ColorSpecification(COLOR e_color) : e_color(e_color) {}
    bool is_satisfied(Product *item) const { return item->m_color == e_color; }
};

struct SizeSpecification : Specification<Product> {
    SIZE e_size;
    SizeSpecification(SIZE e_size) : e_size(e_size) {}
    bool is_satisfied(Product *item) const { return item->m_size == e_size; }
};

template <typename T>
struct Filter {
    virtual vector<T *> filter(vector<T *> items, const Specification<T> &spec) = 0;
};

struct BetterFilter : Filter<Product> {
    vector<Product *> filter(vector<Product *> items, const Specification<Product> &spec) {
        vector<Product *> result;
        for (auto &p : items)
            if (spec.is_satisfied(p))
                result.push_back(p);
        return result;
    }
};

// ------------------------------------------------------------------------------------------------
BetterFilter bf;
for (auto &x : bf.filter(all, ColorSpecification(COLOR::GREEN)))
    cout << x->m_name << " is green\n";

通过模版对外暴露接口,从而使得类可扩展,而不需要修改类的实现。

2.2.3、总结

使用 OCP 原则的方式,可以增强可扩展性、可维护性、灵活性。但实际上,一个类很难保持完全的封闭性,总会存在一些不可预见的、需要修改类实现的情况。对于可预见的,那么 OCP 原则是很不错的修改方式。

2.3、Liskov’s Substitution Principle (LSP)

LSP (里氏替换原则),主要思想是派生类对象可以在程序中代替其基类对象(Subtypes must be substitutable for their base types without altering the correctness of the program)。

以 C++ 举例来说明,就是指向基类对象的指针或引用可以被替换为派生类对象。

2.3.1、反例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Rectangle {
    Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}

    uint32_t get_width() const { return m_width; }
    uint32_t get_height() const { return m_height; }

    virtual void set_width(const uint32_t width) { this->m_width = width; }
    virtual void set_height(const uint32_t height) { this->m_height = height; }

    uint32_t area() const { return m_width * m_height; }

protected:
    uint32_t m_width, m_height;
};

struct Square : Rectangle {
    Square(uint32_t size) : Rectangle(size, size) {}
    void set_width(const uint32_t width) override { this->m_width = m_height = width; }
    void set_height(const uint32_t height) override { this->m_height = m_width = height; }
};

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

    assert((w * 10) == r.area()); // Fails for Square <--------------------
}

在上面的代码案例中,正方形(Square)继承于矩形(Rectangle),在执行面积判断 process 函数时,就会出现替换错误。看似正确,实则矩形面积是以宽和高来定义的,而正方形是以长度来定义的,所以这样继承不是好的方式。

2.3.2、判断兼容

1
2
3
4
5
6
7
8
9
void process(Rectangle &r) {
    uint32_t w = r.get_width();
    r.set_height(10);

    if (dynamic_cast<Square *>(&r) != nullptr)
        assert((r.get_width() * r.get_width()) == r.area());
    else
        assert((w * 10) == r.area());
}

多加一层类型判断来进行兼容,也不是什么好的方式,实际上这并不是真正的可替换。

2.3.3、包含关系

1
2
3
4
5
6
7
8
9
void process(Rectangle &r) {
    uint32_t w = r.get_width();
    r.set_height(10);

    if (r.is_square())
        assert((r.get_width() * r.get_width()) == r.area());
    else
        assert((w * 10) == r.area());
}

因为矩形是包含正方形的,所以不需要创建正方形这个类,在有需要的地方做判断即可,但这也不是推荐的方式。

2.3.4、改进继承关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct Shape {
    virtual uint32_t area() const = 0;
};

struct Rectangle : Shape {
    Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}

    uint32_t get_width() const { return m_width; }
    uint32_t get_height() const { return m_height; }

    virtual void set_width(const uint32_t width) { this->m_width = width; }
    virtual void set_height(const uint32_t height) { this->m_height = height; }

    uint32_t area() const override { return m_width * m_height; }

private:
    uint32_t m_width, m_height;
};

struct Square : Shape {
    Square(uint32_t size) : m_size(size) {}
    void set_size(const uint32_t size) { this->m_size = size; }
    uint32_t area() const override { return m_size * m_size; }

private:
    uint32_t m_size;
};

void process(Shape &s) {
    // Use polymorphic behaviour only i.e. area()
}

通过创建一个更加包容的基类,从而使得正方形类和矩形类都纳入其中,这样就能满足可替换性。

2.3.5、总结

LSP 原则可以达到很好的兼容性、类型安全、可维护性。在面向对象设计当中,仅仅描述“IS-A”(是什么)关系是不够的,更精确的应该是描述“IS-SUBSTITUTABLE-FOR”(可被替换为)关系。

2.4、Interface Segregation Principle (ISP)

ISP (接口隔离原则),主要思想是客户不应被迫使用对其而言无用的方法或功能(Clients should not be forced to depend on interfaces that they do not use)。

也就是说,设计抽象接口应该只完成用户需要的功能,把非常庞大臃肿的接口划分成更小的和更具体的,这样客户只需要知道他们感兴趣的方法,也更方便解耦和重构。

2.4.1、反例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct Document;

struct IMachine {
    virtual void print(Document &doc) = 0;
    virtual void fax(Document &doc) = 0;
    virtual void scan(Document &doc) = 0;
};

struct MultiFunctionPrinter : IMachine {      // OK
    void print(Document &doc) override { }
    void fax(Document &doc) override { }
    void scan(Document &doc) override { }
};

struct Scanner : IMachine {                   // Not OK
    void print(Document &doc) override { /* Blank */ }
    void fax(Document &doc) override { /* Blank */ }
    void scan(Document &doc) override {  
        // Do scanning ...
    }
};

在基类机器(IMachine)要对文档进行操作当中,暴露了三个接口,打印(print)、传真(fax)、扫描(scan),对于打印机而言是正常的,但对于扫描仪而言,它并没有打印和传真的功能,被迫把这两个接口的实现为空。

2.4.2、接口分离

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* -------------------------------- Interfaces ----------------------------- */
struct IPrinter {
    virtual void print(Document &doc) = 0;
};

struct IScanner {
    virtual void scan(Document &doc) = 0;
};
/* ------------------------------------------------------------------------ */

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

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

struct IMachine : IPrinter, IScanner { };

struct Machine : IMachine {
    IPrinter&   m_printer;
    IScanner&   m_scanner;

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

    void print(Document &doc) override { printer.print(doc); }
    void scan(Document &doc) override { scanner.scan(doc); }
};

和单一功能原则(SRP)比较类似,通过分离接口,提供客户感兴趣的接口。

2.4.3、总结

ISP 原则可以使得编译更快(因为如果接口签名改变,所有子类都要重编)、可复用的、可维护的。所以在设计接口类时,要设想这个类中的所有方法是否真的需要用到。

2.5、Dependency Inversion Principle (DIP)

DIP (依赖反转原则),主要思想是:

  • 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口(High-level modules should not depend on low-level modules. Both should depend on abstractions)

  • 抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口(Abstractions should not depend on details. Details should depend on abstractions)

高层次模块是更加抽象以及包含更多的复杂逻辑,低层次模块描述更具体的实现以及是独立的实现细节的部分。

2.5.1、反例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum class Relationship { parent, child, sibling };

struct Person {
    string      m_name;
};

struct Relationships {      // Low-level <<<<<<<<<<<<-------------------------
    vector<tuple<Person, Relationship, Person>>     m_relations;

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

struct Research {           // High-level  <<<<<<<<<<<<------------------------
    Research(const Relationships &relationships) {
        for (auto &&[first, rel, second] : relationships.m_relations) {// Need C++17 here
            if (first.m_name == "John" && rel == Relationship::parent)
                cout << "John has a child called " << second.m_name << endl;
        }
    }
};

在上面的代码案例中,关系链调查,如果 Relationships 类当中的变量改变类型或者名字的话,将会导致高层次模块 Research 类出问题,而这两个类之间还存在很多的关联。

2.5.2、依赖反转

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct RelationshipBrowser {
    virtual vector<Person> find_all_children_of(const string &name) = 0;
};

struct Relationships : RelationshipBrowser {     // Low-level <<<<<<<<<<<<<<<------------------------
    vector<tuple<Person, Relationship, Person>>     m_relations;

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

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

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

我们可以创建一个抽象接口类,来把高层次模块和低层次模块连接到一起。这样其中的一些修改就不会导致另一个类出问题。

2.5.3、总结

如果转换成 DIP 原则比较困难的话,就应该先设计抽象接口类,然后实现基于抽象的高层次模块,而不能提前感知到低层次模块的实现。

DIP 原则可以带来复用性、可维护性。不要尝试混用各个类对象,应该首先使用抽象。

参考

What Is Design Pattern?:http://www.vishalchovatiya.com/what-is-design-pattern/

Single Responsibility Principle in C++:http://www.vishalchovatiya.com/single-responsibility-principle-in-cpp-solid-as-a-rock/

单一功能原则:https://zh.wikipedia.org/zh-cn/%E5%8D%95%E4%B8%80%E5%8A%9F%E8%83%BD%E5%8E%9F%E5%88%99

Open Closed Principle in C++:http://www.vishalchovatiya.com/open-closed-principle-in-cpp-solid-as-a-rock/

开闭原则:https://zh.wikipedia.org/zh-cn/%E5%BC%80%E9%97%AD%E5%8E%9F%E5%88%99

Liskov’s Substitution Principle in C++:http://www.vishalchovatiya.com/liskovs-substitution-principle-in-cpp-solid-as-a-rock/

里氏替换原则:https://zh.wikipedia.org/zh-cn/%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8D%A2%E5%8E%9F%E5%88%99

Interface Segregation Principle in C++:http://www.vishalchovatiya.com/interface-segregation-principle-in-cpp-solid-as-a-rock/

接口隔离原则:https://zh.wikipedia.org/zh-cn/%E6%8E%A5%E5%8F%A3%E9%9A%94%E7%A6%BB%E5%8E%9F%E5%88%99

Dependency Inversion Principle in C++:http://www.vishalchovatiya.com/dependency-inversion-principle-in-cpp-solid-as-a-rock/

依赖反转原则:https://zh.wikipedia.org/zh-cn/%E4%BE%9D%E8%B5%96%E5%8F%8D%E8%BD%AC%E5%8E%9F%E5%88%99