这一章节不同寻常。其他的章节往往会向你展示如何使用一个设计模式,而这一章节告诉你如何不去使用一个设计模式。
尽管用意良好,但设计模式四人组所描述的单例模式通常弊大于利。他们强调这种模式应该谨慎使用,但这一信息往往在游戏产业中被忽略了。
和其他任何模式一样,在不需要使用单例模式的情况下使用它就好比用固定板来治疗枪伤。因为单例的滥用,本章的大部分内容都是关于避免使用单例的,但是首先,让我们温故知新。

当很多行业从C语言转向面向对象编程时,他们遇到的一个问题是“如何获得一个实例?”他们有一些想要调用的方法,但是手头没有提供该方法的对象的实例。单例模式(换句话说,使其全局化)是一种简单的解决方法。

单例模式

《设计模式》一书总结单例为:

确保类只有一个实例,并提供一个对它的全局访问点。 Ensure a class has one instance, and provide a global point of access to it.

我们来将其分成前后两句话来看。

唯一实例

有些时候多个实例会使得某些类出现问题。通常的情况是当一个类和维护着其全局状态的外部系统交互。
现在考虑一个封装了底层文件系统API的类。因为文件操作可能需要一段时间才能完成,所以我们的类是异步地执行操作。这意味着可能并发多个操作,因此它们必须相互协调。如果我们开始一个调用来创建一个文件,另一个调用来删除同一个文件,这个封装类需要知道这两个调用,以确保它们不会相互干扰。
为此,对封装类的调用需要能够访问前面的每个操作。如果用户可以自由地创建类的实例,那么一个实例就无法知道其他实例启动时的操作。现在步入实例的正题。它提供了一种方法,让类在编译时确保只有一个类的实例。

全局访问

多个不同的系统在游戏中将会使用我们的文件系统封装类: 日志、内容加载,游戏状态保存等。如果这些系统不能创建它们自己的文件系统包装器实例,那么它们如何获得一个实例呢?
单例也提供了一个解决方案。除了创建单个实例之外,它还提供了一个全局可用的方法来获取它。
这样,任何人随处都能碰到我们这个受了祝福的单例实例,它还提供了全局方法来获取这个实例。总之,经典的实现是这样的:

  1. class FileSystem{
  2. public:
  3. static FileSystem& instance(){
  4. // Lazy initialize.
  5. if (instance_ == NULL) instance_ = new FileSystem();
  6. return *instance_;
  7. }
  8. private:
  9. FileSystem() {}
  10. static FileSystem* instance_;
  11. };

静态的instance_成员持有一个本类的实例,私有的构造器确保类是独一无二的。公有静态方法instance()保证了实例可以从代码的任何地方获取到。它还负责在第一次有人请求时惰性地实例化单例实例。

更现代一点的代码会这样写:

  1. class FileSystem{
  2. public:
  3. static FileSystem& instance(){
  4. static FileSystem *instance = new FileSystem();
  5. return *instance;
  6. }
  7. private:
  8. FileSystem() {}
  9. };

C++ 11规定局部静态变量的初始化器仅运行一次,即使存在并发性。所以,假设你有一个现代的C++编译器,这段代码是线程安全的,而第一个例子不是。

当然,单例类本身的线程安全性是一个完全不同的问题!这只是确保它的初始化是线程安全的。

为何使用单例

