《游戏程序设计模式》 1.5 - 单例模式

    这章有点反常。其他篇章都是向你展示如何使用一个模式,而这篇则向你展示如何不使用这个模式。

    尽管有着很好的目的,但是“四人帮”描述单例模式往往弊大于利。他们强调应该谨慎使用这个模式,但是这个信息经常在游戏行业传播的过程中丢失。

    像任何一个模式一样,在不应该使用的地方使用单例就像用夹板固定枪伤位置。因为,这个模式过度使用,所以这章讲的是如何避免使用这个模式,但在此之前先看一下这个模式。

The Singleton Pattern

    《设计模式》这样总结了单例模式:

    确保一个类只有一个实例,并且提供一个全局访问点。

    我们以“并且”把句子分成两部分,分别讨论。

Restricting a class to one instance

    有几种情况当一个类有多于一个实例时会出错。一个普通的例子就是与一个保存全局状态的外部系统交互时。

    考虑一个封装了底层文件系统api的类。因为文件操作需要时间,所以我们的类要以异步方式工作。这意味着多任务并行执行,所以它们必须相互协调。如果我们一个调用新建一个文件,另一个调用要删除同一个文件,我们的封装类就要意识到两者并且确保它们互不干扰。

    为了做到这点,封装类的一个调用需要能访问之前的每一个操作。如果用户可以自由创建类的实例,一个实例将无法了解其他实例执行的操作。进入单例。它提供了一种方法确保一个类只有一个实例。

providing a global point of access

    许多不同的系统会使用我们的文件系统类:logging,content loading,game state saving。如果这些系统不创建它们自己的实例,它们如何才能使用此功能?

    单例模式提供了一个解决方法。除了只创建一个实例外,它还提供了一个全局的方法来访问它。通过这个方法,所有人都可以访问这个实例。综上,经典的实现方式就像这样:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // Lazy initialize.
    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要求一个局部静态变量的初始化只能执行一次,即便是多线程中。所以假设你有一个现代版编译器,这段代码就是线程安全的,但是上一段不是。

