跳转至

【设计模式】SOLID设计原则

1、什么是SOLID设计原则

SOLID 是面向对象设计中的五个基本设计原则的首字母缩写,它们是:

单一职责原则(Single Responsibility Principle,SRP)

类应该只有一个单一的职责,即一个类应该有且只有一个改变的理由。这意味着一个类应该只负责一个特定的功能或任务,而不是多个不相关的功能。这样做可以提高类的内聚性,并使得类更容易理解、修改和测试。

开放-封闭原则(Open/Closed Principle,OCP)

软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着在不修改现有代码的情况下,应该能够通过添加新的代码来扩展系统的功能。这样做可以使得系统更加稳定,减少修改现有代码可能带来的风险。

里氏替换原则(Liskov Substitution Principle,LSP)

子类型必须能够替换其基类型。换句话说,任何可以接受基类型的地方都可以接受子类型,而且不会引发意外的行为。这样做可以保持系统的一致性和可靠性,并且确保使用继承时不会破坏代码的正确性。

接口隔离原则(Interface Segregation Principle,ISP)

客户端不应该被迫依赖于其不使用的接口。这意味着应该将接口设计成小而专注的接口,而不是大而臃肿的接口。这样做可以降低耦合性,并且使得系统更加灵活和易于维护。

依赖倒置原则(Dependency Inversion Principle,DIP)

高层模块不应该依赖于低层模块,二者都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。这样做可以降低模块之间的耦合度,并且使得系统更易于扩展和修改。

这些原则是由罗伯特·马丁(Robert C. Martin)等人在面向对象设计中提出的,它们提供了一套指导原则,帮助设计出高质量、可维护和可扩展的面向对象系统。

2、单一职责原则

单一职责原则(Single Responsibility Principle,SRP)要求一个类或模块应该只有一个单一的责任,即一个类或模块应该只负责一个特定的功能或任务。这样做可以提高代码的内聚性、可维护性和可测试性。

让我们通过一个简单的例子来说明单一职责原则:

假设我们有一个简单的应用程序,用于处理用户信息,包括保存用户信息到数据库和从数据库中检索用户信息。我们可以将这个功能拆分成两个类:一个负责保存用户信息,一个负责检索用户信息。

#include <iostream>
#include <string>

// 负责保存用户信息到数据库
class UserSaver {
public:
    void saveUser(const std::string& username, const std::string& email) {
        // 将用户信息保存到数据库
        std::cout << "用户信息已保存到数据库:" << username << ", " << email << std::endl;
    }
};

// 负责从数据库中检索用户信息
class UserRetriever {
public:
    void retrieveUser(const std::string& username) {
        // 从数据库中检索用户信息
        std::cout << "从数据库中检索到用户信息:" << username << std::endl;
    }
};

int main() {
    UserSaver userSaver;
    userSaver.saveUser("Alice", "alice@example.com");

    UserRetriever userRetriever;
    userRetriever.retrieveUser("Alice");

    return 0;
}

在这个例子中,我们有两个类 UserSaverUserRetriever,它们分别负责保存用户信息和检索用户信息。这两个类各自都只有一个单一的职责,即负责一个特定的功能。如果我们需要修改保存用户信息的逻辑,我们只需要修改 UserSaver 类;如果我们需要修改检索用户信息的逻辑,我们只需要修改 UserRetriever 类。这样做提高了代码的可维护性,并且使得每个类更加简单和易于理解。

3、开放-封闭原则

开放-封闭原则(Open/Closed Principle,OCP)是面向对象设计中的一个基本原则,由柏拉图·梅特克斯(Bertrand Meyer)在他的《面向对象软件构造》(Object-Oriented Software Construction)一书中首次提出。它的核心思想是软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。换句话说,软件实体在不修改现有代码的情况下,应该能够通过添加新的代码来扩展系统的功能。

开放-封闭原则的目的是为了提高系统的可维护性、可扩展性和稳定性。通过遵循这一原则,可以使得系统更容易理解和修改,并且减少对现有代码的影响。

在实际应用中,可以通过以下几种方式来遵循开放-封闭原则:

抽象化:通过使用抽象类、接口或者抽象函数来定义可扩展的接口,从而使得系统可以根据需要进行扩展,而不必修改现有代码。

多态性:利用多态性和继承机制,使得系统可以通过添加新的子类来扩展功能,而不必修改基类或现有代码。

组合/聚合:通过组合或聚合关系来构建对象之间的关联关系,从而使得系统可以通过添加新的组件来扩展功能,而不必修改现有组件。

模块化:将系统分解成独立的模块或组件,使得每个模块只负责一个特定的功能,从而使得系统可以通过添加新的模块来扩展功能,而不必修改现有模块。

总之,开放-封闭原则指导我们设计出易于扩展和维护的软件系统,通过封装变化和利用多态性,使得系统可以根据需要进行扩展,而不必修改现有代码。

让我们通过一个简单的例子来说明开放-封闭原则。

假设我们有一个简单的图形绘制程序,它可以绘制不同形状的图形,包括圆形和矩形。现在我们希望在程序中添加新的图形类型,比如三角形。我们可以通过遵循开放-封闭原则来扩展程序的功能,而不必修改现有的代码。

首先,我们定义一个抽象基类 Shape,它有一个纯虚函数 draw 用于绘制图形:

#include <iostream>

// 抽象基类:图形
class Shape {
public:
    virtual void draw() = 0; // 纯虚函数
    virtual ~Shape() {}
};

然后,我们定义具体的图形类,如圆形和矩形,它们继承自 Shape 并实现了 draw 方法:

// 圆形类
class Circle : public Shape {
public:
    void draw() override {
        std::cout << "绘制圆形" << std::endl;
    }
};

// 矩形类
class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "绘制矩形" << std::endl;
    }
};

接着,我们定义一个图形绘制器 ShapeDrawer,它负责绘制所有的图形:

#include <vector>

// 图形绘制器
class ShapeDrawer {
public:
    void addShape(Shape* shape) {
        shapes.push_back(shape);
    }

    void drawAll() {
        for (Shape* shape : shapes) {
            shape->draw();
        }
    }

private:
    std::vector<Shape*> shapes;
};

最后,我们在 main 函数中使用这些类:

int main() {
    ShapeDrawer drawer;

    Circle circle;
    Rectangle rectangle;

    drawer.addShape(&circle);
    drawer.addShape(&rectangle);

    drawer.drawAll();

    return 0;
}

现在,如果我们想要添加一个新的图形类型,比如三角形,我们只需要创建一个新的类 Triangle 并继承自 Shape,然后实现 draw 方法即可:

// 三角形类
class Triangle : public Shape {
public:
    void draw() override {
        std::cout << "绘制三角形" << std::endl;
    }
};

通过这种方式,我们在不修改现有代码的情况下扩展了系统的功能,遵循了开放-封闭原则。

4、里氏替换原则

里氏替换原则(Liskov Substitution Principle,LSP)是面向对象设计中的一个重要原则,由芭芭拉·利斯科夫(Barbara Liskov)在1987年提出。该原则指出:子类型必须能够替换其基类型,而不影响程序的正确性。

换句话说,如果一个程序使用的是基类,那么它必须能够使用其子类而不会出现错误。这意味着子类应该完全实现父类的方法,并且不应该改变父类方法的预期行为。

里氏替换原则的核心思想是:

  • 子类可以扩展父类的功能,但不能改变父类原有的功能
  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  • 子类可以增加自己特有的方法

让我们通过一个例子来说明里氏替换原则:

假设我们有一个鸟类 Bird,其中有一个方法 fly

class Bird {
public:
    virtual void fly() {
        std::cout << "鸟儿在飞翔" << std::endl;
    }
};

现在,我们定义一个企鹅类 Penguin 继承自 Bird。但是企鹅不会飞,如果我们直接继承 Bird 并重写 fly 方法,就会违反里氏替换原则:

// 违反里氏替换原则的做法
class Penguin : public Bird {
public:
    void fly() override {
        // 企鹅不会飞,这里抛出异常或什么都不做
        throw std::runtime_error("企鹅不会飞");
    }
};

正确的做法应该是重新设计类的继承关系。我们可以创建一个更抽象的基类 Animal,然后分别创建 FlyingBirdNonFlyingBird

class Animal {
public:
    virtual void move() = 0;
    virtual ~Animal() {}
};

class FlyingBird : public Animal {
public:
    void move() override {
        fly();
    }

    virtual void fly() {
        std::cout << "鸟儿在飞翔" << std::endl;
    }
};

class NonFlyingBird : public Animal {
public:
    void move() override {
        walk();
    }

    virtual void walk() {
        std::cout << "鸟儿在行走" << std::endl;
    }
};

class Sparrow : public FlyingBird {
    // 麻雀会飞,继承 FlyingBird
};

class Penguin : public NonFlyingBird {
    // 企鹅不会飞,继承 NonFlyingBird
};

通过这种方式,我们遵循了里氏替换原则,确保子类可以替换父类而不会导致程序出错。

5、接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP)要求客户端不应该被迫依赖于其不使用的接口。这意味着应该将庞大的接口拆分成更小、更具体的接口,使得客户端只需要知道它们感兴趣的方法。

接口隔离原则的核心思想是:

  • 一个类对另一个类的依赖应该建立在最小的接口上
  • 客户端不应该依赖它不需要的接口
  • 类间的依赖关系应该建立在最小的接口上

让我们通过一个例子来说明接口隔离原则:

假设我们有一个多功能打印机,它具有打印、扫描和传真功能。如果我们定义一个大的接口:

// 违反接口隔离原则的做法
class IMultiFunctionDevice {
public:
    virtual void print() = 0;
    virtual void scan() = 0;
    virtual void fax() = 0;
    virtual ~IMultiFunctionDevice() {}
};

现在,如果我们有一个简单的打印机,它只能打印,但为了实现这个接口,它必须实现所有的方法:

class SimplePrinter : public IMultiFunctionDevice {
public:
    void print() override {
        std::cout << "打印文档" << std::endl;
    }

    void scan() override {
        // 简单打印机没有扫描功能,但必须实现
        throw std::runtime_error("不支持扫描");
    }

    void fax() override {
        // 简单打印机没有传真功能,但必须实现
        throw std::runtime_error("不支持传真");
    }
};

这违反了接口隔离原则。正确的做法是将大接口拆分成多个小接口:

// 遵循接口隔离原则的做法
class IPrinter {
public:
    virtual void print() = 0;
    virtual ~IPrinter() {}
};

class IScanner {
public:
    virtual void scan() = 0;
    virtual ~IScanner() {}
};

class IFax {
public:
    virtual void fax() = 0;
    virtual ~IFax() {}
};

然后,具体的设备只需要实现它们需要的接口:

class SimplePrinter : public IPrinter {
public:
    void print() override {
        std::cout << "打印文档" << std::endl;
    }
};

class MultiFunctionPrinter : public IPrinter, public IScanner, public IFax {
public:
    void print() override {
        std::cout << "打印文档" << std::endl;
    }

    void scan() override {
        std::cout << "扫描文档" << std::endl;
    }

    void fax() override {
        std::cout << "发送传真" << std::endl;
    }
};

通过这种方式,我们遵循了接口隔离原则,使得客户端只需要依赖它们需要的接口。

6、依赖倒置原则

依赖倒置原则(Dependency Inversion Principle,DIP)是面向对象设计中的一个重要原则。它要求:

  • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
  • 抽象不应该依赖于细节,细节应该依赖于抽象

依赖倒置原则的核心思想是:要面向接口编程,而不是面向实现编程。

让我们通过一个例子来说明依赖倒置原则:

假设我们有一个应用程序,需要记录日志。如果我们直接在高层模块中依赖于具体的日志实现:

// 违反依赖倒置原则的做法
class FileLogger {
public:
    void log(const std::string& message) {
        // 将日志写入文件
        std::cout << "写入文件日志: " << message << std::endl;
    }
};

class Application {
private:
    FileLogger logger; // 直接依赖于具体实现

public:
    void doSomething() {
        // 执行业务逻辑
        logger.log("执行了某些操作");
    }
};

这种方式违反了依赖倒置原则,因为 Application(高层模块)直接依赖于 FileLogger(低层模块)。如果我们想要更换日志实现,比如改为数据库日志,就需要修改 Application 类。

正确的做法是引入抽象接口:

// 遵循依赖倒置原则的做法
class ILogger {
public:
    virtual void log(const std::string& message) = 0;
    virtual ~ILogger() {}
};

class FileLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "写入文件日志: " << message << std::endl;
    }
};

class DatabaseLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "写入数据库日志: " << message << std::endl;
    }
};

class Application {
private:
    ILogger& logger; // 依赖于抽象接口

public:
    Application(ILogger& logger) : logger(logger) {}

    void doSomething() {
        // 执行业务逻辑
        logger.log("执行了某些操作");
    }
};

现在,Application 类依赖于 ILogger 接口,而不是具体的实现。我们可以在运行时注入不同的日志实现:

int main() {
    FileLogger fileLogger;
    DatabaseLogger dbLogger;

    Application app1(fileLogger);
    app1.doSomething();

    Application app2(dbLogger);
    app2.doSomething();

    return 0;
}

通过这种方式,我们遵循了依赖倒置原则,使得系统更加灵活、可扩展和易于维护。

7、总结

SOLID 设计原则是面向对象设计的基石,它们共同帮助我们构建出高质量、可维护和可扩展的软件系统:

原则 核心思想
单一职责原则 (SRP) 一个类只负责一个功能领域
开放-封闭原则 (OCP) 对扩展开放,对修改封闭
里氏替换原则 (LSP) 子类可以替换父类而不影响程序正确性
接口隔离原则 (ISP) 客户端不应该依赖它不需要的接口
依赖倒置原则 (DIP) 依赖于抽象,而不是具体实现

遵循这些原则可以使我们的代码更加健壮、灵活和易于维护。在实际开发中,我们应该根据具体场景灵活运用这些原则,而不是教条式地遵循。

评论