为什么我不用单例

突然发现,其实自己倒是很少写技术相关的内容,仔细想想,还是有些内容是希望聊聊,记录下来的。这是一些经过长期思考、验证,至今依然觉得有其道理的内容。

今天先从一个简单的问题着手吧。

编程学到一定程度,从大学开始接触的话大概在大二大三的样子,基本都会接触一个新鲜事物——设计模式。回想一下我看的第一本设计模式相关的书好像是《设计模式之禅》。设计模式给我的最大震撼是终于理解了之前看到的很多写法到底为什么这么写,这也是自己从把代码作为一个固定模式使用,转变为开始自行思考写法。换言之,从“编写”代码,到“设计”代码的一个转折吧。

初学设计模式,我也和很多人一样,印象最深的,就是“单例模式”了。对于自己已经熟知的写法进行组合,竟然可以产生这样的效用。而真正写好、写正确一个单例,又没这么简单。我至今都很痴迷这种简单与复杂的交织。

大学后来写Web写App,单例倒是确实没怎么用到过,所以渐渐也没那么在意了,再到后来,幻刃成立之后,开始写游戏项目,这个问题很显然地就出现了。

Unity 是脚本驱动的,每个 GameObject 上都可以挂载不同的脚本,每个脚本负责自己的数据和逻辑,这个思路是很清晰的。那么我们来设想一下,一个初次开发游戏的开发者,会怎样组织代码呢?

一开始,肯定是每个脚本自己管自己的事情,需要相互引用就“拖一下”,逻辑简单的时候这样是不会出现什么大问题的。

然后呢,他就会发现,自己有挺多地方有着差不多的代码,这时候呢,挺有可能他也听说了一个新鲜词,叫做“DRY原则”,自己也深以为然,于是开始思考怎么解决这个问题。

首先,可能最容易想到的代码复用方式就是“工具类”。这个平时调用过一些库的话,很容易就能看到并理解这种方式。为什么不是类继承呢?因为这在设计上实在要求太高了,大概在这个阶段我自己对于继承实在还停留在 Rectangle 可以继承 Shape,甚至刚刚搞明白如果我再想要一个 Square 类那该是怎样的继承关系(这个问题确实还挺有意思)。什么接口呀,抽象类呀,还都没在工程上用过呢。后面我也会具体谈到继承的方式在工程上面使用的一些具体情况。

说回工具类,工具类嘛,在面向对象语言中的实现基本就是一个名称以Tools或者Utils什么的结尾,只包含静态方法的一个特殊类,这样,需要复用的方法,就可以复制粘贴过去,然后在任何位置直接调用了。

这种方式好用吗?太好用了,于是你渐渐的就会有自己的MathTools、LogTools、NetTools、FileTools等等以及更多业务相关的类。

再然后,你会发现这种方式好像可以解决一个新遇到的问题,那就是怎样进行脚本间通讯的问题。

比方说吧,大部分游戏,肯定有个概念是主角嘛,Unity中的话,这就是一个GameObject 了。主角对象上可能还挂了一个脚本,负责一些基本逻辑,比如叫做Player。那么,在其他脚本中,如何获取这个脚本呢?

一个比较Unity的做法是直接 GameObject.FindWithTag(“Player”) 拿到GameObject,然后 GetComponent<Player>(),或者是 Object.FindObjectOfType()。这类方法,即使是初学者也会很快明白是不对的。

  1. 需要遍历查找,性能非常差;
  2. 通过 Tag 的形式依赖字符串,所有需要手动保证不输入错误的地方,我觉得都是有问题的;
  3. 如果有大量目标需要查找时,非常难以区分与管理。

那么,很自然的,解决方法就有了,写一个叫做 PlayerManager 的静态类,声明一个公开的 Player 字段,在 Player 初始化的时候,把自己赋值上去就好了。虽然这种初始化时把自己的引用传递出去的方式感觉稍微有些违和,但是至少问题是解决了的。

这个问题本质上其实是任何时候,获取一个特定的引用,在面向对象语言中,基本需要依靠“静态”这个概念,这点是跑不掉的,那么除了直接使用静态字段,还有没有别的方法呢?

很自然,单例天然的解决了这个问题。更因为C#的CLR级别泛型支持,有一个特别方便的单例写法。(本篇不讨论单例正确写法问题,我们先用最朴素的单例实现方法,不考虑懒加载与线程安全问题)

直接一个继承就搞定了。

然后你就可以在任何地方,使用 SingletonExample.Instance 访问这个对象。

咋一看,这和使用静态类没什么区别啊,只是静态类的字段需要多个static,单例访问的时候需要多写个Instance。