Why we use it

    好像我们胜利了。我们可以在任何地方使用文件系统类而不用单调地到处传递。巧妙地保证不会因为产生一堆实例乱作一团。他还有其他一些特点:

    • 如果没人使用它,它不会产生实例。节省内存和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)
  { 
    // Use Sony file IO API...
  }
  virtual void writeFile(char* path, char* contents)
  {
    // Use sony file IO API...
  }
};
class WiiFileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  { 
    // Use Nintendo file IO API...
  }
  virtual void writeFile(char* path, char* contents)
  {
    // Use Nintendo file IO 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类的实现文件中。

    我们有了个文件系统。他工作可靠。它是全局的,每个需要它的地方都可以访问到它。是时候检查代码并庆祝一下了。

Why we regret using it

    从短期来看,单例模式是良性的。像很多设计选择,会在长远上付出代价。一旦我们把不必要的单例写进代码,这将是自找的麻烦:

It is a global variable

    当游戏还是由车库里的几个家伙编写时,驱动硬件比还在象牙塔的软件工程原则重要得多。老的c和汇编程序员使用全局变量与静态变量完全没问题并且成功发行游戏。随着游戏变得越来越大,架构与可维护性开始成为瓶颈。我们艰难发布游戏不是因为硬件的限制,而是因为生产力限制。所以我们转移到像C++这种语言并开始应用前辈软件工程师们好不容易得到的智慧。一课就是全局变量因为各种原因证明是不好的:

    • 它使推导代码变得困难。假设我们正在跟踪一个别人写的函数中的bug。如果函数没有使用任何全局变量,我们只需看懂这个函数和传递给函数的参数即可。

          现在想象这个函数中调用了someClass::getSomeGlobalData()。为了弄清楚怎么回事,我们不得不寻遍整个代码库看看到底是谁使用了全局变量。

          你会恨死全局变量,当你凌晨三点试图从百万行代码里查找一个为静态变量设置错值的错误的调用。

    • 它鼓励耦合。新人不熟悉你游戏的漂亮的可维护的松散耦合的结构,但是他被分配的第一个任务:当巨石落地时发出声响。你和我都不想把物理代码与声音相关的代码耦合,但是他只是想着完成他的工作。不幸的是,AudioPlayer的实例是全局可见的。所以,一个#include之后,这个新来的家伙破坏了精心构造的架构。

      如果AudioPlayer不是一个全局的实例,即使他#include了他还是不能做任何事。这个困难发出了一个明确的信息,这两个模块不应该互相了解,他应该寻找另一个方法来解决这个问题。通过控制对实例的访问,你可以控制耦合。

    • 它对并行是不友好的。游戏运行在单核CPU上已经很长时间了。今天的代码必须能够在多线程下运行,即使无法充分利用多线程。当我们定义一个全局变量时,我们创建了每一个线程都能访问的内存,不管它知不知道有其他线程也正在访问这块内存。这会导致死锁,争用条件和其他很难修复的线程同步的bug。

    这些问题都足够吓到我们离全局变量远远的,还有单例模式,但是这仍然没有告诉我们应该如何设计游戏。你如何创建一个游戏不使用全局变量?

    对这个问题有很多答案(本书中的大部分就是答案),但是他们不是很明显也不是很容易得到。同时,我们先不管游戏。单例模式看起来就是万能的。因为它是《设计模式》中的一个模式,所以它就一定是结构合理的,对吗?而且他是以我们一直用了好多年的方法设计软件。

    很不幸,相比它作为对策更多是个安慰。如果你扫一眼全局变量造成的问题列表,你就会发现单例模式解决不了任何一个问题。那是因为单例就是个全局变量-只是被封装成了类。

It solves two problems even you just have one

    “四人帮”描述语句中的“并且”有点奇怪。这到底是解决一个问题还是两个问题的方法?如果我们只有其中一个问题怎么办?确保只有一个实例很有用,但是谁说我们要所有人都可访问它了?同样,全局访问是很方便,但是对于一个允许创建多实例的类来说也方便。

    这两个问题的后一个,访问方便,总是为什么我们要使用单例模式。考虑一个日志类。游戏中的大多数模块都得益于可以记录诊断信息。然而,为每个函数传递Log实例弄乱函数特征,分散代码的目的。

    最明显的解决方法就是使Log变成单例类。每一个函数都能直接访问Log类实例。当我们这么做,我们不经意得到一个奇怪的小限制。突然,我们不能创建多余一个的logger。

    起初,这不是问题。我们只记录一个文件,所以我们只需要一个log实例。然后,随着深入开发周期,我们碰到问题了。每一个人都使用log记录自己的诊断信息,log文件变成一个大型倾泻地。程序员不得不翻阅好几页查找自己的条目。

    我们想修复这个问题通过把log分成好几个文件。要做到这一点,我们为游戏的不同模块创建不同的logger,像网络,UI,声音,游戏逻辑。但是我们不能这么做。不只因为我们的log类不允许创建多余一个的实例,还有是每个调用的地方都有固定的限制:

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

    为了使我们的log类支持多实例(像最初那样),我们要修复类本身和调用它的每一行代码。我们方便访问变得不再方便。

Lazy initialization takes control away from you

    在拥有虚拟内存和性能要求的桌面PC世界,“懒初始化”是个机智的技巧。游戏是一个不同的动物。初始化一个系统需要时间:分配内存,加载资源,等。如果初始化一个声音系统需要几百毫秒,那么你就要控制什么时候初始化。如果使用对声音系统“懒初始化”,那么可能发生在激动人心的情节中,这将导致丢帧和游戏卡顿。

    同样游戏需要密切控制内存排布,避免内存碎片产生。如果声音系统要分配一块堆空间,我们想要知道何时初始化,这样我们可以控制分配在堆的哪个位置。

    由于这两个问题,我见过的大多数游戏不会依赖“懒初始化”。相反,他们这样实现单例:

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

    这解决了“懒初始化”导致的问题,但是付出了代价丢弃了一些可以使单例比单纯全局变量更好的特征。使用静态实例,我们不可以再使用多态,而且类必须在静态变量初始化阶段是可构造的。也不可以在不需要的时候释放内存。

    我们实际上就是一个静态变量,而不是单例。这不一定是件坏事,如果你静态类能满足你的所有需要,为什么不彻底抛弃instance()方法,而使用静态函数呢?调用Foo::bar()比Foo::instance().bar()简单,而且也清楚地表示你正在处理静态内存。

What we can do instead

    如果我已经达成了目的,那么下次出现问题,你使用单例模式之前会三思。但是你仍有一个问题需要解决。那么你应该使用什么模式呢?取决于你想做什么,我有一些选项供你考虑,但是首先……

see if you need the class at all

    我见过的许多单例类都叫“manager”-这些模糊不清的类存在只是为了照顾其他类。我见过许多代码库似乎其中的每个类都有一个manager:Monster,MonsterManager,particle,particleManager,sound,soundManager,managerManager。有时,为了多样化,他们会命名为"system"和“Engine”,但是都一个意思。

    然而,管理类只是有时候有用,通常它们只是反映了对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,那么你需要多少个BulletManager的实例?

    实际上,答案是0。这是我们如何解决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是为了让对象照顾它们自己。

    除了Manager,我们有一些问题还是要使用单例模式。对于每一个问题,都有一些可选的方案可以考虑。

to limit a class to a single instance

    这是单例模式要解决的一半问题。就像我们文件系统的例子,它严格保证只能有一个实例。然而,那并不意味着我们要提供一个公共的全局的访问实例的方法。我们可以限制某些代码的访问或者把它作为一个类的私有部分。在这些情况下,公共的访问削弱了体系结构。

    我们想要一种方法可以保证只有一个实例,但是不提供公共的访问方法。有很多方法可以实现这个,这里是一个:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }
  ~FileSystem() { instantiated_ = false; }
