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