目录

  • 致谢
  • 序
    • 架构,性能和游戏
  • 重访设计模式
    • 命令模式
    • 享元模式
    • 观察者模式
    • 原型模式
    • 单例模式
    • 状态模式
  • 序列模式
    • 双缓冲模式
    • 游戏循环
    • 更新方法
  • 行为模式
    • 字节码
    • 子类沙箱
    • 类型对象
  • 解耦模式
    • 组件模式
    • 事件队列
    • 服务定位器
  • 优化模式
    • 数据局部性
    • 脏标识模式
    • 对象池模式
    • 空间分区
← 上一章 下一章 → ≡ 首页

单例模式

游戏设计模式Design Patterns Revisited

这一章是个异类。 其他章节展示如何使用某个设计模式。 这个章节展示如何避免使用某个设计模式。

尽管本意是好的,但GoF所描述的单例模式通常弊大于利。 他们强调应当谨慎使用这个模式,但这一忠告传到游戏业界时往往就丢了。

就像其他模式一样,在不合适的地方使用单例模式就好像用夹板处理子弹伤口。 由于它被滥用得太严重了,这章的大部分都在讲如何回避单例模式, 但首先,让我们看看模式本身。

当业界从C语言迁移到面向对象编程时,他们遇到的一个问题是“如何获取实例?” 他们想调用某个方法,但手头没有提供该方法的对象实例。 单例(换言之,把它变成全局的)是一条简单的出路。

单例模式

《设计模式:可复用面向对象软件的基础》这样概括单例模式:

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

我们在“并且”处把这句话拆成两半,分别讨论。

保证一个类只有一个实例

有时候,如果类有多个实例就无法正确运行。 常见情况是,类与一个维护着自身全局状态的外部系统交互。

考虑一个封装了底层文件系统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标准也保证了局部静态变量的初始化器只会运行一次, 因此,假设你有一个现代C++编译器,这段代码是线程安全的,而前面的那个例子不是。

当然,单例类本身的线程安全完全是另一个问题!这里只保证了它的初始化是线程安全的。

为什么我们使用它