赢家已定。现在我们的文件系统封装有求必应而不用被传递来传递去。类本身机智地确保了我们不会因为多创建了几个实例而把事情弄得一团糟。而且还有其他很棒的特点:
有求必应,无求无动。节省内存和CPU周期总是好的。由于单例只有在第一次访问时才初始化,所以如果游戏从不请求它,它就根本不会被实例化。
在运行时初始化。单例的一种常见替代方法是使用静态成员变量的类。我喜欢简单的解决方案,所以我尽可能使用静态类而不是单例类,但是静态成员有一个限制:自动初始化。编译器在调用main()函数之前就初始化静态变量。这意味着他们不能使用只有在程序启动并运行时才知道的信息(例如从文件加载的配置)。这也意味着它们不能可靠地相互依赖——编译器不能保证静态初始化的顺序。懒加载解决了这两个问题。单例将尽可能晚地初始化,因此到那时它所需要的任何信息都应该是可用的。只要它们没有循环依赖,一个单例甚至可以在初始化自己时引用另一个单例。
你可以使单例成为子类。这是一种强大但经常被忽视的能力。假设我们需要文件系统封装类可以跨平台使用。为了实现这一点,我们希望它是一个文件系统的抽象接口,包含实现每个平台接口的子类。下面是基类:

  1. class FileSystem{
  2. public:
  3. virtual ~FileSystem() {}
  4. virtual char* readFile(char* path) = 0;
  5. virtual void writeFile(char* path, char* contents) = 0;
  6. };

然后我们为几个平台定义派生类:

  1. class PS3FileSystem : public FileSystem{
  2. public:
  3. virtual char* readFile(char* path){
  4. // Use Sony file IO API...
  5. }
  6. virtual void writeFile(char* path, char* contents){
  7. // Use sony file IO API...
  8. }
  9. };
  10. class WiiFileSystem : public FileSystem{
  11. public:
  12. virtual char* readFile(char* path){
  13. // Use Nintendo file IO API...
  14. }
  15. virtual void writeFile(char* path, char* contents){
  16. // Use Nintendo file IO API...
  17. }
  18. };

接下来,我们把FileSystem变成一个单例:

  1. class FileSystem{
  2. public:
  3. static FileSystem& instance();
  4. virtual ~FileSystem() {}
  5. virtual char* readFile(char* path) = 0;
  6. virtual void writeFile(char* path, char* contents) = 0;
  7. protected:
  8. FileSystem() {}
  9. };

聪明的部分是如何创建实例:

  1. FileSystem& FileSystem::instance(){
  2. #if PLATFORM == PLAYSTATION3
  3. static FileSystem *instance = new PS3FileSystem();
  4. #elif PLATFORM == WII
  5. static FileSystem *instance = new WiiFileSystem();
  6. #endif
  7. return *instance;
  8. }

通过一个简单的编译器开关,我们将文件系统封装类绑定到适当的具体类型。我们的整个代码库可以使用FileSystem::instance()而不与任何特定于平台的代码耦合。相反,这种耦合被封装在FileSystem类本身的实现文件中。

在解决这类问题时,这和我们大多数人所做的差不多。我们有一个文件系统封装类。它能可靠地工作。它在全局都是可用的,所以任何需要它的地方都可以得到它。是时候呷口饮料然后检入代码了。

为何后悔使用单例

在短期内,单例模式是相对良性的。就像许多设计选择一样,我们要付出长期的代价。一旦我们在写死的代码中加入了一些不必要的单例,我们就会遇到这样的麻烦:

单例是全局变量

当游戏还是由几个人在车库里编写的时候,榨取硬件性能比处于还象牙塔中的软件工程原则更重要。老派的C和汇编程序员使用全局变量和静态变量,没有任何问题,并且发布了很好的游戏。随着游戏的大型化和复杂化,软件架构和可维护性成为了瓶颈。我们挣扎于发行新游戏并不是因为硬件受限,而是因为有限的生产力。

