设计模式快速入门

以一种松散的方式把一些模式串接起来建造建筑是可能的。这样的建筑仅仅是一些模式的堆砌,而不紧凑 。这不够深刻。然而另有一种组合模式的方式,许多模式重叠在一个物理空间里:这样的建筑非常紧凑,在一小块空间里集成了许多内涵;由于这些紧凑,它变得深刻。——A Pattern Language

题图:Cycle; M.C. Escher, lithograph, 1938

如果将计算机编程语言类比为自然语言,那么设计模式就是这门语言的“句型”(小规模的设计模式)亦或是“文章结构”(大规模的设计模式),是在语言的漫长实践中总结出的为了更好的解决现实问题而诞生的“模式”。本文也即做设计模式的笔记兼具个人对之前项目的思考。

创建模式

创建模式能赋予类创建什么怎样被创建,被谁创建,以及何时创建的灵活性

抽象工厂(Abstract Factory)

Abstract

用一个工厂抽象类构造产品对象,工厂们继承实现这个工厂抽象类

Feature
  • 一个抽象工厂类可能包含多个Make方法构实例化不同类
  • 直接调用一次Make方法即可返回一个类
  • 工厂类不直接管理对象
Implementation
  • 单件
  • 基于原型创建工厂子类
  • 使用反射动态选择工厂子类创建
  • 使用模板特化进行静态抽象工厂
Example

一个Roguelike有几种不同的关卡Stage,可以使用抽象工厂StageFactory生成不同风格的关卡。

生成器(Builder)

Abstract

将一个复杂对象的构建和它的表示分离,使同样的构建过程可以创建不同的表示。

Feature
  • 调用者需要过程式调用生成器来生成最终对象
  • 生成器是一个抽象接口/抽象类,可以实现不同的子类来生成不同类的目的对象
Implementation
  • 抽象生成器实现装配和构造接口
Example

markdown转各种不同的文件格式,markdown文档类MarkdownDoc的prase方法将带格式文本/图片作为参数分别调用生成器TextBuilder的方法,有可选子类PDFBuilderTeXBuilder,最终生成器输出不同格式。

工厂方法(Factory Method)

Abstract

定义一个用于创建对象的接口,让子类决定实例化哪个类。工厂方法使一个类的实例化延迟到子类。

Feature
  • 让产品类有一个“父亲”类
Implementation
  • 让产品类保留“父亲类”的引用
  • 让“父亲”类保留产品类的引用
Example
  • 构建树/分层结构

原型(Prototype)

Abstract

使用原型指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

Feature
  • 可以运行时修改产品构造
  • 用类动态配置应用
  • 减少子类的构造
Implementation
  • 使用一个原型管理器
  • 实现克隆操作
  • 初始化克隆对象
  • Copy on Write(CoW技术)

单例/单件

Abstract

保证类只有一个实例,并提供一个访问它的全局访问点。

Feature
  • 类全局只能有一个实例
Implementation
  • getXXX()函数返回局部静态变量(避免初始化顺序问题)

思考

根据名字就可以发现创建模式基本是来自生活中的产品生产过程。只不过语境换成了一个进程/应用下的“产品”生产。因此单例模式也并不完全单例,你可以开启多个进程/应用,单例自然也就在进程/应用的namespace之下。

抽象工厂/生成器这两种模式都是作用于一个类的生成,最终得到的新结构也还是类。那就可以套娃!工厂工厂类。这就得看具体需求了。

生成器适用于需要复杂过程才能创建的类,而且这个过程在不同场景下变化很大,使用抽象工程无法很好的处理。

工厂方法更缺少自由度一些,因为依附于某个具有实际意义的类。所以无法简单的定制。

原型首先这个类必须是能copy的,适用于批量生产类。因为java对象默认引用语义所以更适合java一些,C中则没啥用处,因为C默认都是值语义,直接赋值就能从原型clone了.

单例局限性就更大了,类粒度大到Application或者是一些机器上唯一的的对象才适合用,比如线程池、内存池、io_context、xxApplication这样。

在我的项目中则基本没用到这些方法,构造函数足够了,硬说也只用上了一些单例模式用来维护全局变量。面对复杂需求和扩展性需求还是得适当用用这些创建模式。