看起来我们找到了不错的方案。 文件系统封装类在任何需要的地方都可用,省去了到处传递的麻烦。 类本身也巧妙地保证了我们不会因为多实例化几个实例而把事情搞砸。它还有一些其他不错的特性:

  • 如果没人用,就不必创建实例。 节约内存和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
    {
    public:
      virtual char* readFile(char* path)
      {
        // 使用索尼的文件读写API……
      }
    
      virtual void writeFile(char* path, char* contents)
      {
        // 使用索尼的文件读写API……
      }
    };
    
    class WiiFileSystem : public FileSystem
    {
    public:
      virtual char* readFile(char* path)
      {
        // 使用任天堂的文件读写API……
      }
    
      virtual void writeFile(char* path, char* contents)
      {
        // 使用任天堂的文件读写API……
      }
    };
    

    下一步,我们把FileSystem变成单例:

    class FileSystem
    {
    public:
      static FileSystem& instance();
    
      virtual ~FileSystem() {}
      virtual char* readFile(char* path) = 0;
      virtual void  writeFile(char* path, char* contents) = 0;
    
    protected:
      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()访问文件系统,而不必与任何平台相关的代码耦合。这种耦合被封装在FileSystem类自己的实现文件中。

我们大多数人解决这类问题时,通常也就走到这一步。 我们得到了一个文件系统封装类。 它工作可靠,而且全局可用,每个需要它的地方都能访问它。 是时候提交代码,开瓶好喝的庆祝一下了。

为什么我们后悔使用它

短期来看,单例模式相对无害。 但和许多设计决策一样,代价要在长期才显现。 一旦我们把几个不必要的单例固化进冰冷的代码,就会给自己买来这些麻烦:

它是一个全局变量

当游戏还由几个家伙在车库里完成时,榨干硬件性能比象牙塔里的软件工程原则更重要。 老派的C语言和汇编程序员能毫无问题地使用全局变量和静态变量,并发布好游戏。 但随着游戏变得越来越大、越来越复杂,架构和可维护性开始成为瓶颈。 我们难以发布游戏,不是因为硬件限制,而是因为生产力限制。

于是我们迁移到C++这样的语言, 开始运用软件工程师前辈们辛苦得来的智慧。 我们学到的其中一课是:全局变量之所以有害,原因有很多:

  • 它们让人更难理解代码。 假设我们在排查别人写的函数里的bug。 如果这个函数不接触任何全局状态,我们只需理解函数体以及传给它的参数, 就能把它弄明白。

    计算机科学家把不访问、不修改全局状态的函数称为“纯”函数。 纯函数更容易理解,更容易被编译器优化, 还能让你做记忆化这类巧妙的事——缓存并复用函数之前调用的结果。

    虽然完全只使用纯函数会带来挑战,但它的好处太诱人,以至于计算机科学家创造了像Haskell这样只允许纯函数的语言。

    现在想象一下,这个函数中间有一个对SomeClass::getSomeGlobalData()的调用。为了弄清发生了什么,我们得翻遍整个代码库,看看有哪些地方会碰这块全局数据。只有当你凌晨三点还在grep上百万行代码,只为揪出那个把静态变量改成错误值的捣乱调用时,你才会真正痛恨全局状态。

  • 它们助长耦合。 团队里的新程序员也许不熟悉你那优雅、可维护、松耦合的游戏架构, 而他刚接到第一个任务:让岩石撞击地面时播放声音。 你我都知道我们可不想让物理代码和音频耦合在一起,但他只想把任务完成。 对我们来说不幸的是,我们的AudioPlayer实例是全局可见的。 于是,一个简简单单的#include之后,新同事就破坏了精心构建的架构。

    如果没有音频播放器的全局实例,那么哪怕他确实#include了头文件,他还是什么都做不了。 这种阻碍向他传递了明确的信号:这两个模块不应该互相知道对方,他需要另找解决办法。通过控制对实例的访问,你就控制了耦合。

  • 它们对并发不友好。 游戏在简单的单核CPU上运行的日子早已基本结束。 即便不能充分利用并发,如今的代码至少也得能在多线程环境下工作。 当我们把某个东西变成全局的,就相当于创建了一块每个线程都能看到、都能摆弄的内存, 而它们并不知道其他线程正在对这块内存做什么。 这条路通向死锁、竞态条件,以及其他修起来让人抓狂的线程同步bug。

像这样的问题足以让我们不敢声明全局变量, 进而也不敢用单例模式,但这仍然没有告诉我们应该如何设计游戏。 如何在没有全局状态的情况下构建游戏?

对这个问题有一些详尽的答案(这本书的大部分内容在很多方面就是对这个问题的回答), 但它们并不显而易见,也不容易找到。 与此同时,我们还得把游戏做出来发布。 单例模式看起来像是万能药。 它出现在一本讲面向对象设计模式的书里,所以它在架构上一定是合理的,对吧? 而且它让我们能像多年来一直做的那样设计软件。

不幸的是,它与其说是解药,不如说是安慰剂。 如果浏览一下全局变量造成的问题列表,你会注意到单例模式没有解决其中任何一个。 因为单例就是全局状态——只是被封装在了类里。

哪怕你只有一个问题,它也要解决两个问题

在GoF对单例模式的描述中,“并且”这个词有点奇怪。 这个模式解决的是一个问题,还是两个问题?如果我们只有其中一个问题呢? 确保只有一个实例是有用的,但谁规定我们必须让每个人都能碰它呢? 同样,全局访问很方便,但即便一个类允许多个实例,这一点也成立。

这两个问题中的后者——便利的访问——几乎总是我们使用单例模式的原因。 想想日志类。游戏中的大多数模块都能从记录诊断日志中获益。 然而,把Log类的实例传给每一个函数,会让方法签名变得杂乱,并分散对代码意图的注意力。

显而易见的办法是把Log类变成单例。 这样每个函数都能直接到类本身那里获取实例。 但当我们这样做时,我们无意中给自己加上了一个奇怪的小限制。 突然之间,我们不能再创建多个日志器了。

起初,这不是问题。 我们只写一个日志文件,所以本来也只需要一个实例。 然后,在开发周期进行到深处时,我们遇到了麻烦。 团队里的每个人都用这个日志器记录自己的诊断信息,日志文件变成了一个巨大的垃圾场。 程序员不得不在一页页文本里跋涉,只为找到自己关心的那一条记录。

我们想把日志分到多个文件里来解决这个问题。 为此,我们得为游戏的不同领域创建单独的日志器: 在线、UI、音频、玩法。 但我们做不到。 Log类不再允许我们创建多个实例,而且这一设计限制已经固化在每一个使用它的调用点里:

Log::instance().write("Some event.");

为了让Log类支持多个实例(就像它最初那样), 我们得同时修改类本身和每一处提到它的代码。 原本便利的访问就不再那么便利了。

情况还可能更糟。想象一下你的Log类在一个被多个游戏共享的库里。 现在,为了改变设计,你需要在多组人之间协调这次改动, 而他们中的大多数既没有时间,也没有动机去修复它。

惰性初始化从你那里剥夺了控制权

在有虚拟内存和宽松性能要求的桌面PC世界里,惰性初始化是个聪明的小技巧。 游戏则是另一回事。初始化系统可能需要时间:分配内存、加载资源等等。 如果音频系统的初始化需要几百毫秒,我们就需要控制它何时发生。 如果我们让它第一次播放声音时才惰性初始化自己,这件事可能发生在游戏里动作最密集的时刻,导致肉眼可见的掉帧和卡顿。

同样,游戏通常需要严格控制内存在堆中的布局,以避免碎片化。 如果音频系统在初始化时会分配一块堆内存,我们就需要知道初始化何时发生, 这样我们才能控制这块内存在堆中的位置。

关于内存碎片的详细解释,参见对象池模式。

因为这两个原因,我见到的大多数游戏都不使用惰性初始化。 相反,它们像这样实现单例模式:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }

private:
  FileSystem() {}

  static FileSystem instance_;
};

这解决了惰性初始化的问题,但代价是丢掉了几个单例确实优于普通全局变量的特性。 有了静态实例,我们就不能再使用多态,而且类必须在静态初始化时就能被构造。 我们也不能在不需要时释放该实例占用的内存。

我们这里实际得到的不是一个单例,而是一个简单的静态类。 这未必是坏事,但如果一个静态类就能满足你,为什么不彻底去掉instance()方法, 直接使用静态函数呢?调用Foo::bar()比Foo::instance().bar()更简单, 也更能清楚地表明你在处理静态内存。

通常使用单例而不是静态类的理由是, 如果你后来决定将静态类改为非静态的,你需要修改每一个调用点。 理论上,用单例就不必那么做,因为你可以将实例传来传去,像普通的实例方法一样使用。

实践中,我从未见它真这么运作过。 每个人都是一行Foo::instance().bar()。 如果我们把Foo改成非单例,还是得修改每一个调用点。 鉴于此,我更喜欢简单的类和更简单的调用语法。

那该如何是好

如果到目前为止我达成了目标,那么下次遇到问题时,你在把单例模式从工具箱里掏出来之前就会三思。 但你的问题仍然需要解决。你应该掏出什么工具呢? 这取决于你想做什么,我有几个选项供你考虑,不过首先……

看看你是否真的需要这个类

我在游戏中看到的很多单例类都是“管理器”——那些含义模糊、存在就是为了给其他对象当保姆的类。 我曾看到一些代码库,里面几乎每个类都有管理器: Monster、MonsterManager、Particle、ParticleManager、Sound、SoundManager、ManagerManager。 有时候,为了换换花样,它们会叫“系统”或“引擎”,但思路还是一样的。

这类照管类有时是有用的,但往往它们只是反映出对OOP的不熟悉。看看这两个人为编造的类:

class Bullet
{
public:
  int getX() const { return x_; }
  int getY() const { return y_; }

  void setX(int x) { x_ = x; }
  void setY(int y) { y_ = y; }

private:
  int x_, y_;
};

class BulletManager
{
public:
  Bullet* create(int x, int y)
  {
    Bullet* bullet = new Bullet();
    bullet->setX(x);
    bullet->setY(y);

    return bullet;
  }

  bool isOnScreen(Bullet& bullet)
  {
    return bullet.getX() >= 0 &&
           bullet.getX() < SCREEN_WIDTH &&
           bullet.getY() >= 0 &&
           bullet.getY() < SCREEN_HEIGHT;
  }

