cpp11官方说明文档
C++11 - cppreference.com
https://en.cppreference.com/w/cpp/11
目录
array和C语言的原生数组有啥不同呢?array有啥意义呢?
右值引用如何解决operator+传值返回存在拷贝的问题呢?
但是通过下面的例子,我们发现,右值引用匹配都出现了偏差,都匹配到左值上面了,这是为什么呢?
10.6.5 lock_guard与unique_lock的区别?
1.C++11简介
2.列表初始化
2.1 C++98中{}的初始化问题
对于一些自定义的类型,却无法使用这样的初始化。比如:
vector<int> v{ 1,2,3,4,5 };
2.2 内置类型的列表初始化
注意:列表初始化可以在{}之前使用等号,其效果与不使用=没有什么区别。
2.3 自定义类型的列表初始化
注意:这两个自定义的类型是都没有写构造函数,但是还是可以初始化,是C++兼容C的struct初始化的原因。因为new对于自定义类型是会调用自定义类型的构造函数的,我们并没有显示的去写出来,默认的构造函数又啥也不干。所以这里的new是兼容了C语言的方式。
如果我们写了构造函数就调用构造函数,这里走的就是C++初始化的方式
单参数的构造函数支持隐式类型转换
=初始化就是隐式类型转换,2会构造一个A的临时对象,然后再用临时对象去拷贝构造,但是编译器觉得构造在拷贝构造会浪费,所以进行了优化,所以就变成了直接构造了。
如果你不想隐式类型转换的发生,你就可以用关键字explicit。
C++要支持这种隐式类型转换,string,vector就很好用了,这都是源于他们的构造函数没explicit
但是有些地方,构造函数必须加explicit,比如智能指针的构造函数。
同样对于多参数的构造函数explicit也是一样
C++针对容器初始化的时候也涉及的一些问题
他俩的效果都是一样的,但是像数组一样去初始化vector是如何去支持的呢?
是支持了多参数的隐式类型吗,像刚刚的Point一样,如果是的话,说明vector应该支持了一个4个值的默认构造函数,但是这显然是不可能的,因为vector是个可变的数组,{}中初始化的个数是不确定的,Point是确定的2个参数。
vector支持如此初始化就是因为这个initializer_list。
initializer_list在C++里面也是一个容器,是原生支持的一个容器。
auto il = { 10, 20, 30 }; // the type of il is an initializer_list
这个il对象的类型就是initializer_list。
所以vector可以这样初始化的原理就如下:
除此之外,list也是支持的,也提供了这样的一个构造函数。
map同样支持
map里面的参数是一个个的pair,pair是双操作数的构造函数(有个first,有个second),pair的构造函数没有explicit,就支持{}去初始化。外层调用的是std::map的构造函数 map(std::initializer_list<value_ type>init, const Allocator&)
简单来说,就是C++11 会把{}这个东西认为是initializer_list的对象。
同样的C++11还支持了initializer_list的赋值
3. 变量类型推导
3.1 为什么需要类型推导
在定义变量时,必须先给出变量的实际类型,编译器才允许定义,但有些情况下可能不知道需要实际类型怎么给,或者类型写起来特别复杂,比如:
#include <map>
#include <string>
int main()
{
short a = 32670;
short b = 32670;
// c如果给成short,会造成数据丢失,如果能够让编译器根据a+b的结果推导c的实际类型,就不会存
在问题
short c = a + b;
std::map<std::string, std::string> m{{"apple", "苹果"}, {"banana","香蕉"}};
// 使用迭代器遍历容器, 迭代器类型太繁琐
std::map<std::string, std::string>::iterator it = m.begin();
while(it != m.end())
{
cout<<it->first<<" "<<it->second<<endl;
++it;
}
return 0;
}

C++11中,可以使用auto来根据变量初始化表达式类型推导变量的实际类型,可以给程序的书写提供许多方便。将程序中c与it的类型换成auto,程序可以通过编译,而且更加简洁。

