有必要学一下设计模式,虽然在大部分情况下都是基于Java语言来讨论设计模式,但是面向对象的思想对于各种语言都是通用的,这一系列笔记将使用C++进行讨论。
设计原则
通常设计模式遵循七个基本原则,如下文所示。有的教程中只有六个基本原则,不含单一职责原则。
单一职责原则
- 每个类应该只有一个职责,即该类只有一个引起变化的原因。
- 这样可以减少类之间的耦合,提高系统的可维护性。
与单一职责原则相违背的极端做法是使用上帝对象,它负责了太多的职责,了解了太多的信息,这会导致代码的修改和维护非常困难。
考虑一个情景,我们需要实现一个简单的文件管理类:读取文件内容并输出到控制台,向文件中写入指定内容,我们希望在读写操作的同时在控制台中输出日志。
不符合单一职责原则的例子如下
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
| #include <fstream> #include <iostream> #include <string>
class FileManager { public: void readFile(const std::string &filePath) { std::ifstream file(filePath); if (file.is_open()) { std::string line; while (getline(file, line)) { std::cout << line << '\n'; } file.close(); log("Read file: " + filePath); } else { log("Failed to open file: " + filePath); } }
void writeFile(const std::string &filePath, const std::string &content) { std::ofstream file(filePath); if (file.is_open()) { file << content; file.close(); log("Wrote to file: " + filePath); } else { log("Failed to open file: " + filePath); } }
private: void log(const std::string &message) { std::cout << "Log: " << message << '\n'; } };
int main() { FileManager fileManager; fileManager.readFile("example.txt"); fileManager.writeFile("example.txt", "Hello, World!"); return 0; }
|
这里的FileManager类实际上负责了两个功能:文件读写和日志记录。
如果我们希望调整日志的格式细节和输出位置,必须修改FileManager类本身,但是我们显然不应该为了更新附属的日志功能,而频繁修改主要功能的代码,这是违背单一职责所带来的问题。
更好的做法是将日志功能拆分为单独的Logger类,一个符合单一职责原则的例子如下
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
| #include <fstream> #include <iostream> #include <string>
class Logger { public: void log(const std::string &message) { std::cout << "Log: " << message << '\n'; } };
class FileManager { private: Logger m_logger;
public: explicit FileManager(Logger logger) : m_logger(logger) {}
void readFile(const std::string &filePath) { std::ifstream file(filePath); if (file.is_open()) { std::string line; while (getline(file, line)) { std::cout << line << '\n'; } file.close(); m_logger.log("Read file: " + filePath); } else { m_logger.log("Failed to open file: " + filePath); } }
void writeFile(const std::string &filePath, const std::string &content) { std::ofstream file(filePath); if (file.is_open()) { file << content; file.close(); m_logger.log("Wrote to file: " + filePath); } else { m_logger.log("Failed to open file: " + filePath); } } };
int main() { FileManager fileManager{Logger{}}; fileManager.readFile("example.txt"); fileManager.writeFile("example.txt", "Hello, World!"); return 0; }
|
这里我们将日志类拆分出来,将一个Logger对象作为FileManager的属性,达到自动记录日志的效果。
此时如果我们需要修改日志的细节,只要保证主要接口log()不变,就不需要修改FileManager类的代码。
最少知道原则
- 一个对象应该对其他对象有最少的了解,只与直接相关的对象交互。
- 通过减少对象之间的依赖,可以提高模块的独立性和可维护性。
最少知道原则也被称为迪米特原则。
考虑一个情景:产品类Product拥有一个名称字符串属性,订单类Order拥有一个产品属性,消费者Customer希望获取订单中的产品名称。
直接实现这个需求可能会直接串连起三个类,这导致对产品类的修改也会直接影响到消费者类的代码,如下面的示例(不满足最少知道原则)
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
| #include <iostream> #include <string>
class Product { private: std::string m_name;
public: explicit Product(std::string name) : m_name(std::move(name)) {}
std::string getName() const { return m_name; } };
class Order { public: Product m_product;
explicit Order(Product product) : m_product(std::move(product)) {}
const Product &getProduct() const { return m_product; } };
class Customer { public: void printProductName(const Order &order) { std::cout << "Product Name: " << order.getProduct().getName() << '\n'; } };
int main() { Order order{Product("iPhone")}; Customer{}.printProductName(order); return 0; }
|
这里订单类的接口getProduct完全对外暴露了它的产品属性,消费者直接调用了产品类的接口。
一个更好的选择是由订单类提供产品名称的接口给消费者,消费者无需知道产品类的任何接口,
如果后续我们需要修改产品类,也只需要维护与之直接关联的订单类即可,
如下面的示例(满足最少知道原则)
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
| #include <iostream> #include <string>
class Product { private: std::string m_name;
public: explicit Product(std::string name) : m_name(std::move(name)) {}
std::string getName() const { return m_name; } };
class Order { public: Product m_product;
explicit Order(Product product) : m_product(std::move(product)) {}
std::string getProductName() const { return m_product.getName(); } };
class Customer { public: void printProductName(const Order &order) { std::cout << "Product Name: " << order.getProductName() << '\n'; } };
int main() { Order order{Product("iPhone")}; Customer{}.printProductName(order); return 0; }
|
接口隔离原则
- 使用多个专门的接口,而不是一个通用的接口。
- 客户端不需要负责实现对它无意义的方法,这可以提高系统的灵活性和可维护性。
对于C++而言,这里的要求实际上就是尽可能拆分多个抽象类,一个抽象类的多个方法必须是紧密联系的,不能存在部分方法有意义,部分方法却无意义的情况。
假设我们有一个抽象类IWorker,它提供了纯虚方法work()和eat()。
普通工人Worker和机器人Robot继承自IWorker,Robot类实际上不需要无意义的eat()方法,但是为了语法的合法性,必须实现一个空的函数体。
对应的代码如下(不满足接口隔离原则)
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
| #include <iostream>
class IWorker { public: virtual void work() = 0; virtual void eat() = 0; virtual ~IWorker() = default; };
class Worker : public IWorker { public: void work() override { std::cout << "Worker Working\n"; }
void eat() override { std::cout << "Worker Eating\n"; } };
class Robot : public IWorker { public: void work() override { std::cout << "Robot Working\n"; }
void eat() override {} };
int main() { Worker worker; Robot robot;
worker.work(); worker.eat();
robot.work(); robot.eat();
return 0; }
|
这说明在存在Robot类的情况下,抽象类IWorker的设计是非常不合理的,我们可以将其进一步拆分为两部分:
- 抽象类
IWorkable,提供纯虚方法work();
- 抽象类
IEatable,提供纯虚方法eat()。
普通工人Worker继承IWorkable和IEatable两个抽象类,机器人Robot只需要继承自IWorkable。
满足接口隔离原则的代码如下
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
| #include <iostream>
class IWorkable { public: virtual void work() = 0; virtual ~IWorkable() = default; };
class IEatable { public: virtual void eat() = 0; virtual ~IEatable() = default; };
class Worker : public IWorkable, public IEatable { public: void work() override { std::cout << "Worker Working\n"; }
void eat() override { std::cout << "Worker Eating\n"; } };
class Robot : public IWorkable { public: void work() override { std::cout << "Robot Working\n"; } };
int main() { Worker worker; Robot robot;
worker.work(); worker.eat();
robot.work();
return 0; }
|
在应用接口隔离时,通常会面对多继承的问题,对于多继承的态度:
- 如果继承的基类都具有数据成员,可能会出现菱形继承问题,对应的处理非常繁琐,C++不建议使用这种做法,Java则直接禁止了多继承;
- 如果继承的基类都是没有数据成员的抽象类,那么是没有问题的,C++和Java都是允许的,在Java中对应的是对接口的继承。
里氏替换原则
- 子类对象应该可以替换父类对象,并且程序行为不变。
- 保证子类在继承父类时不会改变父类的预期行为。
我们仍然允许子类重写父类的接口,以实现更丰富的行为,但是更推荐的做法是保持父类的接口不变,子类的新功能通过扩展的新接口提供。
父类和子类的关系并不是通常意义上的一般和特殊的关系,例如从数学上来说,正方形是特殊的长方形,
但是在代码实现中则未必:如果我们首先定义长方形父类Rectangle,然后定义正方形子类Square继承Rectangle,下面的代码就会违反里氏替换原则
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
| #include <iostream>
class Rectangle { protected: double m_width{}; double m_height{};
public: virtual ~Rectangle() = default;
virtual void setWidth(double width) { m_width = width; }
virtual void setHeight(double height) { m_height = height; }
double getArea() const { return m_width * m_height; } };
class Square : public Rectangle { public: void setWidth(double side) override { this->m_width = side; this->m_height = side; }
void setHeight(double side) override { this->m_width = side; this->m_height = side; } };
void printArea(Rectangle *q) { q->setWidth(4); q->setHeight(5); std::cout << "Area: " << q->getArea() << '\n'; }
int main() { Rectangle *r = new Rectangle(); Square *s = new Square();
printArea(r); printArea(s);
return 0; }
|
这是因为长方形的方法和属性并不适用于正方形,正方形显然不能直接继承父类的方法,我们不得不重写方法,在设置时必须同时修改宽度和高度以满足正方形的要求,但是这会导致调用getArea()方法会违反父类的预期行为。
虽然在数学的角度,正方形是特殊的长方形,但是在编程的角度:Rectangle是一个具有长度宽度属性和设置长度宽度方法的长方形类,
正方形类Square不能继承自Rectangle类。
正确的做法是:提供一个更简单的四边形类Quadrilateral,让Rectangle和Square全都继承Quadrilateral。
修改后的代码如下
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 52 53 54 55
| #include <iostream>
class Quadrilateral { public: virtual ~Quadrilateral() = default;
virtual double getArea() const = 0; };
class Rectangle : public Quadrilateral { protected: double m_width{}; double m_height{};
public: void setWidth(double width) { m_width = width; }
void setHeight(double height) { m_height = height; }
double getArea() const override { return m_width * m_height; } };
class Square : public Quadrilateral { private: double m_side{};
public: void setSide(double side) { m_side = side; }
double getArea() const override { return m_side * m_side; } };
void printRectangleArea(Rectangle *r) { r->setWidth(4); r->setHeight(5); std::cout << "Rectangle Area: " << r->getArea() << '\n'; }
void printSquareArea(Square *s) { s->setSide(4); std::cout << "Square Area: " << s->getArea() << '\n'; }
int main() { Rectangle *r = new Rectangle(); Square *s = new Square();
printRectangleArea(r); printSquareArea(s);
delete r; delete s;
return 0; }
|
依赖倒置原则
- 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
- 抽象不应该依赖于细节,细节应该依赖于抽象。
- 应该通过依赖于抽象接口而不是具体实现来降低耦合度。
举个例子:我们使用Shape类代表可绘制的图形基类,并由此继承得到具体的Rectangle和Circle子类,
使用GraphicEditor类来实现绘画功能,对外提供void drawShape(Shape *)接口实现绘画功能。
一个不满足依赖倒置原则的示例如下
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
| #include <iostream>
class Shape { public: int m_type_id; };
class Rectangle : public Shape { public: Rectangle() : Shape() { m_type_id = 1; } };
class Circle : public Shape { public: Circle() : Shape() { m_type_id = 2; } };
class GraphicEditor { public: void drawShape(Shape *s) { if (s->m_type_id == 1) { drawRectangle(); } else if (s->m_type_id == 2) { drawCircle(); } }
private: void drawRectangle() { std::cout << " draw Rectangle \n"; }
void drawCircle() { std::cout << " draw Circle \n"; } };
int main() { GraphicEditor editor;
Shape *r = new Rectangle(); Shape *c = new Circle();
editor.drawShape(r); editor.drawShape(c);
delete r; delete c;
return 0; }
|
这里我们必须使用m_type_id来标识图形自身的类型信息,然后由GraphicEditor类调用不同版本的绘制方法。(高层模块依赖底层模块)
一个满足依赖倒置原则的示例如下
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
| #include <iostream>
class Shape { public: virtual void draw() = 0; virtual ~Shape() = default; };
class Rectangle : public Shape { public: void draw() override { std::cout << " draw Rectangle \n"; } };
class Circle : public Shape { public: void draw() override { std::cout << " draw Circle \n"; } };
class GraphicEditor { public: void drawShape(Shape *s) { s->draw(); } };
int main() { GraphicEditor editor;
Shape *r = new Rectangle(); Shape *c = new Circle();
editor.drawShape(r); editor.drawShape(c);
delete r; delete c;
return 0; }
|
这里在图形基类中引入了纯虚方法draw(),要求每一个图形类自行实现对应的draw()方法,而不是将其留给GraphicEditor处理(底层模块依赖于抽象)。
GraphicEditor类的drawShape方法得到极大的简化:只需要使用图形基类的draw()方法即可(高层模块依赖于抽象)。
与此同时,我们不再需要m_type_id来维护类型标识,虚函数可以自动实现多态,
开闭原则
- 对扩展开放,但是对修改封闭。
- 通过继承或接口来扩展功能,而不修改现有代码,减少对系统本身的影响。
仍然使用上面的例子说明,我们希望支持更多的图形
- 对于修改前的代码,如果我们需要扩展支持更多的图形子类,不仅需要维护
m_type_id的对应关系,更需要修改GraphicEditor类的内部:实现新图形对应的绘制方法,并且在drawShape中加入对应的选择分支,这显然不满足开闭原则。
- 对于修改后的代码,如果我们需要扩展支持更多的图形子类,
GraphicEditor类是不需要进行任何修改的,这就满足了开闭原则。
依赖倒置原则和开闭原则是比较类似的,只是两者的侧重点不同,违背其中一个原则的代码通常也会违背另一个原则。两者的推荐做法都是引入合适的抽象。
合成复用原则
- 优先使用对象组合而不是类继承来实现代码复用。
- 通过组合可以灵活地构建新的功能,减少继承层次带来的复杂性。
类继承通常被称为“黑箱复用”,与之相对的,对象组合被称为“白箱复用”。
我们继续上面的例子,新的需求是给图形都加上颜色属性,不同的图形可以支持不同的颜色属性。
通过继承实现的例子如下(不符合合成复用原则)
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 52 53
| #include <iostream> #include <string>
class Shape { public: virtual void draw() = 0; virtual ~Shape() = default; };
class ColoredShape : public Shape { protected: std::string color; public: void setColor(const std::string& c) { color = c; } };
class Rectangle : public ColoredShape { public: void draw() override { std::cout << " draw Rectangle with color [" << color << "]\n"; } };
class Circle : public ColoredShape { public: void draw() override { std::cout << " draw Circle with color [" << color << "]\n"; } };
class GraphicEditor { public: void drawShape(ColoredShape *s) { s->draw(); } };
int main() { GraphicEditor editor;
ColoredShape *r = new Rectangle(); r->setColor("red"); ColoredShape *c = new Circle(); c->setColor("blue");
editor.drawShape(r); editor.drawShape(c);
delete r; delete c;
return 0; }
|
这里我们使用类继承实现了功能的扩展:继承Shape基类得到ColoredShape子类,它具有颜色属性,具体的图形类需要继承ColoredShape而不是Shape。
改成通过组合实现的例子如下(符合合成复用原则)
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| #include <iostream> #include <string> #include <utility>
class Color { private: std::string m_color;
public: Color(std::string c) : m_color(std::move(c)) {}
std::string getColor() const { return m_color; } };
class Shape { public: virtual void draw() = 0; virtual ~Shape() = default; };
class Rectangle : public Shape { private: Color m_color;
public: Rectangle(Color c) : m_color(std::move(c)) {}
void draw() override { std::cout << " draw Rectangle with color [" << m_color.getColor() << "]" << '\n'; } };
class Circle : public Shape { private: Color m_color;
public: Circle(Color c) : m_color(std::move(c)) {}
void draw() override { std::cout << " draw Circle with color [" << m_color.getColor() << "]" << '\n'; } };
class GraphicEditor { public: void drawShape(Shape *s) { s->draw(); } };
int main() { GraphicEditor editor;
Shape *r = new Rectangle(Color("red")); Shape *c = new Circle(Color("blue"));
editor.drawShape(r); editor.drawShape(c);
delete r; delete c;
return 0; }
|
为了支持具有颜色的图形,我们将颜色对象作为图形子类的一个属性,这并不需要修改原本的那些代码。
事实上,上述代码仍然支持不含颜色的图形绘制,颜色支持只是一个可选项。
经典设计模式
下面分类列举了最经典的二十多种设计模式,后续的笔记会分别进行学习。
鉴于设计模式本身是面向对象的经验总结,在实现代码中会尽可能彻底地基于面向对象的语法来实现,使用智能指针来管理内存,
这样可以更好地与Java的相关代码进行对应。
必须明确的是,设计模式中的部分做法实际上是在给彻底面向对象的语句打补丁,尤其是一部分行为型模式,
如果允许使用面向过程或者函数式的语句,可以更轻松地达到同样的目的。
创建型模式
创建型模式通常包括下面几类设计模式:
- Factory Method (工厂方法)
- Abstract Factory (抽象工厂)
- Builder (建造者/生成器)
- Prototype (原型)
- Singleton (单例)
结构型模式
结构型模式通常包括下面几类设计模式:
- Adapter (适配器)
- Bridge (桥接)
- Composite (组合)
- Decorator (装饰)
- Facade (外观)
- Flyweight (享元)
- Proxy (代理)
行为型模式
行为型模式通常包括下面几类设计模式:
- Chain of Responsibility (责任链)
- Command (命令)
- Iterator (迭代器)
- Mediator (中介者)
- Memento (备忘录)
- Observer (观察者)
- State (状态)
- Strategy (策略)
- Template Method (模板方法)
- Visitor (访问者)