  void move(Bullet& bullet)
  {
    bullet.setX(bullet.getX() + 5);
  }
};

也许这个例子有点蠢,但我见过很多代码,在刮掉那些繁杂的细节后,暴露出的设计和这一模一样。 看到这段代码,你会很自然地认为BulletManager应该是个单例。 毕竟,任何持有Bullet的东西都需要管理器,而你又需要多少个BulletManager实例呢?

事实上,这里的答案是零。 这里是我们如何为管理类解决“单例”问题:

class Bullet
{
public:
  Bullet(int x, int y) : x_(x), y_(y) {}

  bool isOnScreen()
  {
    return x_ >= 0 && x_ < SCREEN_WIDTH &&
           y_ >= 0 && y_ < SCREEN_HEIGHT;
  }

  void move() { x_ += 5; }

private:
  int x_, y_;
};

这就对了。没有管理器,就没有问题。 设计糟糕的单例通常是给另一个类添加功能的“帮手”。 如果可以,就把所有这些行为都移到它所帮助的那个类中。 毕竟,OOP的核心就是让对象自己照顾自己。

不过,除了管理器之外,还有其他问题会让我们想用单例模式来解决。 对于每一类问题,都有一些替代方案可以考虑。

将类限制为单一的实例

这是单例模式所提供的一半能力。 就像文件系统的例子那样,确保类只有一个实例可能很关键。 但是,这不意味着我们还要提供对实例的公开、全局访问。 我们可能想把访问限制在代码的某些区域,甚至让它只对单个类私有。 在这些情况下,提供公开的全局访问点反而会削弱架构。

例如,我们也许想把文件系统封装器包在另一层抽象之中。

我们希望有一种方式能保证只有一个实例,而无需提供全局访问。 有几种方法可以做到。这是其中一种:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }

  ~FileSystem() { instantiated_ = false; }

private:
  static bool instantiated_;
};

bool FileSystem::instantiated_ = false;

这个类允许任何人构造它,但如果你试图构造多个实例,它就会触发断言并失败。 只要正确的代码先创建了实例,就能确保没有其他代码可以拿到该实例或创建自己的实例。 这个类保证了自己所关心的“单一实例”要求,但它没有规定类该如何被使用。

断言函数是一种把契约嵌入代码的方式。 当assert()被调用时,它会求值传入的表达式。 如果结果为true,它就什么都不做,让游戏继续。 如果结果为false,它会立刻让游戏停在该处。 在调试构建中,它通常会唤出调试器,或至少打印断言失败所在的文件和行号。

assert()的意思是: “我断言这个应该永远为真。如果不是,那就是bug,我想现在就停下来,好让你修复它。” 这让你能在代码的不同区域之间定义契约。 如果函数断言它的某个参数不为NULL,那就是在说:“我和调用者之间的契约是:我不会被传入NULL。”

断言能在游戏一做出意外行为时就帮我们追踪bug, 而不是等到错误最终表现为用户可见的异常时才去追查。 它们是代码库中的栅栏,把bug圈起来,让它们无法从产生它们的代码中逃出去。

这种实现的缺点是,阻止多重实例化的检查只在运行时进行。 单例模式则相反,它凭借类结构本身,在编译时就保证了只有一个实例。

为了提供对实例的便利访问

便利的访问是我们使用单例的主要原因。 它们让我们能在许多不同地方轻松拿到需要的对象。 但这种便利是有代价的——在那些我们不希望使用该对象的地方,也能同样轻松地拿到它。