结构型模式

结构型模式设计如何组合类和对象以获得更大的结构,以求更多的灵活性。

适配器(Adapter)/包装器(Wrapper)

Abstract

将一个类的接口转换成客户希望的另外一个接口。Adapter模式使得原本接口不兼容的而不能一起工作的类可以一起工作。

Feature
  • 能够提供一个兼容层
  • 可选不同适配算法组合实现不同的性能需求
Implementation

使用旧类有两种方法

  • 继承
  • 组合

实现适配有两种方法

  • 直接实现对应接口
  • 定义虚接口(Adapter),Adaptee实现接口

桥接(Bridge)

Abstract

将抽象部分和实现部分分离,使它们可以独立变化。

Feature
  • 能将抽象和实现部分解耦,适用于不同的实现和动态的绑定关系
  • 提高可扩展性
  • 完全隐藏抽象的实现部分
  • 需要在多个对象间共享实现
Implementation
  • xxxx抽象类------xxxxImpl实现类

组合(Composite)

Abstract

将对象组合成树形结构以表示“部分-整体”的层次结构。Composite使得用户对单个对象和组合对象的使用具有一致性。

Feature
  • 对象和对象的组合共同实现一个接口
  • 对象的组合可以形成一个树形结构
  • 接口也可以是一个基本对象
Implementation
  • 显式的对象组合引用
  • 最大化接口
  • 声明管理子部件的接口
Example

语法树的程序表示,比如以下语法

1
2
3
EXP : EXP '+' EXP 
| EXP '*' EXP
| NUM

可以实现一个EXP接口然后接着实现三个语法

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
class ExpressionInterface{
virtual ~Expression(){};
virtual double calc()=0;
};
class Expression2op:public ExpressionInterface{
//EXP '+' EXP
//EXP '*' EXP
ExpressionInterface &a,&b;
char op;
Expression2op(ExpressionInterface& a,char op,ExpressionInterface& b):a(a),b(b),op(op){}
~Expression2op(){delete a;delete b;}
virtual double calc(){
auto A=a.calc(),B=b.calc();
if (op=='+')
return A+B;
else if (op=='*')
return A*B;
throw std::exception("underfined operator");
}
};
class ExpressionInstant:public ExpressionInterface{
//NUM
double a;
virtual double calc(){
return a;
}
};

装饰(Decorator)

Abstract

动态的给一个对象添加一些额外的职责。

Feature
  • 一个装饰器就像一个形容词,可以套娃到对象上
  • 不影响其他对象,只修饰目标对象
  • 不改变目标对象的接口
  • 比静态继承更加灵活
  • 避免复杂化
Implementation
  • 一致性接口
  • 改变对象
Example

C++20的std::ranges,计算机网络的分层模型,不动点组合子。

外观(Facade)

Abstract

将一个子系统抽象成一个接口。

Feature
  • 可以使复杂系统简单化,需要更多定制化需求的用户可以越过Facade层
  • 将客户程序和子系统解耦
  • 降低编译依赖性
Implementation
  • 一个接口类封装子系统
Example

同时提供C API和命令行界面的程序。

享元(Flyweight)

Abstract

将很多相同的对象拿个对象池存起来,复用重复的对象。

Feature
  • 节省内存
  • 更好的局部性
Implementation
  • 实现一个(可选单例)工厂类,内部整一个单例map/hashmap过去,附带引用计数(C++)
  • 实现代理类,代理类实现CoW
Example

Java里的String,渲染字符的缓存区。

代理(Proxy)

Abstract

为其他对象提供一种代理其他对象的访问。

Feature
  • 赋予访问对象的灵活性——远程、Lazyload、权限保护、智能指针
Implementation
  • 整一个xxxProxy类,其他调用xxx的类全改成调用xxxProxy
Example

C++的shared_ptr

思考

结构型模式强调的是类/接口形成的结构,使其灵活、优化,很少涉及算法。这些模式均能从类图中发现。

适配器和桥接都是为一个类提供间接性,他们都涉及转发请求,所以有利于系统的灵活性。和桥接的目的是隐藏多个实现不同,适配器更像是一个“事后补救”的措施。使用适配器这种补丁式的模式是软件开始设计时不可预见的。

