
OOP 学习笔记(11)——创建型模式
创建型模式
单例(Singleton)模式
需求:全局计数器
设计一个全局计数器,程序任意位置调用 addCount()
均可以使得计数器 +1。
调用 getCount()
返回计数器数值。
如何实现这两个需求。
分析:全局变量
但这样的设计,用户容易访问到 count
变量使得其存在被随意修改的可能。
并且,我们最好应该避免全局变量。
分析:静态成员
如此实例化的方式显然很麻烦。
分析:静态方法
这样似乎很完美。
新需求:多种计数器
如果我们需要有多种 Counter
,那么很容易想到利用虚函数和继承的方法实现。
也就是说会出现:
但这样是会编译错误的!
换句话说,静态方法加上虚函数是不可行的!
静态方法不可以是虚函数!
分析:根本目的
我们想要使用静态方法的根本目的在于:
- 无论何处调用,访问的是相同的函数和变量。
- 无需多次实例化。
单例模式
单例模式要求:
- 单例类只能有一个实例。
- 单例类必须自主创建唯一实例。
- 单例类必须可以提供这一唯一实例。
实现关键:
- 构造函数不开放(
private
)。 - 通过静态方法、枚举返回单例类对象。
实现:本类作为静态成员
使用时只须:
单例模式封装了全局性变量,只有一次实例化,也没有依赖静态访问方法,满足上面的需求。
优化:惰性初始化(Lazy Initialization)
我们希望单例模式在使用时自动构造单例实例。
只须将 _instance
这个成员改为:
这样,函数内静态变量在第一次执行到其定义时才会被构造。
也就是说,第一次调用 instance()
才会实例化单例。
注:这是考虑到一些较为复杂的类可能需要长时间构造,程序刚开始就构造可能导致程序卡顿之类问题。
问题:意外删除
上面的实现保证了实例不会被重复构造,但实际上不能避免意外删除。
考虑下面代码:
(引用和实例有着同样的实际地址)
所以还应该将析构函数也设置为私有。
实现:单例模式 + 虚函数
尚存在缺陷:
- 单例相关逻辑较多,需要在派生类中分别实现单例代码,尤其是每个单例派生类的
instance()
。
奇特的递归模板模式(CRTP)
全称为 Curiously Recurring Template Pattern。
这里采用的是 CRTP 加上多重继承。
注意,不可将 Singleton
类的实现逻辑实现在 BaseCounter
类中,单例功能与其他功能剥离。
否则 BaseCounter
将成为模板类,无法脱离模板参数存在。
代码合法原因
- 尽管
Singleton
利用到了SimpleCounter
,但其中不直接、间接包含SimpleCounter
类,其大小不依赖于SimpleCounter
,所以实际上不需要其完整定义。 - 在模板类
Singleton
被实例化时,SimpleCounter
类与Singleton
类的定义实际上已经给出。
关于 CRTP
CRTP 是实现多台的另一种方式,实际上并不只是单例模式才会使用到:
- 利用 C++ 模板,让编译器生成重复代码。
其本质上还是编译器多态(静态多态):
- 在编译期确定实际被调用的函数。
关于单例模式
存在着一定争议。
优点:
- 实现似乎较为简单。
- 以相对安全形式提供可供全局访问的数据。
缺点:
- 难以完全正确实现,安全隐患在各种特殊情况下可能仍然存在,防不胜防。
- 该设置为
delete
和private
的构造、析构函数都应该正确设置,朴素的实现方法也未必能保证线程安全。
- 该设置为
- 违反了面向对象单一职责原则。
- 滥用这种方法会使得实际依赖关系变得隐蔽。
- 开发者无需在意类之间关系,只需引用单例即可。
工厂(Factory)模式
需求:泡茶程序
根据需求提供绿茶或红茶。
可见代码中的大量冗余。
优化:抽象出公共逻辑
当然也可以稍作修改:
这种构造过程可能多次用到,也可以进一步抽象。
工厂模式
在 Tea
类中添加一个 factory
静态方法。
这也就是工厂模式。
单独工厂类
当构造逻辑过于复杂,或者有必要进行分离时,可以把工厂放在单独类中。
实际上抽象来看就是:
工厂模式用途
- 包装复杂的构造逻辑。
为重载的构造函数提供描述性名称。
- 一些类可能具有多个重载的构造函数,可以改写为工厂模式以使用描述性名称,方便实例化操作。
- 比如复数从平面坐标构造和极坐标构造两种方式,这两种方式参数类型恰好相同,使用工厂模式更方便也更直观。
- 对象构造需要用到当前函数体无法访问的信息。
- Box2D 是一个物理引擎,继承了大量物理力学、运动学的计算,并将物理模拟过程封装到类对象中。
只须调用引擎中相关对象、函数,就可模拟实现各种物理运动。
使用时物理情境下设置物体,引擎即可自行按照物理规则模拟。
其中类型包括
b2Body
、b2World
、b2BodyDef
,分别表示刚体对象、物理场景以及b2Body
对象拥有的属性。其中
b2Body
的构造函数为私有,必须使用工厂模式构造:
其构造需要使用到
bodyDef
中的内容。 需要集中管理被构造对象的生命周期。
- 上述例子中
b2World
维护了所有b2Body
的列表,这一列表是私有的,可以用于管理其生命周期。 - 而且使用这样的方法,可以使得实例化
b2Body
时无需给出所有属性,并可以方便地构造多个属性相同、类似的对象。 - 除此之外,Box2D 自己实现了小块内存分配器,手动管理内存,人工进行实例化反而会与自动内存分配冲突。
- 也就是说工厂模式可以避免用户误用系统的内存分配机制,让创建过程不暴露给用户。
- 上述例子中
抽象工厂(Abstract Factory)模式
工厂模式局限
工厂模式目的是构造单个类对象。
如果我们需要构造多个类对象,而且有特定组合方式?
需求:编译器前端
一个实例:编译器前端。
- 编译器前端负责由代码构建统一格式的中间表示。
- 通常来说,需要三个步骤,语法分析、语义分析、中间代码生成。
- 对于每种语言来说,每个步骤的实现不同,同种语言应该使用同种实现。
- 编译器前端的框架相同,只须替换是三个步骤不同实现即可。
- 不妨假设有两种语言 C++ 和 Java。
设计
三个步骤类:
- 语法分析:基类
Lexer
,cppLexer
,JavaLexer
。 - 语义分析:基类
Parser
,CppParser
,JavaParser
。 - 中间代码生成:基类
Generator
,CppGenerator
,JavaGenerator
。
框架实现:
框架实现细节
上述工厂模式实现合理,但是有较多代码重复,并且如果需要增加新语言,需要在每个步骤都加上 if
分支。
我们如何在当前框架下减少代码重复。
分析:添加一层抽象
设计一个基类,抽象统一语言所需的所有步骤:
则上述 Compiler
类可以改写为:
调用时只需:
抽象工厂模式
对应术语与类:
- 抽象工厂:
AbstractLanguageFactory
。 - 具体工厂:
CppFactory
、JavaFactory
。 - 抽象产品:
Lexer
、Parser
、Generator
。 - 具体产品:
CppLexer
、JavaParser
等。
抽象工厂模式,即将同类具体产品包装为一个具体工厂,以抽象工厂形式传递给上层代码。
上层代码只需要关心抽象工厂和抽象产品,无需知道具体工厂、产品是哪些。
抽象工厂模式是工厂模式的升级版,用于创建一组相关或相互依赖的对象。
总结
创建型模式
创建型模式是将对象的创建与使用进行划分,规避复杂对象创建带来的资源消耗,满足特殊情况的创建要求。
上面的三种设计模式分别有不同的创建需求,因此有了不同的实现。
设计模式总结
日常任务实际上往往是多种设计模式的综合。
比如策略模式实例化可能过于复杂,可以采用抽象工厂模式进行策略创建的封装。
比如数据存储采用迭代器模式,而数据结构又可以采用适配器模式。
总的来说,设计模式存在的目的是为了面向未来、面向变化:
- 进行代码复用。
- 支持功能拓展。
- 降低维护代价。
- 方便外部调用。
理解与应用也应该将重点放在设计思路而非具体实现代码,也不应该过度设计框架,避免过于精巧设计给实现带来不便。
No Comments