常用设计模式

简介

三类模式

  • 创建型模式(Creational Patterns):隐藏对象创建逻辑
  • 结构型模式(Structural Patterns):类和对象的组合
  • 行为型模式(Behavioral Patterns):对象之间的通信

模式之间的关系图:

六大原则(SOLID)

"SOLID"稳定的

  • 开闭原则(Open Close Principle):对扩展开放,对修改关闭。第一时间应该想到的是如何扩展(组合、继承),而不是改已有代码,这是最基础的原则
  • 单一职责(Single Responsibility Principle):一个类应该只有一个引发变化的原因(对于函数也应该如此)
  • 里氏替换(Liskov Substitution Principle):LSP,子类可以透明地替换掉父类,只要父类能出现的地方,子类就可以出现
  • 依赖倒转(Dependence Inversion Principle):面向接口编程,细节依赖于抽象
  • 接口隔离(Interface Segregation Principle):接口应该最小,不要依赖于不需要的接口。使用多个隔离的接口比使用单个接口好
  • 迪米特法则(Law of Demeter):LoD,又称为最少知道原则。实现细节应该封装起来,真正需要的才以public的方式提供给外部

不应该过分纠结特定语言下的实现,而是应该认真体会其设计思想,也就是六大原则的运用。设计模式是一把不错的锤子,但如果你只有这一把锤子,看什么都会像钉子。

创建型

工厂方法模式(Factory Method)

一个产品基类、多个具体产品;一个工厂基类、多个具体工厂。

具体工厂和具体产品一一对应。简单来说只有一个接口方法/函数用于创建产品。

使用工厂可以避免依赖于具体的产品类,方便扩展。将创建和使用分开,还可以方便地实现对象池。

抽象工厂模式(Abstract Factory)

多个抽象产品,工厂基类中也有多个创建接口,工厂的工厂。

一个具体工厂创建一个系列的具体产品(比如A1和B1来自同一个工厂,A2和B2来自另一个工厂)

单例模式(Singleton)

一个类只有一个实例。隐藏它的创建方法,外界只需要知道如何访问到唯一的实例。

