🛸个人主页: dragoooon34
🚁所属专栏: C++
🚀操作环境: Visual Studio 2022

目录

🌞前言

🌜正文

一、😀lambda表达式

1-1 🍕仿函数的使用

1-2 🍔lambda表达式的语法

1-3🍟lambda表达式的使用

1-4 🌭lambda表达式的原理

1-4 🍿lambda表达式的优点及适用场景

二、😁线程库

2-1 🧂thread 线程类

2-1-1 🐕this_thread 命名空间

2-2 🧂mutex 互斥锁类

2-2-1 🐈‍⬛并行与串行的对比

2-2-2 🐆其他锁类型

2-2-3 🫏RAII 风格的锁

2-3 🥓condition_variable 条件变量类

2-3-1 🐎交替打印数字

2-4 🥚atomic 原子操作类

三、😂包装器

3-1 🍳function 包装器

3-2 🧇bind 绑定

🔥总结🔥


🌞前言

自从C++98以来,C++11无疑是一个相当成功的版本更新。它引入了许多重要的语言特性和标准库增强,为C++编程带来了重大的改进和便利。C++11的发布标志着C++语言的现代化和进步,为程序员提供了更多工具和选项来编写高效、可维护和现代的代码

🌜正文

一、😀lambda表达式

lambda 表达式 源于数学中的 λ 演算,λ 演算是一种 基于函数的形式化系统,它由数学家 阿隆佐邱奇 提出,用于研究抽象计算和函数定义。对于编程领域来说,可以使用 lambda 表达式 快速构建函数对象,作为函数中的参数

1-1 🍕仿函数的使用

仿函数 是 C++ 中的概念,指借助 类+operator()重载 创建的函数对象,仿函数 的使用场景如下

创建一个 vector,通过 sort 函数进行排序,至于结果为升序还是降序,可以通过 仿函数 控制

#include <iostream>
#include <unordered_map>
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

struct cmpLess
{
            
      
	bool operator()(int n1, int n2)
	{
            
      
		return n1 < n2;
	}
};

struct cmpGreater
{
            
      
	bool operator()(int n1, int n2)
	{
            
      
		return n1 > n2;
	}
};

int main()
{
            
      
	vector<int> arr = {
            
       8,5,6,7,3,1,1,3 };

	sort(arr.begin(), arr.end(), cmpLess()); // 升序

	cout << "升序: ";
	for (auto e : arr)
		cout << e << " ";
	cout << endl;

	sort(arr.begin(), arr.end(), cmpGreater()); // 降序

	cout << "降序: ";
	for (auto e : arr)
		cout << e << " ";
	cout << endl;

	return 0;
}

注:sort 如果不传递函数对象,默认排序结果为升序

结果为正确排序,但这种先创建一个仿函数对象,再调用的传统写法有点麻烦了,如果是直接使用 lambda 表达式 创建函数对象,整体逻辑会清楚很多

使用 lambda 表达式 修改后的代码如下,最大的改变就是 可以直接在传参时直接编写函数对象的代码逻辑

#include <iostream>
#include <unordered_map>
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int main()
{
            
      
	vector<int> arr = {
            
       8,5,6,7,3,1,1,3 };

	sort(arr.begin(), arr.end(), [](int n1, int n2) {
            
       return n1 < n2; }); // 升序

	cout << "升序: ";
	for (auto e : arr)
		cout << e << " ";
	cout << endl;

	sort(arr.begin(), arr.end(), [](int n1, int n2) {
            
       return n1 > n2; }); // 降序

	cout << "降序: ";
	for (auto e : arr)
		cout << e << " ";
	cout << endl;

	return 0;
}

最终结果也是正常的

有了 lambda 表达式 之后,程序员不必再通过 仿函数 构建函数对象,并且可以在一定程度上提高代码的可阅读性,比如一眼就可以看出回调函数是在干什么

接下来看看如何理解 lambda 表达式 语法

1-2 🍔lambda表达式的语法

lambda 表达式 分为以下几部分:

  • [ ] 捕捉列表
  • ( ) 参数列表
  • mutable 关键字
  • ->returntype 返回值类型
  • { } 函数体

[ ]( ) mutable ->returntype { }

其中,( ) 参数列表、mutable->returntype 都可以省略

  • 省略 ( )参数列表 表示当前是一个无参函数对象
  • 省略 mutable关键字 表示保持捕捉列表中参数的常量属性
  • 省略 ->returntype返回值类型 表示具体的返回值类型由函数体决定,编译器会自动推导出返回值类型

注意:

  • 捕捉列表 和 函数体 不可省略
  • 如果使用了 mutable关键字 或者 ->returntype 返回值,就不能省略 ( )参数列表,即使为空
  • 虽然返回值类型编译器可以推导,但最好还是注明返回值类型

也就是说,最基本的 lambda表达式 只需书写 [ ]{ } 即可表示,比如这样

int main()
{
            
      
	// 最简单的 lambda表达式
	[]{
            
      };
	return 0;
}

此时的 lambda表达式 相当于一个 参数为空、返回值为空、函数体为空 的匿名函数对象

void func()
{
            
      }

主要区别在于 lambda 表达式 构建出来的是一个 匿名函数对象,而 func 是一个 有名函数对象,可以直接调用

1-3🍟lambda表达式的使用