3.2 decltype类型推导
3.2.1 为什么需要decltype
auto使用的前提是:必须要对auto声明的类型进行初始化,否则编译器无法推导出auto的实际类型。但有时候可能需要根据表达式运行完成之后结果的类型进行推导,因为编译期间,代码不会运行,此时auto也就无能为力。
template<class T1, class T2>
T1 Add(const T1& left, const T2& right)
{
return left + right;
}
如果能用加完之后结果的实际类型作为函数的返回值类型就不会出错,但这需要程序运行完才能知道结果的实际类型,即RTTI(Run-Time Type Identification 运行时类型识别)。
C++98中确实已经支持RTTI:
- typeid只能查看类型不能用其结果类定义类型
typeid简介:typeid是可以知道变量类型的一个函数
但是很明显我们的x是const int的,这算是typeid的一个小缺点。
- dynamic_cast只能应用于含有虚函数的继承体系中
运行时类型识别的缺陷是降低程序运行的效率。
3.2.2 decltype
decltype是根据表达式的实际类型推演出定义变量时所用的类型,比如:
1. 推演表达式类型作为变量的定义类型
int main()
{
int a = 10;
int b = 20;
// 用decltype推演a+b的实际类型,作为定义c的类型
decltype(a + b) c;
cout << typeid(c).name() << endl;
return 0;
}
定义一个函数指针
int func(int a)
{
return a;
}
int main()
{
//假如我们想定义一个函数指针
int(*pfunc1)(int) = func; //第一种写法
auto pfunc2 = func;//第二种
decltype(pfunc2) pfunc3 = func;
decltype(&func) pfunc4 = func; //第三种
}
做容器的类型
int main()
{
//decltype的有一个使用场景
map<string, string> dict = { {"insert","插入"},{"sort","排序"} };
auto it = dict.begin();
//如果我想把it的这个类型存在vector里面
vector<decltype(it)> v;
v.push_back(it);
}
2. 推演函数返回值的类型
void* GetMemory(size_t size)
{
return malloc(size);
}
int main()
{
// 如果没有带参数,推导函数的类型
cout << typeid(decltype(GetMemory)).name() << endl;
// 如果带参数列表,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
cout << typeid(decltype(GetMemory(0))).name() << endl;
return 0;
}
4.C++中STL的一些变化
新增了一些容器(array,forward_list,unordered_map,unordered_set);对已有容器,增加了一些好用或者提高效率的接口。比如:列表初始化initializer_list,右值引用相关的一些接口提高效率。
4.1array
vector是一个动态增长的数组,array就是一个静态的数组。
array和C语言的原生数组有啥不同呢?array有啥意义呢?
说实话他俩没啥区别。也没啥意义。
硬要说的话,array的优点如下:
- 支持迭代器,更好的兼容STL容器(但是原生的数组也支持范围for)。
- 对于越界的检查非常的严格。
C语言对于数组越界的检查是一个抽查的行为,就会导致有些越界你能检查出来,有些越界你就检查不出来,而且不同平台还不一样。比如:你在oj的编译器上能跑过,但是放到vs里面又跑不过了,原因就是vs和oj的编译器不是同一个编译器,oj的编译器通常是g++或者clang++,clang++这个编译器,对于C++语法的支持非常激进,基本上全部支持,g++和vs的编译器就比较保守。
原生的数组对于越界,虽然有个警告但是不报错。原生数组走的是内存那一套,走的是指针访问。这个地方被改是这样一个行为*(a1+14)=0,完全取决于对数组的检查,只能是设置两个标记位,看标记位有没有被改,就比如说:酒驾完全就是一种抽查的行为,所有的酒驾不能都被查到,只能是晚上容易酒驾,就在路口就行抽查。
array就直接检查到并且报错。array能检查到是因为这是一个函数调用a2.operator[](14)=0,这是一个一定检查的,就好比查酒驾,我规定每台车出厂的时候都必须内置有一个检查装置,只要喝酒了就能检测到,报警到系统里。
虽然但是array还是太鸡肋了,几乎没啥人用。
forward_list就是一个单链表也几乎没啥价值。
C++11增加的容器最有用的就是unordered_map和unordered_set。
4.2已有容器新增的函数接口
- 1.列表初始化
- 2.类型cbegin,cend(很鸡肋)
- 3.移动构造,移动赋值
- 4.右值引用版本的插入接口
5.右值引用
C++98中提出了引用的概念,引用即别名,引用变量与其引用实体公共同一块内存空间,而引用的底层是通过指针来实现的,因此使用引用,可以提高程序的可读性。
为了提高程序运行效率,C++11中引入了右值引用,右值引用也是别名,但其只能对右值引用。
5.1右值引用和左值引用
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。左值引用就是给左值取别名,右值引用就是给右值取别名无论左值引用还是右值引用,都是给对象取别名。
5.2什么是左值?什么是左值引用?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。综合而言,左值就是可以取地址的对象。
这个b也认定成左值,它很特殊,因为一般的左值都是可以取地址和赋值的,这个b只能取地址不能赋值。
5.3什么是右值?什么是右值引用?
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,传值返回函数的返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。综合而言,不能取地址的对象就是右值。
5.4左值与右值的区分
- 普通类型的变量,因为有名字,可以取地址,都认为是左值。
- const修饰的常量,不可修改,只读类型的,理论应该按照右值对待,但因为其可以取地址(如果只是const类型常量的定义,编译器不给其开辟空间,如果对该常量取地址时,编译器才为其开辟空间),C++11认为其是左值。
- 如果表达式的运行结果是一个临时变量或者对象,认为是右值。
- 如果表达式运行结果或单个变量是一个引用则认为是左值。
总结:
- 不能简单地通过能否放在=左侧右侧或者取地址来判断左值或者右值,要根据表达式结果或变量的性质判断,比如上述:c常量。
- 能得到引用的表达式一定能够作为引用,否则就用常引用。
C++11对右值进行了严格的区分:
- C语言中的纯右值,比如:a+b, 100
- 将亡值。比如:表达式的中间结果、函数按照值的方式进行返回。
5.5左值引用能否引用右值?右值引用能否引用左值?
5.5.1左值引用能否引用右值?
不能直接引用,但是const左值引用可以引用右值,因为左值引用是必然需要引用右值的,右值引用是C++11才产生的,对于之前的右值就都是通过const左值引用引用右值的。
而且不引用右值,有些接口不好支持。比如:void push_back(const T&x);这种函数。加上const左值传过去没有问题,右值也可以传过去。
5.5.2右值引用能否引用左值?
不能直接引用,但是右值引用可以引用move以后的左值
需要注意的是右值是不能取地址的,但是给右值取别名后(这个别名就成了左值),会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感觉很神奇,这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。
右值引用的产生是为了弥补左值引用的不足。
5.6左值引用的使用场景
用博主自己实现的string去进行实验
namespace pxl
{
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)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
//移动构造 --移动资源
string(string&& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
this->swap(s);
cout << "string(string && s)---资源转移" << endl;
}
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(const string&& s) -- 转移资源" << endl;
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;
}
string operator+(char ch)
{
string tmp(*this);
push_back(ch);
return tmp;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
pxl::string to_string(int value)
{
pxl::string str;
while (value)
{
int val = value % 10;
str += ('0' + val);
value /= 10;
}
reverse(str.begin(), str.end());
return str;
}
}
场景1
左值引用做参数,基本完美的解决深拷贝的问题。
如果是vector,map深拷贝的代价更大。所以我们就用左值引用,而且我们是const左值引用,既可以传左值的参数,又可以传右值的参数,就可以减少拷贝。
场景2
左值引用做返回值,只能解决部分问题
string的operator+=有可能要返回一个对象,出了作用域这个*this还在,就可以使用传引用返回。
可以看出调用+=并没有调用拷贝构造。
如果我们传值返回,就会调用拷贝构造
5.7右值引用的使用场景
场景一
看似已经完美解决问题。但如果是对string里面的operator+呢?
这个时候就只能使用传值返回,因为出了作用域,对象就不在了。+=可以返回*this,*this是个左值,出了作用域还在就可以用引用返回,返回它的别名。tmp是一个左值,语法上可以用左值引用返回,但是左值引用返回的是它的别名,但是tmp出了作用域就已经析构了,那么你返回的就是一个有问题的对象了,当你返回它的别名,它就已经销毁了,所以就不能传引用返回。 但是传值返回是会调用拷贝构造的。
这就是这个地方很为难的问题,所以左值引用只解决了+=,并没有解决+这种场景,也没办法解决基于这场景,C++11就增加了右值引用。
难道直接将返回值改成右值引用就可以了吗?
当然不是,这样是会报错的。也不要想着加个move去解决,因为出了作用域tmp是要销毁的!!!
右值引用如何解决operator+传值返回存在拷贝的问题呢?
它会针对string提供一个构造函数,以前的拷贝构造做的是深拷贝,现在就会提供一个移动构造。拷贝构造既能接收左值又能接收右值,但是有了移动构造以后,编译器走的是类型最匹配的,如果是左值就去拷贝构造,如果是右值就去移动构造。
C++11中将右值分为:纯右值(上述介绍的都是纯右值)和将亡值(比如函数调用返回的是自定义类型的对象,就把他叫做将亡值,再或者临时对象,匿名对象)
比如:这个tmp
移动构造,既然你这个东西已经快亡了,倒不如把你的资源换给我。
有了移动构造。再去调用operator+最后返回的时候就不会再去调用拷贝构造了,没有移动构造之前,你去深拷贝代价非常大,我去针对tmp拷贝构造一份,tmp出了作用域还要调用析构销毁。现在直接把tmp的资源转移给我,现在虽然要销毁,但是销毁的已经是空了。
以前出了作用域我还在,直接用传引用返回;现在出了作用域,不在了,传值返回也没问题,因为有了移动构造,你是一个将亡值,我直接就把你的资源转移了,效率就进一步的得到了提高。
场景二
没有移动构造;以前是传值返回,得拷贝一下str,用拷贝的对象作为返回值,不能用str作为表达式的返回值,因为str出了作用域就没有了。所以先拷贝,再销毁就完全是一种资源的浪费,对于C++98也没办法解决。
有了移动构造,移动构造和构造是重载,现在str就是个将亡值更匹配移动构造。ps:严格意义上说str是个左值,但是编译器这里会做一层优化识别,str是个局部对象,出了作用域就销毁了,我把它当做将亡值。调用移动构造直接把你的资源转移给我,中间就少了一层拷贝,提高了效率。
左值引用的价值:减少拷贝;做一些输出型参数,比如说operator[ ],修改别名就是修改我。左值引用在参数的位置是能完美解决问题的,但是左值引用做返回值只能解决部分的问题。
场景三to_string
我们首先得弄明白编译器对传值返回的优化:
首先我们得注释掉移动构造
这个地方本来所指的右值是左边这个临时对象,它是右值,str是个左值。但是优化以后是直接去拿str拷贝构造ret的。
ps:如果编译器不优化,不能用str做这个函数的返回值就是因为str出了作用域会销毁。
函数调用会建立栈帧,to_string的栈帧里就有一个str的对象,并且要返回str。这个str不能作为main函数里的表达式的返回值是因为只有在main函数里才会访问str这个返回值,但是str在调用完to_string后就已经销毁了(析构函数释放掉它的空间),所以你再去访问就是野指针了,就出事了。所以就得产生一个临时对象,这个临时对象(比较小的临时对象是在寄存器里面的,如果比较大就在main函数的栈帧里)就在main函数的栈帧里。目前的路线就是to_string函数快结束时,str返回前,用str拷贝构造临时对象,临时对象作为to_string的返回值再拷贝构造ret。
所以如果你是编译器你也会进行优化,这个实在是太浪费了。现在编译器优化成:to_string函数快结束时,str返回前,直接用str去构造ret。
如果没有对象去接收函数的返回值,那么str构造临时对象,临时对象作为to_string的函数调用的返回值,此时无法优化,没有优化的空间。
如果编译器不进行优化,并且有移动构造函数:
因为str是个左值,临时对象是个右值。
如果编译器进行优化,并且有移动构造函数:
移动构造是在编译器优化的基础上去进行的
所以有些人说右值引用延长了对象的生命周期,这句话不能说是完全错的,但也是不太对的,右值并没有延长对象的生命周期,只是匹配上移动构造以后,把资源给转移走了。所以并没有延长str的生命周期,只不过是str走了,但是str的资源还在。
资源转移过程
场景四杨辉三角
也就意味着,同样的一份程序,如果你用C++11的编译器啥都不做就会变得更高效,因为C++11支持右值引用,移动构造,库更新了,STL容器的移动构造意义非常大。
5.8移动赋值
场景一
普通的operator= 会进行深拷贝
重载一个移动赋值
ps:这里虽然减少了拷贝,但是s2move后,作为右值,s2直接和s1进行了资源交换。不符合我们常规调用operator=的习惯,此处仅仅是为了演示。
场景二
ps:
场景三
容器的插入接口也能体现移动构造和移动赋值的价值。容器的插入接口都会提供一个右值引用的版本。
这是博主之前模拟实现的list的尾插,库中的push_back逻辑和博主的大差不差,注意我说的是逻辑上.(开节点的时候库中用的是内存池+定位new,博主直接用new一步到位)
list,push_back要给一个节点,就会把string对象调用定位new直接构造到节点上来,用定位new把一个已有的string对象构造都节点上来就是一次深拷贝。STL的内存是从内存池来的,不是new出来的,它就只开了空间,没有初始化就处于一个未初始化的空间。
- s就是一个标准的左值,有没有移动构造都匹配的是传引用的push_back,用s构造一个节点就是一次深拷贝。
- "222222"是个隐式类型转换,必须构造出来,再去调用push_back ,虽然它是个右值,但是目前我们的string没有移动构造,只有拷贝构造,所以还是拿"222222"构造到一个节点上去,进行一次深拷贝。
- 调用to_string,to_string用str构造临时对象是一个深拷贝,然后再用临时对象去构造节点也是一次深拷贝(因为没有移动构造,只有拷贝构造),所以一共2次深拷贝。
- s就是一个标准的左值,用s构造一个节点就是一次深拷贝。有没有移动构造都对他没影响。
- "222222"是个右值,有了移动构造后,就去调用移动构造构造节点,进行资源转移。调用一次移动构造。
- 调用to_string,to_string用str构造临时对象是一次移动构造,用临时对象构造节点也是一次移动构造,调用两次移动构造。
除了第一个,剩下的都是资源转移,效率得到了提高,你的将亡值我都给你转移了,但是你要的值比如s,就没有给你转移,调用的是拷贝构造。
当然想转移s也是可以的,只需要加一个move即可,但是你得谨慎。
但是这样也有后果,转移以后,s的资源就别转走了,后序你就不能用s了。
不仅仅是push_back,所有插入的接口都提供了传右值的插入,因为只要是插入都涉及到把这个对象构造到节点上去,构造到vector的空间里去,构造到树的节点上去...左值匹配左值的插入,右值就匹配右值的插入,但是都得依赖要插入的对象必须实现了移动构造。
5.9完美转发
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值。
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
但是通过下面的例子,我们发现,右值引用匹配都出现了偏差,都匹配到左值上面了,这是为什么呢?
因为右值引用后,这个给右值取的别名就变成做左值了。
这个t就是左值或者右值的别名,左值引用后,这个t还是左值;右值引用后,这个t也成了左值了,所以就都匹配到左值上面去了。这个和模板的万能引用是没有关系的。
eg:匹配的都是左值引用
我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面的完美转发。完美转发就是可以保持你的属性,你原来是什么,你继续就是什么。
所谓完美:函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;如果相应实参是右值,它就应该被转发为右值。这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理(比如参数为左值时实施拷贝语义;参数为右值时实施移动语义)。
这次我们就完美的匹配上了。
完美转发的用途
这个完美转发看似没啥用,但是存在即合理,完美转发一定是有它的价值的。
template<class T>
struct ListNode
{
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
T _data;
};
template<class T>
class List
{
typedef ListNode<T> Node;
public:
List()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
void PushBack(T&& x)
{
// 只要右值引用,再传递其他函数调用,要保持右值属性,必须用完美转发
//Insert(_head, x);
Insert(_head, std::forward<T>(x));
}
void PushFront(T&& x)
{
//Insert(_head->_next, x);
Insert(_head->_next, std::forward<T>(x));
}
//库里的Insert就类似于这种
//void Insert(Node* pos, T&& x)
//{
// Node* prev = pos->_prev;
// Node* newnode = (Node*)malloc(sizeof(Node));
// new(&newnode->_data)T(x); //定位new
// // prev newnode pos
// prev->_next = newnode;
// newnode->_prev = prev;
// newnode->_next = pos;
// pos->_prev = newnode;
//}
void Insert(Node* pos, T&& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = std::forward<T>(x); // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
void Insert(Node* pos, const T& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = x; // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
private:
Node* _head;
};
我们也可以将Insert这样写,定位new就是取调用构造的,STL的内存池就是类似这种写法,先开空间,然后再进行初始化,只不过内存池比我们写的更加高效
只要是右值引用,再传递给其他函数调用,要保持右值属性,必须用完美转发。
ps:库中的Insert和博主自己实现的异同
5.10右值引用作用总结
C++98中引用作用:因为引用是一个别名,需要用指针操作的地方,可以使用指针来代替,可以提高代码的可读性以及安全性。
C++11中右值引用主要有以下作用:
- 实现移动语义(移动构造与移动赋值)
- 给中间临时变量取别名:
int main()
{
string s1("hello");
string s2(" world");
string s3 = s1 + s2; // s3是用s1和s2拼接完成之后的结果拷贝构造的新对象
stirng&& s4 = s1 + s2; // s4就是s1和s2拼接完成之后结果的别名
return 0;
}
- 实现完美转发
6.lambda表达式
有些场景下要增加一些东西,叫做可调用对象。
可调用对象:
- 函数指针(C)
- 仿函数 (C++98)
- lambda(C++11)
- 包装器(C++11)
在C++中我们就很少用函数指针了,都用仿函数
int main()
{
int array[] = { 4,1,8,5,3,7,0,9,2,6 };
// 默认按照小于比较,排出来结果是升序
std::sort(array, array + sizeof(array) / sizeof(array[0]));
// 如果需要降序,需要改变元素的比较规则,greatre<int>()就是个仿函数
std::sort(array, array + sizeof(array) / sizeof(array[0]), greater<int>());
return 0;
}
但是有时候有这样的一些场景,就是我要排序的数据有很多项,我要按照它的某一项进行排序。
我们现在对商品进行排序,要求先按名字排,再按价格排,再按评价排序。我们就可以通过写不同的仿函数,进行排序
struct Goods
{
string _name; //名字
double _price; //价格
int _evaluate; //评价
};
struct ComparePriceLess
{
bool operator()(const Goods& gl, const Goods& gr)
{
return gl._price <= gr._price;
}
};
struct ComparePriceGreater
{
bool operator()(const Goods& gl, const Goods& gr)
{
return gl._price >= gr._price;
}
};
int main()
{
vector<Goods> v = { { "苹果", 2.1,5 }, { "香蕉", 3,4 }, { "橙子", 2.2,3 }, {"菠萝", 1.5,2} };
sort(v.begin(), v.end(), ComparePriceLess());
sort(v.begin(), v.end(), ComparePriceGreater());
return 0;
}
6.1lambda表达式语法
lambda表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }
1. lambda表达式各部分说明:
- [capture-list] : 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。
- (parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略。
- mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。
- ->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
- {statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意: 在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情。
lambda就是一个函数,它也可以叫匿名函数。
返回值也是可以省略的。所以返回值经常不写
没有参数可以省略参数列表
捕捉列表
int a = 1, b = 2;
假如我们要用lambda交换a,b
捕获列表说明
- [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表达式之间不能相互赋值,即使看起来类型相同
回到上面排序,我现在就不用写仿函数,写仿函数又多又繁,而且还需要取名,我直接看这个lambda就知道你比较的是谁。
当然,lambda更多的是结合线程或者包装器才能体现出其更多的优势 。
lambda的类型
lambda的类型是一个class,class的名称是lambda_619d45608b9bd7c5186ba9f193b4f57f,这个字符串叫做UUID,UUID是有人通过算法生成的一个字符串,保证这个字符串在当前程序里不会抄重复。
6.2函数对象与lambda表达式
函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了operator()运算符的类对象。
class Rate
{
public:
Rate(double rate) : _rate(rate)
{}
double operator()(double money, int year)
{
return money * _rate * year;
}
private:
double _rate;
};
int main()
{
// 函数对象
double rate = 0.49;
Rate r1(rate);
r1(10000, 2);
// lamber
auto r2 = [=](double monty, int year)->double {return monty * rate * year; };
r2(10000, 2);
return 0;
}
lambda底层原理,其实是被处理成一个lambda_uuid的一个仿函数 。
从使用方式上来看,函数对象与lambda表达式完全一样。
函数对象将rate作为其成员变量,在定义对象时给出初始值即可,lambda表达式通过捕获列表可以直接将该变量捕获到。
实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()。
lambda表达式和仿函数的关系就像范围for与迭代器的关系,lambda表达式和范围for都是语法糖。
7.默认成员函数控制
在C++中对于空类编译器会生成一些默认的成员函数,比如:构造函数、拷贝构造函数、运算符重载、析构函数和&和const&的重载、移动构造、移动拷贝构造等函数。如果在类中显式定义了,编译器将不会重新生成默认版本。有时候这样的规则可能被忘记,最常见的是声明了带参数的构造函数,必要时则需要定义不带参数的版本以实例化无参的对象。而且有时编译器会生成,有时又不生成,容易造成混乱,于是C++11让程序员可以控制是否需要编译器生成。
7.1显式缺省函数
在C++11中,可以在默认函数定义或者声明时加上=default,从而显式的指示编译器生成该函数的默认版本,用=default修饰的函数称为显式缺省函数。
如果我们什么都不写,生成默认的构造函数。
但是我们自己写了构造函数,默认的构造函数就生不成了,编译也通不过了。我们一般的做法就是在写一个默认的。
但是这样很烦,我就是让编译器显示的去生成,假设因为某种原因默认成员函数生不成了,但是我就想让他生成,我们就可以通过default关键字。
当你提供这个default就是,编译器生不成默认构造函数,但是我强制编译器生成默认构造函数
这个default不是只能用来控制构造函数的,像拷贝构造,赋值等都能强制生成默认的。指定编译器提供默认的成员函数。
7.2 删除默认函数
如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成private,并且不给定义,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。
有些地方可能会不想让你生成某个函数,像单例类,单例模式的要求就是如果你把某个类设计成单例的话,要求这个类只能生成唯一的对象,只能生成唯一的一个对象就不允许拷贝,不允许赋值
方法一
我们可以直接私有
目前有个问题,默认构造也没了,因为拷贝构造也是一种特殊的构造,只要你显示的写了,默认构造函数就都不生成了,我们就可以用刚刚的default关键字。
这个时候看似调用不了拷贝构造了,但是不排除在类里面写一个拷贝的方法,在类里面设置成私有,只是类外面的对象不能调用,但是类里面的成员时可以调用的。
这样我就可以间接的使用拷贝构造了。
方法二
我如果想杜绝这种情况,类里面,类外面都不想让你用,该怎么办呢?
C++98中防止拷贝:
- 1、只声明,不实现
- 2、声明成私有
这样就可以杜绝这种情况了。
如果只声明,不实现能行吗?
看似可以,但是别忘了,如果不设置成私有,那么别人就可以在类外面给你实现出来。
所以说还得设置成私有的。
但是这样也还存在不好的地方。别人同样在类外给你实现出来,类里面是不受影响的,也就是还能通过Copy间接使用拷贝构造。
多多少少还是不能完全避免的,所以C++11就出现了delete关键字。完全不管你是类里类外还是私有公有,只要加上delete你就不能用。
8.新的类功能
默认成员函数
原来C++类中,有6个默认成员函数:
- 1.构造函数
- 2.析构函数
- 3.拷贝构造函数
- 4.拷贝赋值重载
- 5.取地址重载
- 6.const 取地址重载
最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会生成一个默认的。这些自动生成的也很有用。
eg:两个队列实现一个栈
class MyStack { public: void push(int x) {} int pop() {} int top() {} bool empty() {} private: queue<int> q1_; queue<int> q2_; };
针对MyStack这个类,我们不用实现它的四个默认成员函数。默认生成的构造会去调用自定义类型的构造,默认生成的析构调用自定义类型的析构,默认生成的拷贝构造对内置类型完成浅拷贝,对自定义类型完成深拷贝。
C++11新增了两个:移动构造承数和移动赋值运算符重载。所以一个类有几个默认成员函数,在C++98里面是6个,C++11里面是8个。
针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:
- 如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个(意思就是这三个都没有实现)。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝(值拷贝),自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。
- 如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)。
- 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。
默认生成的条件更复杂需要有4个默认成员函数都没实现;默认的移动构造对于内置类型值拷贝,对于自定义类型,如果这个自定义类型实现了移动构造就调用移动构造,没有实现就调用拷贝构造
8.1实例说明
用博主自己实现的string做实验
namespace pxl
{
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)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
//移动构造 --移动资源
string(string&& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
this->swap(s);
cout << "string(string && s)---资源转移" << endl;
}
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(const string&& s) -- 转移资源" << endl;
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;
}
string operator+(char ch)
{
string tmp(*this);
push_back(ch);
return tmp;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
pxl::string to_string(int value)
{
pxl::string str;
while (value)
{
int val = value % 10;
str += ('0' + val);
value /= 10;
}
reverse(str.begin(), str.end());
return str;
}
}
8.1.1默认的移动构造
class Person
{
public:
Person(const char* name = "张三", int age = 0)
:_name(name)
, _age(age)
{}
private:
pxl::string _name;
int _age;
};
int main()
{
Person s1;
Person s3 = std::move(s1);
return 0;
}
编译器默认生成的移动构造对内置类型完成值拷贝,自定义类型调用它的移动构造。
如果我们把string的移动构造屏蔽掉,调用的就是拷贝构造
我们实现一个析构,默认的移动构造就生成不了了,这个_name调用的就是拷贝构造。
我们实现一个赋值重载,默认的移动构造就生成不了了,这个_name调用的就是拷贝构造。
我们实现一个拷贝构造,默认的移动构造就生成不了了,这个_name调用的就是拷贝构造。
8.1.2默认的移动赋值
编译器默认生成的移动赋值对内置类型完成值拷贝,自定义类型调用它的移动赋值。
我们实现一个析构,默认的移动赋值就生成不了了,这个_name调用的就是赋值重载(多大一次拷贝构造时因为我们的赋值重载用的是现代写法)
我们实现一个拷贝构造,默认的移动赋值就生成不了了,这个_name调用的就是赋值重载(多大一次拷贝构造时因为我们的赋值重载用的是现代写法)
我们实现一个赋值构造,默认的移动赋值就生成不了了,这个_name调用的就是赋值重载(多大一次拷贝构造时因为我们的赋值重载用的是现代写法)
8.2类成员变量初始化
C++11允许在类定义时给成员变量初始缺省值,默认生成构造函数会使用这些缺省值初始化
C++98不太好的就是默认生成的构造函数对自定义类型会调用它的构造函数,对于内置类型不进行处理。ps:博主用的是Microsoft Visual Studio Community 2019 版本 16.11.21。这个版本它对内置类型就进行了处理,但是这个只是vs这个版本的个人行为,vs给你优化了。其他编译器不定义做这个处理,因为C++的标准并没有规定要处理。并且vs2019也是有很多版本的,有的版本就不做处理,有的版本就搞优化。
C++11就打了个补丁,给了一个缺省值。
在类中int _a=10;这个不叫初始化,这是声明,类是没有空间的,你用类定义出一个对象才会分配空间。
我们写好的程序是一个文件,这个文件是存在磁盘上面的,编译器对写好的文件中的代码进行编译(预处理,编译,汇编,连接)。最后出来的就是二进制的指令 。linux中编译好就会生成一个a.out的文件,windows下生成.exe,我们双击.exe或者./a.out都是可以执行的。执行a.out里面的指令时候,先执行main函数,然后才建立各种栈帧。建立栈帧的时候提前把空间开好,main函数结束,建立的对象就销毁了,因为这些对象是在栈帧里面的,全局变量不销毁是因为它们存在代码段,数据段的,不随着栈帧走。new,malloc的是执行到他们的时候,去堆上开空间。
类里面定义的成员没有分配空间是因为他们并不是对象,只是声明,声明就是有这个东西,我知道这个东西多大,但是只要类去定义对象的时候,才去开空间。
8.3可变参数模板
C语言就有可变参数,printf中...表示的就是可变参数
C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比C++98,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的.
下面就是一个基本可变参数的函数模板
// Args是一个模板参数包,args是一个函数形参参数包 // 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。 //当然这个模板参数包也不一定就是叫Args,叫A,B,C任何名字都可以,只不过习惯叫Args template <class ...Args> void showList(Args... args) {}
上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数。我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用args[i]这样方式获取可变参数,所以我们的用一些奇招来--获取参数包的值。
比如这个ShowList,我们调用它的时候好调用可以传1个参数,2个参数,3个参数...并且类型都不一样,但是在实现ShowList的时候,如何在函数内部拿到这个参数包里面的内容呢或者如何把参数包里面的内容解析出来呢?
8.3.1sizeof对可变参数的使用
通过sizeof就可以知道可变参数包中参数的个数。
可以通过sizeof拿到每一个参数吗?
如果这样的话,编译是不通过的
从原理上来,讲编译器没办法把内容从参数包里面解析出来,这样写的话,每个对象的类型是什么都不知道。
8.3.2递归函数方式展开参数包
//递归终止函数
template<class T>
void ShowList(const T& t)
{
cout << typeid(t).name() << ":" << t<< endl;
}
//解析并打印参数包中每个参数的类型及值
template <class T,class ...Args>
void ShowList(T val, Args... args)
{
cout << typeid(val).name() << ":" << val << endl;
ShowList(args...);
}
int main()
{
ShowList(1);
cout << endl;
ShowList(1, 'A');
cout << endl;
ShowList(1, 'A', std::string("sort"));
cout << endl;
return 0;
}
不管参数包是多少个参数,第一个参数就匹配到val上了,就解析出第一个参数。然后就把这个参数包传下去,让上下文进行推导。直到只有一个参数,就去调用终止的那个函数。
eg:ShowList(1, 'A', std::string("sort"));解析
8.3.3逗号表达式展开参数包
刚才写这种模板的可变参数不是直接写这个参数包,而是还增加了一个参数。增加了一个终止函数。假设我就只写参数包,像vector的emplace_back就是这样写的
这个时候要解析参数包的内容该怎么办呢?
我们就可以使用逗号表达式展开参数包
这种展开参数包的方式,不需要通过递归终止函数,是直接在expand函数体(这里这个expand指())中展开的,PrintArg不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式。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<class T> void PrintArg(T& t) { T copy(t); cout << typeid(t).name() << ":" << t << endl; } template <class ...Args> void ShowList(Args... args) { int arr[] = { (PrintArg(args),0)... }; //有一个数组,初始化的时候就取决{},列表里有多少个值,我就开多大的空间 //eg:int arr[] = { 1,2,3 };我就开3个 cout << endl; } int main() { ShowList(1); cout << endl; ShowList(1, 'A'); cout << endl; ShowList(1, 'A', std::string("sort")); cout << endl; return 0; }
eg:ShowList(1, 'A', std::string("sort"));解析
我们可以优化一下,直接给PrintArg加一个返回值,这样的话就不用使用逗号表达式了
template<class T> int PrintArg(T& t) { T copy(t); cout << typeid(t).name() << ":" << t << endl; return 0; } template <class ...Args> void ShowList(Args... args) { int arr[] = { (PrintArg(args))... }; //...表示第一次调用PrintArg,第二次调用PrintArg...知道把这个参数包展开完毕 //编译器要计算arr数组的大小,所以必须得把参数包给展开,才能知道数组开多大, //编译器肯定知道参数包里有几个,就知道这个数组开几个,数组开几个PrintArg函数就调用多少次 //通过PrintArg函数就获取了参数包里面的所有内容。 cout << endl; } int main() { ShowList(1); cout << endl; ShowList(1, 'A'); cout << endl; ShowList(1, 'A', std::string("sort")); cout << endl; return 0; }
8.4STL容器中的emplace相关接口
以vector为例
既然push_back的右值版本已经很能提高效率了,那么emplace_back又是干啥的呢?push_back对应emplace_back,insert对应emplace,他们功能都是一样的,那么支持他们到时是干嘛的呢?
8.4.1更加灵活使用
int main()
{
std::list<std::pair<int, char>> mylist;
//push_back固定了这个参数只能是pair
mylist.push_back(make_pair(1, 'a'));
//emplace_back传pair没问题
mylist.emplace_back(make_pair(1, 'a'));
//emplace_back也支持这样插入,里面把参数解析出来,再去构造pair对象
mylist.emplace_back(1, 'a');
for (auto e : mylist)
{
cout << e.first << ":" << e.second << endl;
}
}
8.4.2效率提高
我们发现push_back调用了移动构造,但是emplace_back啥也没调用。其实emplace_back也不只是真的啥都没有调用。
我们将自己实现的string的构造函数也显示的打印出来。
emplace_back就是直接传参数,直接构造到list的空间上去了,push_back是构造一个临时对象出来,然后进行移动构造。其实也没啥区别,push_back就是多一次移动构造,多一次移动构造并不意味着效率就变低了,多调用一次函数的代价很小很小。emplace_bcak就是直接构造开空间,push_back是先构造,再移动构造。
如果插入的是一个左值的话和push_back没有任何的区别
emplace_back可以说对于push_back的左值版本是更加高效的,但是相比push_back的右值版本其实两种差不多。所以说emplace版本更高效是不准确的说法。
9.包装器
9.1function包装器
function包装器也叫适配器。C++的function本质就是一个类模板,也是一个包装器。
ret =func(x);
上面func可能是什么呢?从C语言的角度来讲,这个func要么是函数名,要么就是函数指针(函数名的值也是函数指针)
从C++的角度来讲,那么func可能是函数名,可能是函数指针,可能是函数对象(仿函数对象),也有可能是lamber表达式对象.所以这些都是可调用的类型!如此丰富的类型,可能会导致模板的效率低下!
eg:
template<class F, class T>
T useF(F f, T x)
{
static int count = 0;
//我们定义一个静态成员变量,如果是会实例化出3份,那么这个
//地方就会产生3个count,因为是三个不同的函数了
cout << "count:" << ++count << endl;
cout << "count:" << &count << endl;
return f(x);
}
double f(double i)
{
return i / 2;
}
struct Functor
{
double operator()(double d)
{
return d / 3;
}
};
int main()
{
// 函数名
cout << useF(f, 11.11) << endl;
// 函数对象
cout << useF(Functor(), 11.11) << endl;
// lamber表达式
cout << useF([](double d)->double{ return d / 4; }, 11.11) << endl;
return 0;
}
这个useF就有可能被实例化成3份,因为F的类型被推导成了3个类型,第一个类型是函数指针,第二个类型是仿函数,第三个类型是lambda表达式。
这有什么问题呢?
这样的话效率太低了,模板实例化的太多了,能不能让模板就实例化出一份呢?
当然可以,我们就可以增加一个包装器,把这些可调用对象包装起来,包装成同一个类型,这个地方就相对而言方便了不少。
包装器可以很好的解决上面的问题
std::function在头文件<functiona1>
// 类模板原型如下
template <class T> function;// undefined
template <class Ret, class ... Args>
class function<Ret(Args ...)>;
模板参数说明:
Ret: 被调用函数的返回类型
Args... : 被调用函数的形参
使用方法如下:
int f(int a, int b)
{
return a + b;
}
struct Functor
{
public:
int operator() (int a, int b)
{
return a * b;
}
};
class Plus
{
public:
static int plusi(int a, int b)
{
return a + b + 1;
}
double plusd(double a, double b)
{
return a + b;
}
};
int main()
{
//包装普通函数
function<int(int, int)> f1 = f;
cout << f1(1, 2) << endl;
//包装仿函数
function<int(int, int)> f2 = Functor();
cout << f2(1, 2) << endl;
//包装成员函数
function<int(int, int)> f3 = &Plus::plusi;
cout << f3(1, 2) << endl;
function<double(Plus, double, double)> f4 = &Plus::plusd;
cout << f4(Plus(), 1.1, 2.2) << endl;
}
9.1.1包装成员函数
对于静态的成员函数,&加不加都可以;但是对于非静态的成员函数,&除了必须加以外(因为普通函数函数名就是地址,成员函数得加&符号来取地址,静态的可以不用为了统一加上也可以),还必须得增加一个参数,因为非静态的成员函数不能直接去调用,而是需要对象去调用。
ps:对于包装非静态成员后,调用需要多一个参数的问题,我们可以通过绑定包装器去处理,稍后解决。
所以包装器就可以对不同类型的东西,包装成统一的类型格式。
我们现在通过包装器解决下上面模板实例化多份的问题。
template<class F, class T>
T useF(F f, T x)
{
static int count = 0;
cout << "count:" << ++count << endl;
cout << "count:" << &count << endl;
return f(x);
}
double f(double i)
{
return i / 2;
}
struct Functor
{
double operator()(double d)
{
return d / 3;
}
};
int main()
{
// 函数名
std::function<double(double)> func1 = f;
cout << useF(func1, 11.11) << endl;
// 函数对象
std::function<double(double)> func2 = Functor();
cout << useF(func2, 11.11) << endl;
// lamber表达式
std::function<double(double)> func3 = [](double d)->double{ return d / 4; };
cout << useF(func3, 11.11) << endl;
return 0;
}
通过结果我们可以确定,这个时候模板实例化的就是同一份函数。
9.2包装器的用途
这个包装器在有些地方是非常有用的。比如在逆波兰表达式这个题中就非常的适用
根据 逆波兰表示法,求表达式的值。
有效的算符包括
+
、-
、*
、/
。每个运算对象可以是整数,也可以是另一个逆波兰表达式。注意 两个整数之间的除法只保留整数部分。
可以保证给定的逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。
示例 1:
输入:tokens = ["2","1","+","3","*"] 输出:9 解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9示例 2:
输入:tokens = ["4","13","5","/","+"] 输出:6 解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6示例 3:
输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] 输出:22 解释:该算式转化为常见的中缀算术表达式为: ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / (12 * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = (0 + 17) + 5 = 17 + 5 = 22
提示:
1 <= tokens.length <= 104
tokens[i]
是一个算符("+"
、"-"
、"*"
或"/"
),或是在范围[-200, 200]
内的一个整数逆波兰表达式:
逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。
- 平常使用的算式则是一种中缀表达式,如
( 1 + 2 ) * ( 3 + 4 )
。- 该算式的逆波兰表达式写法为
( ( 1 2 + ) ( 3 4 + ) * )
。逆波兰表达式主要有以下两个优点:
- 去掉括号后表达式无歧义,上式即便写成
1 2 + 3 4 + *
也可以依据次序计算出正确结果。- 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中
思路:我们读完题目明白什么是逆波兰表达式就很容易知道这道题要用到栈(题目已经给了提示属于是),并且运算符运算的总是这个运算符的前两个数字。所以,我们就可以利用一个栈,读取到数字就入栈,读取到运算符就取栈中的前两个元素进行运算,这样就实现了逆波兰表达式求值。
代码如下:
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> s;
for(size_t i = 0; i < tokens.size(); i++)
{
string& str=tokens[i];
//str为数字
if(!("+"==str || "-"==str || "*"==str || "/"==str))
{
s.push(atoi(str.c_str()));//字符串转整形
}
else
{
//str为操作符
int right = s.top();
s.pop();
int left = s.top();
s.pop();
switch(str[0])
{
case '+':
s.push(left + right);
break;
case '-':
s.push(left - right);
break;
case '*':
s.push((long)left * (long)right);
break;
case '/':
s.push(left / right);
break;
}
}
}
return s.top();
}
};
但是整个代码不好的地方就是我们的判断,假设我们要增加加更多的运算符号,我们就要增加一个判断和一个case语句,这样虽然可以,但是非常的不灵活。
再比如你写一个游戏,一个命令对应一个动作,假如你开始就有20多个命令,那么你要全部写成switch,case语句,来一个命令挨着进行判断,这样的效率很低,而且后序可能会增加更多的命令,你接着修改也是很不方便的。
面对这种问题我们就可以借助包装器进行解决。我们就可以搞一个map,第一个参数是命令类型就是string,第二个参数是命令对应的执行动作类型就是包装器(函数指针也可以但是太麻烦所以包装器最合适)。第二个参数这个里就可以传函数指针,仿函数,lambda表达式。
优化
class Solution {
public:
int evalRPN(vector<string>& tokens) {
map<string,function<int(int,int)>> opFuncMap;
opFuncMap["+"] = [](int a, int b)->int{return a+b;};
opFuncMap["-"] = [](int a, int b)->int{return a-b;};
opFuncMap["*"] = [](long a, long b)->int{return a*b;};
opFuncMap["/"] = [](int a, int b)->int{return a/b;};
stack<int> s;
for(size_t i = 0; i < tokens.size(); i++)
{
string& str=tokens[i];
//str为操作数
//说明没有找到,则证明str为操作数,入栈
if(opFuncMap.find(str) == opFuncMap.end())
{
s.push(stoi(str));//字符串转整形
}
else
{
//str为操作符
int right = s.top();
s.pop();
int left = s.top();
s.pop();
//opFuncMap[str]就是一个value,value又是包装器的对象,所以就可以传参
s.push(opFuncMap[str](left, right));
}
}
return s.top();
}
};
我们就可以这样进行优化,这个地方最牛的就是如果你还要增加其他的运算符号,你只需要增加map的映射关系就可以,其他的代码完全不变。这样就变的非常的灵活.
当然我们进行初始化map的时候也可以不用[]进行插入,直接用初始化列表初始化。本质都一样。
9.3 bind
std::bind函数定义在头文件<functional>中,是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来“适应”原对象的参数列表。一般而言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M可以大于N,但这么做没什么意义)参数的新函数。同时,使用std::bind函数还可以实现参数顺序调整等操作。
// 原型如下: template <class Fn, class ... Args> /* unspecified */ bind (Fn&& fn, Args&&... args); // with return type (2) template <class Ret, class Fn, class ... Args> /* unspecified */ bind (Fn&& fn, Args&&... args);
可以将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为第二个参数,以此类推。eg:
auto check=bind(check_size,_1,6);
当我们调用check(str)的时候,实际会调用check_size(str,6).
9.3.1调整函数参数的顺序
eg1: 假设我有种需求需要调整一下函数参数的顺序
int SubFunc(int a, int b)
{
return a - b;
}
int main()
{
//正常的包装器包装
function<int(int, int)> f1 = SubFunc;
cout << f1(10, 3) << endl;
//表示绑定函数SubFunc参数分别由调用f2的第一,二个参数指定,相当于啥也没干
//和正常的包装器包装是一样的,只是包装适配了一下
function<int(int, int)> f2 = bind(SubFunc, placeholders::_1, placeholders::_2);
cout << f2(10, 3) << endl;
}
假如我有需求需要调换函数参数的位置,我就可以先绑定_2,再绑定_1
//通过绑定调整参数的顺序
function<int(int, int)> f3 = bind(SubFunc, placeholders::_2, placeholders::_1);
cout << f3(10, 3) << endl;
ps:当我们调用f3(10,3)的时候,实际上调用的是SubFunc(3,10),_2代表的就是f3的第二个参数,_1代表的就是f3的第一个参数。
这个时候参数的顺序就已经被我们调整了。比如说库里面有些函数,你用起来觉得函数的顺序用起来特别的不舒服,你就可以用绑定去把它调整一下。
9.3.2 将函数参数绑定为固定值
还可以把函数参数绑定为固定值
//表示绑定函数SubFunc的1,2个参数为:3,3
function<int(int, int)> f3 = bind(SubFunc, 3, 3);
cout << f3(10, 3) << endl;
cout << f3(100, 30) << endl;
cout << f3(103, 33) << endl;
此时函数的结果与你传的参数完全无关,函数的两个参数始终是3和3。
ps:一般我们是不会把这种包装器的对象定义成全局对象,因为定义成全局对象有线程安全的问题,链接属性的问题要搞成静态的,静态的又仅在当前文件可见,所以实际编程中全局的对象能不用则不用。
9.3.3 调整参数个数
eg:通过绑定去调整参数的个数
bind适用的场景更多的是通过绑定去调整参数的个数。
class Sub
{
public:
int sub(int a, int b)
{
return a - b;
}
};
因为是非静态的成员函数,使用function包装器时就得多一个Sub,f4调用的时候也得多传一个匿名对象,本质上就是要用Sub类的对象去调用函数。
int main() { //通过bind调整参数个数 function<int(Sub, int, int)> f4 = &Sub::sub; cout << f4(Sub(), 10, 3) << endl; }
这个情况下每次调用就得多传一个参数,我们就可以通过bind调整参数个数,因为f4这个函数的第一个参数永远是Sub(),是固定不变的。
int main() { function<int(int, int)> f5 = bind(&Sub::sub, Sub(), placeholders::_1, placeholders::_2); cout << f5(10, 3) << endl; }
ps:该操作表示绑定成员函数sub与类对象Sub(),函数sub的参数由f5的第一,第二个参数指定。
这个是很有用的,f4第一个参数是固定的,但是你每次都得传一下,就很繁琐,我们就可以通过绑定绑死,剩下的参数依次往下走就可以。
eg:
eg:
同样我们也可以用auto自动推导,但是不如function直观(直接看出返回值,参数),还是建议用function。
10.线程库
10.1thread类的简单介绍
线程库里用的最多的就是线程,条件变量,互斥锁。atomic也比较有意义,叫做原子操作。future用的就很少了,我们暂时不提。
C++11 thread的库就可以跨平台,而且是用面向对象的方式实现的.
线程库:
函数名
|
功能
|
thread()
|
构造一个线程对象,没有关联任何线程函数,即没有启动任何线程
|
thread(fn,args1, args2,...)
|
构造一个线程对象,并关联线程函数
fn
,
args1
,
args2
,
...
为线程函数的参数
|
get_id()
|
获取线程
id
|
jionable()
|
线程是否还在执行,
joinable
代表的是一个正在执行中的线程。
|
jion()
|
该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行
|
detach()
|
在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程
变为后台线程,创建的线程的
"
死活
"
就与主线程无关
|
eg1:
所以t2线程必须进行线程等待,t1线程不用等待的原因是因为t1线程啥也没干,所以不用等待。
eg2:传多参数
eg3: n个线程同时执行Print函数
void Print(int n, int x)
{
for (int i = 0; i < n; i++)
{
cout << i * x << endl;
}
}
int main()
{
int n;
cin >> n;
vector<thread> vthds; //创建多个线程,将线程作为对象存到vector里面
vthds.resize(n); //resize会调用thread的默认构造函数,这个n个线程并没有执行
for (auto& t : vthds)
{
//构造一个匿名对象赋值给vector中的线程
//这个地方利用的就是移动赋值,线程是不支持拷贝和赋值的
//但是支持移动赋值和移动拷贝
t = thread(Print, 100, 2);
}
for (auto& t : vthds)
{
//对vector里的线程进行线程等待
t.join();
}
return 0;
}
注意:
1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态
2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
#include <thread>
int main()
{
std::thread t1;
cout << t1.get_id() << endl;
return 0;
}
// vs下查看
typedef struct
{ /* thread identifier for Win32 */
void *_Hnd; /* Win32 HANDLE */
unsigned int _Id;
} _Thrd_imp_t;
为什么线程id要封装成结构体?
就是因为需要跨平台。linux下的线程id是整数,windows下的线程id不是另外的类型,所以C++11就是为了兼容windows和linux。
- 函数指针
- lambda表达式
- 函数对象
#include <iostream>
#include <thread>
using namespace std;
void ThreadFunc(int a)
{
cout << "Thread1" << a << endl;
}
class TF
{
public:
void operator()()
{
cout << "Thread3" << endl;
}
};
int main()
{
// 线程函数为函数指针
thread t1(ThreadFunc, 10);
// 线程函数为lambda表达式
thread t2([] {cout << "Thread2" << endl; });
// 线程函数为函数对象
TF tf;
thread t3(tf);
t1.join();
t2.join();
t3.join();
cout << "Main thread!" << endl;
return 0;
}
4. thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的执行。
5. 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效
- 采用无参构造函数构造的线程对象
- 线程对象的状态已经转移给其他线程对象
- 线程已经调用jion或者detach结束
并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。
10.2 线程函数参数
eg:
对于这个n,我们都知道形参不影响实参,所以n最终的值是不会改变的始终是0。
如果我们想要通过形参改变实参,我们首先想到的就是传引用。
在vs2019下,这种方式直接报错。因为thread的可执行参数不能是左值引用。ps:在vs2013虽然能编过,但是传引用同样是没效果的。至于为什么不能传左值引用就是因为线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝(想必这句话听了也是一头雾水,因为底层实现太复杂了,了解下即可)。
解决方法:
1.传地址
2.std::ref,虽然不能直接用左值引用,但是可以间接使用
10.3 用两个线程对一个变量x同时进行++
10.3.1 存在线程安全问题
这段代码看似正常,实际上存在着经典的线程安全问题。
++通常是三句指令构成:取数据,进行+1,再放回。
比如:数据初始值是0,线程1取完数据进行+1后,还没来得及放回数据,线程2就直接取数据了(此时拿到的还是0),这样两个线程虽然++了两次,但是数据还是1。这种就是经典的线程安全问题。单句指令是原子的,但是多语句就不是了。
我们的上面代码没有出现问题只是概率问题,因为对于计算机来说只有3据指令,一瞬间就完了,非常快,但是如果我们加大数据量,问题就显现出来了。
我们把数据量增加到万的级别就出现问题了,而且每次的结果都不一样。这就是典型的线程安全问题。
我们把IO加上更容易出现问题。比如我们可以打印下线程id。我们可以用this_thread这个类中的get_id函数。
this_thread中的get_id用法如下:
我们可以通过加锁或者原子操作的方式来解决这个线程安全问题。
10.3.2.加锁(互斥锁)
接口也很简单lock,unlock。如果我去获取锁的时候,别人已经把锁占了就会阻塞,try_lock就是判断是否有人占用这锁,锁被占了返回false。
这个锁加在哪?加在循环里面还是循环外面,为什么?
首先我们得明白,加锁的时候给的原则是:锁的粒度应该是尽量小的(锁的范围),比如能加5行代码,你就只加这5行,不要把范围扩大,因为范围扩大可能会导致效率降低(我锁了以后,别人就进不来)。
这里的加在里面粒度更小而且是并行的++,加在外面就是一个串行的++(t1和t2谁先拿到这个锁谁就进行++,是一个线程整个加完了这10000次,另外一个线程才能进行加,就变成了所谓的串行操作)。
所以加在外面就是串行操作,加在里面算是并行操作(t1++完,t2++;t2++完,t1++)。
锁是加在里面效率高,还是加在外面效率高呢?
通过实验我们发现,把锁加在外面的效率是远高于加在里面的。正常情况下锁的粒度是越小越好,但是这个地方不一样,原因就在于++x太快了(如果执行里面执行的动作很多,那么锁加在里面是完全没用问题的)。这块把锁加在里面支持并行带来了非常大的消耗,这个消耗在于反复的申请锁和释放锁,频繁的去申请锁和释放锁,线程状态需要进行切换,切换状态要保存线程的上下文。
原因是什么呢?
单CPU多核(多个运算核心)就是真正的多线程并行运行。
t1在CPU1上运行,t2在CPU2上运行,这两个线程共享一个进程地址空间,他们的资源是共享的,全局变量x,mtx是在数据段的(C语言层面上叫静态区),每个线程都有独立的栈,t1的栈里面执行自己的Func,t2的栈里面执行自己的Func,各自执行自己的Func,建立各自的栈帧,所以t1有个n,i;t2有个n,i这些都互不影响,因为i和n都是Func里面的局部资源,都是在各自的栈帧里面的。但是Func里面会执行一个++x的操作,x需要加锁就是因为x是在数据段里面的。
加锁加在外面,t1和t2就变成串行运行了,失去了多线程的意义。这样确实没有什么价值,但是在安全面前(线程安全就意味着程序的正确性)和在效率面前我们会毫不犹豫的选择安全。就好比在金钱和生命面前,理论上都是会选择生命的。比如我给你一个亿把你放到沙漠里不能出来,你的一个亿就是废纸。加载外面虽然会损失效率,但是它是安全的。
加锁加在里面,t1和t2,能交替并行运行,它并行运行的不是++x这部分,而是这个for循环是并行运行的,t1的for循环在运行,t2的for循环也在运行,只是++x的那一下被困住了。按理说加在里面效率高,但是通过实验加在里面的效率是低的,源自于++x太快了,如果++x是一个很长的操作,那么这个时候就不一样了,这个时候加载里面效率高。
++x太快了,会导致t1和t2频繁的切换上下文(每个CPU都有给线程用的寄存器资源,比如t1和t2并行运行,遇到锁后,t1拿到了,t1继续运行,t2看到没锁了,就切换上下文进入休眠状态,切换上下文包含保存t2的寄存器,记录t2运行到哪个位置...)但是这个地方最恶心的就是t2有可能还没有完成保存上下文,t1就解锁了,因为++x太快了,这个场景下让t2去休眠非常的不划算。就好比t1和t2在抢提个东西,谁先抢到了谁就去休息,t1抢到了,t2就去休息,往回走,t2正准备躺下去,t1就说回来吧,我完了。但是t2不可能马上回来,t2还得把休息的动作做完,躺下去,在起来处于唤醒的状态,不仅要保存上下文,回来的时候还要把这些资源加载回来。这个时候大量资源消耗在了切换上下文,这个代价太大了。
这个地方我们除了串行运行还可以用一个自旋锁去解决。互斥锁是我们两个一起去抢资源,谁抢到谁就进去获取锁,没抢到的就去进行休息,等抢到的人解锁了,没抢到的人再回来。但是如果抢到锁的人运行的时间太短了,就会导致另外一个人还没有回去就得回来,但是另外一个人必须执行完回去的动作才能回来。这个时候最好的方式是我们两个人在这里抢,另外一个没抢到的人别回去休息,而是在这循环等待,也就是自旋;没锁的那个人不断的在这问t1你好了没。所以如果要加在里面,不要使用互斥锁,使用自旋锁即可,但是很可惜C++11没有自旋锁,我们必须自己造轮子实现一个自旋锁。
10.3.3 原子操作解决
我们用原子操作解决。
虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对x++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。
因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效
原子操作是OS级别所提供的。linux下提供的这几种原子操作函数
atomic_inc(v); // 原子变量自增1
atomic_dec(v); // 原子变量自减1
atomic_read(v) // 读取一个原子量
atomic_add(int i, atomic_t *v) // 原子量增加 i
atomic_sub(int i, atomic_t *v) // 原子量减少 i
windows下提供的原子操作函数
Windows有6个原子操作函数:
- InterlockedExchange:把目标操作数(参数1所指向的内存中的数)与一个值(参数2)交换,返回参数1的原始值。
- InterlockedCompareExchange:是把目标操作数(第1参数所指向的内存中的数)与一个值(第3参数)比较,如果相等, 则用另一个值(第2参数)与目标操作数(第1参数所指向的内存中的数)交换;返回值为参数1的原始值
- InterlockedIncrement:参数所指的内存中的数字加1
- InterlockedDecrement:参数所指的内存中的数字减1
- InterlockedExchangeAdd:参数1所指的内存中的数字,加上参数2对应的值,返回未加前的参数1
- InterlockedExchangePointer:把目标操作数(参数1的指针)设置为参数2对应的指针,返回未重新指向前的参数1指针
原子操作的原理大概就是:++这种指令不是线程安全的,但是OS可以做到让++执行的时候不会被中断,所以就变成线程安全的了。原子操作和自旋锁的效果差不多。
C++11中同样提供了原子操作
这个类型定义的对象在进行++,--的时候就是原子的。
注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
#include <atomic>
int main()
{
atomic<int> a1(0);
//atomic<int> a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
10.3.4 统计加锁和原子操作的效率。
因为多线程分别去统计的时候不太好统计,我们不在使用传统的方式,不在用全局的变量(全局的变量会有链接属性的问题),我们用局部变量和lambda表达式去实现该功能
int main()
{
int x = 0;
mutex mtx;
int N = 100000;
thread t1([&] {
for (int i = 0; i < N; i++)
{
x++;
}
});
thread t2([&] {
for (int i = 0; i < N; i++)
{
x++;
}
});
t1.join();
t2.join();
cout << x << endl;
return 0;
}
此时线程不是安全的。接下来我们就把其变成线程安全的。
1.将锁加在循环外面,相当于就是串行的并统计时间。
int main()
{
int x = 0;
mutex mtx;
int N = 100000;
int costTime = 0;
thread t1([&] {
int begin1 = clock();
mtx.lock();
for (int i = 0; i < N; i++)
{
x++;
}
mtx.unlock();
int end1 = clock();
costTime += (end1 - begin1);
});
thread t2([&] {
int begin2 = clock();
mtx.lock();
for (int i = 0; i < N; i++)
{
x++;
}
mtx.unlock();
int end2 = clock();
costTime += (end2 - begin2);
});
t1.join();
t2.join();
cout << x << ":" << "线程执行时间:" << (float)(costTime) / CLOCKS_PER_SEC << endl;
return 0;
}
大致就是0.001秒,串行走非常的快。
2.把锁加在循环里面,线程并行去运行
int main()
{
int x = 0;
mutex mtx;
int N = 100000;
int costTime = 0;
thread t1([&] {
int begin1 = clock();
//mtx.lock();
for (int i = 0; i < N; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
//mtx.unlock();
int end1 = clock();
costTime += (end1 - begin1);
});
thread t2([&] {
int begin2 = clock();
//mtx.lock();
for (int i = 0; i < N; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
//mtx.unlock();
int end2 = clock();
costTime += (end2 - begin2);
});
t1.join();
t2.join();
cout << x << ":" << "线程执行时间:" << (float)(costTime) / CLOCKS_PER_SEC << endl;
return 0;
}
线程并行运行,时间就变得大的多了。
3.我们把x换成原子操作
int main()
{
atomic<int> x = 0;
mutex mtx;
int N = 100000;
int costTime = 0;
thread t1([&] {
int begin1 = clock();
for (int i = 0; i < N; i++)
{
x++;
}
int end1 = clock();
costTime += (end1 - begin1);
});
thread t2([&] {
int begin2 = clock();
for (int i = 0; i < N; i++)
{
x++;
}
int end2 = clock();
costTime += (end2 - begin2);
});
t1.join();
t2.join();
cout << x << ":" << "线程执行时间:" << (float)(costTime) / CLOCKS_PER_SEC << endl;
return 0;
}
时间比串行的还是慢,但是比加锁并行的就块。
栈里面的变量一定是线程安全的吗?
不一定,如上x是局部变量,在栈里面,但它不是线程安全的,也就是说一个资源不管在哪里,只要被多个线程访问,有读有写,就有可能存在线程安全的问题。
我们这种测试场景实际上是一种极端的场景,串行是最快的。但是在实际项目里,偶尔的进行++这种操作还是原子的更实用。
我们的代码中还有一个小小的安全隐患,但是这个安全隐患不太会暴露出来,发生的概率极低。这里存在一种两个线程结束后同时对costTime进行++的可能,所以多costTime进行+操作也要是原子的,如果加个锁又不划算,所以把它做成原子的是更好的。
int main()
{
atomic<int> x = 0;
mutex mtx;
int N = 100000;
atomic<int> costTime = 0;
thread t1([&] {
int begin1 = clock();
for (int i = 0; i < N; i++)
{
x++;
}
int end1 = clock();
costTime += (end1 - begin1);
});
thread t2([&] {
int begin2 = clock();
for (int i = 0; i < N; i++)
{
x++;
}
int end2 = clock();
costTime += (end2 - begin2);
});
t1.join();
t2.join();
cout << x << ":" << "线程执行时间:" << (float)(costTime) / CLOCKS_PER_SEC << endl;
return 0;
}
10.3.5 原子操作拓展
这些原子操作的底层原理都是使用了CAS机制。
CAS,是Compare and Swap的简称,在这个机制中有三个核心的参数:
- 主内存中存放的共享变量的值:V(一般情况下这个V是内存的地址值,通过这个地址可以获得内存中的值)
- 工作内存中共享变量的副本值,也叫预期值:A
- 需要将共享变量更新到的最新值:B
与CAS相关的还有无锁编程。比如有些地方实现的无锁队列。eg:生产者消费者模型里面就要向队列里面插入数据,这个时候就要加锁,有些地方就考虑直接使用无锁队列。
10.4 实现一个线程池
要求实现N个线程对x++,M次,N和M都是输入进来的
int main()
{
atomic<int> x = 0;
int N, M;
cin >> N >> M;
vector<thread> vthds;
vthds.resize(N);//resize用thread的默认构造去初始化
for (size_t i = 0; i < vthds.size(); i++)
{
vthds[i] = thread([M, &x] {
for (int i = 0; i < M; i++)
{
cout << std::this_thread::get_id() << "->" << x << endl;
++x;
}
});
}
//不用引用编译不通过,因为thread不支持拷贝构造
for (auto& t : vthds)
{
t.join();
}
cout << x << endl;
return 0;
}
因为我们上面的代码x是原子的,所以如果你想让这句IO也是线程安全的,你就可以整体用个锁。
int main()
{
//atomic<int> x = 0;
int x = 0;
mutex mtx;
int N, M;
cin >> N >> M;
vector<thread> vthds;
vthds.resize(N);//resize用thread的默认构造去初始化
for (size_t i = 0; i < vthds.size(); i++)
{
vthds[i] = thread([M, &x, &mtx] {
for (int i = 0; i < M; i++)
{
mtx.lock();
cout << std::this_thread::get_id() << "->" << x << endl;
++x;
mtx.unlock();
}
});
}
//不用引用编译不通过,因为thread不支持拷贝构造
for (auto& t : vthds)
{
t.join();
}
cout << x << endl;
return 0;
}
10.4.1 比对下锁加在循环里面和循环外面的效率
int main()
{
//atomic<int> x = 0;
int x = 0;
mutex mtx;
int N, M;
cin >> N >> M;
vector<thread> vthds;
vthds.resize(N);//resize用thread的默认构造去初始化
atomic<int> costTime = 0;
for (size_t i = 0; i < vthds.size(); i++)
{
vthds[i] = thread([M, &x, &mtx, &costTime] {
int begin = clock();
//mtx.lock();
for (int i = 0; i < M; i++)
{
mtx.lock();
cout << std::this_thread::get_id() << "->" << x << endl;
++x;
mtx.unlock();
}
//mtx.unlock();
int end = clock();
costTime += (end - begin);
});
}
//不用引用编译不通过,因为thread不支持拷贝构造
for (auto& t : vthds)
{
t.join();
}
cout << x << endl;
cout << (float)(costTime) / CLOCKS_PER_SEC << endl;
return 0;
}
这个时候加载里面和加载外面的效率就不会再相差太大了,并且锁中间执行的IO越多,锁加在里面的效率就越高。
10.5 mutex的种类
在C++11中,Mutex总共包了四个互斥量的种类:
1. std::mutex
C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能进行移动。mutex最常用的三个函数:
函数名 | 函数功能 |
lock()
|
上锁:锁住互斥量
|
unlock()
|
解锁:释放对互斥量的所有权
|
try_lock()
|
尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞
|
注意,线程函数调用lock()时,可能会发生以下三种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
线程函数调用try_lock()时,可能会发生以下三种情况:
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量
- 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
简单来说:两者都是加锁,但是lock()是以阻塞的形式,try_lock()则是以非阻塞的形式进行的。
2. std::recursive_mutex
递归互斥锁。如果你在递归程序里加mutex可能就死锁了。
我用递归互斥锁,我发现是自己的话我就不在阻塞我自己,而是可以继续往下走,通过线程id来判断是否自己。
其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,std::recursive_mutex 的特性和
std::mutex 大致相同。
3. std::timed_mutex
time_mutex这个锁增加了try_lock_for和try_lock_until这两个接口。有些地方可能会存在这样的需求,如果我提前完成了工作我就解锁、比如我给你5分钟干这个事情,5分钟你还干不完,我立即就给你解锁。
比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until() 。
try_lock_for()
接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
try_lock_until()
接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。for是锁上一个时间段,比如我锁10秒,20秒...
until是锁上一个时间点,比如我锁到今天中文12:00,我从现在开始,到达12:00我就解锁。
4. std::recursive_timed_mutex
递归时间锁。
这四种互斥锁最常用的还是mutex,其他的仅了解一下就可以。
10.6 lock_guard与unique_lock
10.6.1 容器的线程安全及异常问题
void func(vector<int>& v, int n, int base) { for (int i = 0; i < n; i++) { cout << this_thread::get_id() << ":" << base + i << endl; v.push_back(base + i); } } int main() { vector<int> vec; thread t1(func, std::ref(vec), 150, 1000); thread t2(func, std::ref(vec), 100, 2000); t1.join(); t2.join(); for (auto e : vec) { cout << e << " "; } return 0; }
对于这样的一段代码肯定是存在线程安全的,目前没报错应该是插入的太快了,数据量太少了,随着数据的增加是一定会存在线程安全的。C++的STL是没有提供线程安全版本的,如果两个线程同时插入数据,一个线程就很有可能把另外一个线程插入的数据覆盖掉,同样是一个概率的问题,数据越多,概率越大。
如果将数据增加成1000次程序就直接崩溃了。线程安全在大数据量的范围下几乎必出。所以C++的容器不是线程安全的,我们必须得进行加锁。
加上锁以后,程序就可以正常运行了。但是我们当前程序是存在问题的,在一些极端情况下是存在死锁的问题的。比如:push_back在失败的情况下是会进行抛异常的(一般情况下是不会失败的,插入是会向系统里申请空间的,申请空间失败了就是会进行抛异常的),抛完异常以后,mtx.unlock()是不会进行执行的,直接就走了,下一个线程来的时候就直接进不来了,原因就是前面的线程由于抛异常没有进行解锁。这种问题就是异常安全的问题,不仅体现在加锁解锁的问题上,而且new和delete也有这个问题,抛异常了就会让他没有执行到位,导致内存泄漏。
10.6.2模拟push_back失败的情况
我们设置在线程1插入到888个数据的时候进行抛异常
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << ":" << base + i << endl;
mtx.lock();
//失败了,抛异常
v.push_back(base + i);
//模拟push_back失败抛异常
if (base == 1000 && i == 888)
{
throw bad_alloc();
}
mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
try
{
t1=thread(func, std::ref(vec), 1000, 1000, std::ref(mtx)); //移动赋值
t2=thread(func, std::ref(vec), 1000, 2000, std::ref(mtx));
}
catch (const exception& e)
{
cout << e.what() << endl;
}
t1.join();
t2.join();
for (auto e : vec)
{
cout << e << " ";
}
cout << endl;
cout << vec.size() << endl;
return 0;
}
从结果看出来在1888的时候,t1线程抛完异常,抛完异常后被捕获了,就是bad allocation申请内存失败了,t1线程就不在继续执行了,此时t2线程就卡住了,因为t2就获取不到这个互斥锁(t1抛完异常以后就没有进行释放锁)ps:这个例子中带来的危害就是线程t2里面的数据没有全部push_back进去。因为线程t1抛异常后就停止了。我们要解决的就是即使线程t1停止了,也不影响线程t2把他的工作执行完。
ps:异常了直接就从455行跳转到了462行。 如果没有异常是不会走catch这个执行流的。
如何解决这个问题呢(让t1即使异常了也进行释放锁)?
方法一:我们可以在catch里面进行解锁。
这个时候程序就正常执行,但是只插入了1889个数据,t2线程的1000个数据都插入了,t1线程只插入了889个数据。因为插入到1888的时候就异常了。
方法二: 方法一不好的地方在于可能存在多处需要进行解锁,这个时候反而复杂了;所以有人写了一个类帮助我们进行处理这种情况。
//因为可能是各种锁,所以直接给了一个模板
template<class Lock>
class LockGuard //锁的守卫
{
public:
//构造的时候把锁传过来保存起来,并且进行加锁的动作
//这个地方锁是不支持拷贝的,因为锁要锁到同一个对象上,如果支持了拷贝就很难搞
//解锁和加锁要保持是同一把锁,拷贝了就不是同一把锁了
LockGuard(Lock& lock)
:_lock(lock)
{
_lock.lock();
}
~LockGuard() //析构的时候进行解锁
{
_lock.unlock();
}
private:
Lock& _lock;
//我们这给个引用就能解决拷贝的问题
//1.引用必须在初始化列表初始化
//2.引用_lock就是锁的别名,可以保证加锁解锁都是同一把锁。
};
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; i++)
{
//这个时候就把互斥锁的管理权交给lock这个对象
//我们用mtx构造这个lock对象,构造的时候就直接加锁
//for循环结束,出了作用域就直接调用它的析构函数,析构函数就会自动解锁
//抛异常也是出了作用域,也会进行解锁,
LockGuard<mutex> lock(mtx);
cout << this_thread::get_id() << ":" << base + i << endl;
//失败了,抛异常
v.push_back(base + i);
//模拟push_back失败抛异常
if (base == 1000 && i == 888)
{
throw bad_alloc();
}
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
try
{
t1 = thread(func, std::ref(vec), 1000, 1000, std::ref(mtx)); //移动赋值
t2 = thread(func, std::ref(vec), 1000, 2000, std::ref(mtx));
}
catch (const exception& e)
{
cout << e.what() << endl;
}
t1.join();
t2.join();
for (auto e : vec)
{
cout << e << " ";
}
cout << endl;
cout << vec.size() << endl;
return 0;
}
对于这个类需要注意的几点:
1.这个时候就把互斥锁的管理权交给lock这个对象,我们用mtx构造这个lock对象,构造的时候就直接加锁。for循环结束,出了作用域就直接调用它的析构函数,析构函数就会自动解锁;抛异常也是出了作用域,也会进行解锁,
2.这个类这个地方必须传引用;我们这给个引用就能解决构造函数拷贝的问题
- 引用必须在初始化列表初始化
- 引用_lock就是锁的别名,可以保证加锁解锁都是同一把锁。
ps:锁的拷贝问题
构造的时候把锁传过来保存起来,并且进行加锁的动作。这个地方锁是不支持拷贝的,因为锁要锁到同一个对象上,如果支持了拷贝就很难搞,解锁和加锁要保持是同一把锁,拷贝了就不是同一把锁了 。
用这个类对象同样可以解决当前存在的问题
再比如既涉及到锁的线程安全,又涉及到内存管理的线程安全
eg:假设还有内存管理,有可能是530行抛异常,532行抛异常或者534行抛异常;如果都早catch里面去处理是非常的难受的,因为你不知道是哪一行抛的异常,比如530行抛异常了。你不需要处理;532行抛异常了你需要delete p1并且解锁;534行抛异常了,你需要delete p1与 p2并且解锁。
这样做非常的折磨人,所以这个地方的锁我们就交给LockGuard去管理,内存问题以后就交给智能指针去管理(智能指针和LockGuard的原理是一样的),以后不管是谁跑了异常,出了作用域都会自动管理,自动解锁或者释放内存。
当然这个LockGuard是不用我们自己去手动实现的,库里面是给我们提供了的。就是lock_guard与unique_lock。
这个lock_guard与unique_lock用的都是同一个东西,它们的原理用官方的属于就是RAII。
10.6.3 lock_guard
这个就对应我们刚才手动模拟的LockGuard
std::lock_gurad 是 C++11 中定义的模板类。定义如下:
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供unique_lock
10.6.4 unique_lock
它也是可以达到一样的效果的。
与lock_gard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock对象需要传递一个Mutex对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权),释放(release:返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同),mutex(返回当前unique_lock所管理的互斥量的指针)。
10.6.5 lock_guard与unique_lock的区别?
unique_lock不一样的点在于lock_guard只提供构造函数和析构函数,unique_lock除了提供构造函数和析构函数还多增加了一些功能,因为可能存在这样的情况,我把锁交给你去管理了,在作用域的中途可能会有需求需要进行解锁,待会再把锁给加回来,因此unique_lock就多增加了一些功能(说白了就是在我们自己实现的LockGuard这个类里面再增加一些其他的接口,对应锁本身的接口)。
lock_guard
unique_lock
10.7 条件变量
10.7.1 函数实现
实现一个函数,要求:两个线程交替打印,一个打印奇数,一个打印偶数。
int main() { int n = 100; int i = 0; //t1打印偶数 thread t1([n, &i] { while (i < n) { cout << this_thread::get_id() << ":" << i << endl; ++i; } }); //t2打印奇数 thread t2([n, &i] { while (i < n) { cout << this_thread::get_id() << ":" << i << endl; ++i; } }); t1.join(); t2.join(); return 0; }
当前我们这个代码是保证不了交替打印的,很明显是存在线程安全的。我们的目的就是让t1线程走完,然后再让t2线程再走,t1和t2线程交替着走,就能保证交替打印了。
我们第一时间想到的方法可能就是直接加锁。
int main() { int n = 100; int i = 0; mutex mtx; //t1打印偶数 thread t1([n, &i, &mtx] { while (i < n) { unique_lock<mutex> lock(mtx); cout << this_thread::get_id() << ":" << i << endl; ++i; } }); //t2打印奇数 thread t2([n, &i, &mtx] { while (i < n) { unique_lock<mutex> lock(mtx); cout << this_thread::get_id() << ":" << i << endl; ++i; } }); t1.join(); t2.join(); return 0; }
加锁后会打印到100的原因就是当i等于99的时候,两个线程同时进入循环竞争所,比如t1先竞争上了,i变成了100,释放锁以后又被t2拿到了,t2就执行打印动作,所以就打印出了100。不加锁不打印100的原因则是,开始两个线程都会打印1次0。
而且加完锁后,还是没有达到我们的预期,线程1全程打印0到99,线程2最后一次才竞争上锁打印了个100。因为对于这个锁,每一轮线程t1和t2都是公平竞争的。对于博主的编译器100轮有99轮都是被t1竞争上锁了,只有1轮被t2竞争上。显然加锁是无法解决这个问题的。
ps:可能有些编译器上,这样写是能解决这个问题的,但是这份代码实际上始终是有问题的,并不能完美的解决。拿我们的代码来说,把t1放在前面是不能保证t1被先打印的。线程去运行的时候,为了保持公平,是有一个时间片轮转的,要根据你线程的优先级分配时间片(时间片就是你能在CPU上运行的时间),你的时间只要用完了,就把你切出去,重新给你分配个时间片,让你去排队,排到你了在运行,这就叫做时间片轮转。一个电脑上有很多很多的进程,每个进程又可能会有多个线程。时间片轮转就可呢=以保证多个进程或者线程同时运行。回到我们的代码,t1和t2不会因为谁先写在前面就能保证谁先运行,它们可能是同时创建同时执行的,抢这把锁的时候就看谁快。虽然说99% 的情况都是t1先执行,因为t1就是在前面,就是先创建,可能就是有那么一丢丢的优势,但是某种情况下t2先执行也是完全可能的。通过这种方式是非常的不靠谱的,是不能保证偶数先打印还是奇数先打印。这也是一个问题。
解决方法-条件变量
C++中条件变量是一个对象,能够在通知恢复之前阻止调用线程。条件变量本身不是线程安全的经常和互斥锁搭配使用。
10.7.2 条件变量的接口介绍
这个是关于wait接口的介绍,wait一共实现了两个版本:
版本1:
当前执行的线程是block(阻塞)的直到被notified(通知)。在线程正在阻塞的一瞬间,这个wait函数就会调用lck.unlock()。表达的意思是说,我去进行wait,wait是一个阻塞的接口,我要去进行等待,在我进入等待的那一瞬间,我会把传给我的那把锁给解锁,因为不解这把锁就会卡死。
一旦发生了notified(比如你阻塞了,另外一个线程用这个notified把你去唤醒了),那么这个wait函数就会取消阻塞并且调用lck.lock(),进入非阻塞之前把锁获取到。
版本2:
版本2多了一个Predicate的对象。这个对象是你是否进行wait的一个依据。这个对象是一个可调用对象(lambda表达式,函数指针,仿函数)。
在第二个版本中,这个wait函数只有在可调用对象的返回值是false的时候才会block。如果是true是不会block的,相当于就是封装了一下第一个版本。
//相当于就是这样去实现的
while (!pred())
wait(lck);
这个版本是否进行阻塞就取决于第二个参数的返回值,也就是说阻塞是需要条件的。
我们利用wait的第二个版本就可以解决先打印偶数,后打印奇数的问题。
int main()
{
int n = 100;
int i = 0;
mutex mtx;
condition_variable cv;
bool flag = false;
// 偶数-先打印
thread t1([n, &i, &mtx, &cv, &flag]{
while (i < n)
{
unique_lock<mutex> lock(mtx);
//!flag是true,那么这里获取锁后就不会阻塞,就会优先运行
cv.wait(lock, [&flag]() {return !flag; });
cout <<this_thread::get_id()<<"->:"<<i << endl;
++i;
}
});
// 奇数-后打印
thread t2([n, &i, &mtx, &cv, &flag]{
while (i < n)
{
unique_lock<mutex> lock(mtx);
//flag是false的时候,这里会一直阻塞,直到flag变成true
cv.wait(lock, [&flag]() {return flag; });
cout << this_thread::get_id() << ":->" << i << endl;
++i;
}
});
// 交替走
t1.join();
t2.join();
return 0;
这个时候,如果是t1先竞争到锁,t2就在获取锁的位置阻塞住了,同时t1也不会在wait处阻塞,因为t1的条件变量的wait的参数的返回值是true,所以t1就一路畅通无阻,优先打印;如果是t2先竞争到锁,t1就在获取锁的位置阻塞住了,t2继续向下执行,因为t2的条件变量的wait的参数的返回值是false,所以t2就在wait处阻塞住了不会向下运行,而且在t2进入阻塞的时候t2会立即释放掉锁,此时t1就被唤醒了,成功的拿到了锁,向下执行,执行到wait处,也不会被阻塞住,所以还是一路畅通无阻,优先打印。这样的写法就可以永远保证t1先执行。
t2先竞争到锁,t2就在wait的位置阻塞住了,针对t2,t1应该怎么办呢?
t1第一次一定是先运行的,下一次就一定不能继续运行,如何防止t1继续运行呢或者如何保证下一次是t2运行呢?
我们的思路肯定是在t1运行后唤醒t2,但是整个地方不能单纯的直接唤醒t2,因为即使通知了t2,t2也不能继续运行,因为t2wait的参数的返回值始终是false。所以在t1中我们还要将flag改为true。
这样写就可以保证t1运行完后让t2继续运行。正常情况,t1在将flag变为true的后,唤醒t2的wait,唤醒后t2就在wait处取消了阻塞并且进行了加锁,所以就能保证t1结束后t2进行运行。如果碰到极端情况,t1运行完后,t2被挡在了某个时间片上,t1也不会连续运行,因为刚才flag是false,t1畅通无阻,现在flag是true,t1就一定会被阻塞。
同样的对于t2,为了防止t2连续运行,也应该做相同的操作。
此时就真正实现了交替打印偶数和奇数
int main()
{
int n = 100;
int i = 0;
mutex mtx;
condition_variable cv;
bool flag = false;
// 偶数-先打印
thread t1([n, &i, &mtx, &cv, &flag]{
while (i < n)
{
unique_lock<mutex> lock(mtx);
//!flag是true,那么这里获取锁后就不会阻塞,就会优先运行
cv.wait(lock, [&flag]() {return !flag; });
cout <<this_thread::get_id()<<"->:"<<i << endl;
++i;
flag = true; //保证下一个打印的一定是t2,也可以防止t1连续打印运行
cv.notify_one();
}
});
// 奇数-后打印
thread t2([n, &i, &mtx, &cv, &flag]{
while (i < n)
{
unique_lock<mutex> lock(mtx);
//flag是false的时候,这里会一直阻塞,直到flag变成true
cv.wait(lock, [&flag]() {return flag; });
cout << this_thread::get_id() << ":->" << i << endl;
++i;
flag = false;
cv.notify_one();
}
});
// 交替走
t1.join();
t2.join();
return 0;
}
执行结果:
对于各种各样的情况我们也都可以完美解决。
1.让t2防止前面,并且创建t2的时候完间隔3秒再创建t1.
int main()
{
int n = 100;
int i = 0;
mutex mtx;
condition_variable cv;
bool flag = false;
// 奇数-后打印
thread t2([n, &i, &mtx, &cv, &flag] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cout << "t2获取到锁" << endl;
//flag是false的时候,这里会一直阻塞,直到flag变成true
cv.wait(lock, [&flag]() {return flag; });
cout << this_thread::get_id() << ":->" << i << endl;
++i;
flag = false;
cv.notify_one();
}
});
this_thread::sleep_for(chrono::seconds(3));
// 偶数-先打印
thread t1([n, &i, &mtx, &cv, &flag] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
//!flag是true,那么这里获取锁后就不会阻塞,就会优先运行
cv.wait(lock, [&flag]() {return !flag; });
cout << this_thread::get_id() << "->:" << i << endl;
++i;
flag = true; //保证下一个打印的一定是t2,也可以防止t1连续打印运行
cv.notify_one();
}
});
// 交替走
t1.join();
t2.join();
return 0;
}
照样是t1先执行,t2后执行
2.模拟t2多休眠一会,t1也不会连续执行
int main()
{
int n = 100;
int i = 0;
mutex mtx;
condition_variable cv;
bool flag = false;
// 奇数-后打印
thread t2([n, &i, &mtx, &cv, &flag] {
while (i < n)
{
//模拟中间某次t2时间片用完了,竞争大,多休眠了一会
if (i == 2)
{
cout << this_thread::get_id() << "休眠3s" << endl;
this_thread::sleep_for(chrono::seconds(3));
}
unique_lock<mutex> lock(mtx);
//flag是false的时候,这里会一直阻塞,直到flag变成true
cv.wait(lock, [&flag]() {return flag; });
cout << this_thread::get_id() << ":->" << i << endl;
++i;
flag = false;
cv.notify_one();
}
});
// 偶数-先打印
thread t1([n, &i, &mtx, &cv, &flag] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
//!flag是true,那么这里获取锁后就不会阻塞,就会优先运行
cv.wait(lock, [&flag]() {return !flag; });
cout << this_thread::get_id() << "->:" << i << endl;
++i;
flag = true; //保证下一个打印的一定是t2,也可以防止t1连续打印运行
cv.notify_one();
}
});
// 交替走
t1.join();
t2.join();
return 0;
}