适配器和外观有点相似,都是基于一个现成的类构造一个新的接口。只不过适配器强调的是复用已有的接口,外观则是根据外部调用抽象出一个新的接口。

与其他的结构型模式不同,组合提供的是一个构造类的思路。这个思路在文法的表示上体现的淋淋尽致。

同样代理和装饰虽然也是一个应用于一个类的修改。但是装饰强调的是重复套用带来的组合性质,代理强调的是修改访问对象的方法。装饰表现可能跟原本的类大相径庭,代理则是需要充分表现原本的类。

和其他结构型模式不同的是享元则是一种内存优化手段。

在设计编译器时,使用了基于名字的组合模式来生成表达式。在设计协程库libzio使用了外观来抽象io上下文。

行为型模式

行为型模式设计算法和对象间指责分配。行为型模式不仅描述对象或类的模式,还描述他们之间的通信模式。这些模式刻画了复杂的控制流,将你的注意力从复杂的过程式控制流中转移到对象的联系方式上来。

职责链(Chain of Responsibility)

Abstract

使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理为止。

Feature
  • 简单易理解
  • 可定制化
  • 处理请求需要严格的上下层级关系
Implementation
  1. 对象形成一颗树,请求沿着到根节点的路径传播,直到一个对象处理。
  2. 形成一条链…
Example

Qt中的对象树传播事件

命令(Command)/动作(Action)/事务(Transaction)

Abstract

将请求封装成一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录日志,以及支持可撤销操作。

Feature
  • 函数式化行为
  • 命令类之间可以组合实现更高层次的命令类
Implementation
  • 实现一个抽象Command类,含execute(A& a)=0方法
  • Command类子类实现不同的请求
  • 可以使用反射获取对应Command类
  • 处理Command的类A调用command.execute(this)
Example

CPU的指令

解释器(Interpreter)

Abstract

定义一个DSL及其解释器,DSL文法每一个符号都是解释器的一个类。

Feature
  • 易于改变和扩展文法
  • 易于实现文法
  • 对于一些频繁的操作很适用
Implementation

编译器前端怎么实现的就怎么实现

优化: 享元模式共享终结符

Example

QML,QSS

迭代器(Iterator)

提供一种方法顺序访问一个聚合对象中的各个元素,而不暴露对象的内部表示。

Feature
  • 无需暴露内部表示
  • 适合遍历对象
  • 提供一个统一的接口
Implementation
  • 使用多态迭代适配不同容器
Example

C++ STL容器,Java Collection。

中介器(Mediator)

Abstract

用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式的相互引用,从而解耦,而且可以独立的改变他们之间的交互。

Feature
  • 减少子类的生成
  • 将各子类解耦
  • 简化了对象协议
  • 抽象了对象的协作
  • 使控制集中化
Implementation
  • 没有必要抽象化中介器
  • 考虑达成中介器和参与者的双向通讯
Example

CPU,Qt信号机制

备忘录(Memento)

Abstract

在不破坏封装型的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

Feature
  • 保持了封装边界
  • 简化了记录状态实现
  • 代价可能很高
Implementation

就是各种序列化方法

Example

序列化,游戏存档

观察者(Observer)/发布-订阅(publish-subscribe)

Abstract

定义对象之间的一(目标)对多(观察者)的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。

Feature
  • 目标和观察者间的抽象耦合
  • 支持广播通信
Implementation
  • 实现Notify接口,让观察者继承
  • 创建目标到观察者的映射
  • 观察多个目标
  • 一般目标发更新
Example

MVC

状态(State)

Abstract

允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

Feature
  • 将特定状态相关的行为局部化,并且将不同状态的行为分开来
  • 使状态转换显式化
  • 状态对象可被共享
Implementation

使状态机的节点类化+接口。外部用接口类封装状态机的转换。

Example

状态机的面向对象化,比如TCP。

策略(Strategy)

Abstract

定义一系列的算法,封装起来,并且使它们可互相替换。

Feature
  • 能够按输入规模/性质灵活切换算法达到最短运行时间
