设计模式快速入门
以一种松散的方式把一些模式串接起来建造建筑是可能的。这样的建筑仅仅是一些模式的堆砌,而不紧凑 。这不够深刻。然而另有一种组合模式的方式,许多模式重叠在一个物理空间里:这样的建筑非常紧凑,在一小块空间里集成了许多内涵;由于这些紧凑,它变得深刻。——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
的方法,有可选子类PDFBuilder
、TeXBuilder
,最终生成器输出不同格式。
工厂方法(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 | EXP : EXP '+' EXP |
可以实现一个EXP接口然后接着实现三个语法
1 | class ExpressionInterface{ |
装饰(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
- 对象形成一颗树,请求沿着到根节点的路径传播,直到一个对象处理。
- 形成一条链…
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
思考
行为型模式更多的在于抽象过程,算法也是一堆过程组成的因而也可以适用。
职责链和现实中军队的信息传递过程很相似,这种模式虽然简单但是在处理任务时很有效。不过前提是确保链上每一个节点能够处理事件的完美程度是单调的——越往上越完美,越往上可见性越高,能调动更多资源。
命令/动作/事务具有非常强烈的函数式思想。即命令的应用对应,那么命令的撤销就是增加了一点调用信息的“逆函数”,。
解释器就是定义一个DSL,可以认为最简单的SQL数据库就是使用解释器模式然后使用命令/事务模式读/写盘。
迭代器就不细谈了,指针的泛化,在C++ STL已经应用许多年。到现在已经出现了继任者——记录迭代器哨对的std::ranges。
备忘录使用了序列化技术,强调应用在内存里。但是现在用的不多了,基于差分的数据结构近年来应用迅速。从用户空间git到文件系统btrfs,需要全量备份的备忘录显得如此占空间。但是游戏存档、写到文件这些场合,备忘录还是起到了不小的作用。
观察者、职责链和中介器都是类间通讯的模式。区别在于中介器类只能让其他类互相通信,除了通讯没有实际意义。观察者和职责链通信时参与的类均可以有实际意义,但是观察者强调的是一到多,职责链每个类只有一个传递对象,而且每个类都能接受事件。所以观察者更适合监听状态的变化——频繁、对性能要求高;职责链更适合GUI事件处理这种场合。
状态则是状态机在面向对象中的表示。
策略和模板方法也是算法在面向对象中的表示。
访问者将一个模型和其修改者解耦,可以看成是对于对象结构的装饰模式,一样可以套娃应用。
尾声
在GPT4发布的当下,所有人无不被大语言模型的强大而震撼到。可又有多少人知道,在三四十年前AI的主要工作还是模式识别(Pattern Recognition),尝试在图片/声音中提取出一种模式。可惜走进了一条死胡同,其原因超出了本文的范畴,在此不谈了。
到了现在,大模型所创造出的所谓“智能”虽然有这样或那样的瑕疵,某些提问还会回答错误。但已经足够可用,至少对于搜索这一领域来说是这样。这不得不会出现一个问题,智能是否是可解释的。我们仍然不知道大模型下的形成的一些结构叫什么名字、起什么作用,但是可以断定这些结构都是一种模式。不断重复的模式又组成了一种新的模式,最终表现在文字里。
而描述这些模式的文字是否又是一种新的模式呢?
It’s hard to say…
同样,设计模式中的模式们也不是完全正交的,单纯的按照实现来看,策略和桥接也差不太多。命令和备忘录也离不开序列化。其中是否蕴含着新模式呢?
那么,我的朋友,你是否发现了新模式了呢?
不管是什么样的真理,
只要一经发现,就很容易被人理解。
重点在于,要去发现真理。
——伽利略·伽利莱
Reference:
《设计模式:可复用面向对象软件的基础》/(美)埃里克 伽玛