CategoryResourceRepost/极客时间专栏/设计模式之美/设计模式与范式:创建型/42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

273 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/71/53cff65df464dc98c8f4de7f5acd6271.mp3"></audio>
上一节课中我们通过两个实战案例讲解了单例模式的一些应用场景比如避免资源访问冲突、表示业务概念上的全局唯一类。除此之外我们还学习了Java语言中单例模式的几种实现方法。如果你熟悉的是其他编程语言不知道你课后有没有自己去对照着实现一下呢
尽管单例是一个很常用的设计模式在实际的开发中我们也确实经常用到它但是有些人认为单例是一种反模式anti-pattern并不推荐使用。所以今天我就针对这个说法详细地讲讲这几个问题单例这种设计模式存在哪些问题为什么会被称为反模式如果不用单例该如何表示全局唯一类有何替代的解决方案
话不多说,让我们带着这些问题,正式开始今天的学习吧!
## 单例存在哪些问题?
大部分情况下我们在项目中使用单例都是用它来表示一些全局唯一类比如配置信息类、连接池类、ID生成器类。单例模式书写简洁、使用方便在代码中我们不需要创建对象直接通过类似IdGenerator.getInstance().getId()这样的方法来调用就可以了。但是这种使用方法有点类似硬编码hard code会带来诸多问题。接下来我们就具体看看到底有哪些问题。
### 1.单例对OOP特性的支持不友好
我们知道OOP的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢我们还是通过IdGenerator这个例子来讲解。
```
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
//...
}
}
```
IdGenerator的使用方式违背了基于接口而非实现的设计原则也就违背了广义上理解的OOP的抽象特性。如果未来某一天我们希望针对不同的业务采用不同的ID生成算法。比如订单ID和用户ID采用不同的ID生成器来生成。为了应对这个需求变化我们需要修改所有用到IdGenerator类的地方这样代码的改动就会比较大。
```
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = OrderIdGenerator.getIntance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = UserIdGenerator.getIntance().getId();
}
}
```
除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
### 2.单例会隐藏类之间的依赖关系
我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
### 3.单例对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
在系统设计初期我们觉得系统中只应该有一个数据库连接池这样能方便我们控制对数据库连接资源的消耗。所以我们把数据库连接池类设计成了单例类。但之后我们发现系统中有些SQL语句运行得非常慢。这些SQL语句在执行的时候长时间占用数据库连接资源导致其他SQL请求无法响应。为了解决这个问题我们希望将慢SQL与其他SQL隔离开来执行。为了实现这样的目的我们可以在系统中创建两个数据库连接池慢SQL独享一个数据库连接池其他SQL独享另外一个数据库连接池这样就能避免慢SQL影响到其他SQL的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
### 4.单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源比如DB我们在写单元测试的时候希望能通过mock的方式将它替换掉。而单例类这种硬编码式的使用方式导致无法实现mock替换。
除此之外如果单例类持有成员变量比如IdGenerator中的id成员变量那它实际上相当于一种全局变量被所有的代码共享。如果这个全局变量是一个可变全局变量也就是说它的成员变量是可以被修改的那我们在编写单元测试的时候还需要注意不同测试用例之间修改了单例类中的同一个成员变量的值从而导致测试结果互相影响的问题。关于这一点你可以回过头去看下[第29讲](https://time.geekbang.org/column/article/186691)中的“其他常见的Anti-Patterns全局变量”那部分的代码示例和讲解。
### 5.单例不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
第一种解决思路是创建完实例之后再调用init()函数传递参数。需要注意的是我们在使用这个单例类的时候要先调用init()方法然后才能调用getInstance()方法,否则代码会抛出异常。具体的代码实现如下所示:
```
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}
public static Singleton getInstance() {
if (instance == null) {
throw new RuntimeException(&quot;Run init() first.&quot;);
}
return instance;
}
public synchronized static Singleton init(int paramA, int paramB) {
if (instance != null){
throw new RuntimeException(&quot;Singleton has been created!&quot;);
}
instance = new Singleton(paramA, paramB);
return instance;
}
}
Singleton.init(10, 50); // 先init再使用
Singleton singleton = Singleton.getInstance();
```
第二种解决思路是将参数放到getIntance()方法中。具体的代码实现如下所示:
```
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}
public synchronized static Singleton getInstance(int paramA, int paramB) {
if (instance == null) {
instance = new Singleton(paramA, paramB);
}
return instance;
}
}
Singleton singleton = Singleton.getInstance(10, 50);
```
不知道你有没有发现上面的代码实现稍微有点问题。如果我们如下两次执行getInstance()方法那获取到的singleton1和signleton2的paramA和paramB都是10和50。也就是说第二次的参数2030没有起作用而构建的过程也没有给与提示这样就会误导用户。这个问题如何解决呢留给你自己思考你可以在留言区说说你的解决思路。
```
Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);
```
第三种解决思路是将参数放到另外一个全局变量中。具体的代码实现如下。Config是一个存储了paramA和paramB值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义也可以从配置文件中加载得到。实际上这种方式是最值得推荐的。
```
public class Config {
public static final int PARAM_A = 123;
public static final int PARAM_B = 245;
}
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton() {
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
```
## 有何替代解决方案?
刚刚我们提到了单例的很多问题,你可能会说,即便单例有这么多问题,但我不用不行啊。我业务上有表示全局唯一类的需求,如果不用单例,我怎么才能保证这个类的对象全局唯一呢?
为了保证全局唯一除了使用单例我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如上一节课中讲的ID唯一递增生成器的例子用静态方法实现一下就是下面这个样子
```
// 静态方法实现方式
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
public static long getId() {
return id.incrementAndGet();
}
}
// 使用举例
long id = IdGenerator.getId();
```
不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。具体的代码如下所示:
```
// 1. 老的使用方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}
// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部调用demofunction()的时候传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);
```
基于新的使用方式我们将单例生成的对象作为参数传递给函数也可以通过构造函数传递给类的成员变量可以解决单例隐藏类之间依赖关系的问题。不过对于单例存在的其他问题比如对OOP特性、扩展性、可测性不友好等问题还是无法解决。
所以如果要完全解决这些问题我们可能要从根上寻找其他方式来实现全局唯一类。实际上类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证也可以通过工厂模式、IOC容器比如Spring IOC容器来保证还可以通过程序员自己来保证自己在编写代码的时候自己保证不要创建两个类对象。这就类似Java中内存对象的释放由JVM来负责而C++中由程序员自己负责,道理是一样的。
对于替代方案工厂模式、IOC容器的详细讲解我们放到后面的章节中讲解。
## 重点回顾
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
**1.单例存在哪些问题?**
- 单例对OOP特性的支持不友好
- 单例会隐藏类之间的依赖关系
- 单例对代码的扩展性不友好
- 单例对代码的可测试性不友好
- 单例不支持有参数的构造函数
**2.单例有什么替代解决方案?**
为了保证全局唯一除了使用单例我们还可以用静态方法来实现。不过静态方法这种实现思路并不能解决我们之前提到的问题。如果要完全解决这些问题我们可能要从根上寻找其他方式来实现全局唯一类了。比如通过工厂模式、IOC容器比如Spring IOC容器来保证由程序员自己来保证自己在编写代码的时候自己保证不要创建两个类对象
有人把单例当作反模式主张杜绝在项目中使用。我个人觉得这有点极端。模式没有对错关键看你怎么用。如果单例类并没有后续扩展的需求并且不依赖外部系统那设计成单例类就没有太大问题。对于一些全局的类我们在其他地方new的话还要在类之间传来传去不如直接做成单例类使用起来简洁方便。
## 课堂讨论
1.如果项目中已经用了很多单例模式,比如下面这段代码,我们该如何在尽量减少代码改动的情况下,通过重构代码来提高代码的可测试性呢?
```
public class Demo {
private UserRepo userRepo; // 通过构造哈函数或IOC容器依赖注入
public boolean validateCachedUser(long userId) {
User cachedUser = CacheManager.getInstance().getUser(userId);
User actualUser = userRepo.getUser(userId);
// 省略核心逻辑对比cachedUser和actualUser...
}
}
```
2.在单例支持参数传递的第二种解决方案中如果我们两次执行getInstance(paramA, paramB)方法,第二次传递进去的参数是不生效的,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?
```
Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);
```
欢迎留言和我分享你的思考和见解。如果有收获,也欢迎你把文章分享给你的朋友。