找回密码
 立即注册

彩金塔国际 www.jt10u.com.cn QQ登录

只需一步,快速开始

扫一扫,访问微社区

单例模式 · Design Patterns Revisited · 游戏设计模式

2017-2-6 08:28| 发布者: xmohe| 查看: 465| 评论: 0

摘要: ← 上一章下一章 → 首页单例模式游戏设计模式Design Patterns Revisited  这个章节不同寻常。其他章节展示如何使用某个个设计模式。这个章节展示如何避免使用某个设计模式?! 【」芩囊馔际呛玫?,GoF描述的单 ...

单例模式

游戏设计模式Design Patterns Revisited

  这个章节不同寻常。其他章节展示如何使用某个个设计模式。这个章节展示如何避免使用某个设计模式。

  尽管它的意图是好的,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标准也保证了本地静态变量只会初始化一次,因此,假设你有一个现代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++这样的语言,开始将一些从软件工程师前辈那里学到的智慧应用于实际。其中一课是全局变量有害的诸多原因:

  •   理解代码更加困难。假设我们在查找其他人所写函数中的漏洞。如果函数没有碰到任何全局状态,脑子只需围着函数转,只需搞懂函数和传给函数的变量。

      现在考虑函数中间是个对SomeClass::getSomeGlobalData()的调用。为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。你真的不需要讨厌全局变量,直到你在凌晨三点使用grep搜索数百万行代码,搞清楚哪一个错误的调用将一个静态变量设为了错误的值。

  •   促进了耦合的发生。新加入团队的程序员也许不熟悉你们完美,可维护,松散耦合的游戏架构,但还是刚刚获得了第一个任务:在岩石撞击地面时播放声音。你我都知道这不需要将物理和音频代码耦合,但是他只想着把任务完成。不幸的是,我们的AudioPlayer是全局可见的。所以之后一个小小的#include,新队员就打乱了整个精心设计的架构。

      如果不用全局实例实现音频播放器,那么哪怕他确实#include包含了头文件,他还是啥也做不了。这种阻碍给他发送了一个明确的信号,这两个??椴桓媒哟?,他需要另辟蹊径。通过控制对实例的访问,你控制了耦合。

  •   对并行不友好。那些在单核CPU上运行游戏的日子已经远去。哪怕完全不需要并行的优势,现代的代码至少应考虑在多线程环境下工作。当我们将某些东西转为全局变量时,我们创建了一块每个线程都能看到并访问的内存,却不知道其他线程是否正在使用那块内存。这种方式带来了死锁,竞争状态,以及其他很难解决的线程同步问题。

  像这样的问题足够吓阻我们声明全局变量了,同理单例模式也是一样,但是那还没有告诉我们应该如何设计游戏。怎样不使用全局变量构建游戏?

  有几个对这个问题的答案(这本书的大部分都由答案构成),但是它们并非显而易见。与此同时,我们得发布游戏。单例模式看起来是万能药。它被写进了一本关于面向对象设计模式的书中,因此它肯定是个好的设计模式,对吧?况且我们已经借助它做了很多年软件设计了。

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

它能在你只有一个问题的时候解决两个

  在GoF对单例模式的描述中,“并且”这个词有点奇怪。这个模式解决了一个问题还是两个问题呢?如果我们只有其中一个问题呢?保证实例是唯一存在的很有用的,但是谁告诉我们要让每个人都能接触到它?同样,全局接触很方便,但是必须禁止存在多个实例吗?

  这两问题中的后者,便利的访问,是使用单例模式几乎全部的原因。想想日志类。大部分??槎寄艽蛹锹颊锒先罩局谢褚?。但是,如果将Log类的实例传给每个需要这个方法的函数,那就混杂了产生的数据,模糊了代码的意图。

  明显的解决方案是让Log类成为单例。每个函数都能从类那里获得一个实例。但当我们这样做时,我们无意地制造了一个奇怪的小约束。突然之间,我们不再能创建多个日志记录者了。

  起初,这不是一个问题。我们记录单独的日志文件,所以只需要一个实例。然后,随着开发周期的逐次循环,我们遇到了麻烦。每个团队的成员都使用日志记录各自的诊断信息,大量的日志倾泻在文件里。程序员需要翻过很多页代码来找到他关心的记录。

  我们想将日志分散到多个文件中来解决这点。为了达到这点,我们得为游戏不同的领域创造单独的日志记录者:网络,UI,声音,游戏,玩法。但是我们做不到。Log类不再允许我们创建多个实例,而且调用的方式也保证了这一点:

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

  为了让Log类支持多个实例(就像它原来的那样),我们需要修改类和提及它的每一行代码。之前便利的访问就不再那么便利了。

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

  拥有虚拟内存和软性性能需求的PC里,惰性初始化是一个小技巧。游戏则是另一种状况。初始化系统需要消耗时间:分配内存,加载资源,等等。如果初始化音频系统消耗了几百个毫秒,我们需要控制它何时发生。如果在第一次声音播放时惰性初始化它自己,这初始化有可能发生游戏的高潮,导致可见的掉帧和断续的游戏体验。

  同样,游戏通常需要严格管理在堆上分配的内存来避免碎片。如果音频系统在初始化时分配到了堆上,我们需要知道何时初始化发生了,这样我们可以控制内存待在堆的哪里。

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

