C++11『lambda表达式 ‖ 线程库 ‖ 包装器』

✨个人主页: 北 海
🎉所属专栏: C++修行之路
🎃操作环境: Visual Studio 2022 版本 17.6.5

成就一亿技术人


文章目录

  • 🌇前言
  • 🏙️正文
    • 1.lambda表达式
      • 1.1.仿函数的使用
      • 1.2.lambda表达式的语法
      • 1.3.lambda表达式的使用
      • 1.4.lambda表达式的原理
      • 1.4.lambda表达式的优点及适用场景
    • 2.线程库
      • 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.包装器
      • 3.1.function 包装器
      • 3.2.bind 绑定
  • 🌆总结


🌇前言

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


🏙️正文

1.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 表达式 编写出来的代码过于复杂时,可以考虑转为普通函数,确保代码的清晰性和可读性


2.线程库

关于 线程 相关操作,Linux 选择使用的是 POSIX 标准,而 Windows 没有选择 POSIX 标准,反而是自己搞了一套 API 和系统调用,称为 Win32 API,意味着 LinuxWindows 存在标准差异,直接导致能在 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,而 846026080ntdll.dll 类型的线程,用于为应用程序加载其他动态库,程序运行大概半分钟后,这两个线程就会自动消失,因为当前处于调试状态,并且程序运行时间较短,所以才会看到这个两个系统级线程

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

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

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

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


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

除了 joinableswap,其他功能在 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 函数获取
  • 传入线程吗?不行,因为此时线程还没有完全创建,线程 id0
  • 传入线程对象?不行,线程还没有完全创建,传入的对象也无法使用,也能通过捕获列表进行引用捕捉,不过同样无法使用

如此一来,想要在 线程回调函数 内获取 线程 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_threadstd 中的一个子命名空间,其中包含了一些与线程有关的操作,比如 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多线程【线程互斥与同步】


确保 线程安全 的手段之一就是 加锁 保护,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_guardunique_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_forwait_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.包装器

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

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;

}

此时如果坚持传递参数,会优先使用绑定的参数,再从函数参数列表中,从左到右选择参数进行传递,直到参数数量符合,比如这里第二次调用虽然传递了 1020,但实际调用 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『右值引用与移动语义』

C++11『基础新特性』

C++ 哈希的应用【布隆过滤器】

C++ 哈希的应用【位图】

C++【哈希表的完善及封装】

C++【哈希表的模拟实现】

C++【初识哈希】

C++【一棵红黑树封装 set 和 map】

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/182776.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C/C++ 开发SCM服务管理组件

SCM&#xff08;Service Control Manager&#xff09;服务管理器是 Windows 操作系统中的一个关键组件&#xff0c;负责管理系统服务的启动、停止和配置。服务是一种在后台运行的应用程序&#xff0c;可以在系统启动时自动启动&#xff0c;也可以由用户或其他应用程序手动启动。…

win10戴尔电脑安装操作系统遇到的问题MBR分区表只能安装GPT磁盘

首先按F2启动boot管理界面 调整启动盘的启动顺序&#xff0c;这里启动U盘为第一顺序。 第一步 选择安装程序的磁盘 第二步 转换磁盘为GPT磁盘 一般出现 磁盘0和1&#xff0c;说明存在两个盘 &#xff0c;这里两个盘不是说的是C盘和D盘的问题&#xff0c;而是在物理上实际存在…

2024年度投资策略:AI大模型和半导体国产化加速

今天分享的是AI系列深度研究报告&#xff1a;《2024年度投资策略&#xff1a;AI大模型和半导体国产化加速》。 &#xff08;报告出品方&#xff1a;东方证券&#xff09; 报告共计&#xff1a;48页 前言: 行情回顾与未来展望 电子板块涨幅转正&#xff0c;信心逐渐回归。截至…

Ps:参考线

参考线 Guides用于帮助精确地定位图像或元素&#xff0c;显示为浮动在图像上的非打印线&#xff0c;可以移动或移除&#xff0c;还可以临时锁定。 Ps 中的参考线可分为三大类&#xff1a;画布参考线、画板参考线和智能参考线。 可在“首选项/参考线、网格和切片”中设置参考线的…

【Java线程通信】一窥究竟

今天我们要聊一聊Java线程通信。如果你是一个Java开发者&#xff0c;那么你肯定知道线程是Java中的一个重要概念。线程是程序执行的最小单位&#xff0c;它可以独立运行&#xff0c;也可以与其他线程共享资源。那么&#xff0c;线程之间如何进行通信呢&#xff1f;这就是我们今…

【uniapp】部分图标点击事件无反应

比如&#xff1a;点击这个图标在h5都正常&#xff0c;在小程序上无反应 css&#xff1a;也设置z-index&#xff0c;padding 页面上也试过click.native.stop.prevent"changePassword()" 时而可以时而不行&#xff0c; 最后发现是手机里输入键盘的原因&#xff0c;输…

文心一言 VS 讯飞星火 VS chatgpt (141)-- 算法导论12.1 1题

一、用go语言&#xff0c;对于关键字集合(1&#xff0c;4&#xff0c;5&#xff0c;10&#xff0c;16&#xff0c;17&#xff0c;21)&#xff0c;分别画出高度为 2、3、4、5和6的二又搜索树。 文心一言&#xff1a; 以下是用Go语言为给定的关键字集合(1, 4, 5, 10, 16, 17, 2…

