目录
C++11 简介
在2003年C++标准委员会曾经提交了一份技术勘误表(简称TC1),使得C++03这个名字已经取代了C++98称为C++11之前的最新C++标准名称。不过由于C++03(TC1)主要是对C++98标准中的漏洞进行修复,语言的核心部分则没有改动,因此人们习惯性的把两个标准合并称为C++98/03标准。
从C++0x到C++11,C++标准10年磨一剑,第二个真正意义上的标准珊珊来迟。相比于
C++98/03,C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言。相比较而言,C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个重点去学习。C++11增加的语法特性非常篇幅非常多,我们这里没办法一一讲解,所以主要讲解实际中比较实用的语法。
小故事:
1998年是C++标准委员会成立的第一年,本来计划以后每5年视实际需要更新一次标准,C++国际标准委员会在研究C++ 03的下一个版本的时候,一开始计划是2007年发布,所以最初这个标准叫C++ 07。但是到06年的时候,官方觉得2007年肯定完不成C++ 07,而且官方觉得2008年可能也完不成。最后干脆叫C++ 0x。x的意思是不知道到底能在07还是08还是09年完成。结果2010年的时候也没完成,最后在2011年终于完成了C++标准。所以最终定名为C++11。
1. 统一列表初始化
{} 初始化
在c++98中,标准允许使用大括号{}对数组或者结构体元素进行统一的列表初始值设定:
struct Coord
{
int _x;
int _y;
};
int main()
{
int arr1[]={0,1,2,3,4};
int arr2[5]={0};
Coord c={1,1};
return 0;
}
C++11扩大了大括号括起的列表的使用范围,即使其可以用于所有的内置类型和用户自定义类型,使用初始化列表时,可添加等号(=),也可以省略:
struct Coord
{
int _x;
int _y;
};
int main()
{
int a1=10;
int a2={10};
int a3{10};
int arr1[]{0,1,2,3,4};
int arr2[5]{0};
Coord c={1,1};
//C++11中的列表初始化可以适用于new表达式
int* ptr=new int[5]{0};//5个int全为0
return 0;
}
列表初始化同样适用于容器(构造中涉及到STL中 intializer_list
类):
//列表初始化的构造
list<string> authors{ "authors","Shakespeare","Austen"};
vector<const char*> articles1{ "a","an","the" };
vector<const char*> articles2={ "a","an","the" };
map<string,string> dict1={{"apple","苹果"},{"banana","香蕉"}};
map<string, string> dict2 { {"orange","橘子"},{"peach","桃子"}};
vector<int> data{1,2,3};
//列表初始化的赋值重载
data={10,20,30};
vector<const char*> articles3( "a","an","the" );//错误
自定义对象时也可以使用列表初始化调用构造函数初始化:
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 };
Date d3 = { 2022, 1, 3 };
Date* dd=new Date[2]{{2022,1,4},{2022,1,5}};//使用new注意要有相应的构造函数
return 0;
}
如果不想以这种方式初始化自定义类,可以使用 explicit
修饰构造函数,防止隐式类型转换。
STL的 initializer_list 类
参考:https://cplusplus.com/reference/initializer_list/initializer_list/?kw=initializer_list
此类型用于访问C++初始化列表的值,该容器是const T 类型的元素列表。
此类型的对象由编译器根据初始化列表声明自动构造,初始化列表声明是一串在大括号中由逗号分隔的元素列表。
int main()
{
// the type of il is an initializer_list
auto il = { 10, 20, 30 };
cout << typeid(il).name() << endl;
return 0;
}
拷贝或者赋值一个initializer_list 对象不会拷贝列表中的元素,拷贝后原始列表和副本共享元素(引用语义),这个临时数组的生命周期与 initializer_list 对象相同。
仅采用这种类型的一个参数的构造函数是一种特殊的构造函数,称为初始化列表构造函数。 当使用初始化列表构造函数语法时,初始化列表构造函数优先于其他构造函数:
class myclass
{
private:
int x;
int y;
public:
myclass (int,int)
myclass (initializer_list<int>);
/* definitions ... */
};
myclass foo {10,20}; // calls initializer_list ctor
myclass bar (10,20); // calls first constructor
STL中的一些容器也包含了初始化列表的构造函数:
如 list
更多可参考:
http://www.cplusplus.com/reference/list/list/list/
http://www.cplusplus.com/reference/vector/vector/vector/
http://www.cplusplus.com/reference/map/map/map/
http://www.cplusplus.com/reference/vector/vector/operator=/
https://m.cplusplus.com/reference/set/set/set/
- 简单模拟vector的构造和赋值重载
template<class T>
class vector
{
public:
typedef T* iterator;
vector(initializer_list<T> l)
{
_start = new T[l.size()];
_finish = _start + l.size();
_endofstorage = _start + l.size();
iterator vit = _start;
for (const auto& e : l)
{
*vit++ = e;
}
}
vector<T>& operator=(initializer_list<T> l)
{
vector<T> tmp(l);
std::swap(_start, tmp._start);
std::swap(_finish, tmp._finish);
std::swap(_endofstorage, tmp._endofstorage);
return *this;
}
private:
iterator _start;
iterator _finish;
iterator _endofstorage;
};
2. 声明
C++11提供了多种简化声明的方式:
auto
我们常常需要表达式的值赋值给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而要做到这一点并不容易,C++11引入 auto
关键字,让编译器替我们去分析表达式所属类型,auto让编译器通过初始值来推断变量类型,显然,auto定义的变量必须有初始值:
int main()
{
auto i=0,*p=&i;//正确:i是整数,p是整型指针
//auto sz=0,pi=3.14;//错误:sz和pi的类型不一致
auto pf=strcpy;
cout << typeid(pf).name() << endl;
//auto替代一些过长的类型
map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
//map<string, string>::iterator it = dict.begin();
auto it = dict.begin();
}
- 复合类型、常量和auto
编译器推断的auto类型有时和初始值的类型并不完全一样,编译器会适当的改变结果类型使其更符合初始化规则。
当引用被用作初始化的值,真正初始化的是引用对象的值,引用对象的类型为auto类型:
int i=0,&r=i;
auto a=r;//a是一个int
auto会忽略顶层const,同时底层const则会保留下来:
int i=10;
const int ci=i;
auto a=ci;//a是一个int(顶层const 被忽略)
auto b=&i;//b是一个整型指针
auto c=&ci;//c是一个指向整数常量的指针(对常量独享取地址是一种底层const)
*b = 20;//正确
*c = 20;//错误:表达式必须是可修改的左值
如果希望推断的auto是一个顶层const则需明确指出:
const auto d=ci;
还可以将引用的类型设为auto:
auto& e=ci;//e是整型常量的引用,绑定到ci
auto& f=42;//错误:不能为非常量引用绑定字面值
const auto& g=42;//正确
decltype
关键字decltype将操作数的返回类型声明为表达式指定的类型。在此过程中编译器分析表达式并得到类型却不实际计算表达式的值:
// decltype的一些使用使用场景
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
decltype(t1 * t2) ret;
cout << typeid(ret).name() << endl;
}
int f(int a)
{
return a;
}
int main()
{
const int x = 1;
double y = 2.2;
decltype(x * y) ret; // ret的类型是double
decltype(&x) p; // p的类型是int*
cout << typeid(ret).name() << endl;
cout << typeid(p).name() << endl;
F(1, 'a');
//函数指针
//int(*ff)(int)=f;
decltype(&f) ff;//ff类型为f的函数指针
//定义来自auto的变量
map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
auto it = dict.begin();
vector<decltype<it>> v;//因为不用显式赋值,decltype可作为模板参数
v.push_back(it);
return 0;
}
decltype处理顶层const和引用的方式和auto略有不同,decltype不会忽略顶层const:
const int ci=0,&cr=ci;
decltype(ci) x=0;//x的类型是 const int
decltype(cr) y=x;//y的类型是 const int&
decltype(cr) z;//错误,z是一个引用,必须初始化
decltype如果变量名加上括号会被当成引用:
int i=42;
decltype((i)) a;//错误:a是int&必须初始化
decltype(i) b; //正确:b是一个int
🚩注意:decltype((variable)) 双层括号的结果永远是引用,而decltype(variable)结果只有当variable本身就是一个引用时才是引用。
nullptr
由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
3. C++11新增STL
4. 右值引用与移动语义
在C11中,常量、变量或者表达式一定是左值(lvalue)或者右值(rvalue)
左值与右值
- 左值:非临时的(具名的,可在多条语句中使用,可以被取地址),可以出现在等号的左边或者右边。可分为非常量左值和常量左值:
- 右值:临时的(不具名的,只在当前语句中有效[将亡值],不能取地址)。只能出现在等号的右边。可分为非常量右值和常量右值。
左值引用与右值引用
- 左值引用(lvalue Reference):就是对左值的引用,分别为非常量左值引用和常量左值引用:
int a=10; //非常量左值(有确定存储地址,也有变量名)
const int a1=10; //常量左值(有确定存储地址,也有变量名)
const int a2=20; //常量左值(有确定存储地址,也有变量名)
//非常量左值引用
int &b1=a; //正确,a是一个非常量左值,可以被非常量左值引用绑定
int &b2=a1; //错误,a1是一个常量左值,不可以被非常量左值引用绑定
int &b3=10; //错误,10是一个非常量右值,不可以被非常量左值引用绑定
int &b4=a1+a2; //错误,(a1+a2)是一个常量右值,不可以被非常量左值引用绑定
//常量左值引用
const int &c1=a; //正确,a是一个非常量左值,可以被非常量右值引用绑定
const int &c2=a1; //正确,a1是一个常量左值,可以被非常量右值引用绑定
const int &c3=a+a1; //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
const int &c4=a1+a2; //正确,(a1+a2)是一个常量右值,可以被非常量右值引用绑定
🚩注意:常量左值引用是“万能”的引用类型,可以绑定到所有类型的值,包括非常量左值,常量左值,非常量右值和常量右值。
- 右值引用(Rvalue Reference)对右值的引用就是右值引用。可分为非常量右值引用和常量右值引用。我们通过
&&
而不是&来获得右值引用。
int a=10; //非常量左值(有确定存储地址,也有变量名)
const int a1=20; //常量左值(有确定存储地址,也有变量名)
const int a2=20; //常量左值(有确定存储地址,也有变量名)
//非常量右值引用
int &&b1=a; //错误,a是一个非常量左值,不可以被非常量右值引用绑定
int &&b2=a1; //错误,a1是一个常量左值,不可以被非常量右值引用绑定
int &&b3=10; //正确,10是一个非常量右值,可以被非常量右值引用绑定
int &&b4=a1+a2; //错误,(a1+a2)是一个常量右值,不可以被非常量右值引用绑定
//常量右值引用
const int &&c1=a; //错误,a是一个非常量左值,不可以被常量右值引用绑定
const int &&c2=a1; //错误,a1是一个常量左值,不可以被常量右值引用绑定
const int &&c3=a+a1; //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
const int &&c4=a1+a2; //正确,(a1+a2)是一个常量右值,不可以被常量右值引用绑定
🚩注意:
-
可以将右值引用归纳为:非常量右值引用只能绑定到非常量右值上;常量右值引用可以绑定到非常量右值、常量右值上。
-
给右值绑定右值引用后后,会导致右值存储到特定位置,且可以取到该位置的地址。
-
不能将一个右值绑定到一个左值上。
-
特别需要注意的是,右值引用自身是左值,所以不能用一个右值引用变量初始化一个右值引用。
int &&rr1=42;//正确,字面常量是右值 int &&rr2=rr1;//错误,rr1是左值
-
常量右值引用由于不能修改且自身类型又为左值,所以不太常用,常被常量左值引用代替。
-
左值持久 右值短暂
左值有持久的状态,而右值要么是字面值常量,要么是表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象上,可以得知:
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源
右值引用“窃取”将要被销毁的对象的资源。
查看一下汇编代码,理解编译器是如何对待右值引用的:
可以看出,当一个右值引用变量绑定到右值的时候,编译器会将右值存储到栈内存中,并且将该地址赋值给右值引用。
也就是说,当一个即将消亡的值被一个右值引用变量绑定时,编译器会先把该值保存到栈上,然后把保存位置的内存地址赋值给右值引用。
标准库 move 函数
虽不能将右值引用直接绑定在一个左值上,但我们可以显示地将一个左值转换为对应的右值引用类型——move函数。此函数定义在头文件utility中。
int r=42;
int &&rr=std::move(r);//ok
const int t=12;
const int &&rt=std::move(t);//常量右值需由常量右值引用绑定
move调用告诉编译器:有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺,除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予他新值,但是不能使用一个移后源对象。
移动语义
移动:将资源从一个对象转移到另一个对象, 以减少不必要的临时对象的创建、拷贝与销毁。
为了让我们自定义的类支持移动操作,需要为其定义移动构造函数和移动赋值运算符,这两个成员类似对应的拷贝操作,但是他们从给定对象中转移出资源而不是拷贝资源。
移动构造的参数是该类类型的一个引用,不同于拷贝构造的是,这个引用在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都需要有缺省值。
除了完成资源移动,移动构造需要确保移后源对象处于这样一种状态 —— 销毁他是无害的。一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
作为一个例子,我们观察mystring类(模拟STL中的string)在有无移动构造函数情况下的使用效率:
//mystring.h
#pragma once
class mystring
{
public:
typedef char* iterator;
//构造函数
mystring(const char* str = "") : _size(strlen(str)), _capacity(_size)
{
//开辟空间
_str = new char[_capacity + 1];
strcpy(_str,str);
}
//拷贝构造
//自己封装一个swap的函数来处理mystring类
void swap( mystring& s2)
{
std::swap(_str, s2._str);
std::swap(_size, s2._size);
std::swap(_capacity, s2._capacity);
}
mystring(const mystring& s) :_str(nullptr),_size(0), _capacity(0)
{
//构造生成临时对象
cout << "mystring(const mystring& s) -- 深拷贝" << endl;
mystring temp = mystring(s._str);
//将临时对象的成员与*this互换
//swap将this指向的内容与temp互换
swap(temp);
}
//赋值重载
mystring& operator=(const mystring& s)
{
cout << "mystring& operator=(const mystring& s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
//析构函数
~mystring()
{
delete[] _str;
_str = nullptr;
_capacity = 0;
_size = 0;
}
//push_back
void push_back(char ch)
{
//检查扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
//+=
//mystring operator+=(char ch)//传值返回
mystring& operator+=(char ch)//传引用返回
{
push_back(ch);
return *this;
}
iterator begin()
{
return _str;
}
iterator end()
{
return _str+_size;
}
private:
char* _str;
size_t _size;
size_t _capacity;
static const size_t npos;
};
//整型转字符串
mystring to_mystring(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
mystring 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;
}
- 左值引用的场景
做参数可以提升效率:
#include "mystring.h"
void func1(mystring s)
{
cout << "func1()" << endl;
}
void func2(const mystring& s)
{
cout << "func2()" << endl;
}
int main()
{
mystring s1("Hello");
//func1 和 func2 的调用可以看到左值引用做参数减少了拷贝,提高效率
func1(s1);
func2(s1);
}
做返回值也可以提升效率
int main()
{
mystring s1("Hello");
// string operator+=(char ch) 传值返回存在深拷贝
// string& operator+=(char ch) 传左值引用没有拷贝提高了效率
s1+='!';
}
- 左值引用的短板
当函数的返回对象是一个局部变量,离开函数作用域即销毁,就不能使用左值返回,只能选择传值返回。例如:mystring to_mystring(int value)
函数中可以看到,这里只能使用传值返回,传值返回需要将对象拷贝给临时对象(如果该临时对象再次被用来构造,编译器则会优化成为1次拷贝)
编译器优化后:
可以看到连续两次的拷贝构造被编译器优化为了一次:
to_mystring的返回值是一个右值,用这个右值构造了s2,如果没有移动构造,调用就会匹配调用拷贝构造,因为常量左值引用是可以引用右值的,这里就是一个深拷贝
通过这个临时变量拷贝构造了一个新的对象s2,临时变量在拷贝构造完成之后就销毁了,如果堆内存很大的话,那么,这个拷贝构造的代价会很大,带来了额外的性能损失。每次都会产生临时变量并造成额外的性能损失,有没有办法避免临时变量造成的性能损失呢?
- 右值引用和移动语义解决上述问题
对于右值,深拷贝其资源再销毁的操作是繁琐且无意义的,不如将其资源转移出来(作用域改变),继续为我们所用,相当于为其“续命”。
移动构造的本质是将右值的资源“窃取”过来,占为己有,那么就不用进行深拷贝了。
🚩我们在上述的 mystring
类中添加移动构造函数:
class mystring
{
public:
//...
//移动构造
mystring(mystring&& s) :_str(nullptr), _size(0), _capacity(0)
{
cout << "mystring(mystring&& s) -- 移动语义"<<endl;
swap(s);
}
//...
};
这个移动构造函数并没有做深拷贝,仅仅是将指针的所有者(s)转移到了另外一个对象(_str),同时,将参数对象s的指针置为空,这里仅仅是做了浅拷贝,因此,这个构造函数避免了临时变量的深拷贝问题。
此时我们再调用 to_mystring
函数,函数中的局部变量 str 在函数作用域中为左值,但是编译器优化会将其识别为右值,然后编译器按照最匹配原则,将会调用以右值引用作为参数的移动构造,来把即将消亡的函数返回值的资源转移出来。(如果我们将返回值再次用于拷贝构造,那么两次的移动构造也会被编译器优化为一次)
移动构造没有新开空间,没有拷贝数据,所以效率得以提升。
需要注意的一个细节是,我们提供移动构造函数的同时也会提供一个拷贝构造函数,以防止移动不成功的时候还能拷贝构造,使我们的代码更安全。
🚩不仅仅有移动构造函数,还有移动赋值
如果没有移动构造和移动赋值,只用常规的拷贝构造函数和赋值重载函数进行赋值:
加入移动构造后:
现在加入移动赋值函数
class mystring
{
public:
//...
//移动构造
mystring& operator=(mystring&& s)
{
cout << "mystring& operator=(mystring&& s) -- 移动语义" << endl;
swap(s);
return *this;
}
//...
};
这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果用一个已经存在的对象接受,编译器便无法优化了。
to_mystring 函数中的 str 会被编译器识别为右值,调用移动构造,创建了临时对象,然后临时对象作为 to_mystring 函数的返回值赋给了 s3 ,这里调用的是移动赋值。
临时对象将会进行资源移动,而不是频繁的进行 深拷贝+析构 的操作
通过调试也可以看到资源的转移(数据的地址不曾改变):
普通的左值是否也能借助移动语义来优化性能呢,那该怎么做呢?事实上我们可以使用C++11提供的 std::move
方法来将左值转换为右值,从而方便应用移动语义。move是将对象资源的所有权从一个对象转移到另一个对象,只是转移,没有内存的拷贝,这就是所谓的move语义。
C++11 中的STL容器都是增加了移动构造和移动赋值,例如:
http://www.cplusplus.com/reference/string/string/string/
http://www.cplusplus.com/reference/vector/vector/vector/
https://cplusplus.com/reference/list/list/list/
https://cplusplus.com/reference/map/map/map/
除此之外,容器的插入结构也提供了右值引用参数的版本:
🚩总结:我们并非直接使用右值引用去减少拷贝,而是对于支持深拷贝的类,提供移动构造和移动赋值,这时这些类的对象进行传值返回,或者参数为右值时,则可以使用移动构造和移动赋值转移资源,避免深拷贝提高效率
完美转发
- 模板中的万能引用
-
模板中的
T&&
不代表右值引用,而是万能引用,其既能接受左值又能接收右值。这里的知识涉及到引用折叠,可查看:引用折叠,了解详情
-
模板的万能引用只是提供了能够同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值。
-
如果希望能够在传递过程中保持他的左值或者右值属性,则需要用下面提到的完美转发。
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const右值引用" << endl; }
template<class T>
void PerfectForward(T&& t)//万能引用
{
Fun(t);
}
int main()
{
PerfectForward(10); //右值
int a=10;
PerfectForward(a); //左值
PerfectForward(std::move(a)); //右值
const int b = 20;
PerfectForward(b); //const 左值
PerfectForward(std::move(b)); //const 右值
return 0;
}
但是右值被接收后,会退化为左值:
std::forward
完美转发在传参的过程中保留对象的原生类型属性
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const右值引用" << endl; }
//std::forward<T>(t)在传参的过程中保持了t的原生类型属性
template<class T>
void PerfectForward(T&& t)
{
Fun(std::forward<T>(t));
}
int main()
{
PerfectForward(10); //右值
int a=10;
PerfectForward(a); //左值
PerfectForward(std::move(a)); //右值
const int b = 20;
PerfectForward(b); //const 左值
PerfectForward(std::move(b)); //const 右值
return 0;
}
右值属性得以保留
- 完美转法过程中的使用场景
我们模拟实现list,来窥探完美转发的作用
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 Insert(Node* pos, T&& x)//右值引用
{
cout << "右值Insert" << endl;
Node* prev = pos->_prev;
//法一:移动赋值
Node* newnode = new Node;
newnode->_data = x; // 关键位置: x退化左值属性则调用了T类型的普通赋值重载函数
//newnode->_data = std::forward<T>(x); // 关键位置: x保存右值属性则调用了T类型的移动赋值重载函数
//法二:移动构造
//定位new,调用移动构造
//Node* newnode = (Node*)malloc(sizeof(Node));
//new(&newnode->_data)T(x); //传递的是左值,将调用拷贝构造
//new(&newnode->_data)T(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)//左值引用
{
cout << "左值Insert" << endl;
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;
};
int main()
{
List<mystring> lt;
lt.PushBack("1111");
return 0;
}
我们使用之前自己实现的mystring类,来作为元素插入自己实现的list。
我们在 PushBack
函数中分别使用原值传递和完美转发如下:
在我们使用完美转发调用insert后,再分别使用原值传递和完美转发进行赋值或者构造如下:
只要有右值引用,再传递其他函数调用,要保持右值属性,必须用完美转发。
5. 类的默认成员函数与关键字 default 和 delete
默认成员函数
在c++类中,有6个默认成员函数:
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值重载函数
- 取地址重载函数
- const取地址函数
默认的成员函数,我们如果不显式的定义,那么编译器会为我们自动生成,但是注意如果涉及到深拷贝,需要我们自己实现拷贝和赋值函数以及析构(资源释放)。
在C++11类中新增了两个默认成员函数:移动构造函数和移动赋值重载函数。
这两个函数已在上面介绍右值引用时已解释清楚。
🚩需要注意以下几点:
- 如果你没有实现移动构造函数,且没有实现析构函数、拷贝函数、赋值重载都没有实现,那么编译器会自动生成一个默认的移动构造函数。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员变量,则编译器会将移动操作定义为=delete。
- 如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载都没有实现,那么编译器会自动生成一个默认移动赋值。默认生成的移动赋值函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值,如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员变量,则编译器会将移动操作定义为=delete。(默认移动赋值跟上面移动构造完全类似)
- 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。
为什么我们定义了自己的拷贝构造,拷贝赋值运算符,析构函数后,编译器不会帮我们合成移动构造函数,因为,如果我们定义了这些操作往往表示类内含有指针成员需要动态分配内存,如果需要为类定义移动操作,那么应该确保移动后源对象是安全的,但是默认的移动构造函数不会帮我们把指针成员置空,移后源不是可析构的安全状态,如果这样,当离开移动构造后,源对象被析构,对象内的指针成员空间被回收,转移之后对象内的指针成员出现悬垂现象,程序将引起致命的错误。所以当我们定义了自己的拷贝操作和析构函数时,编译器是不会帮我们合成默认移动构造函数的。
强制生成默认函数的关键字
我们可以将默认的成员函数定义为 =default
来显式的要求编译器生成该函数。
假设我们需要使用某个默认的成员函数,但是由于一些原因,这个函数没有生成。例如:我们定义了拷贝构造函数,就不会默认生成移动构造函数了,我们可以使用default关键字显式指定移动构造生成,查下下方例子:
其中的mystring类的实现在上文的右值引用->移动语义的章节中。
可以看到在创建s2时,调用了拷贝构造。
创建s3时如果没有调用default强制生成移动构造(move语义),那么将会调用拷贝构造函数(常量左值引用可以接受右值)。
可以看到default了之后,编译器就会为我们强制生成了default修饰的默认成员函数。
禁止生成默认函数的关键字delete
如果想要限制某些默认函数的生成,
对于大多数类,我们会定义构造函数和拷贝构造函数,但对于某些类我们需要阻止用户进行进行拷贝,在C++98中我们会把拷贝构造放进private中,防止用户显式调用。为了进一步防止其他成员函数调用拷贝,我们可以在private中只声明不实现拷贝函数。(注意如果在public中只声明不实现,用户可以选择在类外实现函数)
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
private:
Person(const Person& p) = delete;//私有了拷贝构造函数
private:
bit::string _name;
int _age;
};
在新标准下,我们可以将其定义为删除的函数(deleted function)来阻止拷贝。在函数的参数列表后面加上 =delete
来指出我们希望将他定义为删除的:
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
Person(const Person& p) = delete;//删除了拷贝构造函数
private:
bit::string _name;
int _age;
};
int main()
{
Person s1;
Person s2 = s1;//报错:尝试引用已删除的函数
return 0;
}
=delete通知编译器(以及代码的读者),我们不希望定义这些成员。
与=default的另一个不同的之处是,我们可以对任何函数都使用=delete(我们只能对编译器可以生成的默认函数使用=default)。所以我们可以用其引导函数的匹配过程。
🚩注意:析构函数是不能定义为删除的,否则成员将无法销毁。
6. lambda 表达式
引入
C++98的一个例子:如果想要对数组排序,可以使用std::sort方法。
#include <algorithm>
#include <functional>
int main()
{
int array[] = {4,1,8,5,3,7,0,9,2,6};
// 默认按照小于比较,排出来结果是升序
std::sort(array, array+sizeof(array)/sizeof(array[0]));
// 如果需要降序,需要改变元素的比较规则
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> gds = { { "可乐", 3.1, 5}, { "乌龙茶", 3, 4}, { "雪碧", 2.8,3}, {"矿泉水", 1.5, 4} };
sort(gds.begin(), gds.end(), ComparePriceLess());
sort(gds.begin(), gds.end(), ComparePriceGreater());
// 每增加一个比较方式,就要提供一个对应的仿函数
return 0;
}
我们在学习c语言时的qsort函数,其排序是通过函数指针来进行比较的:
我们可以向一个算法(如上例中的排序)传递任何类别的可调用对象:函数,函数指针,仿函数类。
人们觉得上面调用函数指针或者仿函数类过于麻烦,每次实现一个algorithm算法,都要自己去重新写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,都为编程带来了蛮烦。
于是C++11语法实现了lambda表达式。
lambda 表达式
一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个未命名的内联函数。与任何函数相似,一个lambda表达式具有一个返回类型,一个参数列表,一个函数体。但与函数不同,lambda可能定义在函数内部;
lambda表达式的格式如下:
[capture list](parameters)mutable->return_type{statement}
参数说明:
-
[capture list]
: 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下去的代码是否为lambda函数,捕捉列表能够捕捉上下文的变量供lambda函数使用。- [var] : 表示值传递方式,捕捉变量var。(即lambda函数体对var的改变,不会影响到父作用域中的var,相当于普通函数的传值传参)
- [=]:表示值传递方式捕捉所有父作用域中的变量(成员函数包含this)。
- [&var]:表示引用传递捕捉变量var(传引用传参)。
- [&]:表示引用传递方式捕捉所有父作用域中的变量(成员函数包含this)。
-
(parameters)
:参数列表,与普通函数的参数列表一致,如果不需要参数,可以连同()一起省略。 -
mutable
:默认情况下lambda函数的捕捉列表的变量被const修饰,mutable可以取消其常量性。使用该修饰符,参数列表不可以省略(即使参数为空,()
亦需要保留)。具体用法将会在案例中进行介绍。 -
return_value:返回值类型,用追踪返回类型的形式声明函数的返回值类型。没有返回值时,或者当返回值明确可由编译器自动推导返回类型时,这部分可省略。
-
{statement}
:函数体,除了可以使用其参数外,还可以使用捕获到的变量。
🚩注意:
- lambda表达式中,参数列表和返回值类型都是可选部分(可省略),而捕捉列表和函数体可以为空(括号不能省略),所以最简单的lambda函数为:
[]{}
,该lambda表达式不能做任何事情。 - lambda表达式表面上可理解为无名函数,该函数无法直接调用,可借助auto将其赋值给变量。
lambda的用法如下:
int main()
{
int a = 2, b = 3;
//省略参数列表和返回值类型,返回值类型由编译器推导为int
auto add1 = [=] {return a + b; };
cout << add1() << endl<<endl;
//省略返回值类型,无返回值类型
auto add2 = [&](int c) {a = b + c; };
add2(10);
cout << a << " " << b << endl << endl;//a=13,b=3
//各部分很完善的lambda表达式
auto add3 = [=, &b](int c)->int {return b += a + c; };
cout << add3(10) << endl << endl;//a=13,b=26
}
对于上述引入时介绍的例子,我们可以改写为调用lambda函数版本:
int main()
{
std::vector<Goods> v = { { "可乐", 3.1, 5}, { "乌龙茶", 3, 4}, { "雪碧", 2.8,3}, {"矿泉水", 1.5, 4} };
//sort(v.begin(), v.end(), ComparePriceLess());
//sort(v.begin(), v.end(), ComparePriceGreater());
sort(v.begin(), v.end(), [](const Goods& l, const Goods& r)->bool {return l._price < r._price; });
return 0;
}
mutable用法:
默认情况下,对于一个值被拷贝的变量,lambda表达式不会改变其值,如果我们希望能改变一个被捕获变量的值,就必须在参数列表末尾加上mutable:
另外需要注意,lambda使用值捕获捕捉到x时,如果不对捕获值x进行改变,之后在lambda函数体内使用x时,该变量将永远是其捕获时被lambda看到的值,这个值不会改变,即使期间在父作用域中改变了x的值。
mutable的作用是:使“值捕获变量”的值,可以在被捕获的值的基础上进行变化,相当于普通函数中的static变量。
多次调用lambda函数对捕获变量值所造成的改动是累积的:
如果要在lambda函数体中改变父作用域的变量,那只能引用捕捉或者传引用传参。
✔tips:
-
父作用域指包含lambda函数的语句块
-
语法上捕捉列表可由多个捕捉项组成,并以逗号分隔。
比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量。 [ &,a,this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量。
-
捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复。
-
在块作用域以外的lambda函数捕捉列表必须为空。
-
在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错。
-
lambda表达式之间不能相互赋值,即使看起来类型相同
void (*PF)();
int main()
{
auto f1 = []{cout << "hello world" << endl; };
auto f2 = []{cout << "hello world" << endl; };
//f1 = f2; // 编译失败--->提示找不到operator=()
// 允许使用一个lambda表达式拷贝构造一个新的副本
auto f3(f2);
f3();
// 可以将lambda表达式赋值给相同类型的函数指针
PF = f2;
PF();
return 0;
}
仿函数类和lambda表达式
我们写一个仿函数类,来对比lambda的使用:
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.02;
Rate r1(rate);
r1(10000, 2);
// lambda
auto r2 = [=](double monty, int year)->double{return monty*rate*year;
};
r2(10000, 2);
return 0;
}
从使用方式来看,仿函数类与lambda表达式使用起来完全一样。
我们打印出lambda的类型
可以看到lambda表达式是一个类,其名称为 lambda_UUID
,UUID是唯一的辨识信息,而不需要通过中央控制端来做辨识信息的指定。如此一来,保证不与其它类冲突的UUID。
这个类是编译器去调用的,我们再看一眼汇编:
实际在底层编译器对于lambda表达式的处理方式,完全就是按照仿函数类的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()。
7. 包装器
function
function 包装器也叫做适配器,C++中的function本质是一个类模板,首先来看一下为什么需要function:
ret=func(x);
上面的func可能是:函数名、仿函数对象,也可能是lambda表达式,所有的这些可调用对象可能会导致模板的效率低下,让我们来看一下:
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()
{
// 函数名
cout << useF<double(*)(double),double>(f, 11.11) << endl<<endl;
// 函数对象
cout << useF(Functor(), 11.11) << endl<<endl;
// lamber表达式
cout << useF([](double d)->double { return d / 4; }, 11.11) << endl<<endl;
return 0;
}
来看下程序验证:
发现useF函数模板实例化了3份(静态变量count的地址不一样)。那我们能否让模板只实例化一份函数出来呢?
包装器可以很好的解决上面的问题:
包装器在标准库中声明为 std::function
,在头文件<functional>
中,它把所有的可调用对象都转换为function类对象。
funtion的类模板原型如下:
template <class T> function; // undefined
template <class Ret, class... Args>
class function<Ret(Args...)>;
-
参数
Ret:被调用函数的返回类型
Args…:被调用函数的形参
使用方法如下:
#include <functional>
int f(int a, int b)
{
return a + b;
}
struct Functor
{
int operator()(int a, int b)
{
return a * b;
}
};
class Plus
{
public:
static int Plusi(int a, int b)
{
return a + b;
}
double Plusd(double a, double b)
{
return a + b;
}
};
int main()
{
//函数名(函数指针)
cout << "包装函数指针" << endl;
//std::function<int(int, int)> func1 = &f;
std::function<int(int, int)> func1 = f;
cout << func1(1, 2) << endl;
//仿函数类对象
cout << "包装仿函数对象" << endl;
std::function<int(int, int)> func2 = Functor();
cout << func2(1, 2) << endl;
//成员函数
cout << "包装成员函数" << endl;
//静态成员函数与普通函数一致
//std::function<int(int, int)> func3 = &Plus::Plusi;
std::function<int(int, int)> func3 = Plus::Plusi;
cout << func3(1, 2) << endl;
//普通成员函数需在参数前加上类名(非静态的成员函数需要由对象去调用),以及取成员函数时需要加上 & 符号
std::function<double(Plus,double,double)> func4 = &Plus::Plusd;
cout << func4(Plus(),1.1, 2.2) << endl;
//lambda表达式
cout << "包装lambda表达式" << endl;
std::function<int(int, int)> func5 = [](int a, int b)->int {return a + b; };
cout << func5(1, 2) << endl;
return 0;
}
学会使用包装器后再回到上述的实例化多份的问题:如果调用的对象(函数指针,仿函数,lambda表达式)拥有相同的参数和返回值类型,那么可以使用包装器让模板只实例化一个对象。
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<<endl;
// 函数对象
std::function<double(double)> func2 = Functor();
cout << useF(func2, 11.11) << endl<<endl;
// lamber表达式
std::function<double(double)> func3 = [](double d)->double { return d / 4; };
cout << useF(func3, 11.11) << endl<<endl;
return 0;
}
function 的使用场景
- 常规思路
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> s;
for(const auto& str:tokens)
{
if(str=="+" || str=="-"||str=="*"||str=="/")
{
int right=s.top();
s.pop();
int left=s.top();
switch(str[0])
{
case '+':
s.top()=left+right;
break;
case '-':
s.top()=left-right;
break;
case '*':
s.top()=left*right;
break;
case '/':
s.top()=left/right;
break;
}
}
else
{
s.push(stoi(str));
}
}
return s.top();
}
};
- 使用包装器之后
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> s;
//所有的lambda的格式一致,使用包装器进行统一
map<string,function<int(int,int)>> op=
{
{"+",[](int a,int b){return a+b;}},
{"-",[](int a,int b){return a-b;}},
{"*",[](int a,int b){return a*b;}},
{"/",[](int a,int b){return a/b;}},
};
for(const auto& str:tokens)
{
if(op.find(str)!=op.end())
{
int right=s.top();
s.pop();
int left=s.top();
s.pop();
s.push(op[str](left,right));
}
else{
s.push(stoi(str));
}
}
return s.top();
}
};
bind 绑定函数
std::bind 函数定义在头文件<functional>
中,是一个函数模板,也是一个函数包装器,接收一个调用对象,生成一个新的可调用对象(如调整参数位置),来“适应”原对象的参数列表。
// 原型如下:
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可以把一个接收N个参数的函数 fn,通过提前绑定住一些参数,返回一个接收M个参数的新函数,同时使用bind可以实现参数顺序调整等操作。
调用新函数的返回值类型与 fn 一致,如需特定的返回类型则指定为 Ret 。
bind的返回值:一个函数对象,在调用时调用 fn 并将其参数绑定到 args。
调用bind的一般形式为:
auto newCallable=bind(callable,arg_list);
其中,newCallable是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callable参数。当我们调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。
arg_list中可能包含形如 placeholder::_n
的名字,其中_n是一个整数,这些参数为“占位符”,他们占据了传递给callable的参数的位置。_1为 callable 的第一个参数,_2为第二个参数,以此类推。
使用方法如下:
- 绑定函数
int f(int a, int b, int c)
{
return a + b - c;
}
int main()
{
//可使用std::function接收一个bind返回的函数对象
//绑定位置
std::function<int(int, int, int)> func1 = bind(f,placeholders::_3,placeholders::_2,placeholders::_1);
auto func2 = bind(f,placeholders::_3,placeholders::_2,placeholders::_1);
//绑定指定实参
std::function<int()> func3 = bind(f,5,6,7);
//混合
auto func4= bind(f, placeholders::_1, placeholders::_2,7);
//已绑定的值将不会改变
std::function<int(int,int,int)> func5= bind(f, placeholders::_1, 6, placeholders::_3);
cout << f(5, 6, 7) << endl;
cout << func1(7, 6, 5) << endl;
cout << func2(7, 6, 5) << endl;
cout << func3() << endl;
cout << func4(5,6) << endl;
cout << func5(5,10,7) << endl;
return 0;
}
建议使用function接收bind返回值,明确使用可调用对象的类型。
- 绑定成员函数
class Sub
{
public:
int sub(int a,int b)
{
return a - b;
}
};
int main()
{
//绑定成员函数
std::function<int(Sub,int, int)> func1 = &Sub::sub;
cout << func1(Sub(), 2, 1)<<endl;
//更改绑定参数数量,将类对象绑死。
std::function<int(int, int)> func2 = bind(&Sub::sub,Sub(),placeholders::_1,placeholders::_2);
cout << func2( 2, 1) << endl;
std::function<int(int)> func3 = bind(&Sub::sub, Sub(), 100, placeholders::_1);
cout << func3(2) << endl;
return 0;
}
8. 可变参数模板
一个可变参数模板就是一个可以接受可变数目参数的模板函数或者模板类。
可变数目的参数成为参数包(parameter packet)。它里面包含了0到N(N>=0)个模板参数。
使用一个省略号(…)来指出一个参数表示一个包。
使用方法:
- 在一个模板参数列表中,
class...
或者typename...
指出接下来的参数包表示0个或者多个类型列表。 - 参数包名后面跟一个省略号表示0个或者多个给定类型的参数列表。
下面就是一个基本的可变参数的函数模板
//Args 是一个模板参数包;rest是一个函数参数包
//Args 表示0个或者多个模板参数类型
//rest 表示0个或多个函数参数
template<typename T,typename... Args>
void Showlist(const T &t,const Args&... rest);
编译器可以从函数的实参推断模板参数类型。对于一个可变参数模板,编译器还会推断参数包中的参数数目,例如给定下面的调用:
int i=0;double d=3.14;string s="hello world";
Showlist(i,s,42,d); //包中有3个参数
Showlist(d,s); //包中有1个参数
Showlist("hi"); //空包
编译器会为Showlist实例化出3个不同版本:
void Showlist(const int&,const string&,const int&,const double&);
void Showlist(const double&,const string&);
void Showlist(const char[3]&);
在每个实例中,T的类型都是一个从第一个实参的类型推断出来的,剩下的实参提供函数额外的实参数目和类型。
编译器可以在运行时推断参数类型,但是在我们定义函数时我们是无法直接获取参数包rest中的每个参数的,语法并不支持使用 rest[i] 这样的方式获取可变参数,只能通过展开参数包的方式来获取参数包的每个参数,这是使用参数包的一个主要特点亦是难点。
size…运算符 获取参数包元素的个数
当我们需要知道参数包中有多少个元素时,可以使用 sizeof...
运算符:
template<typename T, typename... Args>
void Showlist(const T& t, const Args&... rest)
{
cout << sizeof...(Args) << endl;
cout << sizeof...(rest) << endl<<endl;
}
int main()
{
int i = 0; double d = 3.14; string s = "hello world";
Showlist(i, s, 42, d);
Showlist(d, s);
Showlist("hi");
return 0;
}
递归函数方式展开函数包
第一步接收处理包的第一个参数,然后用剩余实参递归调用自身。为了终止递归,还需要定义一个非可变参数的重载函数,使他只接收一个参数:
template<typename T>
void Showlist(const T& t)
{
cout << t << endl<<endl;
}
template<typename T, typename... Args>
void Showlist(const T& t, const Args&... rest)
{
cout << t << endl;
Showlist(rest...);
}
它打印绑定到t的实参,并调用自身来打印参数包中的剩余值。每次参数包的第一个实参被移除,剩余的实参形成下一个Showlist调用的参数包。
对于最后一次调用,两个函数提供相同的匹配,但是非可变参数模板更为特例化,因此编译器会选择它。
但是每一次都需要由t来接受参数包的第一个参数,有些麻烦,我们换种方法。
初始化列表展开参数包
template<typename T>
void printarg(T val)
{
cout << typeid(T).name() << ":" << val << endl;
}
template< typename... Args>
void Showlist(const Args&... rest)
{
int arr[] = { (printarg(rest),0)... };
cout << endl;
}
int main()
{
int i = 0; double d = 3.14; string s = "hello world";
Showlist(i, s, 42, d);
Showlist(d, s);
Showlist("hi");
return 0;
}
这种展开参数包的方式,不需要通过递归终止函数,而直接在函数体中展开。通过初始化列表来初始化一个变长数组,printarg是一个处理参数包中每一个参数的函数,再得到逗号表达式的结果0,{(printarg(rest),0)...}
将会展开成 ((printarg(rest1),0),(printarg(rest2),0), (printarg(rest3),0), etc... )
,最终会创建一个元素值都为0的数组int arr[sizeof…(Args)]。在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。
如果我们printarg函数的返回值类型一致,也可以不用逗号表达式:
template<typename T>
int printarg(T val)
{
cout << typeid(T).name() << ":" << val << endl;
return 0;
}
template< typename... Args>
void Showlist(const Args&... rest)
{
int arr[] = { printarg(rest)... };
cout << endl;
}
STL中的 emplace 接口函数
STL容器中的empalce相关接口函数:
http://www.cplusplus.com/reference/vector/vector/emplace_back/
http://www.cplusplus.com/reference/list/list/emplace_back/
标准库容器的 emplace_back 成员是一个可变参数成员模板,他用实参在容器管理的内存空间中直接构造一个元素(常用的push_back是事先创建元素,然后将其拷贝或者移动到元素尾部,事后再去销毁先前创建的元素),emplace省去了拷贝或者移动元素的这个过程。
显然容器中的emplace的可变参数可以适应其多个构造函数的参数要求,并且为万能引用(以forward方式将参数传给构造函数)。
int main()
{
std::list< std::pair<int, char> > mylist;
// emplace_back支持可变参数,拿到构建pair对象的参数后自己去创建对象
// 那么在这里我们可以看到除了用法上,和push_back没什么太大的区别
mylist.emplace_back(10, 'a');
mylist.emplace_back(20, 'b');
mylist.emplace_back(make_pair(30, 'c'));
mylist.push_back(make_pair(40, 'd'));
mylist.push_back({ 50, 'e' });
for (auto e : mylist)
cout << e.first << ":" << e.second << endl;
return 0;
}
emplace是直接构造,push_back是先构造再移动,两者效率相差不大。
9. 线程库
< thread > 的简单介绍
- thread 类
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含 < thread >
头文件。C++11线程类。
成员函数 | 功能 |
---|---|
thread() | 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程 |
thread(fn,args1, args2,…) | 构造一个线程对象,并关联线程函数fn(可调用对象:函数,仿函数,lambda表达式,包装器),args1,args2,…为线程函数的可变参数 |
get_id() | 获取线程id |
joinable() | 线程是否还在执行,joinable代表的是一个正在执行中的线程 |
join() | 该函数调用后会阻塞主线程,当该线程结束后,主线程继续执行。 |
detach() | 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关 |
线程对象不允许左值拷贝和拷贝赋值,允许移动。
#include<iostream>
#include<thread>
void Fn()
{
//do stuff
}
int main()
{
std::thread foo;
std::thread bar(Fn);
std::cout << "Joinable after construction" <<std::boolalpha <<endl;
std::cout << "foo: " << foo.joinable() << endl;
std::cout << "bar: " << bar.joinable() << endl;
if (foo.joinable()) foo.join();
if (bar.joinable()) bar.join();
std::cout << "Joinable after joining" <<endl;
std::cout << "foo: " << foo.joinable() << '\n';
std::cout << "bar: " << bar.joinable() << '\n';
return 0;
}
- 线程函数参数 std::ref()
线程函数的参数是以值拷贝的方式拷贝到线程空间中的,因此即使参数为引用类型也没有办法在线程中修改外部的实参。如要修改只能依靠指针,需用到库函数 std::ref()
。
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 在线程函数中对a修改,不会影响外部实参,
//因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝
//thread t1 构造的时候不知道 ThreadFunc1 的参数是引用的,thread只会盲目地复制 a 的值,
//而这个复制出来的值是const的类型,这与 ThreadFunc1 需要的参数类型不匹配,因为 ThreadFunc1 需要的是non - const的引用,
//因此报错——错误 C2672 “std::invoke”: 未找到匹配的重载函数
//thread t1(ThreadFunc1, a);
//t1.join();
//cout << a << endl;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, std::ref(a));
t2.join();
cout << a << endl;
// 或者地址的拷贝
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
return 0;
}
- this_thread 命名空间
thread中的成员变量 id
负责保存线程id,由函数 std::this_thread::get_id()
获取当前线程的id,this_thread 为命名空间。
std::thread::id main_thread_id=std::this_thread::get_id();
void is_main_thread()
{
if (main_thread_id == std::this_thread::get_id())
{
cout << "This is the main thread." << endl;
}
else
{
cout << "This is not the main thread." << endl;
}
}
int main()
{
is_main_thread();
thread t(is_main_thread);
t.join();
return 0;
}
- std::this_thread::yield , std::this_thread::sleep_for,std::this_thread::sleep_until
-
yield()
: 调用线程放弃执行,回到准备状态,重新分配cpu资源。所以调用该方法后,可能执行其他线程,也可能还是执行该线程 -
std::this_thread::sleep_for
:阻塞调用线程,一直到指定时间
template <class Clock, class Duration>
void sleep_until (const chrono::time_point<Clock,Duration>& abs_time);
std::this_thread::sleep_until
:阻塞调用线程,一直到指定时间段后。
template <class Rep, class Period>
void sleep_for (const chrono::duration<Rep,Period>& rel_time);
其中涉及到时间库的使用:chrono
互斥锁 < mutex > 简单介绍
帮助文档戳-> < mutex >
四个互斥锁类
- std::mutex
C++11 所提供的最简单的互斥量,该类的对象之间不能拷贝,也不能进行移动,mutex最常用的三个函数:
lock()
:上锁,锁住互斥量。unlock()
:解锁,释放对互斥量的所有权。try_lock()
:尝试锁住互斥量,如果互斥量被其他线程占有,返回false以及当前线程也不会被阻塞。- 使用场景
volatile int counter(0);
std::mutex mtx;
void add_to_1000()
{
for (int i = 0; i < 1000; ++i)
{
if (mtx.try_lock())
{
counter++;
mtx.unlock();
}
}
}
int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(add_to_1000);
for (auto& th : threads) th.join();
std::cout << counter << " successful increases of the counter.\n";
return 0;
}
try_lock不会阻塞:
若使用的是lock(阻塞等待加锁)
void add_to_1000()
{
for (int i = 0; i < 1000; ++i)
{
mtx.lock();
counter++;
mtx.unlock();
}
}
🚩注意:这里加锁的位置需要仔细斟酌,一般而言加锁与解锁之间的代码区域称为临界区,而这个区域越小越好,即加锁的粒度尽可能小。但是如果在循环中频繁的给互斥量加锁与释放锁,而业务本身的工作量又不大,相当于不停地在用户态和内核态之间切换,那么反复地切换上下文反而得不偿失,导致效率降低。
这种短时间内需要频繁竞争互斥量的情况使用自旋锁更为恰当,因为对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此而得名。
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,只需要等一等(自旋),等到持有锁的线程释放锁后即可获取,避免用户进程和内核切换的消耗。
自旋锁避免了操作系统进程调度和线程切换,通常适用在时间极短的情况,因此操作系统的内核经常使用自旋锁。但如果长时间上锁,自旋锁会非常耗费性能。线程持有锁时间越长,则持有锁的线程被 OS调度程序中断的风险越大。如果发生中断情况,那么其它线程将保持旋转状态(反复尝试获取锁),而持有锁的线程并不打算释放锁,导致结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能,因此可以给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。
遗憾的是c++ 并没有提供自旋锁,我们可以自己造轮子,自己实现C++自旋锁。
当然除了自旋锁,我们还有平替方案:atomic
- 递归互斥锁:std::recursive_mutex
递归互斥量解决同一线程重复使用互斥量的需求
- 调用方线程在从它成功调用 lock 或 try_lock 开始的时期里占有 recursive_mutex 。其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock()。
- 线程占有 recursive_mutex 时,若其他所有线程试图要求 recursive_mutex 的所有权,则它们将阻塞(对于调用 lock )或收到 false 返回值(对于调用 try_lock )。
- 可锁定 recursive_mutex 次数的最大值是未指定的,但抵达该数后,对 lock 的调用将抛出 std::system_error 而对 try_lock 的调用将返回 false 。
此互斥锁适用于递归函数,可避免使用mutex因重复加锁而导致的死锁。
std::recursive_mutex rtx;
int g_val = 5;
void g()
{
rtx.lock();
cout << "g_val:"<<g_val-- << endl;
if(g_val>0)
g();
rtx.unlock();
}
int main()
{
thread t1(g);
t1.join();
return 0;
}
🚩使用建议:
- 递归锁的递归有计数器,超出了计数器最大值会失败,所以控制好递归次数;
- 比mutex效率低
- 代码逻辑不清晰,酌情使用
- 超时互斥量 std::timed_mutex
std::timed_mutex 设置了等待超时的机制,之前的互斥量如果无法等待进入机会,会一直阻塞线程,使用std::timed_mutex可以为锁的等待设置一个超时值,一旦超时可以做其他事情。
较于mutex,多了
try_lock_for()
:在指定时限内尝试锁定互斥量,超过时限则返回false
std::timed_mutex ttx;
void fireworks()
{
//每等待200ms加锁,如果返回false,打印 '-'
while (!ttx.try_lock_for(std::chrono::milliseconds(200)))
{
cout << '-';
}
//成功加锁,等待1s,随后打印 '*'
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "*\n";
ttx.unlock();
}
int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(fireworks);
for (auto& th : threads) th.join();
return 0;
}
try_lock_until()
: 尝试锁定互斥,直至抵达指定时间点互斥如不可用则返回false
- 带超时的递归互斥量std::recursive_timed_mutex
主要结合了超时和递归。
异常安全问题与 lock_guard 、 unique_lock
在临界区,如果抛异常可能会造成死锁问题:
void Func(vector<int>& v,int base,int N,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);//申请空间失败会抛异常,unlock不执行,第二个线程再lock(),会死锁
//模拟临界区内抛异常
if (base == 1000 && i == 888)
{
throw bad_alloc();
}
mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
int main()
{
thread t1;
thread t2;
vector<int> v;
mutex mtx;
t1 = thread(Func, std::ref(v), 1000, 1000,std::ref(mtx));
t2 = thread(Func, std::ref(v), 2000, 2000,std::ref(mtx));
t1.join();
t2.join();
cout << v.size() << endl;
return 0;
}
push_back()成员函数,在申请空间失败后会抛异常,于是原线程没有释放锁的情况下,后续线程又想加锁,于是造成死锁,产生由异常引起的线程安全问题。
类似的情况又如在临界区 动态申请空间(new)与释放空间(delete),如果new抛出了异常无法执行delete,那么会造成内存泄漏。(这需要交给智能指针管理,此处只讲死锁的问题)
为了不影响解锁的操作,我们把解锁操作放入catch中,那么就会不影响后续线程加锁:
catch (const exception& e)
{
cout << e.what() << endl;
mtx.unlock();
}
于是标准专门写了一个类 lock_guard
来帮助管理锁,
lock_guard
lock_guard基于了RAII思想
RAII英文全称是Resource Acquisition Is Initialization,资源请求即初始化,是 C++ 所特有的资源管理方式。它主要是用来解决资源泄漏问题。最常见的资源泄漏问题就是内存泄漏和死锁(忘记释放锁引起)。
lock_guard解决的正是互斥锁没有被正确释放的例子。以下是源码:
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;//成员变量为引用,意为mutex的别名,因为mutex无法拷贝
};
lock_guard具有两种构造方法:
lock_guard(mutex& m)
lock_guard(mutex& m, adopt_lock)
其中mutex& m是互斥量,参数adopt_lock表示假定调用线程已经获得互斥体所有权并对其进行管理了(已经lock了)。
使用到上述例子中:
void Func(vector<int>& v,int base,int N,mutex& mtx )
{
//捕获异常
try
{
for (int i = 0; i < N; ++i)
{
cout << this_thread::get_id() << ":" << base + i << endl;
//mtx.lock();
lock_guard<mutex> lock(mtx,adopt_lock);//若发生异常,离开作用域即析构,调用解锁操作,故不会造成死锁
v.push_back(base + i);//申请空间失败会抛异常
//模拟临界区内抛异常
if (base == 1000 && i == 888)
{
throw bad_alloc();
}
delete p1;
//mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
//mtx.unlock();
}
}
在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。
缺点:太单一,用户没有办法对该锁进行控制,因此C++11又提供了unique_lock
unique_lock
与lock_guard 类似,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所管理的互斥量的指针)。
使用详情戳-> lock_guard 和 unique_guard
原子操作 < atomic >
atomic : 原子操作,可见帮助文档。
所谓原子操作:即不可被中断的一个或者一系列操作,C++11引入原子操作类型,使得线程间的同步变得简易而高效。
我们将临界资源声明为原子类型,那么当多线程访问时仍能保持原子操作,原子类型支持的运算:
#include <iostream>
using namespace std;
#include <thread>
#include <atomic>
atomic_long sum{ 0 };
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
sum ++; // 原子操作
}
int main()
{
cout << "Before joining, sum = " << sum << std::endl;
thread t1(fun, 1000000);
thread t2(fun, 1000000);
t1.join();
t2.join();
cout << "After joining, sum = " << sum << std::endl;
return 0;
}
在c++11中,程序员不需要对原子类型加锁解锁操作,线程能够对原子类型变量互斥地访问。
更为普遍的,可以使用atomic模板,定义出任意的原子类型
atmoic<T> t; // 声明一个类型为T的原子类型变量t
注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11
中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及
operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算
符重载默认删除掉了。
//原子操作
#include <time.h>
int main()
{
atomic<int> x = 0;
int N = 5, M = 1000;
vector<thread> vthds(N);
for (auto& e : vthds)
{
e = thread([M,&x] {
for (int i = 0; i < M; ++i)
{
cout <<"id-> " <<std::this_thread::get_id()<<"-> " << x << endl;
++x;
}
});
}
for (auto& e : vthds)
{
e.join();
}
cout << x <<endl;
}
更多内容可参考博文:原子操作
条件变量 < condition_variable >
为完成同步需要引入条件变量:
何为同步?我们让两个子线程分别打印奇偶数,如果只使用互斥锁:
int main()
{
int n=100;
int i = 0;
mutex mtx;
thread t1([n, &i,&mtx] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cout << this_thread::get_id() << ":" << i << endl;
++i;
}
});
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;
}
可以看到,线程并没有交替完成打印,这便是没有实现同步,同步即是线程交替执行的概念
为此引入条件变量:当一个线程A执行任务时,线程B阻塞,A执行完后,通知线程B开始执行,期间线程A阻塞,直到线程B完成任务后通知线程A,如此交替往复:
条件变量一定要配合互斥锁(经 unique_lock
包装)进行使用,因为条件变量本身不是线程安全的
其中主要使用两个函数 wait
和 notify
wait函数有两种用法:wait文档
用得较多的是带条件的wait:
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
使用wait前,lck已经上锁了,wait时底层会unlock(),来使得其他线程可以竞争这把锁。
pred为可调用对象,返回值为true代表条件已经成立,如果有其他的线程notify(唤醒)了,那么所有条件成立且处于wait的线程就可以开始竞争锁了。
如果notify的时候条件不成立(pred返回false),那么线程将继续处于wait状态。这种方式可很好处理虚假唤醒的情况。
pred的返回值用于while的条件语句中,只有返回true,才能继续向下执行。如果返回false,即使lck得到锁依旧出不了循环,只能继续wait然后底层unlock后,再去不断的竞争锁再看条件符不符合。如果返回true后,wait还没有被唤醒,那么就会阻塞在wait处,直至其他的线程notify。虚假唤醒则是wait的线程因某些误操作导致提前唤醒(条件还没有成立,pred返回false的情况),但由于条件pred的保障,while回来后可以继续wait。
那么上面的例子可以改成:
//条件变量,偶数奇数交替打印
#include <condition_variable>
bool flag = false;
int main()
{
int n=100;
int i = 0;
mutex mtx;
condition_variable cv;
thread t1([n, &i,&mtx,&cv] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, []() {return flag; });
cout << this_thread::get_id() << ":" << i << endl;
++i;
flag = false;
cv.notify_one();
}
});
thread t2([n, &i,&mtx, &cv] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, []() {return !flag; });
cout << this_thread::get_id() << ":" << i << endl;
++i;
flag = true;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
🚩注意:这里的可调用对象pred为lambda表达式,静态变量flag,lambda默认为捕捉状态。
🖊解释:
- flag的初始值为false,故该条件针对线程1是不成立的,对于线程2是成立的,所以参数
pred
可以帮我们控制哪个线程率先运行。即使线程1先lock了mtx,由于条件不成立,wait函数会让线程1进行unlock操作,直至线程2竞争得锁。 - 线程2执行完后,会转换flag,从而使得该条件对线程1成立,而对线程2不成立。线程2在notify后,会释放锁。注意线程1,2会同时竞争这把锁,但是由于线程2的条件不成立,即使竞争得锁,还是会被wait函数将锁吐出来,直至线程1竞争得锁。
- 两个线程由此规律完成交替打印的操作:
可见条件变量帮助我们完成了同步操作。
青山不改 绿水长流