【重走C++学习之路】22、C++11语法
目录
一、列表初始化
1.1 {}初始化
1.2 std::initializer_list
二、变量类型推导
2.1 auto
2.2 decltype
三、右值引用和移动语义
3.1 左值与左值引用
3.2 右值与右值引用
3.3 左值引用与右值引用比较
3.4 右值引用使用场景和意义
3.5 move
3.6 完美转发和万能引用
四、新的类功能
4.1 新增两个默认成员函数
4.2 default和delete
五、可变参数模板
5.1 基本知识
5.2 容器中emplace接口
六、 lambda表达式
6.1 基本结构
6.2 捕获列表详解
1. 值捕获
2. 引用捕获(By Reference)
3. 混合捕获
4. 隐式捕获
6.3 函数对象与lambda表达式
七、包装器
7.1 function包装器
7.2 bind包装器
八、线程库
8.1 基本知识
8.2 原子性操作库
8.3 lock_guard与unique_lock
1. std::lock_guard
2. std::unique_lock
3. 核心区别对比
8.4 condition_variable
等待函数
通知函数
结语
一、列表初始化
1.1 {}初始化
在C++98中,标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定。
struct Point
{int _x;int _y;
};int main()
{// 数组类型int arr1[] = { 1,2,3,4 };int arr2[6]{ 1,2,3,4,5,6 };// 结构体类型Point p = { 1, 2 };
}
到了C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号(=),也可不添加。
class Point
{
public:Point(int x, int y):_x(x),_y(y){}private:int _x;int _y;
};int main()
{// 内置类型变量int a{ 2 };int b = { 3 };int c = { a + b };// 动态数组 C++98不支持int* arr = new int[5]{ 1,2,3,4,5 };// 容器使用{}进行初始化// vector<int> v = { 1,2,3 };vector<int> v{ 1,2,3 }; // 等号可以省略不写map<int, int> m{ {1,1},{2,2},{3,3} };// 自定义类型列表初始化 Point p{ 1, 2 };return 0;
}
1.2 std::initializer_list
initializer_list一般是作为构造函数的参数,C++11对STL中的不少容器就增加initializer_list作为参数的构造函数,这样初始化容器对象就更方便了。也可以作为operator=的参数,这样就可以用大括号赋值对应多个对象的列表初始化,但必须支持一个带有initializer_list类型参数的构造函数。
注意: 这种类型的对象由编译器根据初始化列表声明自动构造,初始化列表声明是用{}和,
容器使用initializer_list作为构造函数的参数的例子:
int main()
{vector<int> v = { 1,2,3,4 };list<int> lt = { 1,2 };// 这里{"sort", "排序"}会先初始化构造一个pair对象map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };// 使用大括号对容器赋值v = {10, 20, 30};return 0;
}
模拟实现vector支持initializer_list初始化和赋值:
template<class T>
class Vector
{
public:Vector(initializer_list<T> l):_size(0),_capacity(l.size()){_a = new T[_capacity];for (auto e : l){_a[_size++] = e;}}Vector<T>& operator=(initializer_list<T> l){vector<T> tmp(l);std::swap(_a, tmp._a);_size = l._size;_capacity = l._capacity;return *this;}private:T* _a;size_t _size;size_t _capacity;
};int main()
{Vector<int> v1 = { 1,2,3 };Vector<int> v2 = { 3,5,7,9 };v2 = { 1,2,3 };return 0;
}
二、变量类型推导
2.1 auto
auto在本系列最开始时就讲解了,还想了解的可以去我的文章中查看。【重走C++学习之路】2、C++基础知识
2.2 decltype
作用:根据表达式的实际类型推演出定义变量时所用的类型。且还可以使用推导出来的类型进行变量声明。
template<class T1, class T2>
void F(T1 t1, T2 t2)
{decltype(t1 * t2) ret;cout << typeid(ret).name() << endl;
}int Add(int x, int y)
{return x + y;
}int main()
{const int x = 1;double y = 2.2;// 用decltype自动推演变量的实际类型decltype(x * y) ret; // ret的类型是doubledecltype(&x) p; // p的类型是int*cout << typeid(ret).name() << endl;cout << typeid(p).name() << endl;// 带参数,推导函数返回值类型,注意这里不会调用函数cout << typeid(decltype(F(1, 'a'))).name() << endl;// 不带参数,推导函数类型cout << typeid(decltype(Add)).name() << endl;return 0;
}
注意:
- 运行时类型识别的缺陷是降低程序运行的效率
- typeid只能推导类型,但是不能使用类型声明和定义变量
三、右值引用和移动语义
在最开始介绍C++的基础知识时,就已经介绍过了引用的语法,只不过那里的引用都是左值引用。C++11新增了右值引用的语法特性,给右值取别名。左值引用和右值引用都是给对象取别名,只不过二者对象的特性不同。
3.1 左值与左值引用
左值:
一个表示数据的表达式(如变量名或解引用的指针),可以取地址和赋值,且左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边,例如:普通变量、指针等。
const修饰后的左值不可以赋值,但是可以取地址,所以还是左值。
左值引用:
给左值的引用,给左值取别名 ,符号为&,例如:int& ra = a;
示例:
int main()
{// 以下的p、b、c、*p都是左值int* p = new int(0);int b = 1;const int c = 2;// 以下几个是对上面左值的左值引用int*& rp = p;int& rb = b;const int& rc = c;int& pvalue = *p;return 0;
}
左值总结:
- 普通类型的变量,可以取地址
- const修饰的常量,可以取地址,也是左值
3.2 右值与右值引用
右值:
一个表示数据的表达式,右值不能取地址,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等
右值引用:
给右值的引用,给右值取别名,符号为&&,例如:int&& ra = Add(a,b)
示例:
int main()
{double x = 1.1, y = 2.2;// 以下几个都是常见的右值10; // 常量x + y; // 临时对象fmin(x, y); // 函数返回的临时对象// 以下几个都是对右值的右值引用int&& rr1 = 10;double&& rr2 = x + y;double&& rr3 = fmin(x, y);// 这里编译会报错:error C2106: “=”: 左操作数必须为左值10 = 1;x + y = 1;fmin(x, y) = 1;return 0;
}
右值总结:右值分为两种
- 纯右值:基本类型的常量或临时对象
- 将亡值:自定义类型的临时对象用完自动完成析构,如:函数以值的方式返回一个对象
3.3 左值引用与右值引用比较
左值引用:
- 左值引用不可以引用右值
- 加了const的左值引用既可以引用左值也可以引用右值
示例:
int main()
{// 左值引用只能引用左值,不能引用右值。int a = 10;int& ra1 = a; // ra为a的别名//int& ra2 = 10; // 编译失败,因为10是右值// const左值引用既可引用左值,也可引用右值。const int& ra3 = 10;const int& ra4 = a;return 0;
}
右值引用:
- 右值引用不可以引用左值
- 右值引用可以引用move后的左值
示例:
int main()
{// 右值引用只能右值,不能引用左值。int&& r1 = 10;// error C2440: “初始化”: 无法从“int”转换为“int &&”// message : 无法将左值绑定到右值引用int a = 10;int&& r2 = a;// 右值引用可以引用move以后的左值int&& r3 = std::move(a);return 0;
}
3.4 右值引用使用场景和意义
右值引用的出现时为了解决左值引用的一些短板,那么我们首先就要明确左值引用的作用。
左值引用做参数和做返回值都可以提高效率。但是当函数返回对象是一个局部变量,出了函数作用域就不存在了,就不能使用左值引用返回,只能传值返回,且传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)。
图解:
局部变量->tmp常量->接收变量,这其中发生了两次拷贝构造,有些编译器会优化成一次。且每一个拷贝都是深拷贝,性能损失很大。
很显然这样是不符合的,因此右值引用就出现了,连带着右值引用的移动语义也出现了。
移动语义:
将一个对象中资源移动到另一个对象中的方式,可以有效减少拷贝,减少资源浪费,提供效率。
实现代码:
String(String&& s)
: _str(nullptr)
, _size(0)
, _capacity(0)
{// 对于将亡值,内部做移动拷贝cout << "移动拷贝" << endl;swap(s);
}
to_string函数结束返回的临时对象是一个右值,用这个右值构造str,所以会优先调用移动构造,这里就是一个移动语义。本质是资源进行转移,此时tmp指向的是一块空的资源。可以看出的是这里解决的是减少接受函数返回对象时带来的拷贝,移动构造中没有新开空间,拷贝数据,所以效率提高了。
除了移动构造,我们还可以增加移动赋值,具体如下:
String& operator=(String&& s)
{cout << "移动赋值" << endl;swap(s);return *this;
}
如果运行下面的代码会出现什么结果?
int main()
{MeiYu::string ret1;ret1 = MeiYu::to_string(1234);return 0;
}
这里运行后,会调用了一次移动构造和一次移动赋值,要注意这里并没有发生编译器优化。因为如果是用一个已经存在的对象接收,编译器就没办法优化了。
实际运行过程:
to_string函数中会先用str生成一个临时对象,编译器在这里把str识别成右值,调用了移动构造。然后在把这个临时对象做为to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。
注意:
- 移动构造和移动赋值函数的参数不能设置成const类型的右值引用,因为资源无法转移而导致移动语义失效。
- 在C++11中,编译器会为类默认生成一个移动构造和移动赋值,该移动构造和移动赋值为浅拷贝,因此当类中涉及到资源管理时,用户必须显式定义自己的移动构造和移动赋值。
- 右值引用本身没有多大意义,引入了移动构造和移动赋值就有意义了。
右值引用和左值引用减少拷贝的场景:
- 作参数时: 左值引用减少传参过程中的拷贝。右值引用解决的是传参后,函数内部的拷贝构造。
- 作返回值时: 如果出了函数作用域,对象还存在,那么可以使用左值引用减少拷贝。如果不存在,那么产生的临时对象可以通过右值引用提供的移动构造生成,然后通过移动赋值或移动构造的方式将临时对象的资源给接受返回值者。
3.5 move
当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
注意:
- 被转化的左值,其生命周期并没有随着左值的转化而改变,即std::move转化的左值变量value不会被销毁。move告诉编译器:我们有⼀个左值,但我们希望像⼀个右值⼀样处理它。
- STL中也有另一个move函数,就是将一个范围中的元素搬移到另一个位置。
使用示例:
int main()
{String s1("123");String s2(move(s1));return 0;
}
3.6 完美转发和万能引用
- 完美转发
在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。
- 万能引用
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
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<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;
}
右值引用在传参的过程中移动要进行完美转发,否则会丢失右值属性,利用std::forward<T>(t)在传参的过程中保持t的原生类型属性。
四、新的类功能
4.1 新增两个默认成员函数
在C++11中由新增了两个默认成员函数:
- 移动构造函数
- 移动赋值运算符重载
需要注意:
- 如果没有实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。
- 默认生成的移动构造函数,对于内置类型成员会按照字节序进行浅拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。
- 如果没有实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。
- 默认生成的移动构造函数,对于内置类型成员会按照字节序进行浅拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。
4.2 default和delete
C++11可以让你更好的控制要使用的默认函数。你可以强制生成某个默认成员函数,也可以禁止生成某个默认成员函数,分别用的的关键字是——default和delete。
示例:
class Person
{
public:Person(const char* name = "", int age = 0):_name(name),_age(age){}Person(Person&& p) = default;// 强制生成默认的Person& operator=(Person&& p) = default;Person(Person& p) = delete;// 强制删除默认的~Person(){}private:string _name;int _age;
};
五、可变参数模板
5.1 基本知识
C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板。具体格式:
template <class ...Args>
void func(Args... args)
{}
args前面有省略号,所以它们是可变参数模板,带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数;Args是一个模板参数包
,args是一个函数形参参数包。
如何获得参数包的值?
- 递归函数方式展开参数包
void ShowList()
{cout << endl;
}template<class T, class ...Args>
void ShowList(T value, Args ...args)
{cout << value << " ";// 递归调用ShowList,当参数包中的参数个数为0时,调用上面无参的ShowList// args中第一个参数作为value传参,参数包中剩下的参数作为新的参数包传参ShowList(args...);
}
int main()
{ShowList(1, 'A');ShowList(3, 'a', 1.23);ShowList('a', 4, 'B', 3.3);return 0;
}
- 逗号表达式展开参数包
什么是逗号表达式?
优先级别最低,将两个及其以上的式子联接起来,从左往右逐个计算表达式,整个表达式的值为最后一个表达式的值。如:(3+5,6+8)称为逗号表达式,其求解过程先表达式1,后表达式2,整个表达式值是表达式2的值。
如果一个参数包中都是同一个的类型,那么可以使用该参数包对数组进行列表初始化,参数包会展开,然后对数组进行初始化。例如:
void ShowList(Args ...args)
{int arr[] = { args... };// 列表初始化
}
int main()
{ShowList(1, 2, 3, 4, 5);return 0;
}
如果参数包中的参数不是同一类型就会报错。但是我们可以利用列表初始化数组时,展开参数包的特性,再与一个逗号表达式结合使用,可以展开都会表达式中的参数,如下:
template<class T>
void PrintArg(T value)
{cout << value << " ";
}template<class ...Args>
void ShowList(Args ...args)
{int arr[] = { (PrintArg(args), 0)... };cout << endl;
}
这里的表达式会按顺序执行,先执行第一个函数,然后把最后的0值赋给数组,这样就把参数包展开了。这个数组的目的纯粹是为了在数组构造的过程展开参数包
(PrintArg(arg1), 0),(PrintArg(arg2), 0),(PrintArg(arg3), 0),(PrintArg(arg4), 0)
5.2 容器中emplace接口
上面的模板参数既支持可变参数,又支持万能引用。
emplace_back和push_back进行对比:
用法上:
list<pair<int, string>> lt;
lt.emplace_back(1, "hehe");
list<pair<int, string>> lt;
lt.push_back({ 2, "haha" });
原理上:
emplace_back是直接构造了,push_back是先构造,再移动构造。
六、 lambda表达式
Lambda 表达式是一种内联定义匿名函数对象的强大方式,它为 C++ 带来了函数式编程的特性,极大地提高了代码的简洁性和灵活性。
6.1 基本结构
[capture list](parameter list)mutable -> return type { function body }
- 捕获列表(
capture list
):用于捕获外部变量,可按值(=
)或引用(&
)捕获。 - 参数列表(
parameter list
):与普通函数的参数列表类似,可省略(但括号不能省)。 - 可变参数(mutable) :默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。
- 返回类型(
return type
):使用尾置返回类型,可省略(编译器自动推导)。 - 函数体(
function body
):与普通函数的函数体相同。
注意:
在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为
空。因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情。
6.2 捕获列表详解
捕获列表允许 Lambda 表达式访问外部作用域的变量,有以下几种形式:
1. 值捕获
int x = 10;
auto lambda = [x]() { return x * 2; }; // 捕获 x 的副本
x = 20;
std::cout << lambda(); // 输出 20(捕获的是 x 的旧值)
2. 引用捕获(By Reference)
int x = 10;
auto lambda = [&x]() { return x * 2; }; // 捕获 x 的引用
x = 20;
std::cout << lambda(); // 输出 40(引用的是当前 x 的值)
3. 混合捕获
int x = 10, y = 20;
auto lambda = [x, &y]() { return x + y++; }; // x 按值,y 按引用
4. 隐式捕获
[=]
:默认按值捕获所有外部变量。[&]
:默认按引用捕获所有外部变量。[=, &x]
:默认按值捕获,但x
按引用捕获。[&, x]
:默认按引用捕获,但x
按值捕获。
注意:
- 父作用域指包含lambda函数的语句块
- 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量[&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量
- 捕捉列表不允许变量重复传递,否则就会导致编译错误。比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复
- 在块作用域以外的lambda函数捕捉列表必须为空。
- 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错。
- lambda表达式之间不能相互赋值,即使看起来类型相同
6.3 函数对象与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);// lamberauto r2 = [=](double monty, int year)->double{return monty * rate * year;};r2(10000, 2);return 0;
}
从使用方式上来看,函数对象与lambda表达式完全一样。
函数对象将rate作为其成员变量,在定义对象时给出初始值即可,而lambda表达式通过捕获列表可
以直接将该变量捕获到。
实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如
果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()。
七、包装器
7.1 function包装器
C++ 中的 std::function
是一个通用的多态函数包装器,它可以存储、复制和调用任何可调用对象(如函数、函数指针、成员函数指针、Lambda 表达式、仿函数等)。使用包装器之前需要包含头文件:#include <functional>
。
使用格式:function<返回类型(参数列表)>
注意:
如果std::function对象未包装可调用对象,使用std::function对象将抛出std::bad_function_call异常。
示例:
#include <iostream>
#include <functional>
#include <string>
using namespace std;//1、普通的函数
void func(const string& str)
{cout << str << endl;
}//2、仿函数
class myclass1
{
public:void operator()(const string& str){ cout << str << endl; }
};//3、类中普通成员函数
class myclass2
{
public:void func(const string& str){cout << str << endl; }
};//4、类中静态成员函数
class myclass3
{
public:static void func(const string& str){cout << str << endl; }
};int main()
{// 普通函数func("我是直接调用的普通函数");// 由函数指针调用void(*fp1) (const string& ) = func;fp1("我是由函数指针调用的普通函数");// 用包装器调用function<void(const string&)> ff1 = func;ff1("我是由包装器调用的普通函数");// 仿函数myclass1 aa;aa("我是由仿函数对象调用的函数");// 用包装器调用function<void(const string&)> ff2 = myclass1();ff2("我是由包装器调用的仿函数");// 用函数指针调用的类的非静态成员函数myclass2 bb;void(myclass2::*fp3) (const string& ) = &myclass2::func; (bb.*fp3)("我是由函数指针调用的类的非静态成员函数");// 用包装器调用,传入类名function<void(myclass2, const string&)> ff3 = &myclass2::func;// 需要传入this指针ff3(bb, "我是由包装器调用类的静态非成员函数");// 用函数指针调用类的静态成员函数myclass3 cc;void(*fp4)(const string& ) = myclass3::func; fp4("我是函数指针调用类的静态成员函数");// 用包装器调用function<void(const string&)> ff4 = myclass3::func;ff4("我是由包装器调用类的静态成员函数");// 匿名函数auto f = [](const string& str){cout << str << endl; };f("我是lambda函数");// 用包装器调用function<void(const string&)> ff5 = f; ff5("我是由包装器调用的lambda函数"); return 0;
}
- 除了类的非静态成员函数,其他的可调用对象通过包装器的包装,得到了一个统一的格式,包装完成得到的对象相当于一个函数指针,和函数指针的使用方式相同。
- 类的非静态成员函数还需要传入this指针,所以单独使用std::function是不够的,还需要结合使用std::bind函数绑定this指针以及参数列表。
7.2 bind包装器
std::bind函数定义在头文件中,是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来“适应”原对象的参数列表。一般而言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M可以大于N,但这么做没什么意义)参数的新函数。同时,使用std::bind函数还可以实现参数顺序调整等操作。
std::bind()返回std::function的对象,调用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为第二个参数,以此类推。
示例:
void func(int i, const string& str)
{while(i--){cout << str << endl;}cout << endl;
}
int main()
{function<void(int, const string&)> fn1 = //bind(可调用对象,参数列表)bind(func, placeholders::_1,placeholders::_2);//placeholders::_1/_2是参数占位符fn1(2, "普通函数");return 0;
}
八、线程库
线程是操作系统中的一个概念,如果大家还没有学到Linux系统编程的话,这一部分可以先放放。
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。
8.1 基本知识
相关成员函数:
- thread()
构造一个线程对象,没有关联任何线程函数,即没有启动任何线程
- thread(fn,args1, args2,...)
构造一个线程对象,并关联线程函数fn,args1,args2,...为线程函数的参数
- get_id()
获取线程id
- jionable()
线程是否还在执行,joinable代表的是一个正在执行中的线程。
- jion()
该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行
- detach()
在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关
注意:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
- 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:函数指针、lambda表达式、函数对象
- thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的执行。
- 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象
- 线程对象的状态已经转移给其他线程对象
- 线程已经调用jion或者detach结束
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在
线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
8.2 原子性操作库
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问
题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数
据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
例如:
#include <iostream>
using namespace std;#include <thread>unsigned long sum = 0L;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, 10000000);thread t2(fun, 10000000);t1.join();t2.join();cout << "After joining,sum = " << sum << std::endl;return 0;
}
这里线程1和线程2的执行并不是一前一后执行的,而是并发执行的,那么就会出现数据最后不是我们想要的结果,学过系统编程的同学很容想到加锁来确保sum的正确性。例如:
#include <iostream>
using namespace std;#include <thread>
#include <mutex>std::mutex m;
unsigned long sum = 0L;void fun(size_t num)
{for (size_t i = 0; i < num; ++i){m.lock();sum++;m.unlock();}
}int main()
{cout << "Before joining,sum = " << sum << std::endl;thread t1(fun, 10000000);thread t2(fun, 10000000);t1.join();t2.join();cout << "After joining,sum = " << sum << std::endl;return 0;
}
虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。
因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,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;
}
程序员可以使用atomic类模板(例如:atmoic<T> t) 定义出需要的任意原子类型。
原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
8.3 lock_guard与unique_lock
为了防止使用锁时出现异常而导致锁没有释放出现死锁,因此:C++11采用RAII的方式对锁进行了封装,即lock_guard和unique_lock。std::lock_guard
和 std::unique_lock
是 C++11 引入的用于管理互斥锁(Mutex)的工具,它们都能自动释放锁资源,避免手动管理锁带来的潜在风险(如忘记解锁导致死锁)。
1. std::lock_guard
特性:
- 简单粗暴:构造时锁定互斥量,析构时自动解锁,不支持手动解锁。
- 不可移动 / 复制:设计为栈上的局部对象,生命周期结束时自动释放锁。
- 轻量级:无额外开销,性能接近手动管理锁。
示例:
#include <mutex>std::mutex mtx;void func()
{std::lock_guard<std::mutex> lock(mtx); // 加锁// 临界区代码
} // 离开作用域时自动解锁
适用场景
- 简单的临界区保护,锁的持有时间短。
- 不涉及锁的转移或条件变量的场景。
2. std::unique_lock
特性:
- 灵活但开销略高:支持手动锁定 / 解锁,可移动(不可复制),可与条件变量配合。
- 延迟锁定:构造时可选择不立即锁定,通过
lock()
和unlock()
手动控制。- 所有权转移:可通过
std::move
转移锁的所有权,适用于复杂场景。
示例:
#include <mutex>std::mutex mtx;void func()
{std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 延迟加锁// 其他操作...lock.lock(); // 手动加锁// 临界区代码lock.unlock(); // 手动解锁// 非临界区代码lock.lock(); // 再次加锁// 临界区代码
} // 离开作用域时自动解锁(若锁被持有)
3. 核心区别对比
特性 | std::lock_guard | std::unique_lock |
---|---|---|
手动锁定 / 解锁 | 不支持 | 支持 lock() 、unlock() 、try_lock() |
延迟锁定 | 构造时必须锁定 | 可通过 std::defer_lock 延迟锁定 |
所有权转移 | 不可移动 | 支持 std::move 转移所有权 |
条件变量支持 | 不能直接配合条件变量使用 | 必须与条件变量配合使用 |
性能 | 轻量级,无额外开销 | 略高(因支持更多特性) |
适用场景 | 简单临界区,锁持有时间短 | 复杂场景(如锁的转移、条件变量) |
8.4 condition_variable
std::condition_variable
是 C++11 引入的线程同步原语,用于线程间的等待 - 通知机制。它允许一个线程阻塞(等待),直到另一个线程通过 notify_one()
或 notify_all()
唤醒它。这种机制常用于生产者 - 消费者模型、任务队列等场景。
核心原理:
- 等待线程:通过
wait()
释放锁并阻塞,直到被唤醒。- 通知线程:通过
notify_one()
或notify_all()
唤醒等待线程。- 互斥锁:必须与
std::unique_lock
配合使用,保证原子性。
关键成员函数:
等待函数
wait(lock)
:释放锁并阻塞,直到被通知,唤醒后自动重新加锁。wait(lock, predicate)
:等价于while(!predicate()) wait(lock)
,避免虚假唤醒。wait_for(lock, timeout)
:限时等待,返回std::cv_status::timeout
或no_timeout
。wait_until(lock, time_point)
:等待到指定时间点。
通知函数
notify_one()
:唤醒一个等待中的线程(若有)。notify_all()
:唤醒所有等待中的线程。
通过一个实例来来见见怎么使用:
(两个线程交替打印奇偶数)
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>void two_thread_print()
{std::mutex mtx;condition_variable c;int n = 100;bool flag = true;thread t1([&](){int i = 0;while (i < n){unique_lock<mutex> lock(mtx);c.wait(lock, [&]()->bool{return flag; });cout << i << endl;flag = false;i += 2; // 偶数c.notify_one();}});thread t2([&](){int j = 1;while (j < n){unique_lock<mutex> lock(mtx);c.wait(lock, [&]()->bool{return !flag; });cout << j << endl;j += 2; // 奇数flag = true;c.notify_one();}});t1.join();t2.join();
}int main()
{two_thread_print();return 0;
}
结语
这一篇文章内容较多,右值引用部分需要着重理解,其他的如果暂时不理解也可以先放一放,这篇文章后半部分基本上都是现在很难用上的,可能现在记住理解了,过段时间也会忘记了。等到学习Linux的时候会用的比较多,那个时候再来回顾,保证会有一个新的理解。
下一篇将会介绍C++中处理异常的方法,有兴趣的朋友可以关注一下。