class FileSystem

  {

  public:

  static FileSystem& instance() { return instance_; }

  private:

  FileSystem() {}

  static FileSystem instance_;

  };

  这解决了惰性初始化问题,但是损失了几个单例确实比原生的全局变量优良的特性。静态实例中,我们不能使用多态,在静态初始化时,类也必须是可构建的。我们也不能在不需要这个实例的时候,释放实例所占的内存。

  与创建一个单例不同,这里实际上是一个简单的静态类。这并非坏事,但是如果你需要的是静态类,为什么不完全摆脱instance()方法,直接使用静态函数呢?调用Foo::bar()Foo::instance().bar()更简单,也更明确地表明你在处理静态内存。

那该如何是好

  如果我现在达到了目标,你在下次遇到问题使用单例模式之前就会三思而后行。但是你还是有问题需要解决。你应该使用什么工具呢?这取决于你试图做什么,我有一些你可以考虑的选项,但是首先……

看看你是不是真正地需要类

  我在游戏中看到的很多单例类都是“管理器”——那些类存在的意义就是照顾其他对象。我曾看到一些代码库中,几乎所有类都有管理器:怪物,怪物管理器,粒子,粒子管理器,声音,声音管理器,管理管理器的管理器。有时候,它们被叫做“系统”或“引擎”,但是思路还是一样的。

  管理器类有时是有用的,但通常它们只是反映出作者对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;

  这个类允许任何人构建它,如果你试图构建超过一个实例,它会断言并失败。只要正确的代码首先创建了实例,那么就保证了没有其他代码可以接触实例或者创建自己的实例。这个类保证满足了它关注的单一实例,但是它没有指定类该如何被使用。

  这个实现的缺点是只在运行时检查并阻止多重实例化。单例模式,正相反,通过类的自然结构,在编译时就能确定实例是单一的。

为了给实例提供方便的访问方法

  便利的访问是我们使用单例的一个主要原因。这让我们在不同地方获取需要的对象更加容易。这种便利是需要付出代价的——在我们不想要对象的地方,也能轻易地使用。

  通用原则是在能完成工作的同时,将变量写得尽可能局部。对象影响的范围越小,在处理它时,我们需要放在脑子里的东西就越少。在我们拿起有全局范围影响的单例对象前,先考虑考虑代码中其他获取对象的方式:

  •   传进来。最简单的解决办法,通常也是最好的,把你需要的对象简单地作为参数传给需要它的函数。在用其他更加繁杂的方法前,考虑一下这个解决方案。

      考虑渲染对象的函数。为了渲染,它需要接触一个代表图形设备的对象,管理渲染状态。将其传给所有渲染函数是很自然的,通常是用一个名字像context之类的参数。

      另一方面,有些对象不该在方法的参数列表中出现。举个例子,处理AI的函数可能也需要写日志文件,但是日志不是它的核心关注点??吹?code>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()。这种使用protected函数,让派生对象使用的模式,被涵盖在子类沙箱这章中。

  •   从已经是全局的东西中获取。移除所有全局状态的目标令人钦佩,但并不实际。大多数代码库仍有一些全局可用对象,比如一个代表了整个游戏状态的GameWorld对象。

      我们可以让现有的全局对象捎带需要的东西,来减少全局变量类的数目。不让Log,FileSystemAudioPlayer都变成单例,而是这样做:

    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模式。为了保证实例是单一的,我通常简单的使用静态类。如果这无效,我使用静态标识位,在运行时检测是不是只有一个实例被创建了。

  书中还有一些其他章节也许能有所帮助。子类沙箱模式通过分享状态,给实例以类的访问权限而无需让其全局可用。服务定位器模式确实让一个对象全局可用,但它给了你如何设置对象的灵活性。


相关阅读

最新评论

发布主题 官方QQ群 微博
  • 新华社评论员:聚焦新目标 开启新征程 2019-05-26
  • 袜子都要1600块的101小姐姐,私下佩戴的珠宝竟是这些 2019-05-26
  • 高考结束了,端午来临了,你想好去哪了吗? 2019-05-25
  • 主动扩大开放再发力 国务院出台六项举措鼓励利用外资 2019-05-24
  • 首付贷等违规行为料再遭“严打” 2019-05-24
  • 沈阳日报社社长程谟刚祝贺人民日报创刊70周年 2019-05-23
  • [微笑]其实很简单就能破这个局:立法禁止通过房地产二次交易获利,炒房就会被杜绝,炒房一旦被杜绝,房价就会受正常供需关系影响波动在合理范围内。 2019-05-23
  • “寓意于物”与“留意于物”(人民论坛) 2019-05-22
  • 热评丨中国,向大洋更深处挺进 2019-05-21
  • 苏州大学研究生支教团网上众筹资助留守儿童 2019-05-20
  • 中共山西省委组织部公示 2019-05-19
  • 再读青春寄语,践行青年使命 2019-05-19
  • 晋冀鲁豫人民日报印刷厂的组建与迁徙 2019-05-18
  • 原来端午节也可以过得很“文艺” 2019-05-17
  • 特朗普下令美国防部组建第六军种“太空军” 2019-05-16
  • 871| 860| 112| 573| 248| 476| 189| 530| 489| 444|