单例模式 (Singleton)
保证一个类只有一个实例,并且提供了访问该实例的全局访问点。
— GoF 定义
动机
这个章节不同寻常。其他章节展示如何使用某个设计模式。这个章节展示如何避免使用某个设计模式。
尽管它的意图是好的,GoF 描述的单例模式通常弊大于利。他们强调应该谨慎使用这个模式,但在游戏业界的口口相传中,这一提示经常被无视了。
像其他模式一样,在不合适的地方使用单例模式就好像用夹板处理子弹伤口。由于它被滥用得太严重了,这章的大部分都在讲如何回避单例模式。但首先,让我们看看模式本身。
译注:当业界从 C 语言迁移到面向对象的语言,他们遇到的首个问题是“如何访问实例?“他们知道有要调用的方法,但是找不到实例提供这个方法。单例(换言之,全局化)是一条简单的解决方案。
单例模式
GoF 这样描述单例模式:
保证一个类只有一个实例,并且提供了访问该实例的全局访问点。
我们从“并且“那里将句子分为两部分,分别进行考虑。
保证一个类只有一个实例
有时候,如果类存在多个实例就不能正确运行。通常发生在类与保存全局状态的外部系统互动时。
考虑封装文件系统的 API 类。因为文件操作需要一段时间完成,所以类使用异步操作。这就意味着可以同时运行多个操作,必须让它们相互协调。如果一个操作创建文件,另一个操作删除同一文件,封装器类需要同时考虑,保证它们没有相互妨碍。
为了实现这点,对封装器类的调用必须访问之前的每个操作。如果用户可以自由地创建类的实例,这个实例就无法知道另一实例之前的操作。而单例模式提供的构建类的方式,在编译时保证类只有单一实例。
提供了访问该实例的全局访问点
游戏中的不同系统都会使用文件系统封装类:日志、内容加载、游戏状态保存,等等。如果这些系统不能创建文件系统封装类的实例,它们如何访问该实例呢?
单例为这点也提供了解决方案。除了创建单一实例以外,它也提供了一种获得它的全局方法。使用这种范式,无论何处何人都可以访问实例。综合起来,经典的实现方案如下:
class FileSystem
{
public:
static FileSystem& instance()
{
// 惰性初始化
if (instance_ == NULL) instance_ = new FileSystem();
return *instance_;
}
private:
FileSystem() {}
static FileSystem* instance_;
};
静态的 instance_ 成员保存了一个类的实例,私有的构造器保证了它是唯一的。公开的静态方法 instance() 让任何地方的代码都能访问实例。在首次被请求时,它同样负责惰性实例化该单例。
现代的实现方案:
class FileSystem
{
public:
static FileSystem& instance()
{
static FileSystem *instance = new FileSystem();
return *instance;
}
private:
FileSystem() {}
};
C++11 标准保证了本地静态变量只会初始化一次(即使多线程),因此这段代码是线程安全的,而前一个例子不是。
译注:当然,单例类本身的线程安全是个不同的问题!这里只保证了它的初始化没问题。
为什么我们使用它
看起来已有成效。文件系统封装类在任何需要的地方都可用,而无需笨重地到处传递。类本身巧妙地保证了我们不会实例化多个实例而搞砸。它还具有很多其他的优良性质:
-
如果没人用,就不必创建实例。 节省内存和 CPU 时间总是好事。由于单例只在第一次被请求时实例化,如果游戏永远不请求,那么它不会被实例化。
-
它在运行时实例化。 通常的替代方案是使用含有静态成员变量的类。我喜欢简单的解决方案,因此我尽可能使用静态类而不是单例,但是静态成员有个限制:自动初始化。编译器会在调用 main() 之前初始化静态变量。这意味着它们无法使用只有在程序运行起来之后才能获得的信息(例如从文件中加载的配置)。这也意味着它们的相互依赖是不可靠的——编译器可不保证以什么样的顺序初始化静态变量。惰性初始化解决了以上两个问题。
-
可继承单例。 这是个很有用但通常被忽视的能力。假设我们需要跨平台的文件系统封装类:
class FileSystem
{
public:
virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;
};
class PS3FileSystem : public FileSystem { /* ... */ };
class WiiFileSystem : public FileSystem { /* ... */ };
// 单例:通过编译器宏选择平台实现
FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
static FileSystem *instance = new WiiFileSystem();
#endif
return *instance;
}
通过一个简单的编译器转换,我们把文件系统包装类绑定到合适的具体类型上。整个代码库都可以使用 FileSystem::instance() 访问到文件系统,而无需和任何平台相关的代码耦合。
为什么我们后悔使用它
短期来看,单例模式是相对良性的。就像其他设计决策一样,我们需要从长期考虑。这里是一旦我们将一些不必要的单例写进代码,会给自己带来的麻烦:
它是一个全局变量
当游戏还是由几个家伙在车库中完成时,榨干硬件性能比象牙塔里的软件工程原则更重要。但随着游戏变得越来越大,越来越复杂,架构和管理开始变成瓶颈——阻碍我们发布游戏的,除了硬件限制,还有生产力限制。
我们学到了全局变量有害的诸多原因:
-
理解代码更加困难。 假设我们在查找别人所写函数中的漏洞。如果函数没有碰到任何全局状态,脑子只需围着函数转。现在考虑函数中间是对
SomeClass::getSomeGlobalData()的调用。为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。 -
促进了耦合的发生。 新加入团队的程序员也许不熟悉你们精心设计的松散耦合架构。但
AudioPlayer是全局可见的,一个#include就能打乱整个架构。如果不用全局实例实现音频播放器,这种阻碍给他发送了一个明确的信号:这两个模块不该访问。通过控制对实例的访问,你控制了耦合。 -
对并行不友好。 现代代码至少应考虑在多线程环境下工作。当我们将某些东西转为全局变量时,我们创建了一块每个线程都能看到并访问的内存,却不知道其他线程是否正在使用那块内存。这带来了死锁、竞争状态,以及其他很难解决的线程同步问题。
因为单例确实是全局状态——它只是被封装在一个类中。
它能在你只有一个问题的时候解决两个
GoF 描述中的“并且“这个词有点奇怪。这个模式解决了一个问题还是两个?保证实例是唯一存在的是很有用的,但是谁告诉我们要让每个人都能访问到它?同样,全局访问很方便,但是必须禁止存在多个实例吗?
便利的访问,几乎是使用单例模式的全部原因。想想日志类。大部分模块都能从记录诊断日志中获益。但是,如果将 Log 类的实例传给每个需要这个方法的函数,那就模糊了代码的意图。
明显的解决方案是让 Log 类成为单例。每个函数都能从类那里获得一个实例。但当我们这样做时,我们无意地制造了一个奇怪的小约束——突然之间,我们不再能创建多个日志记录者了。
起初,这不是一个问题。然后,每个团队的成员都使用日志记录各自的诊断信息,大量的日志倾泻在文件里。我们想将日志分散到多个文件中来解决这点。但是我们做不到——Log 类不再允许我们创建多个实例,而且每一行调用 Log::instance().write(...) 的地方都需要修改。
惰性初始化从你那里剥夺了控制权
在拥有虚拟内存和软性性能需求的 PC 里,惰性初始化是一个小技巧。游戏则是另一种状况。初始化系统需要消耗时间:分配内存,加载资源,等等。如果初始化音频系统消耗了几百个毫秒,我们需要控制它何时发生。如果在第一次声音播放时惰性初始化它自己,这可能发生在游戏的高潮部分,导致可见的掉帧和断续的游戏体验。
同样,游戏通常需要严格管理在堆上分配的内存来避免碎片。
因此,大多数游戏不使用惰性初始化,而是使用静态实例:
class FileSystem
{
public:
static FileSystem& instance() { return instance_; }
private:
FileSystem() {}
static FileSystem instance_;
};
这解决了惰性初始化问题,但也失去了单例相对于裸全局变量的一些优势,例如多态、运行时构造与显式释放。这里实际上是一个简单的静态类。如果你需要的是静态类,为什么不直接使用静态函数呢?调用 Foo::bar() 比 Foo::instance().bar() 更简单,也更明确地表明你在处理静态内存。
那该如何是好
如果我现在达到了目标,你在下次遇到问题使用单例模式之前就会三思而后行。但是你还是有问题需要解决。你应该使用什么工具呢?
看看你是不是真正地需要类
我在游戏中看到的很多单例类都是“管理器“——那些类存在的意义就是照顾其他对象。MonsterManager、ParticleManager、SoundManager、ManagerManager……
看看这个设计:
class Bullet {
int getX() const; int getY() const;
void setX(int x); void setY(int y);
};
class BulletManager {
Bullet* create(int x, int y);
bool isOnScreen(Bullet& bullet);
void move(Bullet& bullet);
};
答案:你根本不需要 BulletManager。把行为放回对象自身:
class Bullet {
public:
Bullet(int x, int y) : x_(x), y_(y) {}
bool isOnScreen() { /* ... */ }
void move() { x_ += 5; }
};
糟糕设计的单例通常会“帮助“另一个类增加代码。如果可以,把所有的行为都移到单例帮助的类中。毕竟,OOP 就是让对象管理好自己。
将类限制为单一的实例(而不提供全局访问)
这是单例模式解决的一半问题。我们希望有种方式能保证只有一个实例而无需提供全局访问点:
class FileSystem
{
public:
FileSystem() {
assert(!instantiated_);
instantiated_ = true;
}
~FileSystem() { instantiated_ = false; }
private:
static bool instantiated_;
};
这个类允许任何人构建它,但在运行时检查并阻止多重实例化。它的缺点:检查只在运行时进行,不像单例模式在编译时保证。
提供方便的访问(而不限制实例数量)
便利的访问才是我们伸手拿单例的主要原因。但便利的代价是耦合。以下是几种替代方案:
1. 传递参数。 最简单的方案,通常也是最好的——把需要的对象作为参数传给函数。渲染函数通常接受一个 context 参数来获取图形设备。
2. 从基类获取。 如果架构有浅而宽的继承层级(GameObject → 各种实体),可以把共享对象挂在基类上,通过 protected 方法提供给子类:
class GameObject {
protected:
Log& getLog() { return log_; }
private:
static Log& log_;
};
class Enemy : public GameObject {
void doSomething() { getLog().write("I can log!"); }
};
这就是子类沙箱模式的思路。
3. 从已经全局的对象获取。 大多数代码库仍有少量全局对象(如单一的 Game 或 World 对象)。我们可以让其他系统“搭车“在它上面,而不是各自成为单例:
class Game {
public:
static Game& instance() { return instance_; }
Log& getLog() { return *log_; }
FileSystem& getFileSystem() { return *fileSystem_; }
AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
};
这样只有 Game 是全局的,其他系统通过它访问。但如果一个类只需要播放声音,它仍需知道 Game 的存在。
4. 从服务定位器获取。 定义一个类,它唯一的目的就是提供全局访问点。这就是服务定位器模式。
单例模式还剩下什么
问题仍然存在:哪里才应该使用真正的单例模式?老实说,我在游戏中从未使用过完整的 GoF 实现。为了保证只有一个实例,我通常直接使用静态类。如果那不行,我会在运行时用静态 flag 检查只有一个实例被构造。
本书中还有其他相关章节:
实践 Demo
待补充
笔记与思考
单例解决了两个问题,分别是:
- 确保只有一个实例
- 提供一个全局访问点 但是往往只需要解决一个问题,例如确保只有一个实例。 强行使用单例会带来更多的问题,例如:
- 耦合度增加
- 难以测试
- 难以扩展 在游戏开发中,通常会使用静态类而不是单例,因为静态类在编译时就确定了只有一个实例,而单例在运行时才确定。