有必要学一下设计模式,虽然在大部分情况下都是基于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 << 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
#include <fstream>
#include <iostream>
#include <string>

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
#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()
<< 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
#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() << std::endl;
}
};

int main() {
Order order{Product("iPhone")};
Customer{}.printProductName(order);
return 0;
}

接口隔离原则

  • 使用多个专门的接口,而不是一个通用的接口。
  • 客户端不需要负责实现对它无意义的方法,这可以提高系统的灵活性和可维护性。

对于C++而言,这里的要求实际上就是尽可能拆分多个抽象类,一个抽象类的多个方法必须是紧密联系的,不能存在部分方法有意义,部分方法却无意义的情况。

假设我们有一个抽象类IWorker,它提供了纯虚方法work()eat()。 普通工人Worker和机器人Robot继承自IWorkerRobot类实际上不需要无意义的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" << 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继承IWorkableIEatable两个抽象类,机器人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" << 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
#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() << 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,让RectangleSquare全都继承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() << 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类代表可绘制的图形基类,并由此继承得到具体的RectangleCircle子类, 使用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 " << 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
#include <iostream>

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
#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 << "]" << 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
#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() << "]"
<< 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 (访问者)