设计模式是在软件开发中常见的解决特定问题的可复用设计方案。它们提供了一种通用的方法来解决常见的设计问题,有助于使代码更加可维护、灵活和可扩展。在以前的时候做过一次总结,也就是下面这个笔记,这次将它分享给大家。
这里先介绍设计模式的七大原则:
单一职责原则(Single Responsibility Principle,SRP):
单一职责原则是面向对象设计中的一个重要原则,它指导着一个类应该只有一个引起变化的原因,或者说一个类应该只负责一组相关的功能。这意味着一个类应该专注于完成单一的任务,而不应该包含过多的责任。如果一个类承担了过多的责任,那么修改其中一个责任可能会影响到其他责任,从而导致代码的脆弱性和不稳定性。
例如,一个类既负责处理用户输入,又负责处理数据存储,违反了单一职责原则。因为用户输入和数据存储是两个不同的功能,应该分别由不同的类来处理。
以下是一个简单的 C++ 代码示例,演示了一个 ViolatingSRP 类违反了单一职责原则,以及如何通过拆分职责来符合该原则:
#include <iostream>
#include <string>
// ViolatingSRP 违反了单一职责原则
class ViolatingSRP {
public:
void processUserInput(const std::string& userInput) {
// 处理用户输入的功能
std::cout << "Processing user input: " << userInput << std::endl;
}
void saveDataToFile(const std::string& data) {
// 保存数据到文件的功能
std::cout << "Saving data to file: " << data << std::endl;
}
};
// 通过拆分职责来符合单一职责原则
class UserInputProcessor {
public:
void processUserInput(const std::string& userInput) {
// 处理用户输入的功能
std::cout << "Processing user input: " << userInput << std::endl;
}
};
class DataSaver {
public:
void saveDataToFile(const std::string& data) {
// 保存数据到文件的功能
std::cout << "Saving data to file: " << data << std::endl;
}
};
int main() {
// ViolatingSRP 违反了单一职责原则的类
ViolatingSRP violatingSRP;
violatingSRP.processUserInput("User input data");
violatingSRP.saveDataToFile("Data to save");
// 通过拆分职责来符合单一职责原则的类
UserInputProcessor userInputProcessor;
userInputProcessor.processUserInput("User input data");
DataSaver dataSaver;
dataSaver.saveDataToFile("Data to save");
return 0;
}
在上面的示例中,ViolatingSRP 类负责处理用户输入和保存数据到文件两个不同的功能,违反了单一职责原则。而拆分职责后的 UserInputProcessor 类专门负责处理用户输入,DataSaver 类专门负责保存数据到文件,每个类都只负责一个功能,符合单一职责原则。
接口隔离原则(Interface Segregation Principle,ISP)
接口隔离原则要求接口应该小而专门,而不是大而笨重。该原则的核心思想是客户端不应该依赖它不需要的接口,一个类不应该被迫实现它用不到的接口。接口隔离原则旨在降低接口的耦合性,使得系统更加灵活、可维护和可扩展。
接口隔离原则主要有以下几个要点:
-
接口应该小而专门:接口应该只包含客户端需要的方法,不应该包含客户端不需要的方法。这样可以避免将不相关的方法暴露给客户端,减少了客户端的依赖。
-
接口设计应该符合客户端的需求:在设计接口时应该考虑到客户端的需求,确保接口提供的方法对客户端来说是有意义的、可理解的,并且只包含客户端需要的方法。
-
避免臃肿的接口:接口不应该包含大量的方法,而应该保持简洁。如果一个接口过于庞大,往往意味着它违反了单一职责原则。
-
接口隔离有助于解耦:通过将大接口拆分为多个小接口,可以降低模块之间的耦合度,提高系统的灵活性和可维护性。
以下是一个简单的 C++ 代码示例,演示了接口隔离原则的应用:
#include <iostream>
// 违反接口隔离原则的示例
class Worker {
public:
virtual void work() = 0; // 工作方法
virtual void eat() = 0; // 吃饭方法
virtual void sleep() = 0; // 睡觉方法
};
// 符合接口隔离原则的示例
class IWorkable {
public:
virtual void work() = 0; // 工作方法
};
class IEatable {
public:
virtual void eat() = 0; // 吃饭方法
};
class ISleepable {
public:
virtual void sleep() = 0; // 睡觉方法
};
// Worker 类只实现了工作相关的接口
class BetterWorker : public IWorkable {
public:
void work() override {
std::cout << "Working..." << std::endl;
}
};
int main() {
// 符合接口隔离原则的示例
BetterWorker worker;
worker.work();
return 0;
}
在上面的示例中,Worker
类违反了接口隔离原则,因为它包含了工作、吃饭和睡觉三个不相关的方法。而在修正后的代码中,我们将接口拆分为 IWorkable
、IEatable
和 ISleepable
三个小接口,分别表示工作、吃饭和睡觉。这样可以使得类只需要实现自己需要的接口,避免了不必要的依赖。
依赖倒置原则(Dependency Inversion Principle,DIP):
依赖倒置原则强调高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于具体实现细节,具体实现细节应该依赖于抽象。换句话说,高层模块和低层模块都应该依赖于抽象,而具体的实现细节则应该依赖于抽象而不是相反。
依赖倒置原则主要有以下几个要点:
-
高层模块和低层模块之间通过抽象进行通信:高层模块和低层模块都应该依赖于抽象,而不是直接依赖于具体的实现细节。这样可以降低模块之间的耦合度,提高系统的灵活性和可维护性。
-
抽象不应该依赖于具体实现细节:抽象应该定义清晰明确的接口,而不应该依赖于具体的实现细节。这样可以保持抽象的稳定性,避免因为具体实现的改变而影响到抽象。
-
具体实现细节应该依赖于抽象:具体的实现细节应该依赖于抽象定义的接口,而不应该依赖于其他具体实现细节。这样可以使得具体的实现细节可以灵活地替换和扩展,而不影响到其他部分。
依赖倒置原则通常通过依赖注入(Dependency Injection)来实现,即通过构造函数注入、方法参数注入或者接口注入等方式将依赖关系转移到外部管理。
以下是一个简单的 C++ 代码示例,演示了依赖倒置原则的应用:
#include <iostream>
// 定义抽象接口
class IWorker {
public:
virtual void work() = 0; // 工作方法
};
// 具体的工作类,实现了抽象接口
class Worker : public IWorker {
public:
void work() override {
std::cout << "Working..." << std::endl;
}
};
// 高层模块,依赖于抽象接口
class Manager {
private:
IWorker* worker; // 依赖注入
public:
Manager(IWorker* w) : worker(w) {}
void manage() {
worker->work(); // 通过抽象接口调用工作方法
}
};
int main() {
// 创建具体的工作类对象
Worker worker;
// 创建高层模块对象,并通过构造函数注入具体的工作类对象
Manager manager(&worker);
// 调用高层模块的方法
manager.manage();
return 0;
}
在上面的示例中,Manager
类是一个高层模块,它依赖于抽象接口 IWorker
而不是具体的 Worker
类。通过构造函数注入的方式将具体的 Worker
对象注入到 Manager
类中,使得 Manager
类可以通过抽象接口调用工作方法,而不需要知道具体的实现细节。这样可以实现依赖倒置原则,降低了模块之间的耦合度。
里氏替换原则(Liskov Substitution Principle,LSP)
里氏替换原则由芭芭拉·利斯科夫(Barbara Liskov)提出。它是针对继承关系的一个原则,指导着子类应该能够替换掉父类并且程序仍然能够正确运行。换句话说,子类必须能够替换其父类并保持程序的逻辑正确性,而不需要修改原有的程序。
里氏替换原则主要有以下几个要点:
-
子类必须能够替换父类:任何基类可以出现的地方,子类一定可以出现,而且要能够替换掉基类并且程序仍然能够正确运行。
-
继承意味着 "is-a" 关系:子类应该继承父类的全部属性和方法,并且符合 "is-a" 关系,即子类对象应该可以被当作父类对象使用。
-
子类不应该重写父类的非抽象方法:子类可以通过重写父类的抽象方法来实现自己的业务逻辑,但是不应该重写父类的非抽象方法,这会破坏原有的程序逻辑。
-
里氏替换原则是实现多态性的基础:通过遵循里氏替换原则,可以实现多态性,提高程序的灵活性和可扩展性。
以下是一个简单的 C++ 代码示例,演示了里氏替换原则的应用:
#include <iostream>
// 父类
class Shape {
public:
virtual double area() const = 0; // 抽象方法:计算面积
};
// 子类:矩形
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
};
// 子类:正方形
class Square : public Shape {
private:
double side;
public:
Square(double s) : side(s) {}
double area() const override {
return side * side;
}
};
// 计算图形面积的函数
void printArea(const Shape& shape) {
std::cout << "Area: " << shape.area() << std::endl;
}
int main() {
Rectangle rectangle(5, 4);
Square square(5);
// 通过基类调用不同的子类,实现多态
printArea(rectangle);
printArea(square);
return 0;
}
在上面的示例中,Rectangle
和 Square
分别是 Shape
的子类,它们都重写了 Shape
中的抽象方法 area()
。在 main()
函数中,通过基类 Shape
调用 printArea()
函数,向函数中传递了不同的子类对象,这样就实现了多态。符合里氏替换原则的设计使得我们可以在不修改 printArea()
函数的情况下,很容易地添加新的子类来扩展程序。
开放封闭原则(Open/Closed Principle,OCP)
开放封闭原则由Bertrand Meyer于1988年提出。该原则指出软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。换句话说,一个软件实体应该通过扩展来实现新功能,而不是通过修改现有的代码来实现。
开放封闭原则主要有以下几个要点:
-
对扩展开放:意味着当需要添加新的功能时,应该通过扩展现有代码来实现,而不是修改原有代码。新功能应该在不影响现有代码的情况下添加进来。
-
对修改关闭:意味着一旦代码编写完成并通过测试,就应该尽量避免修改它。修改现有代码可能会引入新的错误,并且可能会影响到其他部分的代码。
-
通过抽象来实现开放封闭原则:可以通过抽象来定义稳定的接口或者基类,而将实现细节延迟到子类或者实现类中。这样可以保持接口的稳定性,同时可以通过子类或者实现类来实现新的功能。
-
多态性是实现开放封闭原则的基础:通过多态性,可以在不修改现有代码的情况下,通过子类来扩展和修改程序的行为。这样就实现了对修改关闭,对扩展开放的原则。
以下是一个简单的 C++ 代码示例,演示了开放封闭原则的应用:
#include <iostream>
#include <vector>
// 基类:图形
class Shape {
public:
virtual double area() const = 0; // 抽象方法:计算面积
};
// 子类:矩形
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
};
// 子类:圆形
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14 * radius * radius;
}
};
// 计算图形集合的总面积
double totalArea(const std::vector<Shape*>& shapes) {
double total = 0;
for (const auto& shape : shapes) {
total += shape->area();
}
return total;
}
int main() {
std::vector<Shape*> shapes;
shapes.push_back(new Rectangle(5, 4));
shapes.push_back(new Circle(3));
// 计算图形集合的总面积
std::cout << "Total area: " << totalArea(shapes) << std::endl;
// 释放内存
for (const auto& shape : shapes) {
delete shape;
}
return 0;
}
在上面的示例中,Rectangle
和 Circle
分别是 Shape
的子类,它们都重写了 Shape
中的抽象方法 area()
。通过向 totalArea()
函数中传递一个存储 Shape
指针的向量,我们可以很容易地计算出图形集合的总面积,而不需要修改 totalArea()
函数的代码。这样就实现了对修改关闭,对扩展开放的原则。
迪米特法则(Law of Demeter,LoD)
迪米特法则,又称为最少知识原则(Principle of Least Knowledge),是面向对象设计中的一个重要原则,由迪米特(Demeter)提出。迪米特法则强调一个对象应该对其它对象有尽可能少的了解,也就是说,一个对象不应该直接与太多其他对象发生交互。这样可以降低对象之间的耦合度,提高系统的灵活性和可维护性。
迪米特法则的基本思想可以总结为:
- 每个单元(类、模块、函数等)只与其直接的朋友发生交流,不涉及陌生对象。
- 如果两个对象之间的交互过于复杂,应该引入中介者对象来解耦。
迪米特法则的核心在于控制对象之间的通信关系,通过限制对象之间的直接交互来降低耦合性。一个对象应该尽可能少地了解其它对象的细节,只与其直接的朋友交流。这样可以避免对象之间的依赖关系过于复杂,使得系统更易于维护和扩展。
迪米特法则的应用可以通过以下几个方面体现:
- 合理设计对象的接口:尽量设计简洁清晰的接口,减少对象之间的直接交互。
- 使用中介者模式:引入一个中介者对象来管理对象之间的交互,降低对象之间的耦合度。
- 封装对象的细节:将对象的内部细节封装起来,只提供必要的接口给外部使用,减少对象之间的依赖关系。
- 避免链式调用过多对象的方法:过多的链式调用可能导致对象之间的依赖关系过于复杂,应该尽量避免。
下面是一个简单的 C++ 示例,演示了迪米特法则的应用:
#include <iostream>
#include <vector>
// 学生类
class Student {
public:
void study() const {
std::cout << "Student is studying." << std::endl;
}
};
// 班级类,代表了多个学生的集合
class Class {
private:
std::vector<Student> students;
public:
void addStudent(const Student& student) {
students.push_back(student);
}
// 班级负责管理学生的学习行为
void teach() const {
for (const auto& student : students) {
student.study();
}
}
};
int main() {
Student student1;
Student student2;
Class class1;
class1.addStudent(student1);
class1.addStudent(student2);
// 班级对象调用学生的学习行为
class1.teach();
return 0;
}
在上述代码中,Class
类负责管理学生的学习行为,而不需要直接和 Student
类交互。这符合迪米特法则的原则,即一个对象应该只与其直接的朋友发生交互,不应该和陌生对象直接交互。Class
类只与其直接朋友(即 Student
对象)发生交互,而不会与其他对象直接发生交互,降低了对象之间的耦合度。
合成/聚合复用原则(Composition/Aggregation Reuse Principle,CARP)
合成/聚合复用原则(Composition/Aggregation Reuse Principle,CARP)是面向对象设计中的一个重要原则,它强调应该优先使用对象组合和聚合,而不是通过继承来达到代码复用的目的。该原则的核心思想是在新的对象中使用已有的对象,而不是通过继承来获得已有对象的行为。
合成/聚合复用原则的主要思想包括:
-
优先使用对象组合和聚合:在设计类时,应该优先考虑将已有的类对象作为成员变量组合到新的类中,或者通过聚合关系将已有的类对象组合起来,而不是通过继承来获得已有类的行为。
-
对象之间的关系更灵活:通过对象组合和聚合可以实现更灵活的对象之间的关系,对象的行为不会受到父类的限制,避免了类之间过于紧密的耦合。
-
减少继承带来的副作用:通过对象组合和聚合可以避免继承带来的副作用,例如子类与父类之间的强耦合、父类的修改可能影响到子类等问题。
-
增强代码的可维护性和可扩展性:通过对象组合和聚合可以降低类之间的耦合度,提高代码的可维护性和可扩展性,使得系统更加灵活和易于理解。
以下是一个简单的 C++ 代码示例,演示了合成/聚合复用原则的应用:
#include <iostream>
#include <string>
// 飞行行为接口
class FlyBehavior {
public:
virtual void fly() const = 0;
};
// 实现了飞行行为的类
class FlyWithWings : public FlyBehavior {
public:
void fly() const override {
std::cout << "Flying with wings!" << std::endl;
}
};
// 实现了不会飞行的类
class FlyNoWay : public FlyBehavior {
public:
void fly() const override {
std::cout << "Unable to fly!" << std::endl;
}
};
// 鸭子类
class Duck {
protected:
FlyBehavior* flyBehavior; // 使用对象组合
public:
Duck(FlyBehavior* fb) : flyBehavior(fb) {}
virtual void performFly() const {
flyBehavior->fly();
}
virtual void display() const = 0;
};
// 绿头鸭类
class MallardDuck : public Duck {
public:
MallardDuck() : Duck(new FlyWithWings()) {} // 使用对象组合
void display() const override {
std::cout << "Displaying Mallard Duck" << std::endl;
}
};
int main() {
MallardDuck duck;
duck.display();
duck.performFly();
return 0;
}
上述代码中,FlyBehavior
是一个飞行行为的接口,而 FlyWithWings
和 FlyNoWay
分别是实现了飞行行为的具体类。Duck
类通过对象组合的方式持有一个 FlyBehavior
对象,而不是通过继承来获得飞行行为。这样做符合合成/聚合复用原则,使得 Duck
类可以根据需要灵活地组合不同的飞行行为,而不会受到继承带来的限制。