再深入想一层呢?有一个很大的区别就是在单例是可以懒加载的,因为其实本质是是个对象,在需要的时候,还可以解除引用进行GC;还要考虑到序列化方面的问题;再有,比如你写了一个FileSystem的静态类,然后发现其实你需要对不同平台编写不同的实现,使用单例的话只需要写多个子类继承FileSystem然后分别实现,最后使用编译选项控制FileSystem的单例实例化的具体对象就可以了,这些情况静态类就都别想了。

这么说来岂不是使用单例还是挺好的,除了每次需要多写个Instance比较繁琐,但是也是可以依靠IDE代码补全,或者干脆命名为I来省事。

我们回过头来想想,单例本质上是解决什么问题的呢?对了,是解决在同一Runtime下确保只存在一份该类的实例这一问题。只有一份实例之后,可以全局便捷访问到,只是 一个附带的作用而已。所以实际上来说,我们是利用了一个副作用,而忽略了本来使用单例的目的(其实我们确实不管理是不是唯一,甚至在Unity中我们也并不能保证,一个继承了Monobehaviour的单例,因为并不是也不能通过new的方式实例化,所以生成了多个是很容易也常见的错误)。实际上,如果想解决“只有一个实例”和“在全局便捷的访问”其实分别会有更加合适的方法。

再说到全局访问,一个人写小规模代码的时候这是个很方便的做法,但是项目一旦开始扩大,人多了以后,你会发现这几个问题。

  1. 滥用,你可能花了很大心思保证两个系统的解耦合,但是因为他们都可以很便捷的被访问到,那么接下去的事情就不是你能控制的了。此外,如果单例成为了给实例提供方便的访问方法的项目标准做法,那么势必会出现大量单例,虽然很明显,不是每个单例的创建者都希望别人在任何位置使用它,但是你却不能用除了约定以外的方式来确保这一点。
  2. 你无法控制启动顺序,单例的懒加载作为一大优势,同时也可能成为一个难以解决的问题。懒加载单例的加载顺序,是由调用方决定的,大多数情况下,单例们都可以自动根据依赖关系顺利启动,但是这种自动,很多情况下其实都不是你希望的顺序。甚至,很容易造成循环依赖
  3. 别人很难通过代码本身了解到框架的设计者提供了那些功能,文档的编写和维护对于小团队来说成本是挺高的,大家协作开发时,也不可能每写一个功能前都问问大家,是否曾经在框架中已经实现了。我在早起开发中,就经常遇到代码库中,同样功能的函数被不同人实现多次的情况,甚至有时候自己都不记得了,又再写了一遍。

说到这里,我们会发现,对于一个游戏运行时脚本系统来说,单例完全就是个不需要的设计,我们想解决问题,其实很好解决。

所有这些需要便捷访问的模块,我一般称作“服务”,他们全部作为普通的类来实现就可以了,然后写一个静态类,把他们都放进去。服务也可以有子服务,子服务的生成销毁与访问都交给父服务即可,逐级推到最上层,会有一个主服务,负责按顺序实例化所有服务,并注入到这个静态类中。这个静态类,一般被称为“服务定位器”

因为有了“服务”这个概念,就隐含了服务定位器中的对象,一定就是希望可以全局便捷访问的,而服务与服务之间,则是尽量解耦合。当你遇到需要和脚本外部交互的情况,第一反应就应该是敲一个L.(我喜欢把服务定位器类命名为L,表示ServiceLocator),IDE的自动补全即会自动提示我可以使用的服务。即使没有IDE,打开看一眼L类,有哪些功能也都清清楚楚。服务相关的脚本也都可以放在一起。静态工具类也都可以放在一起,并且明确只会包含状态无关的纯函数,比如拓展数学库等。按照这样的方式,单例就可以明确得禁止在项目中使用了,即使可能有人把不应该的模块当做了服务,也非常容易被发现并得到修正。

终究我们会发现,我们大多数使用单例解决的,都不是单例应该解决的问题,反而会带来大量的副作用,所以,用正确的方法解决问题,而不是解决问题。


对于有一些经验的游戏开发初入门者,我一直很推荐三本书:《游戏引擎架构》、《网络游戏核心技术与实战》以及游戏编程模式。最后一本篇幅不长,但是就游戏中使用的设计模式问题进行了大量的有针对性的讨论,关于单例与服务定位器的部分,都有专门的章节描述,比我写的不知道高到哪里去了。

每次看到都想吐槽《网络游戏核心技术与实战(オンラインゲームを支える技術)》这本书为什么翻译了个这么《21天学通C++》的名字,导致我一直以为是本垃圾书没有看……

留下评论

您的电子邮箱地址不会被公开。

Optimized by WPJAM Basic