游戏编程设计模式

【翻译】游戏编程设计模式-Singleton

Singleton

http://gameprogrammingpatterns.com/singleton.html

 

本章是个奇葩。本书的其他章节都是告诉你怎么用一个设计模式。这一章告诉你怎么不用。

除了崇高的理想,GOF在书中对Singleton模式的描述,弊大于利。他们强调这种模式应该保守地使用,但这一条在游戏工业中并不奏效。

像其他模式一样,在不适当的地方使用Singleton,就像用夹板处理子弹伤口一样。由于它被大量地滥用,这章的大部分内容将告诉你避免使用Singleton,但是一开始,我们还是要先从Singleton本身开始。

Singleton模式

设计模式中这样总结Singleton:

确保一个类只有一个实例,并且提供一个全局的指针指向它。

我们在“并且”这个位置将句子分成两个部分,按部分解读。

 

限制一个类只有一个实例

我们会有这样的时候,当一个类有多个实例的时候,就会出错。一个常见的例子就是当一个类同一个维护了自己全局状态的外部系统交互时。

例如,一个类封装了一个基本的文件系统。因为文件操作都需要一段时间,我们的的类实现了异步操作。也就是说多个操作可以同时运行,所以他们必须相互隔离。如果我们开始一个创建文件的调用,同时另一个调用删除同一个文件,我们的封装类必须保证他们之间不冲突。

为了这样的效果,一个调用必须能够访问他的前一个操作。如果用户可以自由的创建我们这个类的实例,一个实例并不能知道其他实例中的操作。

使用Singleton,它提供了一种方法,在编译期就保证只有一个该类的实例。

提供一个全局的访问指针

游戏中,会有很多不同的系统使用我们的文件包装类:日志,内容加载,游戏状态存储,等等。如果这些系统都不能创建他们自己的文件系统,他们怎么获取到那个唯一的文件系统呢?

Singleton给出了一个解决方案,除了创建一个唯一的实例,它还提供了一个全局能够使用的方法,去获取这个实例。用这种方式,任何人在任何地方都能抓到想要的实例。总而言之,类的实现就像这样:

静态成员 instance_ 维护了类的实例,私有的构造函数保证了只有一个实例。公有静态instance()提供了所有代码都能使用的访问实例的入口。同时,也能够实现这个实例在第一次使用的时候才实例化。

更时尚的写法是这样的:

 

C++ 11 规定局部静态变量的初始化只会执行一次,即时是在并发的情况下。所以,假设你在使用现代的C++编译器,这段代码是线程安全的,但第一段不是。

为什么用它?

看起来Singleton有优势,我们的文件系统包装类可以用在任意我们想用到的地方,并且不需要传来传去。这个类本身很智能地确保了我们不会因为创建了一坨实例而带来的麻烦。它还有几个很好的特性:

1、不用的时候不会创建。能够节省内存和CPU总是好的。单例只有在第一次访问的时候被初始化,如果游戏中没有用过,它永远不会被初始化。

2、它是在运行时初始化的。Singleton的另一个常用的替代方案是用类的静态成员。我喜欢简洁的办法,所以我尽可能的使用静态类去代替Singleton。但是静态成员有一个限制,那就是自动初始化。编译器在main()函数调用之前初始化这些静态变量。也就意味着一旦程序运行了,我们就不能用已知信息初始化了(例如,从文件中加载配置)。它还意味着,它们不能相互依赖——编译器并没有确定静态变量的初始化顺序。

延迟初始化,解决了这两个问题。单例被尽可能的推迟了初始化,所以到那个时候把它需要的信息准备好就可以了。只要他们不存在循环依赖,一个单例甚至可以在初始化的时候引用另一个。

3、你可以子类化单例。这是一个很强大但又经常被忽视的能力。来看我们要让我们的文件系统跨平台。为了实现它,我们将文件系统定义为抽象的接口,并且为每一个平台去实现一份接口。基类这样:


然后我们为多个平台定义子类。


然后,我们把FileSystem改成单例。


如何创建实例是最智能的部分:


