一、C++11的更新
C++11 是 C++ 的第二个主要版本,也是自 C++98 以来最重要的更新。引入了大量更改,以标准化现有实践并改进C++程序员可用的抽象
C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中 约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言
C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更 强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个 重点去学习
二、统一的列表初始化
2.1 {}初始化
在C++98中,标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定
C++11扩大了用大括号括起来的列表的使用范围,使其可用于所有内置类型和用户自定义的类型。使用初始化列表时,可以添加等号(=),也可以不添加
#include<iostream>
using namespace std;
int main()
{
//初始化列表
int x1 = 1;
int x2 = { 2 };
int x3{ 3 };//相当于省略=
int x4(1);//int的默认构造
//数组初始化
int array1[]{ 1,2,3,4,5 };
int array2[5]{ 0 };
//对数组的new的初始化
int* p = new int[4]{ 0 };
return 0;
}
2.2 std::initializer_list
std::initializer_list一般是作为构造函数的参数,C++11中对STL中的不少容器增加了该参数
std::initializer_list作为参数的构造函数,更加方便进行初始化容器对象。也可以作为operator=的参数,这样就可以使用大括号赋值
#include<iostream>
#include<vector>
#include<list>
using namespace std;
int main()
{
vector<int> v1 = { 1,2,3,4,5 };
vector<int> v2 = { 10,20,30 };
vector<int> v3 = { 10,20,30,1,1,2,2,2,2,2,1,1,1,1,1,2,11,33 };
list<int> lt1 = { 1,2,3,4,5 };
list<int> lt2 = { 10,20,30 };
auto i1 = { 10,20,30,1,1,2,2,2,2,2,1,1,1,1,1,2,11,33 };
auto i2 = { 10,20,30 };
cout << typeid(i1).name() << endl;//class std::initializer_list<int>
cout << typeid(i2).name() << endl;
initializer_list<int>::iterator it1 = i1.begin();
initializer_list<int>::iterator it2 = i2.begin();
cout << it1 << endl;
cout << it2 << endl;
//*it1 = 1;//在常量区 不可以更改
}
三、声明
C++11更新了auto和decltype
其中auto在范围for和迭代器操作的时候极大的减少了代码量
decltype主要是用于推导表达式类型,可以方便大量计算的类型声明
decltype与auto的区别在于:decltype是推导表达式类型,而auto是推导变量类型
在decltype推导表达式类型是并不会进行计算
#include<iostream>
#include<vector>
using namespace std;
int main()
{
//1.auto
vector<int> v1;
vector<int>::iterator it1 = v1.begin();
auto it2 = v1.begin();
//2.decltype
const int x = 1;
double y = 2.2;
cout << typeid(x * y).name() << endl;//double
decltype(x * y) type1;
decltype(&x) p;
cout << typeid(type1).name() << endl;//double
cout << typeid(type1).name() << endl;//double
cout << typeid(p).name() << endl;//int const* __ptr64
vector<decltype(x* y)> v2;//decltype推导表达式类型
}
四、STL新增
STL库中新增了array、forward_list,unordered_map、unordered_set四个容器。其中实际上有用的容器只有unordered_map和unordered_set两个
array容器实际上就是数组,一般是建议使用std::array来代替普通的数组。他会在访问越界时抛出异常,但实际上使用很少
forward_list就是单向链表,相比于list在某种特定环境下节省了空间
unordered_map和unordered_set提供了以hash为底层的map和set容器。实际上的使用跟map和set相同
五、右值引用和移动语义
5.1 左值引用和右值引用
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址,可以对它赋 值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左 值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引 用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能 取地址。右值引用就是对右值的引用,给右值取别名
左值与右值区分的关键在于:是否可以取地址
void left_num()
{
//以下几个都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
//左值引用
int*& lp = p;
int& lb = b;
const int& lc = c;
int* pvalue = p;
}
void right_num()
{
double x = 1.1, y = 3.3;
//右值:10,x+y,fmin(x,y)
//右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
//以下右值执行赋值(=)操作会报错
//10 = 1;
//x + y = 1;
//fmin(x, y) = 1;
}
5.2 左值引用和右值引用的对比
左值引用:只能引用左值,不能引用右值;const左值引用可以用于右值
右值引用:只能引用右值,不能引用左值;右值引用可以用于move后的左值
void sample()
{
int a = 0;
int b = 1;
int* p = &a;
//左值引用对左值
int& ref1 = a;
//左值引用对右值
//int& ref2 = (a + b);//错误
const int& ref2 = (a + b);
//右值引用对右值
int&& ref3 = (a + b);
//右值引用不能对左值
//int&& ref4 = a;//错误
//
//右值引用给move对左值
int&& ref5 = move(a);
}
5.3 右值引用使用场景
左值引用一般用于做参数和返回值,用于提高效率。但是当函数返回对象是一个局部变量,该变量出了函数作用域就被销毁时,就不能使用左值引用返回,只能传值返回。传值返回会导致至少一次的拷贝构造
因此增加移动构造,移动构造的本质是参数右值的资源窃取过来占为己有,从而减少深拷贝
除了移动构造,还有移动赋值
右值引用作用
①实现移动语义:右值引用可以绑定到临时对象(右值),通过将资源的所有权从一个对象转移到另一个对象,避免了不必要的复制和销毁操作,提高程序效率。移动语义在大规模数据结构中尤为重要,例如std::vector、std::string等。
②支持完美转发:右值引用还可以用于函数模板的完美转发,即将参数以原始的形式传递给下一个函数,避免了不必要的复制和类型转换,提高了程序效率。
③避免内存泄漏:右值引用可以使用std::move()函数将对象强制转换为右值,使得该对象的所有权可以被移交,从而避免了内存泄漏的问题。
不能轻易的对左值使用move,可能导致原值消失,它的作用是将左值临时的转换成一个右值
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<vector>
#include<assert.h>
using namespace std;
using std::strcpy;
namespace my_string
{
class string
{
public:
//迭代器
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
//构造函数
string(const char* str = "")
:_size(strlen(str)),_capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s)--深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
//移动构造
string(string&& s)
:_str(nullptr), _size(0), _capacity(0)
{
cout << "string(string&& s)--移动语义" << endl;
swap(s);
}
~string()
{
delete[] _str;
_str = nullptr;
}
//赋值重载
string& operator=(const string& s)
{
cout << "string& operator(const string& s)--深拷贝" << endl;
string tmp(s._str);
swap(tmp);
return *this;
}
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator(const string& s)--移动语义" << endl;
swap(s);
return *this;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
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)
{
push_back(ch);
return *this;
}
const char* c_str()const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
void func1(my_string::string s)
{}
void func2(const my_string::string& s)
{}
int main()
{
my_string::string s1("hello world");
// func1和func2的调用我们可以看到左值引用做参数减少了拷贝,提高效率的使用场景和价值
func1(s1);
func2(s1);
// string operator+=(char ch) 传值返回存在深拷贝
// string& operator+=(char ch) 传左值引用没有拷贝提高了效率
s1 += '!';
return 0;
}
将原本的拷贝构造->创建临时对象->移动构造,优化为一次移动拷贝
在C++11之后,STL所有的容器都增加了移动构造。因此所有的接口函数也都增加了右值引用版本
5.4 右值引用引用左值
按照语法右值引用只能引用右值,但右值引用并不是一定不能引用左值
有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move 函数将左值转化为右值。C++11中,std::move()函数位于 头文件中,该函数名字具有迷惑性, 它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义
#include<iostream>
using namespace std;
int main()
{
string s1("hello world");
string s2(s1);//拷贝构造
string s3(move(s1));//移动构造,s1被move后变成右值
}
如下图所示,s2是通过拷贝构造生成的。s3是通过移动构造生成的,是由于move函数将s1变成了右值。由于使用了move函数,所以s1的资源被转移给了s3,s1变为空
所以一般不要轻易的对左值使用move函数
如果对s1单独使用move,则会发现s1并没有被转移
5.5 完美转发
完美转发基于万能引用,引用折叠以及std::forward模板函数。STL出现std::forward,一定出现万能引用。其实这也很好理解,完美转发机制,是为了将左值和右值统一处理,节约代码量,而只有万能引用会出现同时接受左值和右值的情况,所以完美转发只存在于万能引用
什么是引用折叠?
引用折叠是模板编程中的一个概念,是为了解决模板推导后出现双重引用的情况
- 左值-左值 T& &
- 左值-右值 T& &&
- 右值-左值 T&& &
- 右值-右值 T&& &&
//假设有如下模板函数
template<typename T>
void PrintType(T&& param){ ... }
当T为int &类型,则param
被推导成int & &&类型。而C++语法是不允许双重引用类型存在的,因此制定了引用折叠的规则
模板编程中参数类型推导出现双重引用时,双重引用将被折叠成一个引用,要么是左值引用,要么是右值引用。 折叠规则就是:如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用
所以上面的例子在折叠后,最终param被折叠成int &类型
实际上的模板类型推导并不像看到的那么直接,如下例
int a = 0; // 左值
PrintType(a); // 传入左值
可能很多人的第一反应就是T被推导成int类型,其实不然,试想下如果T被推导成int类型,那么param
形参就变成int &&类型,这显然无法通过编译,因为无法将一个左值赋给一个右值引用,所以这里编译器会将T推导成int &类型而不是int
为什么需要完美转发?
不论是左值还是右值在函数调用时,都会转换成左值,使得函数的实现与用户的期望并不符合。
std::forward被称为完美转发,它的作用是保持原来的值属性不变。通俗的讲就是,如果原来的值是左值,经std::forward处理后该值还是左值;如果原来的值是右值,经std::forward处理后它还是右值。
#include<iostream>
using namespace std;
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<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
// std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
template<typename T>
void PerfectForward(T&& t)
{
Fun(std::forward<T>(t));
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
六、类内新功能
6.1 默认成员函数
原来C++类中,有6个默认成员函数: 1. 构造函数 2. 析构函数 3. 拷贝构造函数 4. 拷贝赋值重载 5. 取地址重载 6. const 取地址重载
最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会生成一个默认的
C++11 新增了两个:移动构造函数和移动赋值运算符重载
针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:
①如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类 型成员会执行逐成员按字节拷贝(值拷贝),自定义类型成员,则需要看这个成员是否实现移动构造, 如果实现了就调用移动构造,没有实现就调用拷贝构造
②如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)
③如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。
6.2 类成员变量初始化
C++11允许在类定义时给成员变量初始缺省值,默认生成构造函数会使用这些缺省值初始化
缺省值是给初始化列表使用的
6.3 强制生成默认函数的关键字default
C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原 因这个函数没有默认生成
比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以 使用default关键字显示指定移动构造生成
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
Person(const Person& p)
:_name(p._name)
, _age(p._age)
{}
Person(Person&& p) = default;//使用default关键字,强制生成移动拷贝。即使已经存在拷贝构造函数
private:
my_string::string _name;
int _age;
};
int main()
{
Person s1;
Person s2 = s1;//一定是深拷贝,赋值拷贝
Person s3 = std::move(s1);
return 0;
}
6.4 禁止生成默认函数的关键字delete
与default的功能相反,有强制默认生成就有强制禁止生成
如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成private,并且只声明补丁 已,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即 可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数
class Person2
{
public:
Person2(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
Person2(const Person& p) = delete;
private:
my_string::string _name;
int _age;
};
int main()
{
//用于测试禁止默认函数关键字delete
Person2 s1;
Person2 s2 = s1;//一定是深拷贝,赋值拷贝
Person2 s3 = std::move(s1);
return 0;
}
七、可变参数模板
C++11的新特性可变参数模板能够创建可以接受可变参数的函数模板和类模板,相比 C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改 进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}
上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数 包”,它里面包含了0到N(N>=0)个模版参数。
我们无法直接获取参数包args中的每个参数, 只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数
7.1 递归函数方式展开参数包
#include<iostream>
#include<string>
using namespace std;
void ShowList()
{
cout << endl;
}
// 递归终止函数
template <class T>
void ShowList(const T& t)
{
cout << t << endl;
}
// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " ";
ShowList(args...);
}
int main()
{
ShowList();
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', std::string("sort"));
return 0;
}
7.2 逗号表达式展开参数包
这种展开参数包的方式,不需要通过递归终止函数,是直接在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)
{
cout << t << " ";
}
//展开函数
template <class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
7.3 emplace接口函数
STL中的容器在原有的接口上增加了emplace系列。也就是增加了可变参数包。比如emplace_push对应push_back,emplace对应insert。在基础的使用方面并没有太大差距
对于带有拷贝构造和移动构造的类,push_back是先构造匿名对象再移动构造,而emplace是直接构造
push_back支持的emplace_back都支持,因此可以将emplace系列理解为普通接口函数的上位替代
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> v1;
v1.push_back(1);
v1.emplace_back(1);
//push_back不支持多个参数输入,即不支持参数包
//v1.push_back(1, 2, 3, 4);
v1.emplace_back(1, 2, 3, 4);
}
八、lambda表达式
lambda表达式书写格式:[capture-list](parameters)mutable->returntype{statement}
lambda表达式各部分说明:
[capture-list]:捕捉列表。该列表总是出现在lambda函数的考试位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量,供lambda函数使用
(parameters):列表参数。与普通函数的参数列表一致,如果不需要参数传递则可以连同()一起省略
mutable:默认情况下,lambda函数是一个const函数,而mutable可以取消其常量性。使用该修饰符时,参数列表不可以省略(即使参数为空)
->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时该部分可以省略。返回值类型明确的情况下,也可以省略,由编译器对返回值类型进行推断
{statement}:函数体。在函数体内部实现lambda函数功能。函数体内除了可以使用函数体内的参数外,还可以使用参数列表捕获的参数
在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为 空。因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情
//一个简单的加法lambda函数
void test1()
{
auto add1 = [](int x, int y)->int {return x + y; };
cout << "lambda使用测试1\t" << add1(1, 2) << endl;
}
lambda捕获列表说明:
捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用
[var]:表示值传递方式捕捉变量var
[=]:表示值传递方式捕获所有父作用域中的变量(包括this)
[&var]:表示引用传递捕捉变量var
[&]:表示引用传递捕捉所有父作用域中的变量(包括this)
[this]:表示值传递方式捕捉当前的this指针
注意:
a. 父作用域指包含lambda函数的语句块
b. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。 比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量 [&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量
c. 捕捉列表不允许变量重复传递,否则就会导致编译错误。 比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复
d. 在块作用域以外的lambda函数捕捉列表必须为空
e. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错
f. lambda表达式之间不能相互赋值,即使看起来类型相同
void test2()
{
int x = 0, y = 1;
int m = 0, n = 1;
auto swap1 = [](int& rx, int& ry)
{
int tmp = rx;
rx = ry;
ry = tmp;
};
swap1(x, y);
cout << x << " "<< y << endl;
// 传值捕捉
/*auto swap2 = [x, y]() mutable
{
int tmp = x;
x = y;
y = tmp;
};
swap2();
cout << x << " " << y << endl;*/
// 引用捕捉
auto swap2 = [&x, &y]()
{
int tmp = x;
x = y;
y = tmp;
};
swap2();
cout << x << " " << y << endl;
// 混合捕捉
auto func1 = [&x, y]()
{
//...
};
// 全部引用捕捉
auto func2 = [&]()
{
//...
};
// 全部传值捕捉
auto func3 = [=]()
{
//...
};
// 全部引用捕捉,x传值捕捉
auto func4 = [&, x]()
{
//...
};
}
8.1 线程中的lambda
void test3()
{
//lambda表达式在线程中的应用
int n1, n2;
cin >> n1 >> n2;
thread t1([n1](int num)
{
for (int i = 0; i < n1; i++)
{
cout << num << ":" << i << endl;
}
cout << endl;
}, 1);
thread t2([n2](int num)
{
for (int i = 0; i < n2; i++)
{
cout << num << ":" << i << endl;
}
cout << endl;
}, 2);
t1.join();
t2.join();
}
如上述代码所示,在线程的创建和调用中同样可以使用lambda表达式
8.2 lambda与仿函数
函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了operator()运算符的 类对象
在C++98中,如果想要对一个数据集合中的元素进行排序,可以使用std::sort方法。如果待排序元素为自定义类型,需要用户定义排序时的比较规则。比较规则需要使用仿函数来实现,如果对每一种排序顺序都实现一个新的类比较麻烦,并且命名类似对代码的可读性有一定影响
因此C++11中出现lambda表达式来改变这一情况
运行以下代码可以得出如下结论:编译器会将lambda表达式处理为仿函数执行,并且lambda表达式的长度为1
class Rate
{
public:
Rate(double rate) : _rate(rate)
{}
double operator()(double money, int year)
{
return money * _rate * year;
}
private:
double _rate;
};
void test4()
{
//函数对象(仿函数)
double rate = 0.33;
Rate r1(rate);
r1(10000, 2);
//lambda
auto r2 = [=](double money, int year)->double {return money * rate * year; };
r2(10000, 2);
}
九、线程库
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在 并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件
①线程是操作系统中的一个概念,线程对象可以关联一个线程,用于控制线程以及获取线程的状态
②当创建一个线程对象后,如果没有提供线程函数,则该对象实际没有对应任何线程
函数名 | 功能 |
thread() | 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程 |
thread(fn,args1,args2,……) | 构造一个线程对象,关联线程函数fn,args1、args2、……为线程函数的参数 |
get_id() | 获取线程id |
joinable() | 线程是否还在执行,jionable代表的是一个正在执行中的线程 |
join() | 该函数调用后回阻塞住线程,当该线程结束后,主线程继续执行 |
detach() | 在创建线程对象后马上调用,用于将被创建线程与线程对象分离开,分离后的线程变成后台线程,创建线程的“死活”与主线程无关 |
#include<iostream>
#include<thread>
using namespace std;
void Func(int n,int num)
{
for (int i = 0; i < n; i++)
{
cout << num << ":" << i << endl;
}
cout << endl;
}
void test1()
{
thread t1;
cout << t1.get_id() << endl;//get_id的返回值类型为id,实际上是一个类
cout << t1.joinable() << endl;
int n2;
cin >> n2;
thread t2(Func, n2, 2);//Func为线程的关联函数。n2,2为Func的参数
t2.join();
}
int main()
{
test1();
return 0;
}
当创建一个线程对象后,给出线程关联线程函数,该线程就被启动。将于主线程一起运行,线程函数一般情况下可以按照三种方式提供:函数指针、lambda表达式、函数对象
void ThreadFunc(int a)
{
cout << "函数指针--Thread1" << a << endl;
}
class temp
{
public:
void operator()()
{
cout << "函数对象--Thread2" << endl;
}
};
void test2()
{
thread t1(ThreadFunc,1);
temp t;
thread t2(t);
thread t3([]() {cout << "lambda表达式--Thread3"<<endl; });
t1.join();
t2.join();
t3.join();
}
int main()
{
//test1();
test2();
return 0;
}
thread类是不允许拷贝与赋值的,线程的拷贝不具有实际意义。但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他对象,转移期间不影响线程的执行
可以通过joinable()函数判断线程是否有效,如果为以下任意情况,则线程无效
①采用无参构造函数构造线程对象
②线程对象的状态已经转移给其他线程对象
③线程已经调用join或者detach结束
9.1 线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参
如果是将类成员函数作为线程参数时,必须将this作为线程函数参数
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
void test3()
{
int a = 10;
// 在线程函数中对a修改,不会影响外部实参
//因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝
// 会报错C2672 “std::invoke”: 未找到匹配的重载函数
//thread t1(ThreadFunc1, a);
//t1.join();
//cout << a << endl;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, ref(a));
t2.join();
cout << a << endl;
// 地址的拷贝
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
}
9.2 原子性操作库(atomic)
多线程最主要的问题是共享数据带来的问题(即线程安全)。比如上面9.1中线程关联函数的测试用例,在多次运行后会产生不同的结果
如果共享数据都是只读的,那么不会产生问题,因为不产生对数据的修改。但当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦
以下面代码为例
可以看出两个线程有各自独立的栈,所以t1和t2对于n变量的地址不同,但是对于静态区的变量x的地址相同。上面这段程序每次的运行结果并不会产生较大的差异。但如果将参数分别改为100000和200000。则程序运行的结果每次都不同,因为线程中间会出现重叠,即两个线程同时执行++,导致数据缺少了一次计算
①解决线程安全最简单的方式就是加锁,将并行线程改变为串行。避免两个线程同时对同一个数据进行操作。C++中提供了mutex库用于互斥锁操作
②使用原子操作。原子操作就是最小操作单元,一般为汇编中的MOV等语句。高级语言中的i+=1就不属于原子操作。原子操作的本质是CAS(cpu 硬件同步原语(compare and swap))
采用原子操作解决上述问题
#include<iostream>
#include<atomic>
#include<thread>
using namespace std;
int x = 0;//
atomic<int> y = 0;//定义原子整型数据
void thread_test(int n)
{
//无锁状态
//cout << &n << endl;
//cout << &x << endl;
for (int i = 0; i < n; i++)
{
x++;
y++;
}
}
void test1()
{
thread t1(thread_test, 100000);
thread t2(thread_test, 200000);
t1.join();
t2.join();
cout << x << endl;
cout << y << endl;
}
int main()
{
test1();
return 0;
}
9.3 mutex库
9.3.1 mutex基础种类
在C++11中,Mutex总共包了四个互斥量的种类:
1)std::mutex
函数名 | 函数功能 |
lock() | 上锁;锁住互斥量 |
unlock() | 解锁;释放对互斥量的所有权 |
try_lock() | 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞 |
注意,线程函数调用lock()时,可能会发生以下三种情况:
如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前, 该线程一直拥有该锁
如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
线程函数调用try_lock()时,可能会发生以下三种情况:
如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量
如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉
如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
通过锁解决上述样例问题
int x = 0;
mutex mtx;
void thread_test(int n)
{
无锁状态
cout << &n << endl;
cout << &x << endl;
//for (int i = 0; i < n; i++)
//{
// x++;
//}
//mutex mux;//局部的锁
局部的锁仍然不能达到要求,因此需要全局的锁
//for (int i = 0; i < n; i++)
//{
// mux.lock();
// x++;
// mux.unlock();
//}
//全局锁
for (int i = 0; i < n; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
}
void test5()
{
thread t1(thread_test, 100000);
thread t2(thread_test, 200000);
t1.join();
t2.join();
cout << x << endl;
}
2)std::recursive_mutex
递归互斥锁,其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权, 释放互斥量时需要调用与该锁层次深度相同次数的 unlock()
除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同
int y = 0;
//mutex mty;//如果采用mutex mty,则会产生死锁
recursive_mutex mty; //因此要使递归互斥锁
void Func2(int n)
{
if (n == 0)
return;
mty.lock();
++y;
Func2(n - 1);
mty.unlock();
}
void test6()
{
thread t1(Func2, 100);//这里数据量太大的话递归会导致爆栈
thread t2(Func2, 200);
t1.join();
t2.join();
cout << y << endl;
}
3)std::timed_mutex
比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until()
函数名称 | 函数功能 |
try_lock_for() | 接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超 时(即在指定时间内还是没有获得锁),则返回 false |
try_lock_until() | 接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住, 如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指 定时间内还是没有获得锁),则返回 false |
4)std::recursive_timed_mutex
结合上面的各种功能
9.3.2 lock_guard
对于普通的锁,如果在程序执行过程中出现异常,导致略过解锁阶段。就会导致线程的死锁。而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;
};
9.3.3 unique_lock
上节中的lock_guard虽然避免了异常导致的死锁,但是解决方法太过单一,用户没有办法对该锁进行控制
因此产生了unique_lock。相较于lock_guard提供了更多的成员函数:
上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock
修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有 权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)
获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相 同)、mutex(返回当前unique_lock所管理的互斥量的指针)
unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动 (move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化 unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题