设计模式(2) 单例模式(中)为什么不推荐使用单例模式?
上一篇文章中,通过两个案例,说明了单例模式的一些应用场景,比如,表示业务概念上的全局唯一类、避免资源访问冲突。除此之外,还介绍了单例模式的几种实现方法。
尽管单例模式是一个很常用的设计模式,在实际的开发中,我们也确实经常用到它,但是,很多人认为单例是一种反模式,并不推荐使用。所以,今天,我就针对这个来说明几个问题,单例这种设计模式存在哪些问题?为什么会被称为反模式?如果不用单例,该如何表示全局唯一类?有何替代的解决方案?go!go!go!
单例存在哪些问题?
很多情况下我们在项目中使用单例,都是用来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类等。单例模式书写简洁、使用方便简单。
在代码中,我们不需要创建对象,直接通过 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码,会带来很多问题。
单例对 OOP 特性的支持不友好
我们知道,OOP的特征是封装、继承、多态。单例这种设计模式对于其中的继承、多态都支持得不好。为什么这么说呢?我们还是通过IdGenerator这个案例来说明。
IdGenerator的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的OOP的抽象特性。如果某一天,我们希望针对不同的业务采用不同的ID生成算法。比如,订单ID和用户ID采用不同的ID生成器来生成。为了应对这个需求变化,我们需要修改所有用到IdGenerator类的地方,这样代码的改动就会比较大。
而且单例对继承、多态特性的支持也不友好。单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差,让人看得会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着弃疗继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
单例会隐藏类之间的依赖关系
我们知道代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,了解这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能
很容易识别出来。
但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
单例对代码的扩展性不友好
单例类只能有一个对象实例。如果某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。可能想说会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来说明一下。
在系统项目设计开始,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些SQL语句运行得非常慢。这些SQL语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢SQL与其他SQL隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢SQL独享一个数据库连接池,其他SQL独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他SQL的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较多的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现mock替换。
除此之外,如果单例类持有成员变量(比如IdGenerator中的id成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。【其实把单例对象那部分分别抽取出来进行封装函数,将测试用例进行传参到instance中,这样就可以进行mock,大家可以试试】
单例不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
第一种方法:创建完实例之后,再调用init()函数传递参数。需要注意的是,在使用这个单例类的时候,要先调用init()方法,然后才能调用getInstance()方法,否则代码会抛出异常。代码如下图所示:
第二种解决思路是:将参数放到 getIntance() 方法中。具体的代码实现如下所示:
大家发现没有,上面的代码实现是有点问题。如果我们如下两次执行getInstance()方法,那获取到的singleton1和signleton2的A和B都是10和50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没给提示,这样就会误导。【可以校验参数是否和上次传入的参数是否一致】
第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config是一个存储了A和B值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
有何替代解决方案?
我们前面提到单例的很多问题。即便单例有这么多问题,但我不用不行啊。我业务上有表示全局唯一类的需求,如果不用单例,我怎么才能保证这个类的对象全局唯一呢?
为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如,上一篇中讲的 ID 唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子:
不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。看看有没有其他办法。实际上,单例除了我们之前提到的使用方法之外,还有另外一个种使用方法。具体的代码如下所示:
基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对OOP特性、扩展性、可测性不友好等问题,还是无法解决。
所以,如果要完全解决这些问题,我们可能要从根本上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC容器(比如 Spring IOC容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。
如果大家对这块有更好的见解以及问题,或者本文有书写错误的地方,也可以进行留言交流。记得点点关注哦!!!谢谢支持!!
点个在看少个 bug 👇