Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

单例模式 (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. 从已经全局的对象获取。 大多数代码库仍有少量全局对象(如单一的 GameWorld 对象)。我们可以让其他系统“搭车“在它上面,而不是各自成为单例:

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

待补充


笔记与思考

单例解决了两个问题,分别是:

  • 确保只有一个实例
  • 提供一个全局访问点 但是往往只需要解决一个问题,例如确保只有一个实例。 强行使用单例会带来更多的问题,例如:
  • 耦合度增加
  • 难以测试
  • 难以扩展 在游戏开发中,通常会使用静态类而不是单例,因为静态类在编译时就确定了只有一个实例,而单例在运行时才确定。