本节内容,我们要介绍四个模块的内容。按照顺序来,分别是
单例模式、C++11、异常和智能指针。
目录
单例模式
要求设计一个类,只能让其创建在堆上。
你实现一个类,要求该类只能在栈上去创建
设计一个函数,要求防止拷贝构造
单例模式:
饿汉模式
懒汉模式
懒汉模式和饿汉模式的对比
C++11
C++11简介
统一的列表初始化
声明类型
auto类型
decltype
nullptr
范围for循环
STL的新内容
右值引用和移动语义
左值引用和右值引用比较
左值引用
右值引用
完美转发
模板中的&& 万能引用
新的类功能
类成员变量初始化(这里我们在前面也全部说过,这里提一下)
可变参数模板
递归函数方式展开参数包
逗号表达式展开参数包
emplace接口
lambda表达式
lambda表达式书写格式:
lambda表达式各部分说明
捕获列表说明
包装器
C++异常
什么叫异常呢?
异常的抛出和捕获:
异常的抛出和匹配原则
在函数调用链中异常栈展开匹配原则
异常安全
异常规范
自定义异常体系
异常的优缺点
智能指针
为什么需要智能指针
智能指针的使用及原理
RAII
先来说单例模式。
我们如果要面临着这样一个情景:
怎样实现?
可以这样来去实现:
1. 将类的构造函数私有,拷贝构造声明成私有。防止别人调用拷贝在栈上生成对象。
2. 提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建。
来举个例子:
#include
using namespace std;
class HeapOnly
{
public:
static HeapOnly* CreateObj()
{
return new HeapOnly;
}
private:
HeapOnly()
{}
HeapOnly(const HeapOnly&) = delete;
};
如上所示,这里,我们将HeapOnly这个类的构造函数私有,并且将其拷贝构造禁用(注意,后面跟上一个 = delete表示禁用,这是C++11后才支持的用法)
我们如果这个时候去实例化对象,那么就会被报错。如图:
那我如果想实例化这个对象,就必须要去调用CreateObj这个函数,而一旦调用了这个函数,我就实现了动态开辟。
注意,我们需要将这个函数设置成静态的。
因为只有设置成静态的之后,在类外面才可以在没有实例化对象的时候直接调用,否则,我们没有实例化对象,是不能去调用这个函数的。
就是说,如果不加上这个static,那么调用这个函数要先去实例化对象,而要实例化对象要先去调用这个函数。这就变成了先有蛋还是先有鸡的问题了,最终导致一个也创建不了。
并且,我们这样去实例化对象也就不可以了:
理由还是一样,我们将其构造函数私有了。
也就是说,我们只能够这个样子来去实现类的实例化:
在类外,去调用CreateObj这个函数,让其返回一个HeapOnly的指针。
当然了,我们为了防止其拷贝,也不仅仅有这一种方法。
我们也可以像构造函数一样,定义一个静态的函数,然后给出一个接口,接口里面实现动态拷贝。
然后将拷贝构造私有。
就像这样:
那同理,我如果要求你实现一个类,要求该类只能在栈上去创建呢?
可以用同样的方式来实现,就是将构造函数私有化,然后让创建类的时候只能通过一个接口函数来实现,而在这个接口函数中我们将其创建在栈上。
比如下面这样:
class StackOnly
{
public:
static StackOnly CreateObject()
{
return StackOnly();
}
private:
StackOnly() {}
};
但是,实际上,这里就不需要这么麻烦了。
可以直接将动态申请的方式给禁用就可以了。
就是说,我们可以这么写:
class StackOnly
{
public:
StackOnly() {}
//private:
// C++11
void* operator new(size_t size) = delete;
void operator delete(void* p) = delete;
private:
// C++98
//void* operator new(size_t size);
//void operator delete(void* p);
};
这个时候,new和delete所重载的函数就被禁用,也就是说,我们无法进行动态开辟了。而静态开辟的,都是在栈上的。
那想要设计一个函数,要求防止拷贝构造呢?
也很简单啦,照葫芦画瓢,两种方式:
第一种,直接将构造和赋值加上 = delete。
第二种,对构造函数和赋值语句 只声明,不定义。
一种是C++98的方式(左边),一种是C++11的方式(右边)
那我如果想说:设计一个对象,不可以被继承呢?
C++98的一种实现方式还是和之前一样,将构造函数设置成私有。
这样的话,如果有子类想要去继承,那么就无法去实例化对象。因为我们说过,子类在初始化的时候会调用父类的拷贝构造,而父类的拷贝构造如果是私有的话,子类是无法进行访问的。
那C++11实现的方式就比较简单粗暴了,直接用了一个关键字:final。
用final关键字来去修饰,表示该类不能被继承,一旦继承,就报错。
直接这样就可以:
那么接下来,就是我们要重点说的单例模式——设计一个类,要求其只能创建一个对象。
单例模式:
一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
这个我们也会在后面做项目的时候用到。
那我们应当怎样去实现单例模式呢?
我们有两种实现的方式:分别是懒汉模式和饿汉模式。
就是说不管你将来用不用,程序启动时就创建一个唯一的实例对象。
我们先来实现看看:
#include
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
return _inst;
}
void Print()
{
cout << "Print()" << _a << endl;
}
private:
Singleton()
:_a(0)
{}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
int _a;
static Singleton* _inst;
};
Singleton* Singleton::_inst = new Singleton;
int main()
{
cout << Singleton::GetInstance() << endl;
cout << Singleton::GetInstance() << endl;
cout << Singleton::GetInstance() << endl;
Singleton::GetInstance()->Print();
return 0;
}
我们将创建出来的类的地址打印出来,
可以发现,虽然我调用了三次GetInstance函数,也返回了三次_inst,但是每一次所返回的地址都是一样的。
就是说,我们这里只有一个Singleton类。
这就是饿汉模式。
饿汉模式的特点:
优点:简单
缺点:可能会导致进程启动慢,且如果有多个单例类对象实例启动顺序不确定所以,假设单例类构造函数中,要做很多配置初始化工作,那么饿汉就不合适了。导致程序启动非常慢。就好比电脑迟迟开不了机。会造成很差的体验。
我们下面来说说另外一种模式:
以下是我们要说的全部代码:
#include
#include
using namespace std;
// 懒汉
class Singleton
{
public:
static Singleton* GetInstance()
{
// 保护第一次需要加锁,后面都不需要加锁的场景,可以使用双检查加锁
// 特点:第一次加锁,后面不加锁,保护线程安全,同时提高了效率
if (_inst == nullptr)
{
_mtx.lock();
if (_inst == nullptr)
{
_inst = new Singleton;
}
_mtx.unlock();
}
return _inst;
}
static void DelInstance()
{
_mtx.lock();
if (_inst)
{
delete _inst;
_inst = nullptr;
}
_mtx.unlock();
}
void Print()
{
cout << "Print()" << _a << endl;
}
private:
Singleton()
:_a(0)
{
// 假设单例类构造函数中,要做很多配置初始化
//同样的道理,构造函数私有
}
~Singleton()
{
// 程序结束时,需要处理一下,持久化保存一些数据
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 实现一个内嵌垃圾回收类
class CGarbo {
public:
~CGarbo()
{
if (_inst)
{
delete _inst;
_inst = nullptr;
}
}
};
int _a;
static Singleton* _inst;
static std::mutex _mtx;
static CGarbo _gc;
};
Singleton* Singleton::_inst = nullptr;
std::mutex Singleton::_mtx;
Singleton::CGarbo Singleton::_gc;
其核心就是这里的一个静态的函数接口:
我们在类外初始化将_inst给成nullptr。
那么,在其第一次进去上面的static Singleton* GetInstance()接口时,其会进入if里面的判断。
然后new出一个新的对象。
接下来,即使再去调用static Singleton* GetInstance()接口,就也不会再去调用了。
这也是单例模式的一种实现方式。
但是,需要注意的是,这样的一种方式在多线程的时候会不会不安全?
当然会。
即一个线程刚刚执行到_inst = new Singleton的时候,另外的线程if的条件判断完毕了,但此时的_inst仍然为空,所以第二个进程也就进来了。这样,我们的对象就不仅仅创建了一个了,就与单例模式就相悖了。
所以,我们在进入if的条件判断之前,加上一个锁,这样,所有的进程在锁处只能有一个进程通过锁,然后知道该进程解锁,后续的进程才继续往下执行,而此时,_inst已经被初始化完毕,已经不为空,因此所有其他的进程都将不再会进入if的里面。
为了防止内存泄漏,我们可以写出一个专门用于析构该类的函数。需要注意的是,这个函数也要设置成静态的,因为可能无法通过类对象对其实现访问。另外,需要注意的是,其没有this指针。
懒汉模式的使用场景:
如果单例对象构造十分耗时或者占用很多资源,比如加载插件啊, 初始化网络连接啊,读取文件啊等等,而有可能该对象程序运行时不会用到,那么也要在程序一开始就进行初始化,就会导致程序启动时非常的缓慢。 所以这种情况使用懒汉模式(延迟加载)更好。
饿汉
优点:简单
缺点:
1、如果单例对象构造函数工作比较多,会导致程序启动慢,迟迟进不了入口main函数
2、如果有多个单例对象,他们之间有初始化依赖关系,饿汉模式也会有问题。
比如有A和B两个单例类,要求A单例先初始化,B必须在A之后初始化。那么饿汉无法保证这种场景下面用懒汉就可以,懒汉可以先调用A::GetInstance(),再调用B::GetInstance().
懒汉
优点:解决上面饿汉的缺点。因为他是第一次调用GetInstance时创建初始化单例对象
缺点:相对饿汉,复杂一点点。
对设计模式有兴趣的同学,可以下去再看看工厂模式、观察者模式等等
我们时间紧任务重,这里就不做过多赘述了。
现在来说说C++11的一些用法
(注:关于C++11thread里有关线程的一些知识,我们等到后面将Linux多线程结束后再做补充,主要是线程库的一些新用法)
相比于C++98/03,C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言。相比较而言,
C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更
强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个
重点去学习。
C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自
定义的类型,使用初始化列表时,可添加等号(=),也可不添加
比如:
int array1[]{ 1, 2, 3, 4, 5 };
这样的方式也可以用于new中。
int* pa = new int[4]{ 0 }; //new出来的对象也可以这样初始化
甚至可以用在类的初始化中
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
private:
int _year;
int _month;
int _day;
}
int main()
{
Date d1(2022, 1, 1); // old style
// C++11支持的列表初始化,这里会调用构造函数初始化
Date d2{ 2022, 1, 2 }; //就是这样(C++11支持)
Date d3 = { 2022, 1, 3 }; //默认构造一个临时的对象,再去调用拷贝构造
return 0;
}
这是不是很奇怪?尤其是Date d2{2022 , 1 , 2}
我也感觉很奇怪。
甚至于vector这样的类,你也可以采用这样的方式来进行初始化。
实际上,它是有背后的转换的。
这个时候,我们需要引入一个容器——std::initializer_list(字面意思为初始化列表)
这到底是个什么玩意?
我们来看这样的一行代码:
注:上图中的typeid(il).name的返回值就是il的类型名。
可以看到,我们这里的il就是我们说的initializer_list.
是不是说,用 { }去初始化的一个对象就是initializer_list呢?或者说{ }本身就是initializer_list类型呢?
没错,可以这么去理解。
我们刚刚说vector也可以用这种方式来初始化,那再来看看这个,这个是库里面的:
可以注意到,C++11中,增加了用初始化列表容器初始化的方式。
实际上,在C++11中,几乎所有的STL容器都加上了这样一种初始化的方式。
那我们就不难理解,为什么上面的vector可以用{ }来进行初始化了。
很简单:{ }先构造出一个initializer_list的临时对象,然后再用这个临时对象去调用vector的构造函数。
所以我们今后,既可以用{ }来直接进行初始化,也可以按照我们原来的方式进行。
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局
部的变量默认就是自动存储类型,所以auto就没什么价值了。
C++11中废弃auto原来的用法,将其用于实现自动类型推断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。
这个我们在之前已经用过。在这里提一下,用意是需要注意其是C++11的用法。
将变量的类型声明为表达式指定的类型
举个例子:
decltype(5*3.0) ret;
它的意思就是将5*3.0的类型,也就是double类型,给ret。
其也是有着使用场景的。比如,我们下面要说的lambda表达式,因为类型不好定义,所以我们都一般用auto或者用decltype。
由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示
整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针
这个我们也已经用过很多了。我们在这里提一下,其是C++11支持的标准。
我们这个之前已经说过很多了,这里提一下,注意其为C++11的内容。
这个array,和原生数组实际上没有什么区别,不过其相对原生数组来说也是有一定的优化的。比如,当你越界访问的时候,用array会直接报错,而用原生数组有的时候并不会。
forward_list指的就是单向链表。就是我们所说的单链表。
而下面的unordered_map和unordered_set我们已经详细说过,不再赘述。
关于什么是左值,什么是右值的问题,我们在C语言阶段就已经说过了,这里不再赘述。
那么右值引用就是字面意思——对右值的引用。
具体怎么说?
比如:
int&& rr1 = 10;
这就是对右值的引用。
需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可
以取到该位置的地址。
也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。
如果不想rr1被修改,可以用const int&& rr1 去引用。
是不是感觉很神奇,这个了解一下。实际中右值引用的使用场景并不在于此,这个特性也不重要。
(可以这么理解,开始这个10是一个字面常量,就是存储在代码段的字面常量,而我们一旦引用,就相当于为其在栈上开辟了一块空间,这个空间里就存储着这个值)
1. 左值引用只能引用左值,不能引用右值。
2. 但是const左值引用既可引用左值,也可引用右值。
这里就解释了我们之前模拟实现的时候,为什么不加const会报错
1. 右值引用只能右值,不能引用左值。
2. 但是右值引用可以move以后的左值。
那问题来了:右值引用有什么样的存在意义?我们已经有了左值引用了,再有右值引用不是多此一举吗?
当然不是。
对于左值引用我们之前介绍过,其可以用来做返回值,也可以用来传参。
其中,做返回值是为了防止拷贝,或者说减少拷贝。
那用来传参呢?有的时候只能传递右值的时候该怎么办呢?
我们来举个例子:
namespace jxwd//我们自己所实现的string类
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
//cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// 移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
this->swap(s);
}
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
this->swap(s);
return *this;
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
//string operator+=(char ch)
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
}
jxwd::string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
bit::string str;
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false)
{
str += '-';
}
std::reverse(str.begin(), str.end());
return str;
}
int main()
{
jxwd::string ret1;
// ....
ret1 = bit::to_string(1234);
return 0;
}
重点看main函数里面的,我们这里的to_string返回的是一个临时对象,其在出作用域的时候,编译器会将其转变成右值(以为出了作用域之后,返回的对象即将销毁)。然后,其会调用一次拷贝构造,将其拷贝给一个临时的对象(这些我们都在之前说左值引用的时候说到过)。
然后再去调用了赋值拷贝。
这个时候,就是用右值去构造一个新的对象(上述两次拷贝都是如此)。
如果我们用左值引用的拷贝构造,其会进行两次深拷贝。会造成空间浪费,降低效率。
而如果我们用右值引用,采用移动构造的话,那么就可以直接交换资源就行了。
这样的话,会通过减少拷贝,进而能够提高效率。
实际上,就是我们传参的时候,用const T& 的那一套,在浅拷贝的时候,是没有任何区别的。
就是说,浅拷贝用不用移动构造都一样。(默认生成的拷贝构造实际上也就是浅拷贝)因为浅拷贝不涉及开辟新的空间。
而深拷贝就不一样了。
//拷贝构造
Person(const Person& tmp)
:data(new int[blockSize])
{ //此处要新建资源
name = tmp.name;
for(int i = 0;i < blockSize;i ++)
{
data[i] = tmp.data[i];
}
cout << "copy constructor" << endl;
}
//移动构造
Person(Person&& tmp)
{ //此处只需要移动资源
data = tmp.data;
tmp.data = nullptr;
cout << "move constructor";
}
对于上面的两个函数,一个是用拷贝构造,而另外一个是移动语义;
对于拷贝构造而言,其需要开辟新的空间;
而对于移动构造而言:既然你所传过来的都已经没用了,马上就要被销毁了(通常是在函数结束时),那么就给我吧。就是说,我直接接管你的资源,直接将你的资源转移过来。
这样的话,使用移动构造,就可以少进行了一次拷贝。从而提高了效率。
除了拷贝构造用右值引用以外,我们也可以对赋值构造函数使用右值引用。
比如下面的这个例子:(这里的operator=为jwxd::string里的,to_string函数为上面所实现的)
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动语义" << endl;
swap(s);
return *this;
}
int main()
{
jxwd::string ret1;
ret1 = bit::to_string(1234);
return 0;
}
注意到,这里的to_string返回的是一个临时对象的字符串,然后将其赋值给ret1,调用operator=,而这个临时对象字符串是一个右值,所以其调用的就是含有右值引用的operator=,(在左值引用和右值引用同时存在的情况下,会优先调用右值引用的)
即(换种说法):
这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果是用一个已经存在的对象
接收,编译器就没办法优化了。jxwd::to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为jxwd::to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。
STL容器插入接口函数也增加了右值引用版本
以下是图示:
就比如:
template
void PerfectForward(T&& t)
{
Fun(t);
}
模板中的&&不代表右值引用,而是万能引用,即其既能接收左值又能接收右值。
模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,
我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发。
什么意思呢?就是说,我一个右值,将其进行右值引用之后,它就会有地址啊、可修改什么的,所以说,一个右值,被进行右值引用使用后,其类型会进行退化,退化成左值的类型。
那我如果想要其保持右值的类型,该怎么办呢?
那就是需要用完美转发了。
std::forward 完美转发在传参的过程中保留对象原生类型属性
怎么用呢?举个例子吧:
// std::forward(t)在传参的过程中保持了t的原生类型属性。
template
void PerfectForward(T&& t)
{
Fun(std::forward(t));
}
就是这样。
这样的话,这里在调用Fun函数之后,t仍然是一个右值。
再比如,我们模拟实现链表插入的时候:
void Insert(Node* pos, T&& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = std::forward(x); // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
上面的std::forward
C++11在原有的6个默认成员函数的基础上(构造、拷贝、析构、运算符重载、取地址重载、const取地址重载)新加了两个成员函数——移动构造函数和移动赋值运算符重载。
这两个函数如果要默认出现的话,条件是非常苛刻的——比如你没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。
默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造
如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)
如果你提供了移动构造或者移动赋值,编译器不会自动提供默认拷贝构造和默认拷贝赋值。
需要注意的是,这些功能需要在较新的编译器中才能更好地体现。
C++11允许在类定义时给成员变量初始缺省值,默认生成构造函数会使用这些缺省值初始化,这
个我们在雷和对象默认就讲了,这里就不再细讲了。
强制生成默认函数的关键字default:
C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原
因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以
使用default关键字显示指定移动构造生成。
禁止生成默认函数的关键字delete:
如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成private,并且只声明补丁
已,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即
可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。
继承和多态中的final与override关键字
(由于这些我们都已经在前面讲过,这里就不介绍了,只是把框架罗列出来)
C++11的新特性可变参数模板能够让你创建可以接受可变参数的函数模板和类模板,相比
C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改
进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的。现
阶段呢,我们掌握一些基础的可变参数模板特性就够我们用了,所以这里我们点到为止,以后大
家如果有需要,再可以深入学习
下面就是一个基本可变参数的函数模板
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template
void ShowList(Args... args)
{}
上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数
包”,它里面包含了0到N(N>=0)个模版参数。我们无法直接获取参数包args中的每个参数的,
只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特
点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用args[i]这样方式获取可变
参数,所以我们的用一些奇招来一一获取参数包的值
我们来说说其这些神奇的用法:
// 递归终止函数
template
void ShowList(const T& t)
{
cout << t << endl;
}
// 展开函数
template
void ShowList(T value, Args... args)
{
cout << value <<" ";
ShowList(args...);
}
int main()
{
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', std::string("sort"));
return 0;
}
这种方式很好理解,即每次都调出来一个参数(相当于每一次都隔离出来一个参数,然后剩下的参数作为下一层函数的参数包)
那还有没有另外的方式呢?
这种展开参数包的方式,不需要通过递归终止函数,是直接在函数体中展开的。
这种就地展开参数包的方式实现的关键是逗号表达式。
我们知道逗号表达式会按顺序执行逗号前面的表达式。
expand函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行
printarg(args),再得到逗号表达式的结果0。
同时还用到了C++11的另外一个特性——初始化列表。
通过初始化列表来初始化一个变长数组,
{(printarg(args), 0)...}将会展开成((printarg(arg1),0),(printarg(arg2),0), (printarg(arg3),0), etc... ),最终会创建一个元素值都为0的数组int arr[sizeof...(Args)]。
由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)打印出参数,也就是说在构造int数组的过程中就将参数包展开了,不过这个数组的目的纯粹是为了在数组构造的过程展开参数包
还可以这样:
template
int PrintArg(T t)
{
cout << t << " ";
return 0;
}
template
void ShowList(Args... args)
{
// 列表初始化
// {(printarg(args), 0)...}将会展开成((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0), etc... )
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
(上述注释已经注明)
运用数组,将参数包展开。
每一个(PrintfArg(arg),0)相当于逗号表达式,最后的结果是0,而在前面的PrintfArg(arg)就可以一一拿到其参数包然后完成调用。
我们实际上,也可以把后面的0给去掉,直接写成这样也行:
template
int PrintArg(T t)
{
cout << t << " ";
return 0;
}
template
void ShowList(Args&&... args)
{
// 列表初始化
int arr[] = { PrintArg(args)... };//参数包会不断往后展开
cout << endl;
}
我们再来说说emplace接口。
在STL容器中,我们经常能够看到emplace接口。它实际上与push_back是一对。
比如vector:
那emplace_back究竟有什么用呢?
我们可以点开查看一下:
可以发现,emplace_back是直接去内存池中构造,也就是说,其直接调用allocator_traits里的构造。它与push_back比较像。
注意,emplace_back支持可变参数。这实际上也是支撑了其直接从内存池中去拿内存然后构造。
emplace_back可以这样用:
std::list> it;
it.emplace_back(2 , 1);
it.emplace_back(3 , 2);
it.push_back(make_pair(3, 3));
实际上,emplace_back除了在用法上,和push_back没有太大的区别。
就是上面所说的,emplace_back可以直接去构造。而push_back需要自己拿到对象后再去构造。
如果是带有移动构造和拷贝构造的呢?
实际上,还是那个差别,
在用法上,一个直接给值构造就行,而push_back可能需要先构建一个对象,然后利用这个对象再去构造;
在底层上,emplace_back是直接从内存池中去构造,而另一个是是先构造,再移动构造。
总而言之,差别不大。知道即可。
我们就不进行引例然后和C++98对比了,直接来说用法:
[capture-list] (parameters) mutable -> return-type { statement}
[capture-list] : 捕捉列表,
该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。
(parameters):参数列表。
与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略
mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量
性。使用该修饰符时,参数列表不可省略(即使参数为空)。->returntype:返回值类型。
用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
{statement}:函数体。
在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意: 在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体
可以为空。
因此C++11中最简单的lambda函数为:[]{};
该lambda函数不能做任何事情。
我们来举一个小李子:
int main()
{
[]{};
int a = 1, b = 2;
// 实现add的lambda
auto add1 = [](int x, int y)->int{return x + y; };
cout << add1(a, b) << endl;
// 在捕捉列表,捕捉a、b, 没有参数可以省略参数列表,返回值可以通过推演,也可以省略
//auto add2 = [a, b]{}->int{return a + b + 10; };
auto add2 = [a, b]{return a + b + 10; };
cout << add2() << endl;
return 0;
}
捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用。
[var]:表示值传递方式捕捉变量var
[=]:表示值传递方式捕获所有父作用域中的变量(包括this)
[&var]:表示引用传递捕捉变量var
[&]:表示引用传递捕捉所有父作用域中的变量(包括this)
[this]:表示值传递方式捕捉当前的this指针
注意:
a. 父作用域指包含lambda函数的语句块
b. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。
比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量 [&,
a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量c. 捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复
d. 在块作用域以外的lambda函数捕捉列表必须为空。
e. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非
局部变量都会导致编译报错。f. lambda表达式之间不能相互赋值,即使看起来类型相同
我们来举个例子:
int main()
{
int a = 0, b = 1;
// 标准写法1
auto swap1 = [](int& x, int& y)->void//这里的void可以省略不写
{
int tmp = x;
x = y;
y = tmp;
};
swap1(a, b);
// 尝试利用捕捉列表,捕捉当前局部域的变量,
// 这样就不用传参或者减少传参,省略参数和返回值
// 这里传值方式捕捉,拷贝外面的a和b给lambda里面的a、b
// lambda里面的a、b的改变不会影响外面。类似于函数传值
auto swap2 = [a, b]()mutable
{
int tmp = a;
a = b;
b = tmp;
};
swap2();
auto swap3 = [&a, &b]//要像改变的话,需要传引用
{
int tmp = a;
a = b;
b = tmp;
};
swap3();
auto swap4 = [&]//这里意为将所有的父类对象全部捕捉
{
int tmp = a;
a = b;
b = tmp;
};
swap4();
return 0;
}
那么,lambda表达式和我们的仿函数、函数对象是怎样的关系呢?
实际上,lambda表达式是与函数类似的一种使用方式,甚至和函数完全一样。
那么lambda表达式返回的这个类型是什么呢?
从上述的反汇编,我们可以看出,实际上,其是类型为lanbda+u_id。
反正是一堆比较复杂的东西,我们需要知道的是,其和函数的类型是不一样的,并且每一个lambda表达式都会对应着一个不同的类型。
我们通常定义的时候都会选择用auto。
而想要调出其类型,这个时候,上面的decltype就派上用场了。(这里不再细说)
function包装器 也叫作适配器。
我们就介绍有关其三个部分:其是什么,怎么用,有什么用。
首先,包装器本质是C++中的function本质是一个类模板。
其调用原型如下:
// 类模板原型如下
template function; // undefined
template
class function;
模板参数说明:
Ret: 被调用函数的返回类型
Args…:被调用函数的形参
是不是比较难懂?
那就忽略上面的原型,我们来看例子:
#include
#include
template
T useF(F f, T x)
{
static int count = 0;
cout << "count:" << ++count << endl;
cout << "count:" << &count << endl;
return f(x);
}
double func(double i)
{
return i / 2;
}
struct Functor
{
double operator()(double d)
{
return d / 3;
}
};
int main()
{
// 函数名
std::function f1 = func;
cout << useF(f1, 11.11) << endl;
// 函数对象
std::function f2 = Functor();
cout << useF(f2, 11.11) << endl;
// lamber表达式
std::function f3 = [](double d)->double{ return d / 4; };
cout << useF(f3, 11.11) << endl;
return 0;
}
如上,我们可以用包装器来封装函数、仿函数、lambda表达式
封装的方式很简单:
在function的尖括号里面给上返回值类型,然后再在圆括号里面给上参数的类型。
那它有什么作用呢?
ret = func(x);
这样的一个表达式,这里的func可能是什么呢?
按照我们上面所说的话,可以是函数(成员函数)、可以是仿函数、还可以是lambda表达式。
那如果在func里用到了模板的话,其效率会是多么的低下。
因为我如果用上述三种方式分别调用一次,那么模板就会实例化三份出来。
而我如果用包装器去包装,再用包装器去调用,这样的话,其就只会实例化一份代码。
就节省了空间。
就比如刚刚上面的代码,对于类useF而言,如果不用包装器去封装,那么由于func、Function()、和下面的lambda表达式的类型不同,其要产生三份代码。而封装了之后,只用产生一份。
当然了,包装器还有其他的一些使用场景,有兴趣的可以看看下面的题:
150. 逆波兰表达式求值 - 力扣(LeetCode) (leetcode-cn.com)
再说一下bind(仅作了解)
其是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来“适应”原对象的参数列表一般而言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M可以大于N,但这么做没什么意义)参数的新函数。同时,使用std::bind函数还可以实现参数顺序调整等操作。
可以将bind函数看作是一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对
象来“适应”原对象的参数列表。 调用bind的一般形式:auto newCallable =bind(callable,arg_list);`其中,newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callable的参数。当我们调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。
arg_list中的参数可能包含形如_n的名字,其中n是一个整数,这些参数是“占位符”,表示
newCallable的参数,它们占据了传递给newCallable的参数的“位置”。数值n表示生成的可调用对
象中参数的位置:_1为newCallable的第一个参数,_2为第二个参数,以此类推
#include
int Plus(int a, int b)
{
return a + b;
}
class Sub
{
public:
int sub(int a, int b)
{
return a - b;
}
};
int main()
{
//表示绑定函数plus 参数分别由调用 func1 的第一,二个参数指定
std::function func1 = std::bind(Plus, placeholders::_1,
placeholders::_2);
//auto func1 = std::bind(Plus, placeholders::_1, placeholders::_2);
//func2的类型为 function 与func1类型一样
//表示绑定函数 plus 的第一,二为: 1, 2
auto func2 = std::bind(Plus, 1, 2);
cout << func1(1, 2) << endl;
cout << func2() << endl;
Sub s;
// 绑定成员函数
std::function func3 = std::bind(&Sub::sub, s,
placeholders::_1, placeholders::_2);
// 参数调换顺序
std::function func4 = std::bind(&Sub::sub, s,
placeholders::_2, placeholders::_1);
cout << func3(1, 2) << endl;
cout << func4(1, 2) << endl;
return 0;
}
这里就简单了解一下,如果想要更加详细地了解,可以查阅相关文献。
关于C++11中thread线程库有关的知识内容,我们暂且不说,呆到Linux多线程结束再来看。
C++11我们暂时先告一段落。
我们下面看异常。
我们在C语言中,传统的出错后返回方式有哪些呢?
比如,返回空指针、终止程序(assert)、返回错误码等。
在C++中,我们给出了一种全新的处理方式——异常。
异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误
我们将会用到三个关键字:throw、catch、try
throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
catch: 在你想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获。
try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块
实际上,用异常很简单,我们先来举个例子讲解一下就可以了,重点在于其使用时需要注意的点:
关于异常的使用:
1. 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
2. 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
3. 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)
4. catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。
5. 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。
1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。
2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。
#include
int main()
{
try
{
int a, b;
cin >> a >> b;
if (a == 0 || b == 0)
{
throw "devide error";
}
}
catch (string s)
{
cout << s << endl;;
}
return 0;
}
代码如上:
几个关键的点:
1、要先在可能抛异常的地方try;
2、抛什么异常,catch在接收的时候,就需要用什么样的参数,用来接收抛过来的东西。类似于函数传参一样。
3、库里面的默认的接收的使用exception这么一个类来进行的。
1、构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
2、析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
3、C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题,关于RAII我们智能指针这节进行讲解。
1. 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
2. 函数的后面接throw(),表示函数不抛异常。
3. 若无异常接口声明,则此函数可以抛掷任何类型的异常。
而我们有的函数后面加上了noexpect,这表示的就是该函数不会抛出异常。
如果你的函数会抛出异常,最好在后面加上所要抛出的异常
比如:
void* operator new (std::size_t size) throw (std::bad_alloc);
我们上面说过,异常的类型是可以自定义的。并且,我抛出一个派生类的对象,是可以用基类来去捕获的。这个时候的转换就相当于是切片了。
举个例子吧:
class MyExcetion
{
public:
MyExcetion(int errid, const char* errmsg)
:_errid(errid)
, _errmsg(errmsg)
{}
int GetErrId() const
{
return _errid;
}
const string& what() const
{
return _errmsg;
}
private:
int _errid;
string _errmsg;
// ...
};
void f1()
{
int i, j;
cin >> i >> j;
if (j == 0)
{
throw MyExcetion(1, "除零错误");
}
cout << i / j << endl;
}
int* p2 = nullptr;
FILE* p3 = nullptr;
void f2()
{
p2 = (int*)malloc(40);
if (p2 == nullptr)
{
throw MyExcetion(2, "malloc失败");
}
}
void f3()
{
p3 = fopen("test.txt", "r");
if (p3 == nullptr)
{
throw MyExcetion(3, "fopen失败");
}
}
int main()
{
try
{
f1();
f2();
f3();
free(p2);
fclose(p3);
}
catch (int errid)
{
cout << "错误码:" << errid << endl;
}
catch (const string& s)
{
cout << "错误描述:" << s << endl;
if (s == "fopen失败")
{
free(p2);
}
}
catch (const MyExcetion& e)
{
cout << "错误描述:" << e.what() << endl;
if (e.GetErrId() == 3)
{
free(p2);
}
}
return 0;
}
在库里的exception实际上就是许许多多的派生类的基类。(如下图)
说明:实际中我们可以可以去继承exception类实现自己的异常类。但是实际中很多公司像上面一样自己定义一套异常继承体系。因为C++标准库设计的不够好用
int main()
{
try {
vector v(10, 5);
// 这里如果系统内存不够也会抛异常
v.reserve(1000000000);
// 这里越界会抛异常
v.at(10) = 100;
}
catch (const exception& e) // 这里捕获父类对象就可以
{
cout << e.what() << endl;
}
catch (...) //我们也可以用三个点来表示万能捕获
{
cout << "Unkown Exception" << endl;
}
return 0;
}
优点:
1. 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
2. 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误。
3. 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
4. 很多测试框架都使用异常,这样能更好的使用单元测试等进行白盒的测试。
5. 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T&operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
缺点:
1. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
2. 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
3. C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
4. C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
5. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func()throw();的方式规范化。
异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外OO的语言基本都是用异常处理错误,这也可以看出这是大势所趋
异常结束,我们下面来说智能指针
实际上,智能指针没有那么神秘,其本质上就是一个类,它可以通过自己的析构函数,在程序结束的时候自动调用析构函数的时候释放内存。
首先,我们需要来说一下,我们为什么需要智能指针。
1. malloc出来的空间,没有进行释放,存在内存泄漏的问题。
2. 异常安全问题。如果在malloc和free之间如果存在抛异常,那么还是有内存泄漏。这种问题就叫异常安全(就是说,直接通过抛异常跑了,内存并未释放)
所以,我们就可以用智能指针来去完成上述有关事情。
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
通过这种技术,我们实际上把管理一份资源的责任托管给了一个对象。
这种做法有两大好处:
1、不需要显式地释放资源。
2、采用这种方式,对象所需的资源在其生命期内始终保持有效
这是一种思想。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。
我们现在可以用这种思想来设计一个智能指针。
#include
using namespace std;
template
class Smart
{
public:
Smart(T* inst = nullptr)
:_inst(inst)
{}
~Smart()
{
if (_inst)
{
delete _inst;
}
}
private:
T* _inst;
};
如上述代码:
这里的_inst在出来的时候会自动调用其析构函数。这样,我们就能防止其内存泄漏的问题。
而要想这个指针能像原生指针一样使用,我们还需要重载->、*等操作符。
具体怎么用呢?
我们以库里面的作为例子,库里面实际上有四种智能指针的类:std::auto_ptr ; std::unique_ptr; std::shared_ptr; std::weak_ptr
先来介绍auto_ptr。来看:
void test_auto_ptr()
{
std::auto_ptr sp1(new int);
// 拷贝
std::auto_ptr sp2(sp1);
*sp2 = 10;
*sp1 = 20;
}
这里的auto_ptr,是最早期C++98里面的东西。它有一个弊端:就是在拷贝构造的时候,原本的会被置空。就比如这里的sp2(sp1),拷贝构造之后,sp1要么为空,要么就是一个随机值。这就是很坑的了。因为在一个大的项目中,你怎么能让其一次拷贝也不发生呢?对于不拷贝来说,这是不现实的。
而一旦拷贝,就要出错。因为原本的资源就不复存在了。
实际上,auto_ptr是一种管理权转移的思想。
可以来模拟实现一下:
namespace jxwd{
template
class auto_ptr
{
public:
// RAII
auto_ptr(T* ptr)
:_ptr(ptr)
{}
~auto_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
// 管理权转移
// sp2(sp1);
auto_ptr(auto_ptr& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
// ap1 = ap3
// ap1 = ap1
auto_ptr& operator=(auto_ptr& ap)
{
if (this != &ap)
{
delete _ptr;
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
从上述模拟实现的方式可以看出,赋值拷贝的时候,其会将原本的资源置空。
那后来,C++委员会的那些牛人们,又引入了unique_ptr和shared_ptr
关于unique,就很简单粗暴了,直接禁止你拷贝。
但是我们说过,这种方法其不太现实,因为我们很难让一个对象不进行拷贝。
于是乎,就又引入了shared_ptr,比较好地解决了这个问题。
我们来重点说说share_ptr
template
class shared_ptr
{
private:
void AddRef()
{
_pmutex->lock();
++(*_pcount);
_pmutex->unlock();
}
void ReleaseRef()
{
_pmutex->lock();
bool flag = false;
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
flag = true;
}
_pmutex->unlock();
if (flag == true)
{
delete _pmutex;
}
}
public:
// RAII
shared_ptr(T* ptr)
:_ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
{}
~shared_ptr()
{
ReleaseRef();
}
shared_ptr(const shared_ptr& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _pmutex(sp._pmutex)
{
AddRef();
}
// sp1 = sp1
// sp1 = sp2
// sp3 = sp1;
shared_ptr& operator=(const shared_ptr& sp)
{
if (_ptr != sp._ptr)
{
ReleaseRef();
_pcount = sp._pcount;
_ptr = sp._ptr;
_pmutex = sp._pmutex;
AddRef();
}
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pcount;
}
private:
T* _ptr;
int* _pcount;
mutex* _pmutex;
};
解释一下上面的代码,这里的shard_ptr,引入了一个计数参数_conut。
并且,这里必须是要用int*来作为其类型,不能直接创建一个临时变量或者一个静态变量。
这样,每拷贝一次,_count++。
当一个指针delete的时候,我们来去看_conut是否为0,如果为0,那么就delete掉这块空间,否则,就只要_conut--就可以了。
图示一下:
同时,这里也要注意share_ptr的线程安全问题:
1. 智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时++或--,这个操作不是原子的,引用计数原来是1,++了两次,可能还是2。这样引用计数就错乱了。会导致资源未释放或者程序崩溃的问题。所以只能指针中引用计数++、--是需要加锁的,也就是说引用计数的操作是线程安全的。
2. 智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题。
所以嘞
我们就又要上锁啦
但是呢,这里又引发了另一个问题:
就是循环引用的问题。
1. node1和node2两个智能指针对象指向两个节点,引用计数变成1,我们不需要手动delete。
2. node1的_next指向node2,node2的_prev指向node1,引用计数变成2。
3. node1和node2析构,引用计数减到1,但是_next还指向下一个节点。但是_prev还指向上一个节点。
4. 也就是说_next析构了,node2就释放了。
5. 也就是说_prev析构了,node1就释放了。
6. 但是_next属于node的成员,node1释放了,_next才会析构,而node1由_prev管理,_prev属于node2成员,所以这就叫循环引用,谁也不会释放
这就好比两个人掐架,一个人抓着一个人的脖子,另一个人拽着一个人的头发,一个说:“你先放手”,另一个说:“你先放手”...就一直这样耗着。
于是乎,我们就又有了weak_ptr,这个智能指针通常是和share_ptr在一起使用的。
weak_ptr在会使得内部指针去指向的时候,_count不会自增。
比如下面的这段代码:
struct ListNode
{
int _data;
weak_ptr _prev;
weak_ptr _next;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr node1(new ListNode);
shared_ptr node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
上面的意思就是每一个链表的结点是一个share_ptr,而在结点的内部存储的是两个weak_ptr。
我们来看看weak_ptr的底层:(部分)
// 不参与资源管理,不增加shared_ptr管理资源的引用计数,可以像指针一样使用
template
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr& sp)
:_ptr(sp.get())
{}
weak_ptr& operator=(const shared_ptr& sp)
{
_ptr = sp.get();
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
可以看到,weak_ptr并不参加资源的管理,就是说,我用weak_ptr来赋值拷贝,我的_count不会自增。
我们可以通过查看文档来看一下其接口的参数
详细可以自行去查看文档
关于删除器的问题,我们暂时这里先不介绍了,后期和C++11的线程库一起介绍(等Linux多线程结束的时候)
好啦,我们本节的内容就到此结束啦~~~~
希望能多给我几个机器人哈哈哈~~~~