【设计模式】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;
}
在这个例子中,我们有两个类 UserSaver 和 UserRetriever,它们分别负责保存用户信息和检索用户信息。这两个类各自都只有一个单一的职责,即负责一个特定的功能。如果我们需要修改保存用户信息的逻辑,我们只需要修改 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,然后分别创建 FlyingBird 和 NonFlyingBird:
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) | 依赖于抽象,而不是具体实现 |
遵循这些原则可以使我们的代码更加健壮、灵活和易于维护。在实际开发中,我们应该根据具体场景灵活运用这些原则,而不是教条式地遵循。