所以我们转移到C++这样的平台上开始使用起我们的软件工程师前辈们辛苦得来的智慧。我们吸取的教训就是全局变量有一堆坏处:

  • 它们使得对代码进行推理变得更加困难。假设我们正在跟踪别人编写的函数中的一个bug。如果这个函数没有触及任何全局状态,我们可以通过理解函数的主体和传递给它的参数来理解它。

    1. 现在,想象一下在这个函数中间对SomeClass::getSomeGlobalData()的调用。为了弄清楚发生了什么,我们必须遍历整个代码库来查看是什么触及了全局数据。不在凌晨3点用grep查个几百万行代码来搞清楚哪个调用将静态变量赋值错了你是不会对全局变量死心的。
  • 它们鼓励耦合。您团队中的新程序员并不熟悉您的游戏的可漂亮维护的松散耦合架构,但他刚刚接到的第一个任务是:让巨石撞击地面时发出声音。你和我都知道我们不希望掌管物理逻辑代码和所有物体的音频逻辑相连接,但是他只是想完活走人。不幸的是,我们的AudioPlayer实例是全局可见的。因此,一个小的#include之后,我们的菜鸟就把一个精心构建的架构给交代了。如果没有音频播放器的全局实例,即使他写了#include,他仍然不能对它做任何事情。这种困难向他发出了一个明确的信息,即这两个模块不应该相互了解,他需要找到另一种方法来解决他的问题。控制了访问实例的权限,你也就控制了耦合。

    译者注在这里他显然违反了迪米特法则。

  • 不对并发友好。在单核CPU上运行游戏的日子已经结束了。现在的代码至少必须以多线程的方式工作,即使它没有充分利用并发性。当我们使某个东西全局化时,我们已经创建了一个内存块,每个线程都可以看到和查看它,不管它们是否知道其他线程对它做了什么。这条路径会导致死锁、争用和其他难以修复的线程同步错误。

像这样的问题足以让我们在声明一个全局变量,也就是单例模式前掂量三分,但这仍然不能告诉我们应该如何设计游戏。如何在没有全局状态的情况下规划游戏架构?
对于这个问题有一些广泛的答案(本书的大部分内容在很多方面都是对这个问题的答案),但这些答案并不明显,也不容易找到。与此同时,我们必须让游戏走出家门。单例模式看起来像是万灵药。它在一本关于面向对象设计模式的书中,所以它在架构上是可靠的,不错吧? 它让我们能够按照多年来一直在做的方式来设计软件。

不幸的是,它不过是安慰剂罢了。如果您列一张全局变量引起的问题表,您会注意到单例模式并不能解决任何问题。这是因为单例是全局变量——只是封装在一个类中。

二石一鸟

设计模式四人组对单例的描述中的“并”字其实有些微妙。这个设计模式到底解决了几个问题,一个还是两个?要是我只有一个问题呢?确保单一实例是有用的,但我也没说非得让它随随便便就能调用的到啊。同样,全局访问也很方便,但即使对于允许多个实例的类也是如此。

后一个问题,方便访问,几乎总是我们转向单例模式的原因。考虑一个日志类。游戏中的大多数模块都可以从记录诊断信息中获益。但是,将日志类的实例传递给每个函数会使方法签名变得混乱,并影响代码的意图。
最明显的解决方法是使日志类成为单例。然后,每个函数都可以直接访问类本身来获取实例。但当我们这样做的时候,我们无意中获得了一个奇怪的小限制。突然之间,我们不能再创建其他的日志记录实例了。

起初,这没啥大不了。我们只写一个日志文件,因此也只需要一个实例。然后,随着开发周期的深处,我们惹上了麻烦。团队中的每个人都在使用日志记录器进行自己的诊断,日志文件已经变成了一个巨大的垃圾堆。程序员不得不在文本页面中费力地寻找他们所关心的条目。

我们希望通过将日志划分到多个文件中来解决这个问题。为了做到这一点,我们将有不同的游戏区域的日志:online, UI, audio, gameplay. 但现实是不能这么做。我们的日志类不仅不再允许我们创建多个实例,而且每个使用它的调用都存在根深蒂固的设计限制: Log::instance().write("Some event.") ;
为了使我们的日志类支持多个实例化(像它最初所做的那样),我们必须修复类本身和提到它的每一行代码。我们方便的访问已经不那么方便了。

情况可能比这更糟。假设您的日志类位于一个库中,在多个游戏之间共享。现在,要更改设计,您必须跨几个组的人员协调更改,其中大多数人既没有时间也没有动力来修复它。

译者注> 这就是为什么我们时常需要注意满足单一职责原则。

懒加载

