”确保一个类只有一个实例,并为其提供一个全局访问入口。“
尽管单例模式的出发点是好的,但在GOF对单例模式的描述中,它通常弊大于利。他们一再强调应当谨慎使用该模式——然而当其应用于游戏产业中时,这一点却往往被忽略了。
与任何模式一样,在不合适的地方使用单例模式,就像药不对症,不过首先,我们来看看模式本身。
确保一个类只有一个实例
在对系统文件进行操作时,如果我们调用一个方法创建文件,又调用另外一个方法删除这个文件,那么我们的封装类就必须知悉,并确保他们不会互相干扰。为了实现这点,对封装类的调用必须能够知道之前的每一步操作。如果使用者能够自由地创建这个类的实例,那么一个实例就无法知道其他实例所做的操作。而单例模式则提供了在编译期就能确保某个类只有一个实例的做法。
提供一个全局指针以访问唯一实例
除了创建一个单独的实例外,它还提供一个全局的方法以便获取该实例。这样一来,任何模块在任何地方都能得到这个实例了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class FileSystem
{
public:
static FileSystem& instance()
{
//Lazy initialize
if(instance_ == NULL)
{
instance_ = new FileSystem();
}
return *instance_;
}
private:
FileSystem() {}
static FileSystem* instance_;
};
以下是更现代的版本:1
2
3
4
5
6
7
8
9
10
11class FileSystem
{
public:
static FileSystem& instance()
{
static FileSystem *instance = new FileSystem();
return *instance;
}
private:
FileSystem() {}
};
C++11保证一个局部静态变量的初始化只进行一次,哪怕实在多线程的情况下也是如此。所以你有一个现代C++编译器的话,这份代码是线程安全的,而之前的例子却不是。
使用情境
除了不会因为初始化多个实例而将事情弄糟,它还具备一些其他的优良特性。
- 如果我们不使用它,就不会创建实例——节省CPU周期以及内存空间。
- 它在运行时初始化。
- 你可以继承单例。这是一个强大但是经常被经常忽视的特性。
比如FileSystem,我们可以继承它并为不同平台定义派生类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class FileSystem
{
public:
static FileSystem& instance();
virtual ~FileSystem() {}
virtual char* read(char *path) = 0;
virtual void write(char* path, char* text) = 0;
private:
FileSystem() {}
};
class PS3FileSystem : public FileSystem
{
public:
virtual char* read(char* path)
{
// Use Sony file IO API...
}
virtual void Write(char *path, char *text)
{
// Use Sony file IO API...
}
};
这里巧妙的地方在于如何创建实例:1
2
3
4
5
6
7
8FileSystem& FileSystem:: instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif ...
// Other Platforms
#endif
}
我们可以通过FileSystem::instance()来访问文件系统,而不必和任何平台相关的代码发生耦合。
后悔使用单例的原因
它是一个全局变量
- 它们令代码晦涩难懂
- 全局变量促进了耦合
- 它对并发不友好
单例就是一个全局状态——它只是被封装到了类中而已。
它是个画蛇添足的解决方案
便利的访问,是使用单例模式的主要原因。比如日志类,将Log类的实例传递给每个函数会扰乱函数签名,并分散代码意图。把Log类变为单例能解决问题,但是不能够创建多个日志器了。但是一旦需要加入日志过滤的需求,通过将日志分割成不同的文件才解决这个问题,除了要修改log不能有多个实例的问题,同时需要修改每个调用点。
延迟初始化剥离了你的控制
游戏通常需要仔细地控制内存在堆中的布局来防止碎片化。如果我们的音频系统在初始化时分配了内存,我们需要知道初始化发生的时间,以便让我们控制它在堆中的内存布局。
那么我们该怎么做
看你究竟是否需要类
保姆类有时是有用的,不过这通常反映出他们对OOP不熟悉。如果可以,你只需要将这些功能移动到它所帮助的类中去就可以了。毕竟面向对象就是让对象自己管理自己。
将类限制为单一实例
为实例提供便捷的访问方式。
通用的原则是,在保证功能的情况下将变量限制在一个狭窄的范围内。对象的作用域越小,我们需要记住它的地方越少。让我们考虑下代码库访问一个对象的其他途径:
- 传递进去。 将这个对象当作一个参数传递给需要它的函数。
- 在基类中获取它。 把Log定义在GameObject中,这样所有派生自GameObject的对象都可以范围它。(子类沙盒)
- 通过其他全局对象访问它。
- 通过服务定位器来访问。定义一个类专门用来给对象做全局访问。