通过简单地转换编译器,我们把系统文件的包装类绑定到正确的类型上。我们的具体业务逻辑代码可以通过FileSystem::instance()访问文件系统,而不需要为不同平台写特定的代码。平台相关的代码可以被封装在文件系统实现类内部。

它最多也就解决这些问题了。我们得到了一个文件系统包装类,它很可靠。它全局可用,所以在任何地方都可以使用它。是时候提交代码,然后喝上一杯庆祝了。

我们为什么后悔使用它

从短期来看,Singleton模式比较好用。就像其他设计模式一样,我们也需要把眼光放长远。一旦我们在代码中写进一些不需要的单例,那我们实在为自己找麻烦。

它是全局变量

当游戏还是几个人在车库里写出来的时候,硬件的运用要比象牙塔中的软件工程师那些狗屁原则重要的多。老式的C语言或者汇编程序员无所顾忌地使用了使用全局和静态变量,并做出了好游戏。随着游戏越来越大,越来越复杂,结构和可维护性开始成了瓶颈。我们在游戏开发中遇到的困难再也不是因为硬件限制,而是因为受限与生产力。

所以,我们改用了C++,也开始接受一些程序员先贤们难以理解的智慧。其中有一课讲的就是全局变量有如下几个坏处。

1、它们会让代码很难分析。想一下如果我们在一个别人写的函数里找一个bug。如果这个函数没有用全局变量,我们可以通过理解函数体和传进的参数来推测问题的来源。

现在,如果在函数中间调了一个 SomeClass::GetSomeGlobalData()。为了弄明白发生了什么,我们必须翻遍代码库去找什么与这个全局变量相关。当你在凌晨三点,在上百万代码中找一个导致静态变量值错误的调用时,你会恨死全局变量。

2、它会导致紧耦合。你团队中的一个新程序员对游戏中漂亮的、可维护的、松耦合体系不熟悉。但是他被分配了一个任务:让卵石在地上崩裂的时候播放声音。你和我都知道物理代码不能和声音代码耦合到一起,但是他只是想完成任务。不幸的是,AudioPlayer的实例是全局可见的。所以一个小小 #include后,我们的小伙子就搞坏了我们小心翼翼维护的结构。

如果没有一个全局的AudioPlayer实例,即时他#include了这个头文件,他也做不了什么。这样的不同会给他一个明确的信息,那就是这两个模块是不应该知道彼此的。他需要找到另一种方法解决这个问题。通过对实例的访问控制,可以控制耦合度。

3、对并发性不友好。游戏运行于单核设备的年代差不多结束了。今天的代码至少要运行在多线程下,即时它并没有得到并发的所有优势。当我们让一些代码全局可见,我们就创建了一些所有线程都可以看到并且能修改的内存,无论他们是否知道有别的线程也在做同样的事情。这会导致死锁,竞争,和其他一些很难修复的线程同步bug。

这些问题足够把我们从使用全局变量中吓跑,Singleton模式也一样,但是那并没有告诉我们如何设计游戏。怎样不实用全局变量架构游戏。

这个问题有很多答案(本书的大部分内容某种意义上正是这个问题的答案),但是它们不明显或者说不容易实现。大部分时间,我们必须先把游戏放一边。Singleton模式看起来像灵丹妙药。它在一本面向对象设计模式的书里,所以它具有结构合理性,对吗?它让我们多年来都使用这种方式设计软件。

不幸的是,它只是一味安慰剂,而不是治愈之药。如果你读一遍全局变量引起的问题的列表,你会发现Singleton并没有解决任何问题。那是因为Singleton就是一个全局变量,只不过被封装在一个类里而已。

它解决了两个问题即使剩一个

GOF的描述中“并且”这个次莫名其妙。这个模式到底是要解决一个问题还是两个?我们只解决一个会怎么样呢?一个单例的确很有用,谁说非要所有人都能访问呢?同样,全局访问的确很方便,但是让一个类有多个实例也是一样呀。

第二个问题,便捷的访问,几乎就是为什么我们使用Singleton模式的理由。考虑一下日志类。游戏中的大多数模块会从输出诊断日志中得到好处。然而,如果把一个Log类实例当成参数传给所有的函数,会让这个方法看起来很混乱,并且扰乱人的注意力。

