单例模式
为什么要使用单例?什么时候使用单例?
当可能出现线程不安全、资源访问冲突以及某些类对象数据只应保存一份时应使用单例
1.处理资源访问冲突
我们自定义实现了一个往文件中打印日志的Logger
类:
class FileWriter {};
class Logger
{
private:
fstream file;
public:
Logger() {
file.open("/Users/test/log.txt",ios::in | ios::out);
}
void log(string message)
{
file.write(message.c_str(),message.length());
}
};
class UserController {
public:
UserController(Logger logger_):logger(logger_){}
void log(string id, string password) {
logger.log(id + "login");
}
private:
Logger logger;
};
class Order {};
class UserController {
public:
UserController(Logger logger_):logger(logger_){}
void log(Order order) {
logger.log("log" + order.toString());
}
private:
Logger logger;
};
在上面的代码中,我们注意到,所有的日志都写入到同一个文件/Users/test/log.txt 中。在 UserController
和 OrderController
中,我们分别创建两个 Logger
对象。在多线程环境下,如果两个线程同时分别执行 login()
函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。
那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给 log()
函数加互斥锁,同一时刻只允许一个线程调用执行 log()
函数。
不过,你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log()
函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log()
函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。
那我们该怎么解决这个问题?我们需要把对象级别的锁,换成类级别的锁就可以了。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用log()
函数,而导致的日志覆盖问题。
除了使用类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。
相对于这两种解决方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄。将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对象,所有的线程共享使用的这一个 Logger 对象,也就避免了多线程情况下写日志会互相覆盖的问题。
2.表示全局唯一类
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。
比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。以及,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。
单例存在哪些问题?
大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。
但是单例模式也会存在一些问题,比如:
-
单例对 OOP 特性的支持不友好
单例对继承、多态特性的支持也不友好。因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。 -
单例会隐藏类之间的依赖关系
我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。 -
单例对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。 -
单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。 -
单例不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
第一种解决思路是:创建完实例之后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。具体的代码实现如下所示:
class Singleton
{
private:
Singleton(int paraA, int paraB) :paraA(paraA), paraB(paraB) {}
~Singleton() = default;
Singleton (const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static std::unique_ptr<Singleton> instance;
static std::once_flag onceFlag;
int paraA;
int paraB;
public:
static std::unique_ptr<Singleton> getInstance(int paraA, int paraB)
{
std::call_once(onceFlag, [&] {instance.reset(new Singleton(paraA, paraB)); });
return instance;
}
static void init(int paraA, int paraB)
{
if (instance != nullptr)
{
perror("Singleton has been created!");
return;
}
instance.reset(new Singleton(paraA, paraB));
}
};
std::unique_ptr<Singleton> Singleton::instance = nullptr;
std::once_flag Singleton::onceFlag;
第二种解决思路是:将参数放到 getIntance() 方法中。不过这种实现方法的问题是。如果我们两次执行getInstance() 方法,那获取到的singleton1和singleton2 相同,第二次的参数没有起作用,而构建的过程也没有给与提示,这样就会误导用户。
第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。
class Config
{
public:
static const int paraA = 123;
static const int paraB = 456;
};
class Singleton
{
private:
Singleton(int paraA, int paraB) :paraA(Config::paraA), paraB(Config::paraB) {}
~Singleton() = default;
Singleton (const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static std::unique_ptr<Singleton> instance;
static std::once_flag onceFlag;
int paraA;
int paraB;
public:
static std::unique_ptr<Singleton> getInstance(int paraA, int paraB)
{
std::call_once(onceFlag, [&] {instance.reset(new Singleton(paraA, paraB)); });
return instance;
}
static void init(int paraA, int paraB)
{
if (instance != nullptr)
{
perror("Singleton has been created!");
return;
}
instance.reset(new Singleton(paraA, paraB));
}
};
std::unique_ptr<Singleton> Singleton::instance = nullptr;
std::once_flag Singleton::onceFlag;
有什么替代单例模式的方案?
可以使用静态方法以依赖注入的方式。
基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。
//老的使用方式
long function()
{ //...
long id = idGenerator.getInstance().getId();
//...
}
//新的方式:依赖注入
long function(IdGenerator idGenerator)
{
long id = idGenerator.getId();
return id;
}
//外部调用function()的时候,传入idGenerator