C++实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton {
public:
// 禁止外部创建
Singleton(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator= (const Singleton&) = delete;
Singleton& operator= (Singleton&&) = delete;

static auto& GetInstance() {
static Singleton instance;
return instance;
}
private:
Singleton() = default;
};

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
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
66
67
68
69
70
71

// 懒汉(非线程安全)
public sealed class Singleton
{
private static Singleton instance = null;
private Singleton() { }
public static Singleton Instance
{
get
{
// 延迟实例化
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}
}

// 懒汉(双重检查锁定)
public sealed class Singleton
{
private static Singleton instance = null;
private static readonly object padlock = new object();
private Singleton() { }
public static Singleton Instance
{
get
{
// 先判断再锁定,提高性能
if (instance == null)
{
lock (padlock)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
}

// 饿汉
public sealed class Singleton
{
// 类加载时就创建,不存在多线程问题
private static readonly Singleton instance = new Singleton();
static Singleton() { }
private Singleton() { }
public static Singleton Instance
{
get
{
return instance;
}
}
}

// 懒汉(System.Lazy<T>)
public sealed class Singleton
{
// 写法上像饿汉,但是Lazy会延迟初始化内部Value
private static readonly Lazy<Singleton> lazy = new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance => lazy.Value;
private Singleton() { }
}

生成器模式(Builder)

当一个类型的构造函数参数很多,或者构造过程繁琐的时候,把构造过程封装到对象里,也就是生成器。由生成器提供各种build方法,由指挥者按顺序执行。

使用

1
2
3
4
5
Director director;

CarBuilder builder;
director.constructSportCar(builder); // 传递特定的builder
Car car = builder.getProduct(); // 复杂的设置参数过程就被隐藏了

比较常见的例子是DoTween里设置一个Tweener

1
2
3
4
5
6
7
// Same as the previous examples, but force the transform to
// snap on integer values (very useful for pixel perfect stuff)
transform.DOMove(new Vector3(2,2,2), 2)
.SetOptions(true)
.SetEase(Ease.OutQuint)
.SetLoops(4)
.OnComplete(myFunction);

原型模式(Prototype、Clone)

基类/接口(Clonable)提供一个抽象的克隆方法(Clone),由子类去实现具体的克隆逻辑,比如调用拷贝构造函数或新建一个。使用时不需要按照具体类型去new了。

为了方便找到可用于复制的原型,还可以实现一个中心化的原型注册表,其中实现了常用原型的获取方法:

C++可以参考这篇文章的实现:用一个哈希表保存子类对象的实例或者创建函数(统一接口),从而实现由基类获取子类对象。

结构型

适配器模式(Adapter、Wrapper)

现有的接口不兼容,那么就使用一个适配器进行封装,对外提供需要的接口。实际上是在内部进行了转换之后再使用原有的接口

这个封装的过程可以用组合,也可以用继承。可以认为是把转换的过程封装到了对象里。

STL里的stackqueue的默认实现就是对deque的接口进行了简单的封装,这也是一种适配器。

桥接模式(Bridge)

把具体的实现交给内部组合的对象,虽然对外接口不变,但是表现的行为已经可以根据组合对象而变。

  • 可以将类的多种功能分派给其它类,方便修改

  • 通过派生一个类,只能获得一个维度上的扩展。如果使用对象组合,就可以获得二维的扩展

  • 可以很方便地改变底层,从而实现跨平台

桥接模式的一种特例是「Pimpl Idiom」,不仅把具体实现完全封装在内部对象中,而且只保存了它的指针,在隐藏了实现细节的同时还减少了头文件的大小。(如果要使用智能指针,需要注意析构函数需要出现在实现类的完整定义之后)

实现Pimpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Test.h
class Test {
public:
Test() = default;
~Test();
private:
class impl;
std::unique_ptr<impl> _impl; // 注意unique_ptr不能拷贝,需要自行实现拷贝函数
};

// Test.cpp
class Test::impl{ // 也可以放到private头文件中,然后include到这里
// 实现细节
};

Test::Test() : _impl(std::make_unique<Test::impl>()) {}
Test::~Test() = default;

组合模式(Comopsite、Object Tree)

前面已经用过很多次了,就是在对象中组合其它对象,称为组件。组件需要一个容器来承载,这个容器可以是一个包装类,也可以是一个数组。

适配器和桥接都是基于组合模式,另外比如GameObject上的多种Component也是一种组合。

装饰器模式(Decorator、Wrapper)

也是组合模式的应用,同时还在覆写基类方法时调用了组合对象的方法,关键就在于这个组合的对象可能也组合了其它对象,于是最终这个方法被调用后的结果将是层层包装过的,这也就是装饰(俄罗斯套娃)。

为了实现这样任意的组合,需要具体的组件和装饰器都实现了组件接口,并且在装饰器里组合了一个组件

Java中具体实现可见这篇文章

现在支持匿名函数对象的语言可以直接接受一个函数,然后返回另一个包装的函数对象,这也是一种装饰的应用。

外观模式(Facade)

封装复杂的子系统,暴露给外界一个简单的接口(但这个类可能会跟程序中非常多的类耦合)。

一般会用单例来实现,比如游戏中的一个单例管理器。

享元模式(Flyweight、Cache)

将具有重复不可变数据的对象缓存起来,使用的时候应用不同的外在状态即可,而不用重复创建对应状态的对象。比如在屏幕上绘制多个相同的图片,实际上图片资源只有一份,只不过绘制的位置的不一样。

这个模式是用来提高性能的,通常结合工厂来实现一个对象池。

代理模式(Proxy)

还是先进行对象组合,然后对外提供与组合对象一致的接口。通过代理类可以额外进行延迟初始化、访问检查、日志、缓存、自动内存管理之类的处理。

对于实际情况来说,比如:Lazy<T>shared_ptr<T>

行为型

责任链模式(Chain of Responsibility/Command)

假设某件事情需要经过多个步骤进行处理,这些步骤还可能变化,这时可以把整个流程看成一个链表,每个步骤就是一个链表节点,从而实现动态的处理流程

一般和组合模式一起使用,可以把请求派发给各组件。又比如,对于树形的UI层级,一个点击下去,需要逐层寻找能够处理点击的组件并使之响应。再比如,到达某个动画帧时,可能需要逐个进行改变颜色、改变大小等处理。

命令模式(Command、Action、Transaction)

将每个操作封装为对象,这个对象就是命令,统一有个执行的接口。需要调用操作的地方只需要创建对应的命令实例。因为是对象,这个命令还可以可以传递和保存

可以构造命令执行队列,可以实现操作回滚和重做。

迭代器模式(Iterator)

把容器内部实现隐藏起来,对外提供一个遍历访问容器元素的接口(迭代器本身是个对象,应该说是迭代器向外部提供了接口)

这在STL里很常见,STL容器对应的迭代器统一使用了using别名,比如list::iterator,很容易获取

应用

1
2
3
4
5
6
7
8
9
std::list l{ 9, 0, 1 ,3 };
for (std::list<int>::iterator itor{ l.begin() }; itor != l.end(); itor++) {
// get *itor to do something
}

// C++11有了auto和基于范围的for之后就不需要写得这么麻烦了,但是底层实际上还是在调用迭代器
for(auto item : l){
// ...
}

中介者模式(Mediator、Controller)

多个对象之间并不直接通信,而是添加一个中间类,所有需要交互的对象都访问中间类。

就好比是聊天室:所有人都只需要把消息发送给消息中心而不用知道其它人的存在,消息中心保存了所有参与者的引用并负责群发消息

跟外观模式的区别是,这些对象之间不会有依赖,而外观包装的接口的实现里,并不会限制那些对象之前的关系。比如M-V-VM中的VM就是一个中介者,V和M都不会直接通信。

备忘录模式(Memento、Snapshot)

对象的状态需要保存,但是又不能把所有数据都暴露出去,于是可以采用一个嵌套类来记录自身状态。这样被保存的类可以访问嵌套类的所有数据,而外界状态栈只需要保存这些状态对象又不会访问到里面的具体数据

对于现代的编程语言,可以直接序列化和反序列化来实现快照这一功能。

观察者模式(Observer、Listener、Event-Subscriber)

可以实现一对多的依赖关系:发布者保存了订阅者的列表,当状态改变时,就遍历订阅者进行通知

C++一种实现可以参考这篇文章:C++不定长参数委托

状态模式(State)

最直接的应用就是有限状态机,把对象所有状态都抽象成一个一个类,每个状态类都实现了当前状态下的执行接口,同时上下文本身还要支持状态间的转换

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StateBase {
public:
virtual void Update() = 0;
};

class Context {
public:
void Update() {
_currentState->Update();
}
void ChangeTo(StateBase* newState) {
_currentState = newState;
}
private:
StateBase* _currentState;
};

class State1 : public StateBase{
public:
virtual void Update() override {
// do something
}
};

策略模式(Strategy)

把算法封装到对象中,最直接的应用就是STL里的仿函数,也就是一个个函数对象

与状态模式的区别在于:策略之间不需要相互转换,也就不需要知道其它策略

应用

1
2
3
4
5
std::vector v{ 9, 0, 1, 3 };
auto cmp = [](const int& a, const int& b) { // 这就是一种策略
return a < b;
};
std::sort(v.begin(), v.end(), cmp);

模板方法模式(Template Method)

这里的模板指的是方法的执行顺序已经基本确定下来,子类只需要对具体的方法进行重写,而不需要改变其流程

实现

1
2
3
4
5
6
7
8
9
10
11
12
class APP {
public:
void TemplateMethod() { // 固定流程
Step1();
if(Step2()) {
Step3();
}
}
virtual void Step1() = 0; // 其中的具体操作
virtual bool Step2() = 0;
virtual void Step3() = 0;
};

访问者模式(Visitor)

主要解决的问题是希望添加新功能,但是新添加的功能可能跟原有的功能不相符,也就是不符合单一职责原则,所以不想直接把功能代码加进来。于是需要另外一个称为访问者的类,由它接收对象后完成新功能。

看起来用函数重载就可以完成,但是问题在于我们遍历对象时用的肯定是一个统一的基类,当传入重载函数之后只能进入基类型对应的重载函数,并不能得到它真正的泛化类型。

解决方法是「双分派」:让被访问者实现accept方法,在内部调用访问者的功能函数,这样就一定可以传递为其真实的类型了。详细描述可以看这里

实现

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
/*
* Visitor.h
*/
class Child1; // 一定要用前向声明而不能直接引入头文件,不然会发生循环引用
class Child2;

class Visitor { // 定义访问接口,针对具体类型的静态绑定
public:
void Visit(const Child1& c1); // .cpp中:std::cout << "Visit Child1\n";
void Visit(const Child2& c2); // .cpp中:std::cout << "Visit Child2\n";
};

/*
* Visitable.h
*/
class Visitor; // 一定要用前向声明

class Visitable { // 定义接收接口,动态绑定
public:
virtual void Accept(Visitor& v) = 0;
};

/*
* Child1.h
*/
class Child1 : public Visitable {
public:
virtual void Accept(Visitor& v) override; // .cpp中:v.Visit(*this);
};

/*
* Child2.h
*/
class Child2 : public Visitable {
public:
virtual void Accept(Visitor& v) override; // .cpp中:v.Visit(*this);
};

原本遍历一个集合中的对象时,可能需要先通过dynamic_cast判断类型再调用接口,现在可以直接调用

使用

1
2
3
4
5
6
7
8
9
Visitable* cs[] = {new Child1(), new Child2()};
Visitor v;
for (auto c : cs) {
c->Accept(v); // 动态绑定到两个子类实现的接口,而子类实现里的调用的Visit是函数重载(静态绑定)
}

// 输出
Visit Child1
Visit Child2

这个模式的应用场景是在已经有一些类的时候希望统一添加某个行为,如果直接添加接口,则需要在每个类中都实现一遍。这个模式提出了一种这种的方法,虽然还是要添加接口,但是接口的实现非常简单(也就是上面的Accept),只需要调用访问者提供的对应类型参数的重载函数(也就是Visit)。这样对原有类型的改动相对比较小

参考

[1] https://www.runoob.com/design-pattern/design-pattern-tutorial.html

[2] https://refactoringguru.cn/design-patterns