在桌面PC世界对虚拟内存和软性能的要求中,懒加载/初始化是一个聪明的技巧。游戏则完全不同。初始化一个系统需要耗费时间的:分配内存,加载资源等。如果初始化音频系统要耗费数百毫秒的话,我们就应当控制该行为发生的时间。如果我们让它在第一次播放声音时惰性地初始化自己,这可能是在游戏中充满动作场景的部分进行,也就导致了卡顿和大幅帧数下降。
同样的,游戏通常需要精细地控制堆内存的布局规划以防止内存碎片。如果我们的音频系统在初始化堆时分配一块堆,我们想知道什么时候会发生初始化,这样我们就可以控制内存在堆中的位置。 关于内存碎片化的详细解释见Object Pool 一章

由于这两个问题,我所见过的大多数游戏都不依赖于延迟初始化。相反,他们实现了这样的单例模式:

  1. class FileSystem{
  2. public:
  3. static FileSystem& instance() { return instance_; }
  4. private:
  5. FileSystem() {}
  6. static FileSystem instance_;
  7. };

这解决了延迟初始化问题,但代价是放弃了一些比原始全局变量更好的单例特性。对于静态实例,我们不能再使用多态,类在静态初始化时必须是可构造的。我们也不能释放实例在不需要时使用的内存。

这里我们需要的其实不是创建单例,而是一个简简单单的静态类。这未必是件坏事,但如果只需要一个静态类,为什么不完全去掉instance()方法而使用静态函数呢? 调用Foo::bar()比调用Foo::instance().bar()更简单,也更清楚地表明你实际上是在处理静态内存。

选择单例而不是静态类的常见理由是,如果您决定稍后将静态类更改为非静态类,则需要修复每个调用点。理论上讲,对于单例对象则不必如此,因为你可以传递实例并像普通实例方法那样调用它。

其他方案

如果到目前为止我已经实现了我的目标,那么下次遇到问题时,在从工具箱中取出Singleton之前,您会三思而后行。但是你仍然有一个问题需要解决。你应该拿出什么工具? 这取决于你想做什么,我有一些选择供你考虑,但首先……

你是否真的需要一个类

我在游戏中看到的许多单例类都是“Managers”——即那些为了照顾其他对象而存在的模糊类。我见过这样的代码库,似乎每个类都有一个Manager: Monster, MonsterManager, Particle, ParticleManager, Sound, SoundManager, ManagerManager. 有时候为了多样性他们会挂个“System”或“Engine”的名头在那儿,但仍然是换汤不换药。
虽然管理员类有时很有用,但它们常常反映出对OOP的陌生性。考虑下面这两个实现类:

  1. class Bullet{
  2. public:
  3. int getX() const { return x_; }
  4. int getY() const { return y_; }
  5. void setX(int x) { x_ = x; }
  6. void setY(int y) { y_ = y; }
  7. private:
  8. int x_, y_;
  9. };
  10. class BulletManager{
  11. public:
  12. Bullet* create(int x, int y)
  13. {
  14. Bullet* bullet = new Bullet();
  15. bullet->setX(x);
  16. bullet->setY(y);
  17. return bullet;
  18. }
  19. bool isOnScreen(Bullet& bullet)
  20. {
  21. return bullet.getX() >= 0 &&
  22. bullet.getX() < SCREEN_WIDTH &&
  23. bullet.getY() >= 0 &&
  24. bullet.getY() < SCREEN_HEIGHT;
  25. }
  26. void move(Bullet& bullet)
  27. {
  28. bullet.setX(bullet.getX() + 5);
  29. }
  30. };

这个例子可能看起来傻傻的,但是抛开细枝末节我已经见过太多类似的代码设计了。如果您查看这段代码,你会很自然地认为BulletManager应该是个单例。毕竟任何与Bullet扯上关系的事情都需要一个Manager, 但是你又需要多少个Manager呢?
答案是零个,真的。下面是我们如何解决Manager类的“单例”问题:

  1. class Bullet{
  2. public:
  3. Bullet(int x, int y) : x_(x), y_(y) {}
  4. bool isOnScreen(){
  5. return x_ >= 0 && x_ < SCREEN_WIDTH &&
  6. y_ >= 0 && y_ < SCREEN_HEIGHT;
  7. }
  8. void move() { x_ += 5; }
  9. private:
  10. int x_, y_;
  11. };

