12 KiB
设计模式笔记
简单工厂(Simple Factory)
简单工厂将对象的创建过程与使用过程解耦,客户端只需传入参数,而不需要关心创建的细节和具体的子类类型。这样,当需要添加新的产品子类时,只需在工厂中扩展创建逻辑,无需修改客户端代码,从而实现了对变化的封装,提升了系统的可扩展性与可维护性。
工厂模式(Factory Method)
简单工厂里,我们的输入参数是一样的,如何处理建造时参数不同的情况呢?例如 Circle 接受圆心和半径,而 Square 接受两个对角的点。
工厂模式给出了解决方案。对于每个新的子类,有一个专门的工厂类生成这个新的子类,进而工厂类可以使用不同的参数构建新的子类。
工厂模式相对简单工厂,通过解耦获取工厂和生成子类实例,获取了更大的灵活性。比如客户端可以在得到工厂后,进一步根据工厂类型向用户请求输入。但是对于一个新类就需要额外需要一个工厂,稍微麻烦些。通过同时使用两种方式,可以达到最大的灵活性。
抽象工厂(Abstract Factory)
抽象工厂进一步的将工厂本身也作为一种产品。它定义了一个“工厂族”的接口,规定了工厂需要生产哪些种类的产品。客户端从抽象工厂获取一个具体的工厂,再从这个工厂中制造一组相互关联的具体产品。
这可以用于创建一系列相关或相互依赖的对象,而不指定它们的具体类。也就是说,它解决了产品族一致性的问题。这在组件风格切换、平台适配、多模块协作等方面很有用。
生成器模式(Builder)
假定有一种复杂对象(比如电脑,需要有 CPU,内存,主板等),如何构建这种复杂对象呢?朴素的做法是有一个接受很多参数的构造函数,这显然不好,因为参数不总是都用上的。另一种做法是扩展电脑基类,但是这意味着给电脑增加新的外设会很复杂,因为需要添加子类。
解决方案是有一种生成器类专门负责构建复杂对象。对于每一种参数,生成器类定义了接受各个参数的函数,从而做到分步创建对象。参数输入结束后,生成器类可以基于之前输入的参数创建这一复杂对象。
对于复杂场景,比如生成器需要输入十种参数,可以添加一个指导者封装这些输入这几种参数的过程。它提供了一种简化的构建流程,客户端不需要关心构建细节,代价是牺牲了一定的灵活性,因为客户无法定制每一步。
单例模式(Singleton)
有些时候,我们希望一个类只被实例化一次,且能在全局被访问,比如一个事件模拟器。朴素的想法是直接用一个全局变量。但这使得模块间强耦合,这些模块依赖隐式的、看不见的全局状态。且多个全局变量出现时依赖难以管理。
单例模式是一种解决方案,这个类只通过静态的 get_instance() 提供统一访问方式,并且不提供拷贝构造和赋值操作。这样多个依赖的全局变量可以在这个类里管理。此外测试时可以只改内部实现,对外接口不变,提高了可维护性与可测试性。
具体实现上有一些技巧。为了只创建一次,可以用 get_instance() 中使用 static 变量(懒汉式),也可以这个类有一个 static 类成员(饿汉式),还可以用 static 指针成员,对于指针为空时(第一次访问)额外做初始化(需要注意创建时的线程安全,比如双检查)。
原型模式(Prototype Pattern)
有时需要在项目中创建副本,那么如何创建重复对象?很容易想到使用复制或赋值操作符,而不是普通的构造函数。但是这些方法没有多态性,如果很多子类需要复制就会有点麻烦。
解决方案也很简单,父类提供一种 clone 方法。这样客户端只需要调用 clone 方法,不需要知道这个类具体是什么。这种方法常配合对象注册表使用,通过维护一个原型缓存容器(如map<string, Prototype*>),根据名字/类型复制对象。
适配器模式(Adapter)
在开发过程中,可能会遇到接口不兼容的情况。例如,类 A 有一个向服务器发送请求的接口,而类 B 也需要实现类似的功能,但它并不继承自 A,因此无法通过多态方式调用 A 的接口。
一种解决方法是让 B 继承 A,这是一个很推荐的做法,但是这样做并不总是可行的,特别是当 B 是由他人设计或无法修改时。此时,可以设计一个适配器类。
适配器类可以继承自 A,并将 B 作为构造参数。在构造过程中,适配器将 B 的接口转换为符合 A 的接口形式。这样,通过适配器,B 就可以像 A 一样调用接口,达到接口兼容的效果。考虑到适配性,比如希望这个适配器类还能适配其他的 C,D 等,可以让适配器类接受一个更普遍的共性作为构造参数。
需要注意的是,这种做法显然会使得代码复杂很多,条件允许的话,还是直接修改 B 类较好。
桥接模式(Bridge)
有时类有多个变化维度需要组合。以图形为例,它既有“形状”的维度,也有“纹理”的维度。若采用继承来组合这两个维度,将导致类数量呈乘法增长(M * N)。而且,一个维度的变更都可能影响到多个类。
桥接模式给出的解决方案是:“组合优于继承”。它通过将“形状”和“纹理”分别建模为独立的类层次结构,并在“形状”中组合一个“纹理”的引用,使两者的组合关系从原来的 M * N 转变为 M + N。这样可以自由扩展任意一侧,而无需影响另一侧。即使实现中可能仍需要 M * N 种行为(这是无法减少的),但是当一个纹理要改变时,也只需要改这个纹理类而不影响其他类。此外,这种组合关系是运行时决定的,因此可以动态地将不同的抽象与实现配对,增强了系统的灵活性。
在桥接模式中,抽象部分(也称为接口)定义的是高层的控制逻辑,而真正的工作则由被称为实现部分的组件完成。抽象层在调用时,会将具体任务委派给实现层,从而实现灵活的组合与运行时绑定。
组合模式(Composite)
在实际开发中,我们经常需要将多个对象组织成一个整体来进行处理。例如,在一个绘图应用中,一个画板可能包含多个图形元素(如圆形、矩形、线条等),我们希望能够将这些元素打包成一个更大的组件,便于统一地移动、缩放或删除。
但这就引出一个问题:组件和基本元素类型不同,无法使用统一的接口进行操作,需要额外判断和处理。
组合模式正是为了解决这个问题。它的核心思想是:将对象组合成树形结构来表示“整体-部分”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。也就是说,我们可以将一个组件(由多个元素组成)当作一个普通元素一样来处理,统一操作接口,简化客户端代码。
装饰器模式(Decorator)
有时,我们需要给动态的一个类添加一些组件。例如:对于制作一杯咖啡,可以添加牛奶,糖等。一种做法是使用继承,这显然会造成类爆炸。另一种做法是将这些配料抽象成一个类,咖啡类维护一个配料的列表。对于纯数据,如计算最终价格,这样是合理的。但是对于添加额外行为,这就会将逻辑集中到 Coffee 类中。例如希望添加牛奶时咖啡必须加热,就不得不修改 Coffee 类,在它的 make 函数中添加处理逻辑。
装饰器模式解决了这个问题。让所有的配料都继承自 Coffee,构建时则额外需要一个被装饰对象的指针,构造函数中还能插入额外的处理。这样可以让每一层都能主动拦截、加工、改写行为,而不需要集中到一个地方。对于客户端代码来说,装饰器和原对象是一样的。当需要对咖啡做某个动作时,可以对装饰器做,装饰器再调用被修饰对象指针的对应动作。由于包含关系,装饰器还天然记录了顺序。
外观模式(Facade)
在一个复杂系统中,往往由多个子系统协同工作来完成一项功能。例如,一个编译器可能包含词法分析器、语法分析器、语义分析器、优化器和代码生成器等多个模块。如果每次都要让调用者直接与这些子系统交互,不仅复杂、容易出错,而且对用户也不友好。而实际上,许多操作是常用的、固定的组合,例如“编译整个程序”就总是涉及所有子模块的调用顺序。
很容易想到通过一个统一的入口,也就是新增一个“外观类”或“外观函数”,封装这些子系统的内部调用逻辑。这就是外观模式,对外提供一个简化接口,同时又保留了对细节的操控能力。
享元模式(Flyweight)
在创建同一类的大量对象实例时,往往会面临内存占用过高的问题。这种情况的一个常见原因是:对象实例中包含了大量冗余的、可以共享的数据。例如,在游戏开发中,多个实体可能共用相同的模型和贴图资源。如果每个实例都独立持有这些资源,就会造成不必要的内存浪费。
为了解决这一问题,享元模式提供了一种有效的解决方案。它的核心思想是将对象的数据划分为可共享的和不可共享的。在实现过程中,享元模式通常通过一个享元工厂来统一管理和缓存内部状态。当需要创建新对象时,工厂会首先检查是否已有对应的内部状态资源,若有则复用,从而避免重复实例化共享部分,显著降低内存开销。
代理模式(Proxy)
我们在使用一个类时,有时会觉得这个类的功能较简单,实际使用需要额外添加一些访问控制,缓存管理等功能。简单的想法是直接在这个类中添加这些功能,但这会使得这个类急剧膨胀,且核心功能变得不那么明确。
代理模式解决了这个问题。通过一个代理类实现目标类相同接口,客户端使用代理时就像在直接操作目标类。代理类内部有目标类的指针,在处理缓存、权限等逻辑后再去调用目标类的相关函数,从而做到外围与核心逻辑的分离。
责任链模式(Chain of Responsibility)
有时我们会遇到如何处理用户请求的框架。一种做法是通过条件判断,得到不同条件下请求对应的处理函数。这样的话耦合严重,各个不同的 handler 需要放在一个函数或类中,每次扩展都需要改变这个类。
责任链模式解决了这个问题。一个 handler 要么处理这个请求,要么交给下一个 handler 处理,增加处理逻辑时,只需要新增一个 handler,并且正确设置相应的 next 即可。这样,每个 handler 只关心自己的职责,所有 handler 处理清晰。当我们能将所有的 handler 组成一个链式或者树式的结构时,这会非常有用。
但也正如前面提到的,这不自然支持“一个请求需多个处理者同时处理”的场景。此时可以通过责任链进一步推广成“责任网”,观察者模式,流程引擎式结构等,增强责任链模式的处理能力。
命令模式(Command)
一个系统通常分为前端和后端两部分,前端负责接收用户输入,后端处理核心业务逻辑。那么,前后端之间如何高效、清晰地交互呢?如果每个后端模块都暴露自己的接口,系统一旦扩展,接口之间容易混乱、难以维护。
命令模式通过引入统一的“命令对象”,将请求的发送者(前端)与执行者(后端)解耦。前端只需构造命令并发送,不关心命令的执行细节;而后端根据命令对象来执行相应逻辑。
更重要的是,所有操作都被抽象为统一的命令接口,这使得命令可以统一记录、排队、撤销、重做。多个前端只需绑定相同的命令对象,即可复用相同的后端逻辑,增强了代码复用性与系统扩展性。