前面我们使用Log类的Singleton模式解决了这个问题。每一个函数能狗直接从类中得到一个实例。但是一旦我们这样做了,我们就无意地接收了一个约束。突然,我们就不能创建更多的log对象了。

一开始,这并不是问题。我们只写一个log文件。所以我们只需要一个实例。然后随着开发周期的深入,我们遇到了麻烦。团队中的每一个人都在用log输出他们的日志,然后大量的日志被记得到处都是。程序员需要通篇看这些文本才能找到他们真正关心的那部分。

我们想解决这个问题,把日志分成多个文件。为了做到这一点,我们需要根据游戏中的模块划分日志:网络,UI,音频,玩法。但是我们做不到。不只是因为我们的Log类不允许我们创建多个实例,而且设计上也限制了每一个调用的地方都必须这样调用:

Log::instance().write(“Some event.”);

为了让我们的Log类支持多个实例(就像最开始的那样),我们必须既修改Log类本身,又修改每一行调用它的地方。我们方便的访问变得不再方便了。

延迟初始化剥夺了你的控制权

在PC桌面开发中,有虚拟内存并且对性能要求不高,延迟初始化是一个很聪明的做法。但游戏开发不同。初始化系统会消耗时间:申请内存,加载资源,等等。如果初始化音频系统消耗了几百毫秒的时间,我们就需要控制它了。如果我们让它在第一次播放声音的时候初始化,它会发生在游戏中的一个事件中,会导致掉帧并且影响游戏体验。

另外,游戏通常会严格控制内存在堆中的分布,以避免碎片化。如果初始化音频系统申请了一块堆,我们就希望知道初始化什么时候进行,这样才能控制这些内存在堆中的位置。

由于这些原因,我见过的大多数游戏不允许有延迟初始化。相反,他们像这样实现Singleton模式:


这样解决了延迟初始化的问题,但是付出了几个单例的特色为代价。这些特色使得Singleton模式比一行全局变量要好。用一个静态实例,我们再也不能使用多态,并且这个类必须在静态初始化的时候被构建。而且,当实例使用的内存不需要的时候,我们也不能释放。

这不是在创建单例,实际上我们这里只是使用了一个简单的静态类。不是什么坏事,但是如果你需要静态类,为什么不去掉instance()方法,而直接用静态函数代替呢?调用Foo::bar()比调用Foo::instance().bar()简单多了,并且它让你清楚你真正使用的是静态内存。

我们应该怎么做?

如果我的目的到此为止,你在下次遇到问题使用Singleton时会三思而行。但是你仍然有一个问题。应该用啥呢?它取决于你要干什么,我有几个选项供你考虑,但是首先…

看一下你是否真的需要类

我发现游戏中的很多单例类都是各种“managers”,这些朦胧的类都只是用来管理其他对象的。我看过的代码中,几乎每一个类都有一个manager:怪物,怪物Manager,例子,例子Manager,声音,声音manager。有的时候,为了搞特殊,他们跑出了“System”,“Engine”这些字眼,但其实意思是一样的。

有时这些管理类是有用的,但他们经常反映的是使用者对OOP的陌生。看这两个不恰当的类:


也许这个例子没有太大的说服力,但是你能看到大量的代码使用了这样的设计思想,只要你忽略掉那些细节。如果你看到这些代码,很自然的你会想到BulletManager应该是一个单例。然后,所有有子弹的东西都需要这个Manager,你需要多少个BulletManager实例呢?

事实上,答案是0个。这里就是我们怎样为Manager类解决单例问题:

 

信春哥得永生。没有Manager,就没有麻烦。设计巨烂的单例往往是帮助别的类加功能。如果可以,请把那些函数都移到类中。毕竟OOP的思想是让对象只关注自己的事情。

除了这些manager外,我们使用Singleton的地方仍有其他问题,每一个问题,都有一些解决方案供参考。

限制一个类只有一个实例

这是Singleton为你提供的一半功能。在我们的文件系统例子中,我们必须严格地保证类只有一个实例。然而,这并不必然限制我们的实例只能提供公用的、全局的访问。我们可能希望访问权限限定在一定的区域,甚至是只有一个类能访问的私有实例。在这种情况下,提供一个全局公开的访问指针,就破坏了结构。