通用原则是:在能完成工作的前提下,让变量的作用域尽可能窄。 对象的作用域越小,我们处理它时需要记在脑子里的地方就越少。 在采取“用全局作用域单例”这种散弹枪式做法之前,先考虑一下代码库中获取对象的其他方式:

  • 传进来。 最简单、往往也是最好的办法,就是把需要的对象作为参数传给需要它的函数。 在因为嫌它太麻烦而放弃之前,这个方案值得先考虑一下。

    有些人用“依赖注入”这个术语来指代它。不是让代码向外伸手,通过调用全局对象来找到依赖, 而是通过参数把依赖注入到需要它的代码中。 另一些人则把“依赖注入”留给更复杂的、向代码提供依赖的方式。

    想想渲染对象的函数。为了渲染,它需要访问一个代表图形设备并维护渲染状态的对象。 把这个对象传给所有渲染函数是很常见的,通常会用一个类似context这样的参数名。

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

    像日志这样散布在代码库各处的事物,术语叫做“横切关注点”(cross-cutting concern)。 优雅地处理横切关注点一直是架构上的挑战,特别是在静态类型语言中。

    面向切面编程被设计出来应对它们。

  • 从基类中获得。 很多游戏架构有浅而宽的继承层次,通常只有一层深。 例如,你也许有一个GameObject基类,游戏中的每个敌人或对象都有对应的派生类。 在这样的架构下,很大一部分游戏代码会存在于这些“叶子”派生类中。 这就意味着所有这些类都已经能访问同一个东西:它们的GameObject基类。 我们可以利用这一点:

    class GameObject
    {
    protected:
      Log& getLog() { return log_; }
    
    private:
      static Log& log_;
    };
    
    class Enemy : public GameObject
    {
      void doSomething()
      {
        getLog().write("I can log!");
      }
    };
    

    这保证了GameObject之外的任何代码都不能访问它的Log对象,但每个派生实体都能通过getLog()访问。 这种让派生对象基于提供给它们的protected方法来实现自身的模式, 会在子类沙箱一章中介绍。

    这又引出一个问题:“GameObject是怎样获得Log实例的?”一个简单的方案是,让基类直接创建并持有一个静态实例。

    如果你不想让基类承担这么主动的角色,可以提供一个初始化函数把它传进来, 或使用服务定位器模式来找到它。

  • 从已经是全局的东西中获取。 移除所有全局状态的目标令人钦佩,但很少能实现。 大多数代码库仍会有几个全局可用对象,比如一个代表整个游戏状态的Game或World对象。

    我们可以借助这类已有的全局对象,来减少全局类的数量。 不要把Log、FileSystem和AudioPlayer都变成单例,而是这样做:

    class Game
    {
    public:
      static Game& instance() { return instance_; }
    
      // 设置log_, et. al. ……
    
      Log&         getLog()         { return *log_; }
      FileSystem&  getFileSystem()  { return *fileSystem_; }
      AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
    
    private:
      static Game instance_;
    
      Log         *log_;
      FileSystem  *fileSystem_;
      AudioPlayer *audioPlayer_;
    };
    

    这样,只有Game是全局可见的。 函数可以通过它访问其他系统。

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

    纯粹主义者会说这违反了迪米特法则。我则认为这仍比堆成山的单例要好。

    如果之后架构被改为支持多个Game实例(也许是为了流式加载或测试), Log、FileSystem和AudioPlayer都不会受影响——它们甚至察觉不到区别。 当然,缺点是有更多代码最终耦合到了Game本身。 如果一个类只是想播放声音,在我们的例子中,它仍然需要先了解整个世界,才能拿到音频播放器。

    我们用混合方案来解决这一点。 已经知道Game的代码可以直接通过它访问AudioPlayer。 对于不知道的代码,我们用前面描述的其他选项之一来提供对AudioPlayer的访问。

  • 从服务定位器中获得。 到目前为止,我们假设全局类是一个普通的具体类,比如Game。 另一种选择是定义一个类,其存在的唯一目的就是提供对对象的全局访问。 这种常见模式被称为服务定位器模式,它有专门的一章。

单例中还剩下什么

问题仍然是:我们到底该在哪里使用真正的单例模式? 说实话,我从来没在游戏里用过GoF那套完整实现。 为了保证实例唯一,我通常直接使用静态类。 如果这行不通,我会用一个静态标志,在运行时检查该类是否只被构造了一个实例。

书中还有一些其他章节也许能有所帮助。 子类沙箱模式让类的实例能访问某些共享状态, 而又无需让这些状态全局可用。 服务定位器模式确实会让一个对象全局可用, 但它让你在如何配置该对象方面有更多灵活性。

← 上一章 下一章 → ≡ 首页
© 2009-2015 Robert Nystrom