好了。没有经理,没有问题。设计糟糕的单例对象通常是向另一个类添加功能的“助手(helper)”。如果可以的话,把所有这些行为都移到类中,这会很有帮助。毕竟,OOP就是让对象自己照顾自己。不过,除了Manager之外,还有其他一些问题需要单例来解决。对于每个问题,都有一些可供考虑的替代解决方案。

确保唯一实例

这是单例模式的另一半内容。在我们的文件系统示例中,确保只有一个类的实例是非常重要的。但是,这并不一定意味着我们还需要提供对该实例的公共全局访问。我们可能希望限制对代码的某些区域的访问,甚至使其成为单个类的私有属性。在这些情况下,提供公共全局访问点会削弱体系结构。

举个例子,我们可能把文件系统的封装类封装到另一层的抽象当中。

我们需要一种方法来确保单一实例化而不提供全局访问。有几种方法可以做到这一点。这就是其中一个:

  1. class FileSystem{
  2. public:
  3. FileSystem(){
  4. assert(!instantiated_);
  5. instantiated_ = true;
  6. }
  7. ~FileSystem() { instantiated_ = false; }
  8. private:
  9. static bool instantiated_;
  10. };
  11. bool FileSystem::instantiated_ = false;

这个类允许任何人构造它,但是如果你试图构造多个实例,它就会断言并失败。只要正确的代码首先创建实例,那么我们就可以确保没有其他代码能够获得该实例或创建它们自己的实例。类确保了它所关心的单个实例化需求,但它没有规定应该如何使用类。

断言是嵌入代码中的契约。当assert()被调用时,他会检查传入的表达式。如果表达式结果为true,continue;如果是false,则会立即在调用处挂起游戏,在调试构建时断言失败经常带来调试器或者增加输出文件及行号的输出。 assert()的意思是,“我断言这应该总是正确的。”如果不是,那就是一个bug,我现在想停下来,这样您就可以修复它了。”这允许您定义代码区域之间的契约。如果一个函数断言它的一个参数不为空,这就是说,“我和调用者之间的约定是,我将不会被传递为空。” 断言可以帮助我们在游戏做一些意料之外的事情时立即跟踪bug,而不是在错误最终以用户明显错误的形式出现时才跟踪。它们是你的代码库的围栏,将bug隔离开来,这样它们就无法从创建它们的代码中逃脱。

此实现的缺点是,防止多个实例化的检查仅在运行时执行。相比之下,单例模式在编译时根据类结构的本质保证了一个实例。

提供便捷访问实例的方法

方便的访问是我们访问单例对象的主要原因。它们使我们很容易得到我们需要在许多不同地方使用的东西。然而,这种便利是有代价的——在我们不希望它被使用的地方,我们同样很容易得到它。
一般的规则是,我们希望变量的作用域尽可能狭窄,同时仍能完成任务。一个对象的作用域越小,我们在处理它时需要记住的地方就越少。在我们采用带全局作用域的单例对象的散弹方式之前,让我们考虑一下我们的代码库访问对象的其他方式:

  • 将之传入。最简单、通常也是最好的解决方案。将你需要的对象作为参数传入被调用的函数。在这种方法变得麻烦之前还是很值得考虑的。

    有些人使用术语“依赖注入”来指代它。代码不是通过调用全局变量来寻找它的依赖项,而是通过参数将依赖项推入到需要它的代码中。其他人则将“依赖注入”保留给更复杂的方式来提供对代码的依赖。

考虑一个用于渲染对象的函数。为了渲染,它需要访问一个表示图形设备并维护渲染状态的对象。常见做法是简单地将其传递给所有呈现函数,通常作为一个名为context之类的参数。

另一方面,有些对象不属于方法的签名。例如,处理AI的函数可能也需要写入日志文件,但日志不是它的核心关注点。看到Log出现在它的参数列表中会很奇怪,所以对于这种情况,我们要考虑其他选项。

