掌握单例模式的实现与优化
一、引言:如何学习设计模式?
学习设计模式最主要要抓住一点:就是怎么分析这个稳定点和变化点。自己实现一个框架,或者是实现一个具体的小功能,本质上分析问题的思路都是一样的,首先要去把稳定点给它抽象出来,然后针对这个变化点想着怎么去扩展它。所以这里还是要反复的介绍怎么分析这个稳定点和变化点;具体不同的设计模式是怎么来处理这个扩展(就是扩展的问题);稳定点它是怎么处理的;用C++的语言特性是怎么去解决这些问题的;沿着这个思路去学习。
前面已经介绍了设计模式当中的模板方法、观察的模式、以及策略模式,这里再次强调以下学习、掌握设计模式的学习步骤。
- 首先,需要来了解设计模式解决了什么问题。本质上是分析它的稳定点和变化点,实际在做具体功能开发的时候也需要去抽象具体的稳定点以及想办法去扩展变化点,这样在实际开发过程当中,尽量写少量的代码去应对未来需求的变化。
- 第二点,设计模式的代码结构是什么 。需要培养一个看代码、看一些框架或者看项目代码结构的时候马上能够反应出来使用了什么设计模式,或者它符合什么设计原则,从而可以推断出代码具体的意图。熟悉实现具体设计模式的代码结构能够帮助我们对一个代码有一个敏感度,以便能够快速的进行推断和反应。
- 第三点,看这些设计模式符合了哪些设计原则。因为设计模式是由设计原则推导过来的,所以可以按照这一个设计模式的产生的流程重新去思考这一个问题,能够帮助我们去很好的去设计我们的代码。相信很多人在具体的工作当中都有自己不同的一些设计方式,它不一定符合某一些设计模式,未来大家应对的某些需求也会自己去设计一个框架,所以可以思考它符合哪些设计原则。
- 第四点,如何在上面扩展代码。尤其是对于初学者或刚刚参加工作的朋友们,对这个扩展代码一定要非常的清楚,就是如果在这个设计模式的基础上要修改哪些代码,这个是必须要掌握的。
- 第五点,按照自己的需求或者自己的项目以及自己的工作场景进行一个联系,哪些需求变化可以使用设计模式;在看开源框架的时候也可以去看一下它是怎么解决这一个问题的。记住几个关键设计模式的一些典型应用场景能够帮助我们快速的反应;当具体需求来了知道该怎么使用某一些设计模式。
学习步骤 设计模式解决什么问题 稳定点 变化点 设计模式的代码结构是什么 设计模式符合哪些设计原则 如何在上面扩展代码 该设计模式有哪些典型应用场景 联系工作场景 开源框架
这个就是设计模式具体的学习步骤。讲解设计模式的思路也是按照这五个步骤来进行讲解,前面有一个章节已经讲解了模板方法、观察的模式、以及策略模式,接下来讲解一个非常重要的设计模式:单例模式 。后面还会讲解工厂模式、抽象工厂模式、责任链模式、装饰模式、组合模式等,这些设计模式说开发过程中常见的设计模式。
二、前置知识:对象的创建的销毁
这里是通过C++语言进行分析的设计模式,所以会涉及到C++语言的知识点特别的多。
cpp
class T{
public:
T(){
cout<<"T():"<<this<<endl;
}
~T(){
cout<<"~T():"<<this<<endl;
}
T(const T&){
cout<<"T(const T&) 拷贝构造:"<<this<<endl;
}
T& operator=(const T&){
cout<<"T& operator=(const T&)拷贝赋值构造: "<<this<<endl;
}
T(T &&){
cout<<"T(T &&)移动构造: "<<this<<endl;
}
T& operator=(T &&){
cout<<"T& operator=(T &&)移动赋值构造: "<<this<<endl;
}
};
T CreateT(){
T temp;
return temp;
}
在这里构造了一个类,分别有构造函数,析构函数,拷贝构造函数,拷贝赋值构造,移动移动构造,以及移动赋值构造。并且进行了一个打印,跟大家来介绍一下这几种构造在什么情况下会被调用,这些都是比较隐藏的,相信有很多的朋友根本就没有考虑过我们去实现这些东西时哪些行为会触发这些构造。
2.1、拷贝构造
拷贝构造就是用一个类构造一个对象,用另外一个类去初始化。拷贝构造的触发有三个方式:
(1)直接用对象构造。比如用这个T1类去初始化T2类,那它会触发拷贝构造。
cpp
T t1;
T t2=t1;
(2)传入参数构造。
cpp
T t1;
T t2(t1);
(3)C++ 11出现的 初始化列表的构造方式。
cpp
T t1;
T t2{t1};
这三种方式都会触发拷贝构造。在单例模式中是不希望这个对象能够去负责构造另外一个对象,显然要禁掉这种行为。
2.2、拷贝赋值构造
两个对象之间赋值。比如说有两个对象T1和T2,T1复制了T2,这个时候就会进入拷贝赋值构造,也就是在这里有一个操作承载的这个=
操作符,通过引用的方式去构造它。
cpp
T t1;
T t2;
t1=t2;
2.3、移动构造
C++11出现的,有两种方式:
(1)函数返回 。通过某一个函数的一个返回值将临时对象返回,但是需要注意,如果是没有禁掉返回值优化-fno-elide-constructors
,C++编译器默认会进行一个返回值优化,即它其实只会有一个构造函数以及一个析构函数,这个临时对象相当于这个接收的对象,这会进行一个返回值的优化,只会进行一次构造和一次析构。如果禁掉返回值优化-fno-elide-constructors
的话,这时候编译器对临时对象有三种行为:
- 先看这个类有没有移动构造。
- 如果没有移动构造的话,就会去看有没有拷贝构造。
- 如果这前面两个都没有的话,就报错。
cpp
T t=CreateT();
yes no yes no 函数返回方式 禁掉返回值优化 不禁掉返回值优化 有没有移动构造函数 OK 有没有拷贝构造函数 error 只有一个构造函数 只有一个析构函数
就是首先优先来调用它的移动构造,因为移动构造不需要重新分配内存,如果里面有一些动态内动态空间的话,直接去去移交;第二个就会去看有没有拷贝构造,如果有拷贝构造,会调用拷贝构造函数,如果都没有,那么就会报错,这个是它的行为。
(2)std::move()。
cpp
T t1;
T t2(std::move(t1));
2.4、移动赋值构造
方式一:
cpp
T t;
t=T();
方式二:
cpp
T t1,t2;
t1=std::move(t2);
三、单例模式的定义
学习设计模式呢,首先来看一下它的定义,从定义当中分析出它的具体解决了什么问题。单例模式的定义:保证一个类仅有一个实例,并提供一个该实例的全局访问点。
Singleton - _instance +Instance()
接下来分析一下它的稳定点和变化点,解决了什么问题。解决什么问题主要来帮助分析具体设计模式解决什么问题。
- 稳定点:类仅有一个实例,并提供一个该实例的全局访问点。
- 变化点:有多个类都是单例,能不能复用代码。从定义上分析只有一个稳定点(即要解决一个什么问题,就是解决一个类提供一个实例,并且提供一个全局的好问点这样的一个问题),硬要说有什么变化点的话,就是可能某一个项目当中有多个类都是单例,能不能复用单例模式的代码,这个是牵强附会的认为要的变化点。
代码结构:
(1)私有的构造和析构。单例模式和程序的生命周期是相同的,不希望new和delete的存在,应用程序退出时单例模式才会释放。所以,需要把构造函数和析构函数隐藏起来,让用户不能调用。
(2)禁掉一些构造。把所有能构造的方式都关闭。比如 拷贝构造、拷贝赋值构造、移动构造、移动拷贝构造。
(3)静态类成员函数。
(4)静态私有成员变量。
结构图:
四、单例模式的实现与优化
接下来会了解一下单例模式的代码结构,这里是通过C++语言进行分析的设计模式,所以会涉及到C++语言的知识点特别的多,单例模式在这里准备了六个版本来进行讲解,一步一步的来看一下它分别隐藏了一些什么样的问题,以及是怎么去解决它的。刚刚也跟大家说了要解决的问题就是一个实例全局访问点,那么怎么来实现这个需求呢?这里再反复强调一下,稳定点是通过抽象出里面的稳定流程来实现这个稳定点,让稳定点变得更加稳定;变化点通过扩展的方式来进行扩展,扩展又分为两种:第一个通过继承,第二个是通过组合,通常通过这两种方式去扩展这些变化点。
类名对于用户而言就希望去利用它、去产生它。要实现一个只有一个实例,显然要关闭这一种行为,以及不希望用户去delete
它,也不希望别人去delete
我们创建的这一个对象,通常这个单例模式会跟应用程序的生命周期是一模一样,应用程序退出的时候,单例的对象才会得到释放。所以第一步要把这个构造函数和析构函数隐藏掉,让别人不能够去调用它,用户不能够去构造这个对象,以及析构这个对象。
第二个需要去禁掉一些内容,因为单例模式是仅有一个实例,那么所有构造它的方式都要关掉它,主要有四种容易忽视的构造方式:
- 拷贝构造。
- 拷贝赋值构造。
- 移动构造。
- 移动拷贝构造。
这四个是最容易被忽视的,它们又能够帮助去构建对象,所以呢也要把它进行限定住,让其他的用户不能够去调用它。
小结:
- 把构造和析构私有化,让其他用户不能够去调用它们。
- 禁掉一些构造方式。
下面给大家简单的来看一下几个代码。
4.1、版本一
cpp
class Singleton {
public:
static Singleton * GetInstance() {
if (_instance == nullptr) {
_instance = new Singleton();
}
return _instance;
}
private:
Singleton(){}; //构造
~Singleton(){};
// = delete 就是关闭这些行为
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
static Singleton * _instance;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
在单例模式中必须要把拷贝构造、拷贝赋值构造、移动构造、移动拷贝构造这四个禁止掉。在C++11当中直接=delete
就可以把这些行为都给它关闭掉,这些构造都不能够去构造了,这个对象才能够实现一个类仅有一个实例,都不能够用这一个具体的实例去构造另外一个对象。
接下来要实现一个提供单个实例的全局访问点,全局的访问点也就是说在项目当中,各个地方都能够访问到该实例。如果是构造一个全局对象,全局对象不是很安全,因为是对象的话很容易被直接去使用它(直接去调用构造、拷贝构造函数),现在它的构造都在private进行私有化了,构造不出来,所以前面代码中实现仅有一个实例的时候,它就已经没法实现这种全局对象了,因为所有可以构造的函数都已经放在private
下面了,它不能够去生成一个全局对象,只能考虑从堆上去分配内存。
因此,可以考虑用一个接口来访问对象,提供全局访问点,并且是通过静态成员函数的方式来实现访问这一个全局的访问点,这个具体的值放在堆上面。因为是静态成员函数,那么对应的变量_instance
也必须要是静态成员变量,因为如果不是静态成员变量的话,在静态成员函数里面是不能够访问具体对象的变量的,而只能访问静态全局变量,因此必须要是一个静态的成员变量。
静态成员变量必须要进行一个初始化,然后就可以通过一个if
判断全家实例是否为nullptr
的方式来实现一个全局的访问点,从而保证只有一个实例。因为如果instance == nullptr
的话就会通过new Singleton
,如果第二次再来调用的时候它肯定不等于空指针,不等于空指针就直接返回了。这里通过这种方式,就实现了一个只有一个实例,并且提供了一个全局的访问点。
代码结构:
(1)私有的构造和析构。
(2)把所有能构造的方式都禁掉。比如 拷贝构造、拷贝赋值构造、移动构造、移动拷贝构造。
(3)静态类成员函数。通过他来实现我们的全局访问点。
(4)静态私有成员变量。通过它来帮助在堆上去分配一下内存。
从上面具体的代码实现当中思考一下存在哪些问题?
首先要注意到_instance
在静态全局区进行分配的,它是静态全局分配的;也就是说程序退出的时候这个变量它是能够释放的,通过程序会自动进行释放的,但是这个_instance
只是一个指针,它指向了一个堆上的资源,但是这个堆上分配的内存它是不能够回收的。也就是析构函数是不会被调用的,这肯定是有bug的(要注意到析构函数是空的,没必要释放这个对象的内存),如果这个单例当中操作了某一块文件,往文件当中写内容,理论上程序推出的时候,这个单例要析构,调用这个析构函数,去把可能要将一些数据刷到文件当中的操作继续操作完,然后把这个文件的句柄close
掉(即把它的资源释放掉),但是如果在析构函数这个地方什么都不处理的话(即这里的析构函数是根本不会被调到),那么文件就不会得到释放,有一些没有来得及写到文件里面的内容也不会去写到文件里。
有的朋友可能想到了智能指针,这里使用智能指针是可以解决的,那么思考一下这个地方如果用智能指针的话应该使用什么样的智能指针?智能指针有shared_ptr和unique_ptr,显然使用unique_ptr可以解决这一个问题的。
4.2、版本二
接下来先不使用智能指针,自己实现一份代码来解决一下上面的这个问题。
cpp
class Singleton {
public:
static Singleton * GetInstance() {
if (_instance == nullptr) {
_instance = new Singleton();
atexit(Destructor);// 当程序退出时调用atexit里设置的Destructor函数
}
return _instance;
}
private:
static void Destructor() {
if (nullptr != _instance) { //
delete _instance;
_instance = nullptr;
}
}
Singleton(){}; //构造
~Singleton(){};
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =
delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =
delete;//移动拷贝构造
static Singleton * _instance;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
// 还可以使⽤ 内部类,智能指针来解决; 此时还有线程安全问题
析构函数没有被调用到的主要的原因是没有地方去把这块内存给释放掉,现在增加一个接口,在C语言当中有一个当程序退出的时候去回调指定方法的接口atexit(...)
,可以在这个接口当中去回调析构函数把相对应的内存释放掉,主动调用delete
。
这个就是第二个版本,其他的地方都没有改变,这里主要解决了一个内存泄露的问题。
4.3、版本三
在版本二中不支持多线程,它是一个单线程的,不支持多线程。虽然atexit(...)
这个方法是一个线程安全的,但是整个类来说不是线程安全的。
可以马上联想到加锁操作,来看一下这个版本是怎么加锁的以及加在哪个地方。很显然哪个地方是临界资源,就给哪个地方加锁。_instance = new Singleton();
可能会产资源竞争,因为new
具体对象的时候会产生资源竞争,需要在这个地方进行加锁。
加锁的第一版实现代码:
cpp
#include <mutex>
class Singleton { // 懒汉模式 lazy load
public:
static Singleton * GetInstance() {
// RAII
std::lock_guard<std::mutex> lock(_mutex); // 3.1 切换线程
if (_instance == nullptr) {
_instance = new Singleton();
// 1. 分配内存
// 2. 调用构造函数
// 3. 返回指针
// 多线程环境下 cpu reorder操作
atexit(Destructor);
}
return _instance;
}
private:
static void Destructor() {
if (nullptr != _instance) {
delete _instance;
_instance = nullptr;
}
}
Singleton(){}; //构造
~Singleton(){};
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
static Singleton * _instance;
static std::mutex _mutex;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
std::mutex Singleton::_mutex; //互斥锁初始化
按照这样的方式来进行加锁,使用了RAII的思想(即利用类的生命周期好来进行资源管理)。从效率优先角度思考,它仍然具有一些性能上的问题,还可以进行一个优化。
对于这整个接口而言,只有第一次调用的时候是需要加锁的,调用这个类的接口的对象要获取这个类的对象的全局访问点,只有第一次调用的时候才需要给进行加锁,因为第一次才会涉及到写操作(会有一个赋值操作,分配一块内存,并且调用他的构造函数),其他的情况下都是读操作,而读操作是没有必要加锁的,所以上面的代码中会导致很多地方涉及到无用的加锁。
那么怎么解决它呢?可以使用双重检测,这个是编程当中的经常用到的一个技术(双重检测,double checking)。也就是上面的代码改为两次if (_instance == nullptr)
,而锁加在第一次和第二次检测之间。
加锁的第二个版实现代码:
cpp
#include <mutex>
class Singleton { // 懒汉模式 lazy load
public:
static Singleton * GetInstance() {
if (_instance == nullptr) {
std::lock_guard<std::mutex> lock(_mutex); // 3.2
if (_instance == nullptr) {
_instance = new Singleton();
// 1. 分配内存
// 2. 调用构造函数
// 3. 返回指针
// 多线程环境下 cpu reorder操作
atexit(Destructor);
}
}
return _instance;
}
private:
static void Destructor() {
if (nullptr != _instance) {
delete _instance;
_instance = nullptr;
}
}
Singleton(){}; //构造
~Singleton(){};
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
static Singleton * _instance;
static std::mutex _mutex;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
std::mutex Singleton::_mutex; //互斥锁初始化
对于第一次访问这个GetInstance()
接口的时候才会涉及到资源竞争,也就是写操作,当出现有两个及以上个线程同时进入到第一个if (_instance == nullptr)
里面,此时只有一个线程能够持有锁,持有锁的线程才会进去new
这个对象,另外的线程会在加锁的地方等待,并且是在第二个if (_instance == nullptr)
的前面进行等待;当获得锁的线程new
完对象后并结束持有锁的生命周期,第二个线程就可以持有这把锁了,第二个线程持有这把锁的时候就会判断出第二个if (_instance == nullptr)
此时不为空,那么就直接把它进行退出了。通过这种方式避免了多个线程同时进入的问题,同时又增加了一个对于后面来访问这个新对象的这些读操作直接是在第一个判断就出去了,因为我们这个对象已经被构造了,所以就直接出去了。
4.4、版本四
上面版本三虽然解决了线程安全问题,但是仍然存在一些问题。在如今的多核时代跟前面的时代已经不一样了,C++ 98版本的语言语义是基于单线程的,而C++11在它的基础上进行了一些封装,包括封装的这种线程操作,C++98线程操作都是用的标准外的一些函数,比如说pthread_create
和pthread_mutex
等,加锁都是利用的标准之外的一些库来帮助加锁;C++11在这上面封装了一些线程安全的操作,比如std::mutex
,还封装了原子操作,比如std::atomic
,以及内存栅栏。在多核时代,前面的多线程操作在C++ 98版本都是有问题的,因为在多核时代会进行一些优化,包括编译器重排,CPU重排等。这样子一来会带来了一个问题,可能会违反顺序一致性(前面写的语句必须要在后面的语句之前执行),那么在多核时代,编译器跟CPU都会进行一个优化,它会让程序能以最快的方式来执行;只要不影响后面结果的情况下,可能第二条语句的执行会在第一条的前面,但是这个不影响结果是因为它是考虑到对于CPU运行而言的,这时就会产生一些问题:
- 可见性问题。
- 执行序问题。
C++ 11为了解决这些问题提供了一个同步原语,除了同步原语还有锁。这个同步原语又分为两个部分,第一个是原子变量,第二个是内存栅栏啊,或者叫内存屏障。下面就会利用这两个技术来帮助解决问题。
版本三虽然加了一把锁,但是它没有考虑到可能会出现这种CPU的进行一个指令重排,在这里CPU指令重排会出现在这个new操作,也就是_instance = new Singleton();
这一条语句,在汇编当中它是由多个指令构成的,而且new是一个操作符,它不是函数,在具体类当中都会有一个操作符的实现,这个操作符呢,默认的情况下,第一步先分配内存,然后去调用它的构造函数,最后返回指针。在多核的环境下,CPU会帮助进行一个指令重排,在这个语句当中可能会重排成先调用1、3,然后再调用2,然而本来在单核环境下调用顺序是1、2、3。这时可能就会出现内存异常问题,调用1、3、2也就意味着到3时已经有内存化的地址就直接返回了,但是此时它还没有去构造数据,没有构构造数据当另外一个线程走到第一个if (_instance == nullptr)
时发现了_nstance
实例不为空,不为空它就返回出去,返回出去它就可能要操作这个对象数据,操作对象数据就会发现这个构造函数都还没有构造,那里面虽然内存指针确实指向了一块区域,但是这块区域没有被初始化,此时去调用里面的数据的时候就可能出现异常,进而导致程序奔溃。
版本三中的加锁只是从单线程语义,就是C++ 98的时候思程序的一种方式,对于现在现代语言,也就是多核时代,这种思考还不够,还要考虑这种指令重排的问题,考虑怎么用C++的语言特性来解决这个问题,即通过C++ 11提供的一些同步原语来帮助解决这个问题。
cpp
#include <mutex>
#include <atomic>
class Singleton{
public:
static Singleton *GetInstance(){
Singleton *tmp=_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);//获取内存屏障
if(tmp==nullptr)
{
std::lock_guard<std::mutex> lock(_mutex);
tmp=_instance.load(std::memory_order_relaxed);
if(tmp==nullptr){
tmp= new Singleton;
std::atomic_thread_fence(std::memory_order_release);// 释放内存屏障
_instance.store(tmp,std::memory_order_relaxed);
atexit(Destructor);
}
}
return tmp;
}
private:
static void Destructor(){
Singleton* tmp=_instance.load(stdd::memory_order_relaxed);
if(nullptr!=tmp)
delete tmp;
}
Singleton(){};
~Singleton(){};
Singleton(const Singleton &) = delete;
Singleton& operator=(const Singleton &) = delete;
Singleton(Singleton &&)=delete;
Singleton& operator=(Singleton &&)=delete;
static std::atomic<Singleton*> _instance;
static std::metex _mutex;
};
std::atomic<Singleton*> Singleton::_instance;//静态成员变量需要初始化
std::mutex Singleton::_mutex;//互斥锁初始化
// 编译
// g++ Singleton.cpp -o singleton -std=c++11
这里使用到一个原子变量,我们把这个具体的指针对象的指针加上std::atomic<>
类型的一个原子变量,现在的_instance
是一个原子变量,原子变量解决了三个问题:
(1)原子执行的问题 。也就是同一时间只有一个线程执行它。
(2)可见性问题 。原子变量提供了load(可以看见其他线程最新操作的数据)和和store(修改数据让其他线程可见)来解决。 store通常是写操作,store操作目的是在线程里面操作修改的数据能够让其他线程对这个数据是可见的,这里面要涉及到内核的知识,这里就不讲特别复杂;在这里有一级缓存、二级缓存、三级缓存(只是核心的私有缓存),在这里修改数据其他线程是不可见的, store的作用就是让其他线程可以看到数据的修改。load()是可以看见其他线程最新操作的数据。
(3)执行绪问题 。使用内存模型解决,memory_order_acquire、memory_order_release
。C++ 11给了六个内存模型,即六种内存序,这里只给大家解释这两个内存序。memory_order_acquire
通常对应的读操作,它的意思是它后面的语句不能够优化到外面去(即这一个语句的上面),因为有CPU指令重排,所以这个指令要求它不能够优化到上面去;memory_order_release
意思是它上面的代码不能够优化到它下面来;这两个一起使用的就是它们中间的代码既不能出去(不能在往上面去)也不能够往下面去。
内存栅栏不是具体的原子变量,它主要解决了可见性的问题跟执行序的问题。
解决了这个原子序的问题后,安全性就解决了,多线程环境下单例模式这个时候就彻底没有问题了。
4.5、版本五:最安全、最精简的单例模式
上面版本四写的太复杂了,写一个安全线程的代码太长了,如果有多个类是构造函数,那写代码的时候就有一点要抓狂的;那么有一个更精简的方式:直接使用静态成员变量,不使用指针。主要是利用了c++11 的 magic static 特性:如果当变量在初始化的时候,并发同时进⼊声明语句,并发线程将会阻塞等待初始化结束。
cpp
// c++11 magic static 特性:如果当变量在初始化的时候,并发同时进⼊声明语句,并发线程将会阻塞等待初始化结束。
// c++ effective
class Singleton
{
public:
static Singleton& GetInstance() {
static Singleton instance;
return instance;
}
private:
Singleton(){}; //构造
~Singleton(){};
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
};
// 继承 Singleton
// g++ Singleton.cpp -o singleton -std=c++11
/*该版本具备 版本4 所有优点:
1. 利⽤静态局部变量特性,延迟加载;
2. 利⽤静态局部变量特性,系统⾃动回收内存,⾃动调⽤析构函
数;
3. 静态局部变量初始化时,没有 new 操作带来的cpu指令
reorder操作;
4. c++11 静态局部变量初始化时,具备线程安全;
*/
版本四写了一个很长的代码才实现了一个安全的单例模式。其实可以利用C++ 11的特性用最简单的方式来实现一个安全的单例模式,并且是一个线程安全的。主要利用了C++11的magic特性,由c++ effective这个作者提出来的。这是一种最安全的、最精简、最简单的一个单例模式(直接用一个静态的全局变量构造对象,因为静态的全局变量只会初始化一次,并且是多线程安全的,最重要的是它不会进行CPU指令重排和在生命周期时可以调用析构)。
该版本具备 版本4 所有优点:
- 利⽤静态局部变量特性,延迟加载。
- 利⽤静态局部变量特性,系统⾃动回收内存,⾃动调⽤析构函数。
- 静态局部变量初始化时,没有 new 操作带来的cpu指令reorder操作。
- c++11 静态局部变量初始化时,具备线程安全。
4.6、版本六:可复用的
cpp
template<typename T>
class Singleton {
public:
static T& GetInstance() {
static T instance; // 这⾥要初始化DesignPattern,需要调⽤DesignPattern 构造函数,同时会调⽤⽗类的构造函数。
return instance;
}
protected:
virtual ~Singleton() {}
Singleton() {} // protected修饰构造函数,才能让别⼈继承
private:
Singleton(const Singleton &) = delete; //拷⻉构造
Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
Singleton(Singleton &&) = delete;//移动构造
Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
};
class DesignPattern : public Singleton<DesignPattern> {
//friend 能让Singleton<T> 访问到 DesignPattern构造函数
friend class Singleton<DesignPattern>;
private:
DesignPattern() {}
~DesignPattern() {}
};
这个版本就是在版本的基础上添加了多态,因为还有一个变化点没有解决,如果项目当中有多个类都是单例,能不能够去复用这个代码呢?因为不想每一个单例都跑去实现这样的一个静态的变量、静态的函数,还要写这么多这种把它拷贝构造、拷贝赋值、移动构造、移动拷贝构造全部把它给关闭掉,太复杂了,想去重复利用这一块代码,这个时候只能使用模板来实现了,通过模板加继承的方式去解决这一个变化点的问题,因为变化点的扩展通常是通过继承的方式来扩展它,并且加入多态的方式。用上面在版本五的基础上来进行迭代,使用友元类可以让基类调用到子类的构造函数(因为构造函数声明了private,不设置友元会无法正常调用)。
总结
- 通过六个示例描述一步步完善单例模式的设计过程,需要考虑的问题。
- C++类的构造有:构造函数、拷贝构造、拷贝赋值构造、移动构造、移动赋值构造。
- 编写单例模式代码时,需要考虑其线程安全性问题。
- 同一个对象,它们是friend class的关系,可以互相访问私有成员。单例模式是很常见的设计模式,需要掌握。
思维导图: