SOLID 代码设计原则

不同于设计模式,在面向对象软件设计当中,还有 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)。

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

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、反例

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 原则的方式,可以采用多态和模版。

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、反例

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、判断兼容

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、包含关系

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、改进继承关系

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、反例

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、接口分离

/* -------------------------------- 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、反例

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、依赖反转

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/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值