对于像日志记录这样分散在整个代码库的事情,术语是“横切关注点”。优雅地处理横切关注点是一个持续的架构挑战,尤其是在静态类型语言中。 面向切面编程 旨在解决这些问题。

  • 从基类中获取。许多游戏架构具有浅而宽的继承层次结构,通常只有一层。例如,你可能有一个基本的GameObject类,它包含游戏中每个敌人或对象的派生类。有了这样的架构,游戏代码的很大一部分将存在于这些“叶子”派生类中。这意味着所有这些类已经能够访问相同的东西:它们的GameObject基类。我们可以利用这一点: ```cpp class GameObject{ protected: Log& getLog() { return log_; }

private: static Log& log_; };

class Enemy : public GameObject{ void doSomething() { getLog().write(“I can log!”); } };

  1. 这保证了GameObject外部的任何东西都不能访问它的日志对象,但是每个派生实体都可以使用getLog()。<br />这种让派生对象根据提供给它们的受保护的方法实现自身的模式在[Subclass Sandbox](http://gameprogrammingpatterns.com/subclass-sandbox.html) 一章中有介绍。
  2. > 这就提出了一个问题,“GameObject如何获得日志实例?”一个简单的解决方案是让基类简单地创建并拥有一个静态实例。
  3. >
  4. 如果您不希望基类扮演这样的主动角色,您可以提供一个初始化函数来传递它,或者使用> [Service Locator](http://gameprogrammingpatterns.com/service-locator.html)模式来查找它。
  5. - 从已有的全局变量获取。移除所有的全局变量虽然美满但现实却骨感。大多数代码库仍然会有两个全局可用的对象,例如表示整个游戏状态的单个游戏或世界对象。
  6. 我们可以通过利用现有的类来减少全局类的数量。与其从日志、文件系统和AudioPlayer中生成单例,不妨这样做:
  7. ```cpp
  8. class Game{
  9. public:
  10. static Game& instance() { return instance_; }
  11. // Functions to set log_, et. al. ...
  12. Log& getLog() { return *log_; }
  13. FileSystem& getFileSystem() { return *fileSystem_; }
  14. AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
  15. private:
  16. static Game instance_;
  17. Log *log_;
  18. FileSystem *fileSystem_;
  19. AudioPlayer *audioPlayer_;
  20. }

有了这个,只有Game是全局的。函数可以通过它到达其他系统:

  1. Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);

纯粹主义者会声称这违反了迪米特法则。我认为这还是比一大堆单例好。

译者注实际上就我自己接触过的几个可以直接使用脚本语言编程的游戏引擎中,大多数并没有采用这种做法。

如果稍后将架构更改为支持多个游戏实例(可能用于流媒体或测试目的),日志、文件系统和AudioPlayer都将不受影响——它们甚至不知道其中的区别。当然这样做的缺点是,最终会有更多的代码耦合到游戏本身。如果一个类只需要播放声音,我们的例子仍然需要它了解这个游戏整体,以便获得音频播放器。

我们用混合的方式解来解决这个问题。已经知道游戏的代码可以直接访问AudioPlayer。对于不需要的代码,我们使用这里描述的其他选项之一来提供对AudioPlayer的访问。

  • 从Service Locator中获取。目前为止,我们假设全局的类都是像Game这样的实体类。另一个选择是定义一个其存在的惟一原因就是提供对对象的全局访问的类。这种常见的模式称为Service Locator,详见相关章节。

    单例的用武之地

    问题依旧,到底哪里才真正用得上单例模式?实话实说,我从来都没在游戏中实现过设计模式四人组的全部。要想确保单一实例,我通常会使用静态类,要是还不行,我就使用一个静态的标记在运行时检查是否在构造类的时候创建了单一实例。
    这本书中还有一些其他的章节也可以在这里有所帮助。Subclass Sandbox模式给予类的实例访问某些共享状态的权限而不必将其设置为全局可见。Service Locator模式则令一个对象全局可见,但它在如何配置该对象方面为您提供了更大的灵活性。