private:
  static bool instantiated_;
};
bool FileSystem::instantiated_ = false;

    此类允许任何人构造它,但是它会断言当创建多余一个的实例时会失败。只要正确的代码首先构造了一个实例,我们可以保证其他人无法再构造实例。这个类保证只有一个实例,但是它不决定如何使用这个类。这个实现方法的缺点是只在运行时检查阻止多实例初始化。相反,单例模式实在编译阶段就保证了只能有一个实例。

To provide convenient access to an instance

    方便访问是我们使用单例模式主要的理由。这使我们在不同的地方访问需要的对象变得很简单。这个方便有个代价-使我们在不需要访问的地方访问对象也变得容易了。

    一般的规则是使变量的作用范围尽可能得小但是仍然能把工作做好。一个对象的作用范围越小,当我们处理它时,要记的它出现的地方就越少。

    在我们拿着猎枪靠近一个全局范围的单例之前,让我们考虑一下能访问一个对象的其他方法:

  • 传进去。最简单的方法,也是最好的方法,是把对象作为一个参数传给需要的函数。在我们嫌它太笨重要丢弃它之前值得考虑一下这个方法。

    考虑一个渲染对象的函数。为了渲染,它需要访问代表图形设备的对象和维护渲染状态。把它传递给所有渲染函数是很普遍的,一般被称为context。

    另一方面,一些对象不符合函数特征。例如,一个函数处理AI也会写log,但是log不是主要功能。在参数列表中看到log就会很奇怪,所以对于这种情况我们会考虑其他选项。

  • 从基类中获得。许多游戏架构都有浅的广的继承结构,一般就一层继承。例如,你可能有一个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()。这个让子类实现它们自己的基类提供的方法的模式,会在子类沙盒(subclass sandbox)中讲到。

  • 从已经是全局的对象处获得。删除所有全局变量的目的是可敬的,但是却不实际。大多数代码都有一些全局对象,比如“Game”或“World”代表整个游戏状态。我们可以减少全局类的数量,通过包含一些类。相比把log,FileSystem,AudioPlayer作为单例,我们这么做:

class Game
{
public:
  static Game& instance() { return instance_; }
  // Functions to set 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);

        如果,之后,架构改变可以支持多实例(可能为了流和测试目的),log,FileSystem,AudioPlayer不会被影响-它们甚至不用知道不同点。缺点就是最后更多的代码与Game耦合。如果一个类只是需要播放声音,我们的例子仍然需要它可以访问world目的是获得AudioPlayer。

        我们通过一个混合方法来解决它。已经了解Game的,可以很容易地从它获得AudioPlayer。对于不了解Game的,我们使用另一种方法来访问AudioPlayer,像下面这种。

  • 从服务定位器(ServiceLocator)中获得。到目前,我们假设全局类都是某种常规具体类像Game。另一个选项是定义一个类,它唯一的目的是为了为对象提供全局访问的方法。这个普通的模式称为服务定位器(servicelocator),后面有一章单独讲这个。

what's left for singleton

    那么问题来了,我们应该在什么地方使用单例模式?我承认,我从未使用“四人帮”的完整的实现方式。为了保证只产生一个实例,我经常使用静态实例。如果不好使,我会添加一个静态标志变量,在运行时检查是否只有一个实例被构建。

    还有一些章节很有帮助。子类沙盒模式是一个类的所有实例可以访问公共的部分而不用把它变成全局的。服务定位模式是定义一个全局的类,但是它给了配置的灵活性。

你可能感兴趣的:(《游戏程序设计模式》 1.5 - 单例模式)