Implementation

将多个算法抽象化成一个接口

Example

寄存器分配算法,排序算法等

模板方法(Template Method)

Abstract

定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。可以使子类不改变一个算法的结构即可重定义算法的某些特定步骤。

Feature
  • 定制算法
Implementation

小规模可以直接传函数,大规模就需要一个接口封装一下。

Example

std::sort

访问者(Visitor)

Abstract

表示一个作用于某对象结构中的各元素的操作。可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

Feature
  • 让对对象结构的操作和元素解耦
  • 可扩展的操作
Implementation
  • 让对象结构所有类定义Accept(Visitor)
  • 写一个Visitor接口,包含应对不同元素的方法,并实现对应的子类表示操作
  • 操作可调用子类的Accept(this)继续访问对象结构内的其他元素
Example

clang的各个pass

思考

行为型模式更多的在于抽象过程,算法也是一堆过程组成的因而也可以适用。

职责链和现实中军队的信息传递过程很相似,这种模式虽然简单但是在处理任务时很有效。不过前提是确保链上每一个节点能够处理事件的完美程度是单调的——越往上越完美,越往上可见性越高,能调动更多资源。

命令/动作/事务具有非常强烈的函数式思想。即命令的应用对应f(object,arg0,arg1,...)f(object,arg0,arg1,...),那么命令的撤销就是增加了一点调用信息的“逆函数”,f1(f(object,arg0,arg1,...),arg0,arg1,...)=objectf^{-1}(f(object,arg0,arg1,...),arg0,arg1,...)=object

解释器就是定义一个DSL,可以认为最简单的SQL数据库就是使用解释器模式然后使用命令/事务模式读/写盘。

迭代器就不细谈了,指针的泛化,在C++ STL已经应用许多年。到现在已经出现了继任者——记录迭代器哨对的std::ranges。

备忘录使用了序列化技术,强调应用在内存里。但是现在用的不多了,基于差分的数据结构近年来应用迅速。从用户空间git到文件系统btrfs,需要全量备份的备忘录显得如此占空间。但是游戏存档、写到文件这些场合,备忘录还是起到了不小的作用。

观察者职责链中介器都是类间通讯的模式。区别在于中介器类只能让其他类互相通信,除了通讯没有实际意义。观察者职责链通信时参与的类均可以有实际意义,但是观察者强调的是一到多,职责链每个类只有一个传递对象,而且每个类都能接受事件。所以观察者更适合监听状态的变化——频繁、对性能要求高;职责链更适合GUI事件处理这种场合。

状态则是状态机在面向对象中的表示。

策略模板方法也是算法在面向对象中的表示。

访问者将一个模型和其修改者解耦,可以看成是对于对象结构的装饰模式,一样可以套娃应用。

尾声

在GPT4发布的当下,所有人无不被大语言模型的强大而震撼到。可又有多少人知道,在三四十年前AI的主要工作还是模式识别(Pattern Recognition),尝试在图片/声音中提取出一种模式。可惜走进了一条死胡同,其原因超出了本文的范畴,在此不谈了。

到了现在,大模型所创造出的所谓“智能”虽然有这样或那样的瑕疵,某些提问还会回答错误。但已经足够可用,至少对于搜索这一领域来说是这样。这不得不会出现一个问题,智能是否是可解释的。我们仍然不知道大模型下的形成的一些结构叫什么名字、起什么作用,但是可以断定这些结构都是一种模式。不断重复的模式又组成了一种新的模式,最终表现在文字里。

而描述这些模式的文字是否又是一种新的模式呢?

It’s hard to say…

同样,设计模式中的模式们也不是完全正交的,单纯的按照实现来看,策略和桥接也差不太多。命令和备忘录也离不开序列化。其中是否蕴含着新模式呢?

那么,我的朋友,你是否发现了新模式了呢?

不管是什么样的真理,
只要一经发现,就很容易被人理解。
重点在于,要去发现真理。
——伽利略·伽利莱

Drawing Hands; M.C. Escher, lithograph, 1948

Reference:

《设计模式:可复用面向对象软件的基础》/(美)埃里克 伽玛

refactoringguru.cn