C++ 设计模式笔记——1. 概述
有必要学一下设计模式,虽然在大部分情况下都是基于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
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 << std::endl; }
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 << std::endl;
}
};
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
class Logger {
public:
void log(const std::string &message) {
std::cout << "Log: " << message << std::endl;
}
};
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 << std::endl; }
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
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()
<< std::endl;
}
};
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
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() << std::endl;
}
};
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
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" << std::endl; }
void eat() override { std::cout << "Worker Eating" << std::endl; }
};
class Robot : public IWorker {
public:
void work() override { std::cout << "Robot Working" << std::endl; }
// Robot类不需要eat方法,但仍然必须实现它
void eat() override {} // Do nothing
};
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
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" << std::endl;
}
void eat() override {
std::cout << "Worker Eating" << std::endl;
}
};
class Robot : public IWorkable {
public:
void work() override {
std::cout << "Robot Working" << std::endl;
}
};
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
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() << std::endl;
}
int main() {
Rectangle *r = new Rectangle();
Square *s = new Square();
printArea(r); // Area: 20
printArea(s); // Area: 25
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
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() << std::endl;
}
void printSquareArea(Square *s) {
s->setSide(4);
std::cout << "Square Area: " << s->getArea() << std::endl;
}
int main() {
Rectangle *r = new Rectangle();
Square *s = new Square();
printRectangleArea(r); // Area: 20
printSquareArea(s); // Area: 16
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
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 " << std::endl; }
void drawCircle() { std::cout << " draw Circle " << std::endl; }
};
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
class Shape {
public:
virtual void draw() = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
void draw() override { std::cout << " draw Rectangle " << std::endl; }
};
class Circle : public Shape {
public:
void draw() override { std::cout << " draw Circle " << std::endl; }
};
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
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 << "]" << std::endl;
}
};
class Circle : public ColoredShape {
public:
void draw() override {
std::cout << " draw Circle with color [" << color << "]" << std::endl;
}
};
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
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() << "]"
<< std::endl;
}
};
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() << "]"
<< std::endl;
}
};
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 (访问者)