思考并回答以下问题:
- 单例模式的官方定义是什么?
- 什么时候使用单例模式?
- 管理类要使用单例模式吗?为什么?
- 单例继承是什么意思?
本章涵盖:
- 游戏实现中的唯一对象
- 单例模式
- 单例模式的定义
- 单例模式的说明
- 单例模式的实现范例
- 使用单例模式获取唯一的游戏服务对象
- 游戏服务类的单例模式实现
- 实现说明
- 使用单例模式后的比较
- 反对使用单例模式的原因
- 少用单例模式时如何方便地引用到单一对象
- 结论
游戏实现中的唯一对象
生活中的许多物品都是唯一的,地球是唯一的、太阳是唯一的等。软件设计上也会有唯一对象的需求,例如:服务器端的程序只能连接到一个数据库、只能有一个日志产生器;游戏世界也是一样的,同时间只能有一个关卡正在进行、只能连线到一台游戏服务器、只能同时操作一个角色(RTS不是)等。
在《P级阵地》中,也存在唯一的对象,例如游戏主要类--外观模式提到的,用来包含所有游戏子系统的PBaseDefenseGame类,它负责游戏中几个主要功能之间的串接,外界通过它的接口就能存取《P级阵地》的主要游戏服务,可以称这个PBaseDefenseGame类为“游戏服务”的提供者。因为它提供了运行这个游戏所需要的功能,所以PBaseDefenseGame类的对象只需要一个,并且由这个唯一的对象来负责游戏的运行。另外,PBaseDefenseGame类实现了外观模式(Facade),包含了游戏中大部分的操作接口。因此,在实际应用上,会希望有一种方法能够快速获取这个唯一的对象。在实现上,程序设计师会希望这个PBaseDefenseGame类具备两项特质:
(1)同时间只存在一个对象;
(2)提供一个快速获取这个对象的方法。
如果使用比较直接的方式来实现,可能会使用程序设计语言中的“全局静态变量”功能来满足上述两项需求。若以C#来编写的话,可能会是如下这样:
Listing 1 使用全局静态对象的实现方式
1 | public static class GlobalObject |
在一个静态类中,声明类对象为一个静态成员,这样实现的方式虽然可以满足“容易获取对象”的需求,但无法避免刻意或无意地产生第二个对象,而且像这种使用全局变量的实现方式,也容易产生全局变量命名重复的问题。
所以,最好的实现方式是,让PBaseDefenseGame类只产生一个对象,并提供便利的方法来获取这唯一的对象。GoF中的单例模式讲述的就是如何满足上述需求的模式。
单例模式
单例模式是笔者过去常使用的设计模式,单例模式确实令人“着迷”,因为它能够让程序设计师快速获取提供某项服务或功能的对象,可以省去层层传递对象的困扰。
单例模式的定义
单例模式(Singleton)在GoF中的定义是:1
确认类只有一个对象,并提供一个全局的方法来获取这个对象。
单例模式在实现时,需要程序设计语言的支持。只要具有静态类属性、静态类方法和重新定义类构造函数存取层级(private)这3项语句功能的程序设计语言,就可以实现出单例模式。C#具备这3项条件,可以用来实现单例模式。
不过单例模式也是许多设计模式推广人士不建议大量使用的模式,详细的原因会在本章后面加以说明。
单例模式的说明
单例模式的结构如图1所示。
图1 单例模式的结构示意图
Singleton参与的角色说明如下:
- 能产生唯一对象的类,并且提供“全局方法”让外界可以方便获取唯一的对象。
- 通常会把唯一的类对象设置为“静态类属性”。
- 习惯上会使用Instance作为全局静态方法的名称,通过这个静态函数可能获取“静态类属性”。
单例模式的实现范例
C#支持实现单例模式的特性,以下是使用C#实现的方式之一:
Listing 2 单例模式的实现范例(Singleton.cs)
1 | public class Singleton |
在类内定义一个Singleton类的“静态属性成员”_instance,并定义一个“静态成员方法”Instance,用来获取_instance属性。这里也应用了C#的getter存取运算符功能来实现Instance方法,让原本Singleton.Instance()的调用方式可以改为Singleton.Instance方式,虽然只是少了对小括号(),但以笔者的开发经验来说,少打一对小括号对于程序编写及后续维护上仍有不少的帮助。
Instance的getter存取运算符中,先判断_instance是否已被产生过,如果没有,才继续下面的new Singleton(),之后再返回_instance。
最后,将构造函数Singleton()声明为私有成员,这个声明主要是让构造函数Singletion()无法被外界调用。一般来说,有了这个声明就可以确保该类只能产生一个对象,因为构造函数是私有成员无法被调用,因此可以防止其他客户端有意或无意地产生其他类对象。
打开测试类SingletonTest,测试程序代码如下:
Listing 3 单例模式测试方法(SingletonTest.cs)
1 | void UnitTest() |
在范例中,分别使用Singleton.Instance来获取类属性Name,从输出信息中可以看到:
执行结果:产生Singleton测试范例产生的信息
1 | World |
使用两次Singleton.Instance只会产生一个对象,从Name属性最后显示的是World也可以证实存取的是同一个对象。
测试程序代码,最后试着再产生另一个Singleton对象:
执行结果 再产生另一个Singleton对象时,产生的错误信息
1 | error CS0122: 'DesignPattern_Singleton.Singleton.Singleton()' is inaccessible due to its protection level. |
但从C#编译报错的信息可以看出,构建式Singleton()是在保护阶段,无法被调用,所以无法产生对象。
使用单例模式获取唯一的游戏服务对象
游戏系统中哪些类适合以单例模式实现,必须经过挑选,至少要确认的是,它只能产生一个对象且不能够被继承。笔者过去的许多经验中,都会遇到必须将原本是单例模式的类改回非单例模式,而且还必须开放继承的情况。强制修改之下会使得程序代码变得不易维护,所以分析上需要多加注意。
游戏服务类的单例模式实现
在《P级阵地》中,因为PBaseDefenseGame类包含了游戏大部分的功能和操作,因此希望只产生一个对象,并提供方便的方法来取用PBaseDefenseGame功能,所以将该类运用单例模式,设计如图2所示。
图2 游戏服务类的单例模式实现示例
参与者的说明如下:
- PBaseDefenseGame
- 游戏主程序,内部包含了类型为PBaseDefenseGame的静态成员属性_instance,作为该类唯一的对象。
- 提供使用C# getter实现的静态成员方法Instance,用它来获取唯一的静态成员属性_instance。
- BattleState
- PBaseDefenseGame类的客户端,使用PBaseDefenseGame.Instance来获取唯一的对象。
实现说明
在《P级阵地》范例中,只针对PBaseDefenseGame类运用单例模式,实现方式如下:
Listing 4 将游戏服务类以单例模式实现(PBaseDefenseGame.cs)
1 | public class PBaseDefenseGame |
按照之前说明的步骤,实现时先声明一个PBaseDefenseGame类的静态成员属性_instance,同时提供一个用来存取这个静态成员属性的“静态成员方法”Instance。在静态成员方法中,实现时必须确保只会有一个PBaseDefenseGame类对象被产生出来。最后,将构造函数PBaseDefenseGame()设置为私有成员。
在实际应用中,直接通过PBaseDefenseGame.Instance获取对象,立即可以使用类功能:
Listing 5 战斗状态中以单例的方式使用PBaseDefenseGame对象(BattleState.cs)
1 | public class BattleState : ISceneState |
在《P级阵地》中,除了BattleState类会使用到PBaseDefenseGame对象之外,在后续的说明中也会看到其他类的使用情况。以下是另一个使用的例子:
Listing 6 兵营用户界面中以单例的方式使用PBaseDefenseGame对象(SoldierClickScript.cs)
1 | public class SoldierOnClick: MonoBehaviour |
在SoliderOnClick中完全不需要设置PBaseDefenseGame对象的引用来源,直接调用PBaseDefenseGame.Instance就可以马上获取对象并调用类方法。
使用单例模式后的比较
对于需要特别注意“对象产生数量”的类,单例模式通过将“类构造函数私有化”,让类对象只能在“类成员方法”中产生,再配合“静态成员属性”,在每一个类中只会存在一个的限制,让系统可以有效地限制产生数量(有需要时可以放宽一个的限制)。在两者配合下,单例模式可以有效地限制类对象产生的地点和时间,也可以防止类对象被任意产生而造成系统错误。
反对使用单例模式的原因
按照笔者过去的开发经验,单例模式好用的原因之一是:可以马上获取类对象,不必为了“安排对象传递”或“设置引用”而伤脑筋,想使用类对象时,调用类的Instance方法就可以马上获取对象,非常方便。
如果不想使用单例模式或全局变量,最简单的对象引用方式就是:将对象当成“方法参数”,一路传递到最后需要使用该对象的方法中。但此时若存在设计不当的程序代码,那么方法的参数数量就会容易失控而变多,造成难以维护的情况。
而程序设计师一旦发现这个“马上获取”的好处时,就很容易在整个项目中看到许多单例模式的应用(包含实现与调用),这种情况使开发者过于沉迷于使用单例模式。
很不幸,笔者在过去的开发经验中也有过这个“症状”,大多是为了想“省略参数传递”及“能够快速获取唯一对象”等原因。所以在实现上,只要发现“游戏子系统类”或“用户界面类”的对象,在整个游戏运行中是唯一时,就会将单例模式运用在该类上,因此项目内处处可见标示为Singleton的类。
单例模式之所以被滥用,是开发时过度使用“全局变量”及不仔细思考对象的“适当可视性”所造成的产物,因此这是可以避免的。下面提供另一种模型来避开使用单例模式。
归咎滥用单例模式的主要原因,多数还是认为是在设计上出现了问题。大多数情况是不需要使用单例模式的,开发者只需要再多花点时间重新思考、更改设计,就可避免使用。
再深入探讨的话,单例模式还违反了“开—闭原则(OCP)”。因为,通过Instance方法获取对象是“实现类”而不是“接口类”,该方法返回的对象包含了实现细节的实体类。因此,当设计变更或需求增加时,程序设计师无法将其替代为其他类,只能更改原有实现类内的程序代码,所以无法满足“对修改关闭”的要求。
当然,如果真的要让单例模式返回接口类——即父类为单例模式类型,并让子类继承实现,并不是没有办法,有以下两种方式可以实现:
- 子类向父类注册实体对象,让父类的Instance方法返回对象时,按条件查表返回对应的子类对象。
- 每个子类都实现单例模式,再由父类的Instance去获取这些子类。(《P级阵地》采用类似的方式来实现)。
不过“返回子类的单例模式的对象”有时会引发“白马非马”的逻辑诡辩问题——返回的对象是否就能代表父类呢?举一个实例来说明会发生逻辑诡辩的设计方式:
今天服务器端的系统有项设计需求,需要连线到某一个数据库服务,并要求同时只能存在一个连接。程序设计师们经过设计分析后,决定使用单例模式只能产生唯一对象的特性,来满足只能存在一条连接的需求。接着定义数据库连接操作的接口,并运用单例模式,如图3所示。
图3 实现数据库服务唯一连接的单例模式示例
又因为服务器端可以支持MySQL和Oracle这两种数据库连接,所以定义两个子类,并实现“子类向父类注册实体对象”的方式,让IDBConnect.Instance()方法可以返回对应的子类,如图4所示。
图4 实现“子类向父类注册实体对象”的方式
客户端现在可以按当前的设计结果,获取某一种数据库的连接对象,并同时确保只存在一条连接。
经过多次需求追加后,服务器数据库功能的操作需求也增加了,这次希望的需求是:每次的数据库操作能够被记录下来,即当数据库完成操作后必须将操作记录写入“日志数据库”中。但由于“日志数据库”具有“只写不读”的特性,在实现上会选择再启用另一条连接,连接到另一组数据库(有针对“只写不读”特性进行优化的数据库),这样除了可以减少每次操作记录写入时的延迟,也不会增加主数据库的负担。
所以,如果要在不更改原有接口的要求下实现新的功能,最简单的方式就是再从MySQLConnect和OracleConnect各自继承一个子类,并在子类中增加另一条“日志数据连接”和日志操作方法,类结构如图5所示。
图5 从MySQLConnect和OracleConnect各自继承一个子类后的类结构图
现在获取的MySQLConnectWithLog或OracleConnectWithLog对象,是否还是IDBConnect对象?从IDBConnect的设计需求来看:
- 数据库连接——有;
- 只能有一个对象——有;
- 一个对象代表一条连接——没有。
因为有两条连接存在……,所以MySQLConnectWithLog还是IDBConnect对象吗?
赞同方会说:“因为单例模式负责产生的对象只有一个,不会去管数据库的连接数量,所以还是单例对象”;反对方则会说:“当初就是希望利用单例模式只能产生唯一对象的特性,来限制数据库的同时连接数,现在子类却有两条连接,所以当然不是”……,白马是不是马的辩论就这样产生了。而笔者认为,花点时间修改原有的设计,让这种辩论消失才是真正解决的方式。
少用单例模式时如何方便地引用到单一对象
单例模式包含两个重要特性:唯一的对象和容易获取对象。那么要如何减少单例模式的使用呢?可以从分析类的“使用需求”开始,先确认程序设计师在使用这个类时,是希望同时获得上述两个好处还是只需要其中一个。若是只需要其中一个,那么下面几种方式可以用来设计系统。
让类具有计数功能来限制对象数量
在有数量限制的类中加上“计数器”(静态成员属性)。每当类构造函数被调用时,就让计数器增加1,然后判断有没有超过限制的数量,如果超过使用上限,那么该对象就会被标记为无法使用,后续的对象功能也不可以被执行。适当地在类构造函数中加入警告或Assert,也有助于调试分析,范例程序如下:
Listing 7 有计数功能的类(ClassWithCounter.cs)
1 | public class ClassWithCounter |
Listing 8 有计数功能类的测试方法(SingletonTest.cs)
1 | void UnitTest_ClassWithCounter() |
设置成为类的引用,让对象可以被取用
某个类的功能被大量使用时,可以将这个类对象设置为其他类中的成员,方便直接引用这些类。而这种实现方法是“依赖性注入”的方式之一,可以让被引用的对象不必通过参数传递的方式,就能被类的其他方法引用。按照设置的方式又可以分为“分别设置”和“指定类静态成员”两种。
1.分别设置
在《P级阵地》中,PBaseDefenseGame是最常被引用的。虽然已经运用了单例模式,但笔者还是以此来示范如何通过设置它成为其他类引用的方式,来减少对单例模式的使用。
由于在《P级阵地》中,每个游戏子系统都会使用PBaseDefenseGame类的功能,所以在各个游戏系统初始化设置时,就将PBaseDefenseGame对象指定给每一个游戏系统,并让游戏系统设置为类成员。那么,后面若有游戏系统的方法需要使用PBaseDefenseGame的功能时,就可以直接使用这个类成员来调用PBaseDefenseGame的方法:
Listing 9 将PBaseDefenseGame设置为其他类中的对象引用
1 | public class PBaseDefenseGame |
在上面的范例中,兵营系统的构造函数将传入的PBaseDefenseGame对象设置类成员m_PBDGame,并在有需求时(ShowCaptiveCamp)通过m_PBDGame来调用PBaseDefenseGame的方法。
2.指定类的静态成员
A类的功能中若需要使用到B类的方法,并且A类在产生其对象时具有下列几种情况:
- 产生对象的位置不确定;
- 有多个地方可以产生对象;
- 生成的位置无法引用到;
- 有众多子类。
当满足上述情况之一时,可以直接将B类对象设置为A类中的“静态成员属性”,让该类的对象都可以直接使用:
Listing 10 将PBaseDefenseGame设置为类的静态引用成员
1 | public class PBaseDefenseGame |
举例来说,敌方单位AI类(EnemyAI),在运行时需要使用关卡系统(StageSystem)的信息,但EnemyAI对象产生的位置是在敌方单位建造者(EnemyBuilder)之下:
Listing 11 Enemy各部位的建立
1 | public class EnemyBuilder : TCharacterBuilder |
按照“最少知识原则(LKP)”,会希望敌方单位的建造者(EnemyBuilder)减少对其他无关类的引用。因此,在产生敌方单位AI(EnemyAI)对象时,敌方单位建造者(EnemyBuilder)无法将关卡系统(StageSystem)对象设置给敌方单位AI,这是属于上述“生成的位置无法引用到”的情况。所以,可以在敌方单位AI(EnemyAI)类中,提供一个静态成员属性和静态方法,让关卡系统(StageSystem)对象产生的当下,就设置给敌方单位AI(EnemyAI)类:
Listing 12 敌方AI的类(EnemyAI.cs)
1 | public class EnemyAI : ICharacterAI |
使用类的静态方法
每当增加一个类名称就等同于又少了一个可以使用的全局名称,但如果是在类下增加“静态方法”就不会减少可使用的全局名称数量,而且还能马上增加这个静态类方法的“可视性”——也就是全局都可以引用这个静态类方法。如果在项目开发时,不存在限制全局引用的规则,或者已经没有更好的设计方法时,使用“类静态方法”来获取某一系统功能的接口,应该就是最佳的方式了。它有着单例模式的第二个特性:方便获取对象。
举例来说,在《P级阵地》中,有一个静态类PBDFactory就是按照这个概念去设计的。由于它在《P级阵地》中负责的是所有资源的产生,所以将其定义为“全局引用的类”并不违反这个游戏项目的设计原则。它的每一个静态方法都负责返回一个“资源生成工厂接口”,注意,是“接口”,所以在以后的系统维护更新中,是可以按照需求的改变来替换子类而不影响其他客户端:
Listing 13 获取P-BaseDefenseGame中所使用的工厂(PBDFactory.cs)
1 | public static class PBDFactory |
但如果在系统设计的需求上,又要求每个游戏资源工厂都“必须是唯一的”,那么此时可以在各个子类中运用单例模式,或者采取前面提到的“让类具有计数功能来限制对象数量”的方式来满足需求。
结论
单例模式的优点是:可以限制对象的产生数量;提供方便获取唯一对象的方法。单例模式的缺点是容易造成设计思考不周和过度使用的问题,但并不是要求设计者完全不使用这个模式,而是应该在仔细设计和特定的前提之下,适当地采用单例模式。
在《P级阵地》中,只有少数地方引用到单例类PBaseDefenseGame,而引用点可以视为单例模式优点的呈现。
其他应用方式
- 网络在线游戏的客户端,可以使用单例模式来限制连接数,以预防误用而产生过多连接,避免服务器端因此失效。
- 日志工具是比较不受项目类型影响的功能之一,所以可以设计为跨项目共享使用。此外,日志工具大多使用在调试或重要信息的输出上,而单例模式能让程序设计师方便快速地获取日志工具,所以是个不错的设计方式。