我们想要一种方式能够确保单一实例,又不提供全局访问。有很多实现方法,这里有一个:

 

 

这个类允许所有人构建它,但是如果你要构建多次,它会触发断言并且失败。一旦先在正确的代码里创建了实例,我们就能确保其他代码不会得到这个实例或者自行创建它。这个类保证了它关心的要求单一实例的特性,但是它不指定如何使用它。

这种实现的劣势是对多个实例的检测只能在运行时。而Singleton模式就很自然地通过类结构在编译期就确保了单个实例。

 

提供一个方便访问实例的入口

方便地访问是我们使用单例的最主要的原因。这样我们很容易地可以在很多不同的地方拿到这个对象。但这种是有代价的,就是它也让在错误的地方拿到对象更加容易。

一个通用的准则是我们希望变量的作用域越小越好。对象的作用域越小,我们处理它的时候,需要关注的地方就越少。在我们鲁莽地决定用全局作用域的单例对象之前,让我们考虑一下代码中得到对象的其他方式:

1、传进去。最简单的办法,通常也是最好的。为你的函数传一个它需要的对象作为参数,很简单。在我们因为太麻烦放弃这个方案前,值得认真考虑一下。

我们看一个渲染对象的函数。为了渲染,它需要访问一个提供图像设备和渲染状态的对象。通常,我们简单地把它传到所有的渲染函数中,这个参数被命名为context。

话说回来,有一些对象并不属于这个方法。例如,一个处理AI函数需要写日志文件,但是日志不是它的核心任务。如果在参数列表中出现一个Log会很奇怪,所以对于这些例子,我们就要想其他招了。

2、从基类中获取。很多游戏架构都采用了浅切广的继承体系,经常只有一层继承。例如,你可能有一个基类GameObject,所有游戏中的敌人和其他对象都继承自它。这种结构下,大量的代码会出现在子类中。也就意味着所有这些类都能够访问一个相同的东西:他们的GameObject基类。我们可以用到这个特点:


这确保了不是GameObject的不能访问它的Log对象,但是所有继承类都能使用getLog()。这种让继承对象在一些他们的Protected方法中实现自己的方法,会在Subclass Sandbox章节中展开。

3、从已有的全局变量中获取。消除所有全局变量的目的是值得钦佩的,但是实际上很难实现。大多数项目代码仍然需要一些全局变量对象,像一个Game或者World对象,提供了一些游戏状态。

我们可以用打包的方式减少全局类的数量,不去用单例实现Log,FileSystem,和AudioPlayer,这样做:


这样,只有Game一个全局变量。函数可以通过这样的方式获得其他系统:

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

如果后来结构要改成支持多个Game实例(也许是因为工作流或者测试的目的),Log,FileSystem和AudioPlayer都不受影响,他们甚至都不知道有何不同。这种办法的确定,当然就是太多的代码需要跟Game关联。如果一个类只需要播放声音,我们的例子中依然需要他去知道Game这个类,这样才能获得AudioPlayer对象。

我们使用混合方案解决这个问题。那些知道Game对象的代码,可以直接从中访问AudioPlayer。那些不知道的,我们提供了另一个访问AudioPlayer的方法,在这里解释。

3、从Service Locator中获取。到此为止,我们规定全局类就是一些通常意义上的混合类,如Game。另一个做法是定义一个类,他的基本功能就是为一些对象提供全局访问入口。这个模式被叫做Service Loctor,我们单独拿出一章来叙述。

单例还剩什么?

问题还在,我们应该在什么地方使用真正的Singleton模式?说实话,我从来没有在游戏中完全按照GOF的描述来实现Singleton。为了确保单个实例,我通常简单地使用静态类。如果不管用,我会用一个静态标志去在运行时检查是否只有一个类的实例被构建。

本书的其他一些章节也有帮助,Subclass Sandbox模式提供了访问一个类实例的属性,而又不需要把他做成全局变量。Service Locator模式使得对象全局可见,但是它让你更灵活的配置这个对象。