lambda 表达式 构建出的是一个 匿名函数对象,匿名函数对象也可以调用,不过需要在创建后立即调用,否则就会因为越出作用域而被销毁(匿名对象生命周期只有一行

下面通过 lambda 表达式 构建一个简单的 两整数相加 函数对象并调用

int main()
{
            
      
	int ret = [](int x, int y)->int {
            
       return x + y; }(1, 2);

	cout << ret << endl;
	return 0;
}

直接使用 lambda 表达式 构建出的 匿名函数对象 比较抽象,一般都是将此 匿名函数对象 作为参数传递(比如 sort),如果需要显式调用,最好是将创建出来的 匿名函数对象 赋给一个 有名函数对象,调用时逻辑会清晰很多

使用 auto 推导 匿名函数对象 的类型,然后创建 add 函数对象

int main()
{
            
      
	auto add = [](int x, int y)->int {
            
       return x + y; };

	int ret = add(1, 2);

	cout << ret << endl;
	return 0;
}

lambda 表达式 还有很多玩法,接下来逐一介绍,顺便学习其他组成部分

利用 lambda 表达式 构建一个交换两个元素的 函数对象

最经典的写法是 函数参数设为引用类型,传入两个元素,在函数体内完成交换

int main()
{
            
      
	int x = 1;
	int y = 2;

	cout << "交换前" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;

	auto swap = [](int& rx, int& ry)->void
				{
            
      
					auto tmp = rx;
					rx = ry;
					ry = tmp;
				};

	swap(x, y);

	cout << "交换后" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;
	return 0;
}

这种经典写法毋庸置疑,肯定能完成两数交换的任务

除此之外,还可以借助 lambda表达式 中的 捕捉列表 捕获外部变量进行交换

int main()
{
            
      
	int x = 1;
	int y = 2;

	cout << "交换前" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;

	auto swap = [x, y]() ->void
		{
            
      
			auto tmp = x;
			x = y;
			y = tmp;
		};

	swap();

	cout << "交换后" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;
	return 0;
}

因为现在 函数对象 是直接捕获外部变量进行操作,调用函数对象时,无需传参

代码写完,编译器立马给出了报错:xy 不可修改

这是因为 捕捉列表 中的参数是一个值类型(传值捕捉),此时的捕获的是外部变量的内容,然后赋值到 x、y 中,捕捉列表 中的参数默认具有 常量属性,不能直接修改,但可以添加 mutable 关键字 取消常性

int main()
{
            
      
	int x = 1;
	int y = 2;

	cout << "交换前" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;

	auto swap = [x, y]()mutable ->void
		{
            
      
			auto tmp = x;
			x = y;
			y = tmp;
		};

	swap();

	cout << "交换后" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;
	return 0;
}

但是程序运行结果不尽人意,外部的 x、y 并没有被交换,证明此时 捕捉列表 中的参数 x、y 是独立的值(类似函数中的值传递)

想让外部的 x、y 被真正捕获,需要使用 引用捕捉

int main()
{
            
      
	int x = 1;
	int y = 2;

	cout << "交换前" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;

	// 引用捕捉
	auto swap = [&x, &y]() ->void
		{
            
      
			auto tmp = x;
			x = y;
			y = tmp;
		};

	swap();

	cout << "交换后" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;
	return 0;
}

现在 x、y 被成功交换了

注意: 捕捉列表中的 &x 表示引用捕捉外部的 x 变量,并非取地址(特例)

所以说 mutable 关键字不常用,因为它取消的是值类型的常性,即使修改了,对外部也没有什么意义,如果想修改,直接使用 引用捕捉 就好了

捕捉列表 支持 混合捕捉,同时使用 引用捕捉 + 传值捕捉

int main()
{
            
      
	int x = 1;
	int y = 2;

	cout << "调用前" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;

	// 混合捕捉
	auto func = [&x, y]()mutable ->void
		{
            
      
			x = 100;
			y = 200;
		};

	func();

	cout << "调用后" << endl;
	cout << "\tx: " << x << endl << "\ty: " << y << endl;
	return 0;
}

x 被修改了,而 y 没有

除了 混合捕捉 外,捕捉列表 还支持 全部引用捕捉 和 全部传值捕捉

全部引用捕捉

int main()
{
            
      
	int x, y, z, a, b, c;
	x = y = z = 0;
	a = b = c = 1;
	string str = "Hello lambda!";
	cout << "&str: " << &str << endl << endl;

	auto func = [&]()->void
				{
            
      
					cout << x << " " << y << " " << z << " " << endl;
					cout << a << " " << b << " " << c << " " << endl;
					cout << str << endl;
					cout << "&str: " << &str << endl << endl;
				};

	func();

	return 0;
}

无需指定 捕捉列表 中的参数,& 可以一键 引用捕捉 外部所有变量

注:只能捕捉已经定义或声明的变量

全部传值捕捉

int main()
{
            
      
	int x, y, z, a, b, c;
	x = y = z = 0;
	a = b = c = 1;
	string str = "Hello lambda!";
	cout << "&str: " << &str << endl << endl;

	auto func = [=]()->void
				{
            
      
					cout << x << " " << y << " " << z << " " << endl;
					cout << a << " " << b << " " << c << " " << endl;
					cout << str << endl;
					cout << "&str: " << &str << endl << endl;
				};

	func();

	return 0;
}

全部传值捕捉 也能一键捕捉外部变量,不过此时捕获的是外部变量的值,并非变量本身,无法对其进行修改(可以通过 mutable关键字 取消常性)

注意: [=] 表示全部传值捕捉,[] 表示不进行捕捉,两者不等价

捕捉列表 的使用非常灵活,比如 [&, x] 表示 x 使用 传值捕捉,其他变量使用 引用捕捉 [=, &str] 表示 str 使用 引用捕捉,其他变量使用 传值捕捉

捕捉列表 就像一个 “大师球”,可以直接捕捉到外部的变量,在需要大量使用外部变量的场景中很实用,有效避免了繁琐的参数传递与接收

有没有 全部引用捕捉 + 全部传值捕捉 ?

当然没有,这是相互矛盾的,一个变量不可能同时进行 引用传递 和 值传递,即便传递成功了,编译器在使用时也不知道使用哪一个,存在二义性,所以不被允许

注意: 关于 捕获列表 有以下几点注意事项

  1. 捕捉列表不允许变量重复传递,否则就会导致编译错误
  2. 在块作用域以外的 lambda 函数捕捉列表必须为空
  3. 在块作用域中的 lambda 函数不仅能捕捉父作用域中局部变量,也能捕捉到爷爷作用域中的局部变量

lambda表达式 还可以完美用作 线程回调函数,比如接下来使用 C++11 中的 thread 线程类,创建一个线程,并使用 lambda 表达式 创建一个线程回调函数对象

int main()
{
            
      
	// 创建线程,并打印线程id
	thread t([] {
            
       cout << "thread running... " << this_thread::get_id() << endl; });
	
	t.join();
	return 0;
}

总之 lambda 表达式 在实际开发中非常好用,关于 thread类的相关知识放到后面讲解,接下来先看看 lambda 表达式 的实现原理

1-4 🌭lambda表达式的原理

lambda 表达式 生成的函数对象有多大呢?

是像 普通的函数对象指针 一样占 4/8 字节,还是像 仿函数 一样占 1 字节,通过 sizeof 计算大小就可以一探究竟

// 普通函数
int add(int x, int y)
{
            
      
	return x + y;
}

// 仿函数
class addFunc
{
            
      
public:
	int operator()(int x, int y)
	{
            
      
		return x + y;
	}
};

int main()
{
            
      
	auto typeA = add;
	addFunc typeB;
	auto typeC = [](int x, int y)->int {
            
       return x + y; };

	cout << "普通函数: " << sizeof(typeA) << endl;
	cout << "仿函数: " << sizeof(typeB) << endl;
	cout << "lambda表达式: " << sizeof(typeC) << endl;

	return 0;
}

结果显示,lambda 表达式 生成的函数对象与 仿函数 生成的函数对象大小是一样的,都是 1字节


仿函数 生成的函数对象大小为 1字节是因为其生成了一个空类,实际调用时是通过 operator() 重载实现的,比如上面的 addFunc 类,空类因为没有成员变量,所以大小只为 1字节

由此可以推断 lambda 表达式 本质上也是生成了一个空类,分别查看使用 仿函数 和 lambda 表达式 时的汇编代码

可以看到,这两段汇编代码的内容是一模一样的,都是先 call 一个函数(operator() 重载函数),然后再执行主体逻辑(两数相加),只不过使用 仿函数 需要自己编写一个 空类,而 使用 lambda 表达式 时由编译器生成一个 空类,为了避免这个自动生成的 空类 引发冲突,会将这个 空类 命名为 lambda_uuid

uuid 是 通用唯一标识码,可以生成一个重复率极低的辨识信息,避免类名冲突,这也意味着即便是两个功能完全一样的 lambda 表达式,也无法进行赋值,因为 lambda_uuid 肯定不一样

所以在编译器看来,lambda 表达式 本质上就是一个 仿函数

1-4 🍿lambda表达式的优点及适用场景

lambda 表达式 作为一种轻量级的匿名函数表示方式,具备以下优点:

  1. 简洁性: 对于简单的函数操作,无需再手动创建函数、调用,只需要编写一个 lambda 表达式生成函数对象
  2. 方便些: lambda 表达式具有 捕捉列表,可以轻松捕获外部的变量,避免繁琐的参数传递与接收
  3. 函数编程支持: lambda 表达式可以作为函数的参数、返回值或存储在数据结构中
  4. 内联定义: lambda 表达式Lambda表达式可以作为函数的参数、返回值或存储在数据结构中
  5. 简化代码: 对于一些简单的操作,使用 lambda 表达式可以减少代码的行数,提高代码的可读性

总的来说,lambda 表达式 可以替代一些代码量少的函数,使用起来十分方便,如果 lambda 表达式 编写出来的代码过于复杂时,可以考虑转为普通函数,确保代码的清晰性和可读性

二、😁线程库

关于 线程 相关操作,Linux 选择使用的是 POSIX 标准,而 Windows 没有选择 POSIX 标准,反而是自己搞了一套 API 和系统调用,称为 Win32 API,意味着 Linux 与 Windows 存在标准差异,直接导致能在 Linux 中运行的程序未必能在 Windows 中运行

 C++11 之前,编写多线程相关代码如果保证兼容性,就需要借助 条件编译,分别实现两份代码,根据不同平台编译不同的代码(非常麻烦)

// 确保平台兼容性
#ifdef __WIN_32__
	CreateThread // Windows 中创建线程的接口
	// ...
#else
	pthread_create // Linux 中创建线程的接口
	// ...
#endif

 C++11 中,加入了 线程库 这个标准,其中包含了 线程、互斥锁、条件变量 等常用线程操作,并且无需依赖第三方库,也就意味着使用 线程库 编写的代码既能在 Linux 中运行,也能在 Windows 中运行,保障了代码的可移植性,除此之外,线程库 还新加入了 原子相关操作

2-1 🧂thread线程类

thread 线程类的概况如下

首先看看 thread 类中的 线程 id

Linux 中的 线程 id 表示每个轻量级进程 TCB 的起始地址,用一个 unsigned long int 表示,理解起来比较费劲;在 thread 类中,直接创建了一个 id 类,也就是这里的 thread::id,这个类用于标识 线程,同时在类中重载了一系列 operator 函数,用于两个 thread::id 对象的比较

线程创建后,系统会为其分配一个类型为 thread::id 的标识符,也就是该线程的唯一标识符

获取当前线程的 id,并进行比较

int main()
{
            
      
	thread::id id1 = std::this_thread::get_id();
	thread::id id2 = std::this_thread::get_id();

	cout << "id1: " << id1 << " " << "id2: " << id2 << endl;

	if (id1 == id2)
		cout << "id 相同" << endl;
	else
		cout << "id 不同" << endl;

	return 0;
}

注意: thread::id 是一个类,不支持初始化或赋值,用于获取线程 id

至于 thread::native_handle_type 代表一个底层线程的本地(native)句柄或标识符,本地句柄通常是由操作系统提供的,用于标识和管理线程的底层资源

在绝大多数情况下,使用 C++ 标准库提供的高级线程抽象是足够的,而无需直接访问线程的本地句柄。直接使用底层线程句柄通常是为了执行与平台相关的线程操作,这可能包括与操作系统相关的调度、优先级、特定的线程控制等。这样的操作通常是为了满足对底层线程管理的特殊需求,而不是一般性的 C++ 线程编程。

总结就是 thread::native_handle_type 一般用不上,现阶段不必关心

接下来看看 构造函数 部分

创建 线程类 对象,支持:

  • 创建一个参数为空的默认线程对象
  • 通过可变参数模板传入回调函数和参数,其中 Fn 表示回调函数对象,Args 是传给回调函数的参数包(可以为空)
  • 移动构造,根据线程对象(右值)来构造线程对象

注意 thread 类不支持 拷贝构造,因为线程对象拥有自己的独立栈等线程资源,所以这里的 拷贝构造 使用 delete 关键字删除了

使用 thread 类需要包含 thread 这个头文件

#include <iostream>
#include <thread>

using namespace std;

int main()
{
            
      
	// 参数为空的默认线程对象
	thread t1; 

	// 传入回调函数及参数
	thread t2([](int x, int y)->void 
				{
            
       
					while(true)
						cout << "x + y = " << x + y << endl; 
				}, 1, 2);

	// 只传入回调函数
	thread t3([]()->void 
				{
            
      
					while(true)
						cout << "thread running..." << endl; 
				});

	//t1.join(); // t1 线程状态为空,不能 join 等待
	t2.join();
	t3.join();

	// 无法拷贝构造
	//thread t4(t3);

	return 0;
}

线程回调函数不止可以使用 lambda 表达式,还可以传入 函数指针 或者 函数对象

通过调试可以看到 t2t3 线程正在运行中,而 t1 因为没有指定回调函数,所以也就没有完全创建,自然也就没有在运行

其中 1739230925964 分别为 主线程、次线程 t2 和 次线程 t3,而 8460 和 26080 是 ntdll.dll 类型的线程,用于为应用程序加载其他动态库,程序运行大概半分钟后,这两个线程就会自动消失,因为当前处于调试状态,并且程序运行时间较短,所以才会看到这个两个系统级线程

注意: 线程如果没有完全创建,是不能 join 等待的,并且线程不支持拷贝操作

同样的,thread 只支持 移动赋值,不支持 传值赋值

部分构造函数后跟的 noexcept 关键字表示当前函数不会抛出 异常,详细知识放到 『异常』 文章中讲解

当线程对象生命周期结束时,会调用 析构函数 销毁对象

thread 类还提供了一批线程相关接口,比如 获取 id、等待、分离、交换

除了 joinable 和 swap,其他功能在 pthread 库中都已经使用过了

  • get_id 对应 pthread_self
  • join 对应 pthread_join
  • detach 对应 pthread_detach

简单使用如下

int main()
{
            
      
	// 创建线程
	thread t([]()->void {
            
       cout << "thread running..." << endl; });

	// 获取线程 id
	thread::id id = t.get_id();

	// 线程剥离
	// t.detach();

	cout << "线程 " << id << " 已经创建了" << endl;
 
	// 等待线程退出
	t.join();

	return 0;
}

注意: 分离线程后,主线程运行结束,整个程序也会随着终止,会导致正在运行中的次线程终止

joinable 是非阻塞版的线程等待函数,等待成功返回 true,否则返回 false

swap 则是将两个线程的资源进行交换(线程回调函数、线程状态等)

注意: swap 并不会交换 thread::id,因为这是线程唯一标识符

至于最后两个函数不常用,这里就不介绍了

这些都是线程常见操作,有了 Linux 多线程编程的基础,学习起来会轻松很多,接下来编写一个成员:创建一批线程,并分别打印十次自己的 id

int main()
{
            
      
	vector<thread> vts(5); // 5 个次线程(未完全创建)

	for (int i = 0; i < 5; i++)
	{
            
      
		// 移动构造
		vts[i] = thread([]()->void
			{
            
      
				for (int i = 0; i < 10; i++)
				{
            
      
					// 如何获取 id ?
					cout << "我是线程 " << " 我正在运行..." << endl;
				}
			});
	}

	// 等待线程退出
	for (auto& t : vts)
		t.join();

	return 0;
}

此时面临一个尴尬的问题:如何在回调函数中获取线程 id ?

  • 线程 id 目前之前通过线程对象调用 get_id 函数获取
  • 传入线程吗?不行,因为此时线程还没有完全创建,线程 id  0
  • 传入线程对象?不行,线程还没有完全创建,传入的对象也无法使用,也能通过捕获列表进行引用捕捉,不过同样无法使用

如此一来,想要在 线程回调函数 内获取 线程 id 还不是一件容易的事,好在 C++11 中还提供了一个 this_thread 命名空间,其中提供了获取 线程 id 等函数,可以自由调用

2-1-1 🐕this_thread 命名空间

this_thread 是一个命名空间,其中包含了 获取线程 id、线程休眠、线程时间片 相关函数

有了 this_thread 命名空间之后,就可以轻松获取 线程 id

int main()
{
            
      
	vector<thread> vts(5); // 5 个次线程(未完全创建)

	for (int i = 0; i < 5; i++)
	{
            
      
		// 移动构造
		vts[i] = thread([]()->void
			{
            
      
				for (int i = 0; i < 10; i++)
				{
            
      
					// 获取 id
					auto id = this_thread::get_id();
					cout << "我是线程 " << id << " 我正在运行..." << endl;
				}
			});
	}

	// 等待线程退出
	for (auto& t : vts)
		t.join();

	return 0;
}

可以看到,正常获取到了每个线程的 线程 id

注:这里打印错乱很正常,因为显示器也是临界资源,多线程并发访问时,也是需要加锁保护的

this_thread 只是一个命名空间,是如何做到正确调用 get_id 函数并获取线程 id 的?
this_thread 是 std 中的一个子命名空间,其中包含了一些与线程有关的操作,比如 get_id,当线程调用 this_thread::get_id 时,实际调用的就是该线程的 thread::get_id,所以才能做到谁调用,就获取谁的线程 id

除此之外,this_thread 命名空间中还提供了 线程休眠 的接口:sleep_untilsleep_for

sleep_util 表示休眠一个 绝对时间,比如线程运行后,休眠至明天 6::00 才接着运行;sleep_for 则是让线程休眠一个 相对时间,比如休眠 3 秒后继续运行,休眠 绝对时间 用的比较少,这里来看看如何休眠 相对时间

相对时间 有很多种:时、分、秒、毫秒、微秒…,这些单位包含于 chrono 类中

比如分别让上面程序中的线程每隔 200 毫秒休眠一次,修改代码如下

int main()
{
            
      
	vector<thread> vts(5); // 5 个次线程(未完全创建)

	for (int i = 0; i < 5; i++)
	{
            
      
		// 移动构造
		vts[i] = thread([]()->void
			{
            
      
				for (int i = 0; i < 10; i++)
				{
            
      
					// 获取 id
					auto id = this_thread::get_id();
					cout << "我是线程 " << id << " 我正在运行..." << endl;

					// 休眠 200 毫秒
					this_thread::sleep_for(chrono::milliseconds(200));
				}
			});
	}

	// 等待线程退出
	for (auto& t : vts)
		t.join();

	return 0;
}

也可以让线程休眠其他单位时间

最后在 this_thread 命名空间中还存在一个特殊的函数:yield

这里的 yield 表示 让步、放弃,带入多线程环境中就表示 主动让出当前的时间片

yield 主要用于 无锁编程(尽量减少使用锁),而无锁编程的实现基于 原子操作 CAS,关于原子的详细知识放到后面讲解

原子操作 CAS 是一个不断重复尝试的过程,如果尝试的时间过久,就会影响整体效率,因为此时是在做无用功,而 yield 可以主动让出当前线程的时间片,避免大量重复,把 CPU 资源让出去,从而提高整体效率

2-2 🧂mutex 互斥锁类

多线程编程需要确保 线程安全 问题

首先要明白 线程拥有自己独立的栈结构,但对于全局变量等 临界资源,是直接被多个线程共享的

如果想给线程回调函数传递 左值引用 类型的参数,需要使用 ref 引用包装器函数进行包装传递

比如通过以下代码证明 线程独立栈 的存在

int g_val = 0;

void Func(int n)
{
            
      
	cout << "&g_val: " << &g_val << " &n: " << &n << endl << endl;
}

int main()
{
            
      
	int n = 10;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();
	return 0;
}

可以看到,全局变量 g_val 的地址是一样,而局部变量 n 的地址相差很远,证明这两个局部变量不处于同一个栈区中,而是分别存在线程的 独立栈

如果多个线程同时对同一个 临界资源 进行操作

  • 操作次数较少时,近似原子
  • 操作次数多时,有线程安全问题

这里同时对 g_val 进行 n 次 ++ 操作

 n = 100 时,结果还算正常(正确结果为 200

int g_val = 0;

void Func(int n)
{
            
      
	while (n--)
		g_val++;
}

int main()
{
            
      
	int n = 100;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();

	cout << "g_val: " << g_val << endl;
	return 0;
}

但如果将 n 改为 20000,程序就出问题了(正确结果为 40000

n = 20000;

并且几乎每一次运行结果都不一样,这就是由于 线程安全 问题带来的 不确定性 导致的

关于线程安全的更多知识详见 [Linux——Lesson24.线程:线程同步与互斥]

确保 线程安全 的手段之一就是 加锁 保护,C++11 中就有一个 mutex 类,其中包含了 互斥锁 的各种常用操作

比如创建一个 mutex 互斥锁 对象,当然 互斥锁也是不支持拷贝的mutex 互斥锁 类也没有提供移动语义相关的构造函数,因为锁资源一般是不允许被剥夺的


互斥锁 对象的构造很简单,使用也很简单,常用的操作有:加锁、尝试加锁、解锁

  • lock 对应 pthread_mutex_lock
  • try_lock 对应 pthread_mutex_trylock
  • unlock 对应 pthread_mutex_unlock

这些操作使用起来十分简单,对上面的程序进行加锁保护

注:使用 mutex 类需要包含 mutex 这个头文件

int g_val = 0;

// 互斥锁对象
mutex mtx;

void Func(int n)
{
            
      
	while (n--)
	{
            
      
		mtx.lock();
		g_val++;
		mtx.unlock();
	}
}

int main()
{
            
      
	int n = 20000;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();


	cout << "g_val: " << g_val << endl;
	return 0;
}

此时无论数据量有多大,最终的结果都是符合预期的

注意: 这里的两个线程只需要一把锁,并且要保证两个线程看到的是同一把锁

2-2-1 🐈‍⬛并行与串行的对比

互斥锁 的加锁、解锁位置也是有讲究的,比如只把 g_val++ 这个操作加锁,此时程序就是 并行化 运行,线程 A 线程 B 都可以进入循环,但两者需要在循环中竞争 锁资源,只有抢到 锁资源 的线程才能进行 g_val++,两个线程同时竞争,相当于同时进行操作

也可以把整个 while 循环加锁,程序就会变成 串行化线程 A 或者 线程 B 抢到 锁资源 后,就会不断进行 g_val++,直到循环结束,才会把 锁资源 让出

理论上来说,并行化 要比 串行化 快,实际结果可以通过代码呈现

int main()
{
            
      
	int n = 20000;

	size_t begin = clock();
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();
	size_t end = clock();

	cout << "g_val: " << g_val << endl;
	cout << "time: " << end - begin << " ms" << endl;
	return 0;
}

首先来看看在 n = 20000 的情况下,并行化 耗时

注:测试性能需要在 release 模式下进行

耗时 4ms,似乎还挺快,接下来看看 串行化 耗时

串行化 只花了 2ms,比 并行化 还要快

为什么?
因为现在的程序比较简单,while 循环内只需要进行 g_val++ 就行了,并行化中频繁加锁、解锁的开销要远大于串行化单纯的进行 while 循环

如果循环中的操作变得复杂,那么 并行化 是要比 串行化 快的,所以加锁时选择 并行化 还是 串行化,需要结合具体的场景进行判断

这里为了让两个线程看到的是同一把锁,将 mutex 对象定义成了一个 全局对象,其实也可以定义为 局部对象,配合 lambda 表达式 的捕捉列表捕获 mutex 对象

int main()
{
            
      
	int n = 20000;
	int val = 0;
	mutex mtx; // 局部锁对象

	size_t begin = clock();

	thread t1([&, n]()mutable->void
			{
            
      
				mtx.lock();
				while (n--)
					val++;
				mtx.unlock();
			});

	thread t2([&, n]()mutable->void
			{
            
      
				mtx.lock();
				while (n--)
					val++;
				mtx.unlock();
			});

	t1.join();
	t2.join();
	size_t end = clock();

	cout << "val: " << val << endl;
	cout << "time: " << end - begin << " ms" << endl;
	return 0;
}

注意: n 是传值捕捉,如果相对其进行修改,需要使用 mutable 关键字取消常性

2-2-2 🐆其他锁类型

除了最常用的 mutex 互斥锁C++11 中还提供了其他几种版本

recursive_mutex 递归互斥锁,这把锁主要用来 递归加锁 的场景中,可以看作 mutex 互斥锁 的递归升级版,专门用在递归加锁的场景中

比如在下面的代码中,使用普通的 mutex 互斥锁 会导致 死锁问题,最终程序异常终止

// 普通互斥锁
mutex mtx;

void func(int n)
{
            
      
	if (n == 0)
		return;

	mtx.lock();
	n--;

	func(n);
	mtx.unlock();
}

int main()
{
            
      
	int n = 1000;
	thread t1(func, n);
	thread t2(func, n);
	
	t1.join();
	t2.join();
	return 0;
}

为什么会出现 死锁
因为当前在进入递归函数前,申请了锁资源,进入递归函数后(还没有释放锁资源),再次申请锁资源,此时就会出现 锁在我手里,但我还申请不到 的现象,也就是 死锁

解决这个 死锁 问题的关键在于 自己在持有锁资源的情况下,不必再申请,此时就要用到 recursive_mutex 递归互斥锁

// 递归互斥锁
recursive_mutex mtx;

使用 recursive_mutex 递归互斥锁 后,程序正常运行

timed_mutex 时间互斥锁,这把锁中新增了 定时解锁 的功能,可以在程序运行指定时间后,自动解锁(如果还没有解锁的话)

其中的 try_lock_for 是按照 相对时间 进行自动解锁,而 try_lock_until 则是按照 绝对时间 进行自动解锁

比如在下面的程序中,使用 timed_mutex 时间互斥锁,设置为 3 秒后自动解锁,线程获取锁资源后,睡眠 5 秒,即便睡眠时间还没有到,其他线程也可以在 3 秒后获取锁资源,同样进入睡眠

// 时间互斥锁
timed_mutex mtx;

void func()
{
            
      
	// 3秒后自动解锁
	mtx.try_lock_for(chrono::seconds(3));

	// 睡眠5秒
	for (int i = 1; i <= 5; i++)
	{
            
      
		this_thread::sleep_for(chrono::seconds(1));
		cout << "线程 " << this_thread::get_id() << " 已经睡眠了 " << i << " 秒" << endl;
	}

	mtx.unlock();
}

int main()
{
            
      
	thread t1(func);
	thread t2(func);

	t1.join();
	t2.join();
	return 0;
}

至于最后一个 recursive_timed_mutex 递归时间互斥锁,就是对 timed_mutex 时间互斥锁 做了 递归 方面的升级,使其在面对 递归 场景时,不会出现 死锁

2-2-3 🫏RAII 风格的锁

手动加锁、解锁可能会面临 死锁 问题,比如在引入 异常处理 后,如果在 临界区 内出现了异常,程序会直接跳转至 catch 中捕获异常,这就导致 锁资源 没有被释放,其他线程申请锁资源时,就会出现 死锁 问题

// 死锁
mutex mtx;

void func()
{
            
      
	for (int i = 0; i < 2; i++)
	{
            
      
		try
		{
            
      
			mtx.lock();

			if (i % 2 == 0)
				throw exception("抛出异常");

			mtx.unlock();
		}
		catch (const std::exception& msg)
		{
            
      
			cout << msg.what() << endl;
		}
	}
}

int main()
{
            
      
	thread t1(func);
	thread t2(func);

	t1.join();
	t2.join();
	return 0;
}

这里引发 死锁问题 的关键在于 线程在出现异常后,直接跳转至 catch 代码块中,并且没有释放锁资源

解决方法有两个:

  1.  catch 代码块中手动释放锁资源(不推荐)
  2. 使用 RAII 风格的锁(推荐)

RAII 风格就是 资源获取就是初始化 ,也就是利用对象出了作用域会自动调用析构函数这个特性,来 自动释放锁资源

编写一个 LockGuard 

// RAII 风格
template<class locktype>
class LockGuard
{
            
      
public:
	LockGuard(locktype& mtx)
		:_mtx(mtx)
	{
            
      
		// 加锁
		_mtx.lock();
	}

	~LockGuard()
	{
            
      
		// 解锁
		_mtx.unlock();
	}

private:
	locktype& _mtx;
};

注意:

  1. 需要使用模板,因为互斥锁有多个版本
  2. 成员变量 _mtx 需要使用引用类型,因为所有的锁都不支持拷贝

使用引用类型作为类中的成员变量时,需要在 初始化列表 中进行初始化,以下三种类型需要在初始化列表进行初始化:

  1. 引用类型
  2. const 修饰
  3. 没有默认构造函数的类型

修改之前的代码,不再手动加锁、解锁

void func()
{
            
      
	for (int i = 0; i < 2; i++)
	{
            
      
		try
		{
            
      
			LockGuard<mutex> lock(mtx);

			if (i % 2 == 0)
				throw exception("抛出异常");
		}
		catch (const std::exception& msg)
		{
            
      
			cout << msg.what() << endl;
		}
	}
}

此时再次运行,可以发现程序正常运行,证明锁资源被自动释放了

其实库中已经提供了 RAII 风格的类了,分别是 lock_guard 和 unique_lock

其中 lock_guard 和我们自己实现的 LockGuard 几乎一样,功能十分简单(构造时加锁,析构时解锁)

 unique_lock 在此基础上增加了一些功能,比如 加锁、解锁、赋值、交换 等,因为在某些场景中,需要在临界区内对锁资源进行操作,此时就比较适合使用 unique_lock


在使用 互斥锁 时,推荐使用 lock_guard 或者 unique_lock 进行 自动加锁、解锁,避免 死锁问题

2-3 🥓condition_variable 条件变量类

线程安全 不仅需要 互斥锁,还需要 条件变量条件变量 主要用来同步各线程间的信息(线程同步),同时可以避免 死锁问题,因为如果线程条件不满足,它就会主动将 锁资源 让出,让其他线程先运行

C++11 提供了一个 condition_variable 条件变量类,其中包含了 构造、析构、等待、唤醒 相关接口

条件变量 也是不支持拷贝的,在 wait 等待时,有两种方式:

  1. 传统等待,传入一个 unique_lock 对象
  2. 带仿函数的等待,传入一个 unique_lock 对象,以及一个返回值为 bool 的函数对象,可以根据函数对象的返回值判断是否需要等待

为什么要在条件变量 wait 时传入一个 unique_lock 对象❓
因为条件变量本身不是线程安全的,同时在条件变量进入等待状态时,需要有释放锁资源的能力,否则无法将锁资源让出;当条件满足时,条件变量要有申请锁资源的能力,以确保后续操作的线程安全,所以把互斥锁传给条件变量合情合理

注:使用条件变量需要包含 condition_variable 头文件

int main()
{
            
      
	mutex mtx;
	condition_variable cond;

	// unique_lock 对象
	unique_lock<mutex> lock(mtx);

	// 传统等待
	cond.wait(lock);

	// 带函数对象的等待
	cond.wait(lock, []()->bool {
            
       return true; });
	return 0;
}

注意: 函数对象返回 true 表示条件为真,不需要等待,返回 false 表示需要等待

至于 wait_for 和 wait_until 就是带时间限制的等待,这里不再细谈

notify_one 表示随机唤醒一个正在等待中的线程,notify_all 表示唤醒所有正在等待中的线程,如果唤醒时,没有线程在等待,那就什么都不会发生

条件变量 的使用看似简单,关键在于如何结合具体场景进行设计

2-3-1 🐎交替打印数字

题目要求
给你两个线程 T1T1,要求 T1 打印奇数T2 打印偶数,数字范围为 [1, 10],两个线程必须交替打印

两个线程交替打印,并且打印的是同一个值,所以需要使用 互斥锁 保护,由于题目要求 T1 打印奇数,T2 打印偶数,可以使用 条件变量 来判断条件是否满足,只有满足才能打印,具体实现代码如下

int main()
{
            
      
	mutex mtx;
	condition_variable cond;

	int n = 10;
	int x = 1; // 从 1 开始

	// 创建线程
	thread T1([&, n]()->void 
		{
            
      
			while (x <= n)
			{
            
      
				unique_lock<mutex> lock(mtx);

				// 避免非法情况
				if (x == n && n % 2 == 0)
					break;

				// 不为奇数就等待
				while (x % 2 != 1)
					cond.wait(lock);

				//// 直接这样写也是可以的
				//cond.wait(lock, [&]()->bool { return x % 2 == 1; });

				cout << "T1: " << x++ << endl;

				// 唤醒其他线程
				cond.notify_one();
			}
		});

	thread T2([&, n]()->void
		{
            
      
			while (x <= n)
			{
            
      
				unique_lock<mutex> lock(mtx);

				// 避免非法情况
				if (x == n && n % 2 == 1)
					break;

				// 不为偶数,就等待
				while (x % 2 != 0)
					cond.wait(lock);

				//// 这样写也是可以的
				//cond.wait(lock, [&]()->bool {return x % 2 == 0; });

				cout << "T2: " << x++ << endl;

				// 唤醒其他线程
				cond.notify_one();
			}
		});


	T1.join();
	T2.join();

	return 0;
}

如何确保两个线程交替打印❓
某个线程在打印后,条件必定不满足,只能 wait 等待,在这之前会唤醒另一个线程进行打印,因为数字范围全是正数,即只有奇数和偶数两种状态,所以两个线程可以相互配合、相互唤醒,从而达到交替打印的效果

如何确保打印时不会出现非法情况❓
判断待打印的数字是否符合范围,如果不符合就不进行打印,直接 break 结束循环,因为这里是 RAII 风格的锁,所以不必担心死锁问题

2-4 🥚atomic 原子操作类

在学习 atomic 原子操作类 之前,需要先看看什么是 原子操作

原子操作 是一种 “可靠” 的操作,只允许存在 成功 和 失败 两种状态,比如对变量的修改,要么修改成功,要么修改失败,不会存在修改一半被切走的状态(被别人影响)

要想实现 原子操作 就得确保硬件支持 CAS(compare and swap)硬件同步原语CAS 简单来说就是 操作前先保存旧值,准备进行操作时,取操作数的值与旧值进行比较,如果相同就进行操作,否则就更新旧值,准备重新操作

结合具体的场景理解,假设现在有一个单链表 list线程A 在进行尾插时,线程B 也进行了尾插,并且插入过程比 线程A 快,此时得益于 CAS线程A 发现需要连接的节点变了,也就不再进行插入,而是更新尾节点信息,重新尾插

也就是说,基于 CAS 的 原子操作 需要确保待操作数没有发生改变,如果被其他线程更改了,就不能进行之前的操作,而是需要更新信息后重新操作

类似的代码实现如下(基于无锁队列实现的链表)

EnQueue(Q, data) //进队列
{
            
      
    //准备新加入的结点数据
    n = new node();
    n->value = data;
    n->next = NULL;
    do {
            
      
        p = Q->tail; //取链表尾指针的快照
    } while( CAS(p->next, NULL, n) != TRUE); 
    //while条件注释:如果没有把结点链在尾指针上,再试
    CAS(Q->tail, p, n); //置尾结点 tail = n;
}

如果只是单纯的进行 i++ 操作,CAS 逻辑可以写成这样

int i = 0;
int old = i; // 保存旧值

// 如果 CAS 函数在对 old 和 i 进行比较时,发现两者不相等
// 就会返回 `false`,进入循环更新 `old` 旧值,准备下一次 CAS 判断
// 直到两者相等,才会进行操作,确保整个过程是原子的
while (!CAS(&i, old, old+1))
{
            
      
	old = i;
}

// 进行操作
// ...

关于 CAS 的更多详细信息可以看看 陈皓 大佬的这篇文章:《无锁队列的实现

CAS 操作可以自己手搓,也可以使用库中提供的,比如 C++11 中的 atomic 原子操作类,其中提供了一系列 原子操作,比如 加、减、位运算

借助 atomic 原子操作 类,就可以在不使用锁的情况下,确保整型变量 g_val 的线程安全

注:使用 atomic 原子操作类需要包含 atomic 这个头文件

// 定义为原子变量
atomic<int> g_val = 0;

void Func(int n)
{
            
      
	while (n--)
		g_val++;
}

int main()
{
            
      
	int n = 20000;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();

	cout << "g_val: " << g_val << endl;
	return 0;
}

除了整型 int 之外,atomic 还支持定义以下类型为 原子变量

atomic 定义的原子变量类型与普通变量类型并不匹配,比如使用 printf 进行打印时,就无法匹配 %d 这个格式

int main()
{
            
      
	// 定义为原子变量
	atomic<int> val = 0;

	printf("%d\n", val);
	return 0;
}

此时可以借助 atomic 类中的 load 函数,加载该原子类型的普通类型值

此时可以正常匹配

// ...
printf("%d\n", val.load());
// ...

除了 load 之外,还可以使用 store 获取其中的值

// ...
int tmp = 0;
val.store(tmp);
printf("%d\n", tmp);
// ...

线程库中还有一个 future 类,用于 异步编程和数据共享,并不是很常用,这里就不作介绍,使用细节可以看看这篇文章 《C++11中std::future的使用

三、😂包装器

包装器 属于 适配器 的一种,正如 栈和队列 可以适配各种符合条件的容器实现一样,包装器 也可以适配各种类型相符的函数对象,有了 包装器 之后,对于相似类型的多个函数的调用会变得十分方便

3-1 🍳function 包装器

现在我们已经学习了多种可调用的函数对象类型

  • 普通函数
  • 仿函数
  • lambda 表达式

假设这三种函数对象类型的返回值、参数均一致,用于实现不同的功能,如何将它们用同一个类型来表示?

// 普通函数
void func(int n)
{
            
      
	cout << "void func(int n): " << n << endl;
}

// 仿函数
struct Func
{
            
      
public:
	void operator()(int n)
	{
            
      
		cout << "void operator()(int n): " << n << endl;
	}
};

// lambda 表达式
auto lambda = [](int n)->void
				{
            
      
					cout << "[](int n)->void: " << n << endl;
				};

如果 C 语言中的指针学的还可以的话,可以试试使用 函数指针 来表示这三个函数对象的类型

遗憾的是,无法直接使用 函数指针 指向 仿函数对象,也无法指向 类对象

int main()
{
            
      
	void(*pf)(int); // 返回值为 void,参数为 int 的函数指针

	pf = func;
	pf(10);

	//Func f;
	//pf = f(); // 无法赋值

	pf = lambda;
	pf(20);
	return 0;
}

 C++11 中,增加了 function 包装器 这个语法,专门用来包装函数对象,function 包装器 是基于 可变参数模板 实现的,原型如下

template <class Ret, class... Args>
class function<Ret(Args...)>;

其中 Ret 表示函数返回值,Args 是上文中提到的可变参数包,表示传给函数的参数,function 模板类通过 模板特化 指明了包装的函数对象类型

有了 function 包装器 后,可以轻松包装之前的三个函数对象

使用 function 包装器需要包含 functional 头文件

int main()
{
            
      
	// 包装器
	function<void(int)> f;

	f = func;
	f(10);

	f = Func();
	f(20);

	f = lambda;
	f(30);

	return 0;
}

包装器 可以结合 哈希表 使用,提前准备一批任务,根据用户发出的不同指令来调用不同的任务,比如下面这个程序,完美地在 指令 与 函数 之间建立了映射关系

int main()
{
            
      
	// 包装了返回值为 void,参数为 void 的函数类型
	unordered_map<string, function<void(void)>> hash;

	hash["下载请求"] = []()->void {
            
       cout << "正在进行下载任务..." << endl; };
	hash["SQL查询"] = []()->void {
            
       cout << "正在进行SQL查询..." << endl; };
	hash["日志记录"] = []()->void {
            
       cout << "正在记录日志信息..." << endl; };

	string comm; // 指令
	while (cin >> comm)
	{
            
      
		if (!hash.count(comm))
			cout << "该指令不存在,请重新输入" << endl;
		else
			hash[comm](); // 调用函数
	}

	return 0;
}

根据给出的指令,调用对应的函数

function 包装器 还可以用在刷题中,比如下面这道题目中,就可以使用 包装器 在 运算符 与 具体操作 之间建立映射关系,使用起来十分方便

150. 逆波兰表达式求值

class Solution 
{
            
      
public:
    int evalRPN(vector<string>& tokens) 
    {
            
      
        // 解题思路:操作数入栈,遇到操作符,取两个数计算后,入栈

        // 建立映射关系
        unordered_map<string, function<int(int, int)>> hash = 
        {
            
      
            {
            
      "+", [](int x, int y)->int {
            
       return x + y; } },
            {
            
      "-", [](int x, int y)->int {
            
       return x - y; } },
            {
            
      "*", [](int x, int y)->int {
            
       return x * y; } },
            {
            
      "/", [](int x, int y)->int {
            
       return x / y; } },
        };

        stack<int> s;

        for(auto str : tokens)
        {
            
      
            if(str != "+" && str != "-" && str != "*" && str != "/")
                s.push(stoi(str));
            else
            {
            
      
                // 注意:先获取 y,再获取 x
                int y = s.top();
                s.pop();
                int x = s.top();
                s.pop();

                s.push(hash[str](x, y));
            }
        }

        return s.top();
    }
};

关于这道题的详细题解可以看看这篇文章 《C++题解 | 逆波兰表达式相关

function 包装器 除了可以包装常规函数对象外,还可用于包装 类内成员函数

包装 静态成员函数 很简单,指明归属于哪个类就行了

class Test
{
            
      
public:
	Test(int n = 0)
		:_n(n)
	{
            
      }

	static void funcA(int val)
	{
            
      
		cout << "static void funcA(int val): " << val << endl;
	}

	void funcB(int val)
	{
            
      
		cout << "void funcB(int val): " << val * _n << endl;
	}

private:
	int _n = 10;
};

int main()
{
            
      
	// 包装静态函数
	function<void(int)> f = Test::funcA;
	//function<void(int)> f = &Test::funcA; // 这么写也是可以的

	f(10);

	return 0;
}

如果包装 非静态成员函数 就有点麻烦了,因为 非静态成员函数 需要借助 对象 或者 对象指针 来进行调用

解决方法是:构建 function 包装器时,指定第一个参数为类,并且包装时需要取地址 &

使用时则需要传入一个 对象,此时传入 匿名对象 或者 普通对象 都行

// 包装非静态函数
function<void(Test, int)> f = &Test::funcB;

// 传入匿名对象
f(Test(10), 10);

// 传入普通对象
Test t(10);
f(t, 10);

关于包装时的参数设置问题

为什么不能设置为 类的指针,这样能减少对象传递时的开销
因为设置如果设置为指针,后续在进行调用时,就需要传地址,如果是普通对象还好说,可以取到地址,但如果是匿名对象(右值)是无法取地址的,也就无法调用函数了

那能否设置成 类的左值引用 呢?

不行,如果是左值还好,但右值无法被左值引用接收

参数设置为 const 指针 或者 右值引用 又会导致 左值 无法正常传递,所以这里最理想的方案就是单纯设置为 普通类类型,既能接受 左值,也能接受 右值

将参数写成 && 不是会触发引用折叠机制吗,这样不就既能接收左值,也能接收右值了?
不行,引用折叠(万能引用)是指模板推导类型的行为,普通函数是没有这个概念,如果普通函数既想接收左值,又想接收右值,只能重载出两个参数不同的版本了

3-2 🧇bind 绑定

bind 绑定 是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表

bind 绑定 可以修改参数传递时的位置以及参数个数,生成一个可调用对象,实际调用时根据 修改 规则进行实际的函数调用,具体原型如下

template <class Fn, class... Args>
bind (Fn&& fn, Args&&... args);

fn 是传递的 函数对象args 是传给函数的 可变参数包,这里使用了 万能引用(引用折叠),使其在进行模板类型推导时,既能引用左值,也能引用右值

使用 bind绑定 改变参数传递顺序

注:placeholders 是一个命名空间,其中的 _1_2_N 称为占位符,分别表示函数中的第1、第2、第N个参数,直接使用就行了

void Func(int a, int b)
{
            
      
	cout << "void Func(int a, int b): " << a << " " << b << endl;
}

int main()
{
            
      
	// 正常调用
	Func(10, 20);

	// 绑定生成一个可调用对象
	auto RFunc = bind(Func, placeholders::_2, placeholders::_1);
	RFunc(10, 20);

	return 0;
}

经过 bind 绑定 后,同样的参数传递,出现了不同的调用结果

bind 的底层也是仿函数,生成一个对应的类,根据用户指定的规则,去调用函数,比如这里经过绑定后,实际调用时,RFunc 中实际在调用 Func 传递的参数为 20 10

除了使用 auto 自动推导 bind 生成的可调用对象类型外,还可以使用 包装器 来包装出类型

// 使用包装器包装出类型
function<void(int, int)> RFunc = bind(Func, placeholders::_2, placeholders::_1);

bind 绑定 改变参数传递顺序很少使用,只需要简单了解即可

注意: 在使用 bind 绑定改变参数传递顺序时,参与交换的参数类型,至少需要支持隐式类型转换,否则是无法交换传递的

bind 绑定 还可以用来指定参数个数,比如对上面的函数 Func 进行绑定,将参数 1 始终绑定为 100,后续进行调用时,只需要传递一个参数

int main()
{
            
      
	// 使用包装器包装出类型
	auto RFunc = bind(Func, 100, placeholders::_1);

	RFunc(20);

	RFunc(10, 20);
	
	return 0;

}

此时如果坚持传递参数,会优先使用绑定的参数,再从函数参数列表中,从左到右选择参数进行传递,直到参数数量符合,比如这里第二次调用虽然传递了 10 和 20,但实际调用 Func 时,RFunc 会先传递之前绑定的值 100 作为参数1传递,而 10 会作为参数2传递,至于 20 会被丢弃


注意: 无论绑定的是哪一个参数,占位符始终都是从 _1 开始,并且连续设置

绑定普通参数显得没意思,bind 绑定 参数个数用在 类的成员函数 上才舒服,比如对之前 function 包装器 包装 类的成员函数 代码进行优化,直接把 类对象 这个参数绑定,调用时就不需要手动传递 对象 了

class Test
{
            
      
public:
	Test(int n = 0)
		:_n(n)
	{
            
      }

	static void funcA(int val)
	{
            
      
		cout << "static void funcA(int val): " << val << endl;
	}

	void funcB(int val)
	{
            
      
		cout << "void funcB(int val): " << val * _n << endl;
	}

	void funcC()
	{
            
      }

private:
	int _n = 10;
};

int main()
{
            
      
	function<void(int)> RFuncB = bind(&Test::funcB, Test(10), placeholders::_1);

	RFuncB(10);
	return 0;
}

除了可以绑定类对象外,也可以直接绑定 val 这个参数,亦或是两者都绑定

// 绑定对象
function<void(Test, int)> f1 = bind(&Test::funcB, placeholders::_1, 10);
f1(Test(), 0);

// 两者都绑定
function<void(int)> f2 = bind(&Test::funcB, Test(10), 20);
f2(0);

注意: 虽然参数已经绑定了,但实际调用时,仍然需要传递对应函数的参数,否则无法进行函数匹配调用,当然实际传入的参数是绑定的值,这里传参只是为了进行匹配;并且如果不对类对象进行绑定,需要更改包装器中的类型,调用时也需要传入参数进行匹配

🔥总结🔥

在这C++11系列的收尾文章中,我们深入研究了lambda表达式,为函数对象提供了快速构建的方法。接着,我们学习了标准线程库,包括线程、互斥锁、条件变量等,为跨平台的多线程编程提供了强大工具。最后,通过包装器和绑定工具,我们获得了统一函数对象类型的新手段,使得代码更灵活、可读性更强,为现代C++编程提供了丰富的工具和技巧

结束语
以上就是我对于【C++】——「C++11『lambda表达式 ‖ 线程库 ‖ 包装器』」的理解

感谢你的三连支持!!!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