马养殖场建设VR模拟实训教学平台具有灵活性和复用性

为保障养殖场生物安全&#xff0c;避免疫病传播&#xff0c;学生出入养殖场受时间和地域的限制&#xff0c; 生产实习多以参观为主&#xff0c;通过畜牧企业技术人员的讲解&#xff0c;学生被动了解生产过程。为了解决畜牧养殖实训难的问题&#xff0c;借助VR技术开展畜牧养殖虚…

CUDA与GPU编程

文章目录 CUDA与GPU编程1. 并行处理与GPU体系架构1.1 并行处理简介1.1.1 串行处理与并行处理的区别1.1.2 并行处理的概念1.1.3 常见的并行处理 1.2 GPU并行处理1.2.1 GPU与CPU并行处理的异同1.2.2 CPU的优化方式1.2.3 GPU的特点 1.3 环境搭建 CUDA与GPU编程 1. 并行处理与GPU体…

关于easy-es的聚合问题

es实体类&#xff1a; public class ChemicalES {IndexId(type IdType.CUSTOMIZE)private Long id;HighLightIndexField(fieldType FieldType.TEXT, analyzer "ik_max_word")private String name;IndexField(fieldType FieldType.KEYWORD)private List<Stri…

某60区块链安全之未初始化的存储指针实战一学习记录

区块链安全 文章目录 区块链安全未初始化的存储指针实战一实验目的实验环境实验工具实验原理实验过程 未初始化的存储指针实战一 实验目的 学会使用python3的web3模块 学会分析以太坊智能合约未初始化的存储指针漏洞 找到合约漏洞进行分析并形成利用 实验环境 Ubuntu18.04操…

Vue3 封装组件库并发布到npm仓库

一、创建 Vue3 TS Vite 项目 输入项目名称&#xff0c;并依次选择需要安装的依赖项 npm create vuelatest 项目目录结构截图如下&#xff1a; 二、编写组件代码、配置项和本地打包测试组件 在项目根目录新建 package 文件夹用于存放组件 &#xff08;以customVideo为例&a…

HTTPS攻击怎么防御?

HTTPS 简介 超文本传输安全协议&#xff08; HTTPS &#xff09;是一种通过计算机网络进行安全通信的传输协议。HTTPS 经由 HTTP 进行通信&#xff0c;但利用 SSL/TLS 来加密数据包。 HTTPS 开发的主要目的&#xff0c;是提供对网站服务器的身份认证&#xff0c;保护交换数据的…

【开源】基于Vue.js的数据可视化的智慧河南大屏

项目编号&#xff1a; S 059 &#xff0c;文末获取源码。 \color{red}{项目编号&#xff1a;S059&#xff0c;文末获取源码。} 项目编号&#xff1a;S059&#xff0c;文末获取源码。 目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块三、系统展示四、核心代码4.1 数据模块 …

基于遗传优化的多属性判决5G-Wifi网络切换算法matlab仿真

目录 1.算法运行效果图预览 2.算法运行软件版本 3.部分核心程序 4.算法理论概述 5.算法完整程序工程 1.算法运行效果图预览 2.算法运行软件版本 MATLAB2022a 3.部分核心程序 .......................................................................... %接收功率、网…

浅谈 Guava 中的 ImmutableMap.of 方法的坑

作者&#xff1a;明明如月学长&#xff0c; CSDN 博客专家&#xff0c;大厂高级 Java 工程师&#xff0c;《性能优化方法论》作者、《解锁大厂思维&#xff1a;剖析《阿里巴巴Java开发手册》》、《再学经典&#xff1a;《EffectiveJava》独家解析》专栏作者。 热门文章推荐&…

练习七-在Verilog中使用任务task

在Verilog中使用任务task 1&#xff0c;任务目的2&#xff0c;RTL代码&#xff0c;交换3&#xff0c;测试代码4&#xff0c;波形显示 1&#xff0c;任务目的 &#xff08;1&#xff09;掌握任务在verilog模块设计中的应用&#xff1b; &#xff08;2&#xff09;学会在电平敏感…

新一代网络监控技术——Telemetry

一、Telemetry的背景 传统的网络设备监控方式有SNMP、CLI、Syslog、NetStream、sFlow&#xff0c;其中SNMP为主流的监控数据方式。而随着网络系统规模的扩大&#xff0c;网络设备数量的增多&#xff0c;网络结构的复杂&#xff0c;相应监控要求也不断提升&#xff0c;如今这些…

CUDA学习笔记9——CUDA 共享内存 / Shared Memory

由于共享内存拥有仅次于寄存器的读写速度&#xff0c;比全局内存快得多。因此&#xff0c;能够用共享内存访问替换全局内存访问的场景都可以考虑做对应的优化。 不利用共享内存的矩阵乘法 不利用共享内存的矩阵乘法的直接实现。每个线程读取A的一行和B的一列&#xff0c;并计…

CVE-2022-0543(Redis 沙盒逃逸漏洞)

简介 CVE-2022-0543是一个与Redis相关的安全漏洞。在Redis中&#xff0c;用户连接后可以通过eval命令执行Lua脚本&#xff0c;但在沙箱环境中脚本无法执行命令或读取文件。然而&#xff0c;攻击者可以利用Lua沙箱中遗留的变量package的loadlib函数来加载动态链接库liblua5.1.s…