C++ The Comprehensive Guide学习:C++ 系统错误处理:`system_error`
一、问题的起源:操作系统返回的错误码是"数字"
当操作系统或者某些底层库遇到错误时,通常会用一个数字把错误报告给程序,而不是给一句人话描述。举个例子,在 POSIX 系统(比如 Linux)上,“权限不足"这个错误会用错误码 1(对应常量 EPERM)来表示。
这就带来一个很现实的问题:如果你想写能跨平台运行的代码,同一个"权限不足"的语义,在不同操作系统上对应的数字可能完全不一样。你不能在代码里到处写"如果错误码等于1就是权限问题”,因为这在 Windows 上可能压根对不上。
于是就出现了一个两难的局面:
- 一方面,你希望有一种统一的、可移植的方式来判断"这个错误到底是什么类型"(不管跑在哪个系统上,判断逻辑都一样);
- 另一方面,有时候你又确实需要拿到系统原始的、具体的错误码(比如写日志、排查问题的时候,原始数字往往更有价值)。
<system_error>头文件就是为了同时满足这两个需求而设计的。它提供了一个轻量级的error_code对象:一方面装着系统相关的、具体的整数错误码;另一方面又能关联到更抽象、更具可移植性的error_condition对象。这样你既能拿到"原始数字",也能用"统一的判断标准"去检查错误类型。
二、两种错误处理风格都被支持
system_error 这套机制有一个很人性化的设计:它既能配合异常使用,也能完全不用异常。
- 如果你的项目不想用异常(比如某些嵌入式场景、实时系统里,出于性能或者内存开销的考虑,异常经常被禁用),你可以只用
error_code这种"返回值"风格来处理错误,完全不涉及异常机制。 - 如果你想用异常,也可以把一个
error_code连同一段说明文字,一起打包成一个system_error异常抛出去。
这两个字段(错误码本身、以及配套的抽象分类)都关联着一个叫error_category(错误类别)的东西,这也是整个系统"可扩展"的关键——用户可以自己定义新的错误分组,也可以识别系统已经定义好的那些分组。
三、<system_error> 里的核心组件一览
下面这张表列出了这个头文件里最主要的几个组件,方便有一个整体印象:
| 组件 | 作用 |
|---|---|
error_code | 表示一个具体的、系统相关的错误值,通常由某次系统调用等操作返回 |
error_condition | 一个通用的、可移植的错误分类,用来在跨平台代码里做判断 |
error_category | 把错误分成不同的组/家族,也是"把错误码翻译成可读文字"的抽象基类 |
system_error | 异常类,当错误通过异常传播时,携带一个error_code |
enum class errc | 一组通用的、源自POSIX标准的error_condition枚举值 |
is_error_code_enum<>、is_error_condition_enum<>、make_error_code、make_error_condition | 一整套机制,把枚举类型的值转换成error_code或error_condition |
generic_category() | 返回一个能解释errc相关错误码和错误分类的error_category实例 |
system_category() | 返回一个能解释操作系统原生错误的error_category实例 |
后面会逐个把这些组件的用法讲清楚。
四、为什么两种错误处理方式都要保留
system_error 虽然定义了自己的异常类,但并不意味着"遇到错误就应该抛异常"。文档里给了一个很实际的场景理解这一点:
- 假设你的程序在尝试连接多个备用的网络服务器,如果第一个连不上就抛异常,你就得在一个
try/catch循环里不断捕获异常再重试下一个——这种写法通常不如直接检查一个"错误返回值"来得清晰和高效。因为"某个服务器连不上"在这种场景下更像是一种正常的控制流分支,而不是"意料之外的严重问题"。 - 更进一步,某些领域完全不适合用异常:
- 实时系统对"确定性的响应时间"要求极高,异常处理机制的执行时间往往不好精确预测,所以有些实现干脆完全不用异常。
- 嵌入式系统里,内存资源非常紧张,而某些异常处理的实现本身会带来额外的内存开销,这个代价往往是不能忽视的。
正因为存在这些现实场景,C++ 很务实地同时支持"用返回值携带错误码"和"用异常传播错误"这两种方式,并且尝试在两者之间架起桥梁,让开发者可以根据具体场景灵活选择。
五、errno 与多元的错误来源
在传统 C 语言风格的代码里,发生错误时经常会用一个全局变量 errno 来存放更详细的错误信息。<stdio>、<math> 里的很多函数失败的时候,都会往这个变量里写入具体的错误码。
不同平台的做法也不完全一样:
- 在符合 POSIX 标准的平台上,很多系统函数调用失败时,细节信息就存在
errno里。 - 在 Windows 上,经常额外(或者干脆替代地)使用
GetLastError这个函数来获取详细错误信息。
而且,你用的库越多,可能提供错误码的"来源"也就越多——每个库都可能有自己的一套错误码体系。C++ 的这套错误处理机制,专门考虑到了"错误码可能来自不同来源,需要被统一评估"这个现实情况,并且支持库自行扩展这套体系,同时保证已有函数的接口不会因此发生变化。
六、可扩展性带来的两难:既要底层细节,又要可移植的解释
当你把错误码系统的扩展能力开放给用户和第三方库开发者之后,会出现一个新的设计挑战:
- 底层的、系统相关的错误码(这些码在不同系统上可能完全不同)需要被"映射"到某个地方;
- 同时,用户还需要一种可移植的方式去理解这个错误到底是什么意思。
举个具体例子:假设你在实现一个 HTTP 服务器,你想把 RFC 标准里定义的那些错误码,以一种"大家都能看懂"的方式传递出去,但你实现的时候,底层依赖的还是操作系统相关的错误码。
最初的设计想法是:库(不管是标准库还是第三方库)应该只把"已经翻译好的"错误码提供给用户,用户不需要接触底层细节。但实际情况是,原始的底层错误信息对排查问题同样非常重要,所以在做错误日志记录、写 bug 报告的时候,这些原始信息也得想办法保留下来。
因此,错误码处理机制必须同时提供两种东西:底层的、原始的错误码,以及经过统一解释的、可移植的错误值。这正是error_code和error_condition这一对搭档要解决的核心问题。
七、error_code 与 error_condition:看起来很像,但角色不同
这两个类的接口看起来几乎一模一样,但它们的定位完全不同:
| 类型 | 定位 |
|---|---|
error_code | 传递系统相关的、具体的错误信息 |
error_condition | 用于更通用、更可移植的错误分类判断 |
7.1 最简单的用法:只关心成功还是失败
来看一个最基础的例子,展示如何用 error_code 判断一个操作到底成功了没有:
// https://godbolt.org/z/PT1h593T7
// 完整可运行代码:演示 error_code 判断成功/失败的最简单用法
#include <system_error> // error_code 定义在这里
#include <string>
#include <iostream>
// 假设这是一个"创建目录"的函数(这里只声明,不实现具体逻辑,
// 因为具体的系统调用细节和平台相关,这里重点是演示error_code的用法)
// 参数二是"输出参数":函数不会抛异常,而是把错误信息写进ec里
void create_dir(const std::string& pathname, std::error_code& ec) {
// 实际实现会调用具体的系统API(比如POSIX的mkdir,或者Windows的CreateDirectory)
// 这里为了演示,故意模拟一次"失败"的情况
// 用generic_category()配合errc枚举,构造一个"权限不足"的错误码
ec = std::make_error_code(std::errc::permission_denied);
}
void run() {
std::error_code ec; // 默认构造的error_code代表"没有错误"
create_dir("/some/path", ec);
// error_code 重载了到bool的隐式转换:
// 如果ec代表"没有错误",!ec 为 true;如果ec代表某种错误,!ec 为 false
if (!ec) {
std::cout << "创建目录成功\n"; // Success...
} else {
std::cout << "创建目录失败,错误信息: " << ec.message() << "\n"; // Failure...
}
}
int main() {
run();
return 0;
}
代码关键点说明:
create_dir的第二个参数是一个std::error_code&,也就是"输出参数"——函数不通过异常报告错误,而是把错误信息写进这个引用参数里,调用者可以自己决定要不要检查它。if (!ec)是error_code一个非常方便的设计:它可以隐式转换成bool。默认构造出来的error_code代表"没有发生错误",转换成bool是false,所以!ec为true,表示"操作成功"。这个设计模仿的是我们熟悉的传统写法——就像FILE* f = fopen(...); if (f == NULL) { /* 出错了 */ }那种检查返回值的方式,只不过每个函数的检查写法可能都不一样,error_code提供了一种更统一的写法。
7.2 想知道更详细的错误原因,该怎么做
如果只判断"成功还是失败"还不够,你还想知道具体是什么原因——是权限不够?文件名已经存在?路径太长?父目录不存在?
error_code 内部确实存了一个系统相关的具体数值(类似传统代码里的 errno,但只在真的发生错误时才有意义),可以通过 ec.value() 拿到这个数字。
但这里有一个非常重要的坑:直接用 value() 返回的数字去比较,是错误的做法!
比如你想判断"是不是因为文件已经存在导致失败",如果你写 if (ec.value() == EEXIST),这段代码只在 POSIX 平台上碰巧能工作——因为 EEXIST 是 POSIX 定义的常量。但如果换到 Windows 上,对应"文件已存在"的常量根本不是 EEXIST,而是 ERROR_ALREADY_EXISTS,数值完全不同,这段代码在 Windows 上就彻底失效了。
一个经验法则是:如果你发现自己在调用 value(),很可能说明你正在用错误的方式处理这个问题。
7.3 正确做法:用 error_condition 做可移植的判断
正确的做法是,把 error_code 拿去和一个 error_condition 对象比较,而不是直接比较数字。比如判断"文件已存在"这个语义,应该用 std::errc::file_exists:
// https://godbolt.org/z/xzT9W3s3K
// 完整可运行代码:用 error_condition 做可移植的错误判断
#include <system_error> // error_code, errc
#include <string>
#include <iostream>
void create_dir(const std::string& pathname, std::error_code& ec) {
// 模拟"目录已存在"这个错误
ec = std::make_error_code(std::errc::file_exists);
}
void run() {
std::error_code ec;
create_dir("/some/path", ec);
if (!ec) {
std::cout << "创建成功\n"; // Success...
} else if (ec == std::errc::file_exists) {
// 这里的比较是"语义上等价",而不是比较具体数值
// 不管在POSIX上底层数值是EEXIST,还是在Windows上是ERROR_ALREADY_EXISTS
// 这行代码在两边都能正确工作
std::cout << "目录已经存在\n"; // specifically...
} else {
std::cout << "其他类型的失败: " << ec.message() << "\n"; // Failure...
}
}
int main() {
run();
return 0;
}
代码关键点说明:
ec == std::errc::file_exists这一行是整个例子的核心。这里比较的不是两个数字是不是完全相等,而是判断"这个error_code所代表的错误"和"errc::file_exists这个通用错误分类"在语义上是不是等价。不管底层平台用的是哪个具体数字,只要它逻辑上表示"文件/目录已存在",这个比较就会成立。- 这种"比较语义而非比较数值"的能力,是靠标准库重载了一组
operator==自由函数实现的:
// 这些重载版本使得 error_code 和 error_condition 之间可以互相比较
// (这里只是展示函数签名,不是需要你自己实现的代码)
bool operator==(const error_code& lhs, const error_code& rhs) noexcept;
bool operator==(const error_code& lhs, const error_condition& rhs) noexcept;
bool operator==(const error_condition& lhs, const error_code& rhs) noexcept;
bool operator==(const error_condition& lhs, const error_condition& rhs) noexcept;
正是这几个重载版本,在背后悄悄保证了:不管 error_code 里存的是 POSIX 的 EEXIST,还是 Windows 的 ERROR_ALREADY_EXISTS,只要它们在语义上都代表"文件已存在",跟 error_condition 类型的 errc::file_exists 做比较时,结果都会是"相等"。至于具体怎么把不同平台的原始数值映射到同一个语义分类上,这是标准库实现内部负责的工作。
八、enum class errc:一组现成的可移植错误分类常量
标准库预先定义好了一个枚举 errc,里面装了一大堆通用的、可移植的错误分类,大致长这样(这里只展示结构,不是完整列表):
// 这是标准库内部的定义,你不需要自己写,这里展示是为了说明结构
enum class errc {
address_family_not_supported,
address_in_use,
// ... 中间省略了很多项 ...
value_too_large,
wrong_protocol_type,
};
看到前面 ec == std::errc::file_exists 这样的写法,可能会觉得:“errc 这个枚举的元素,好像被隐式转换成了 error_condition?”
这个直觉基本正确,但背后的具体机制值得深挖一下,因为它解释了整个系统"如何做到可扩展"这个关键问题。
8.1 为什么不能简单地"隐式转换"就完事
标准库要处理的一个现实问题是:不同领域的错误码,数值上可能会"撞车"。比如数字 66,在文件操作相关的错误里可能代表一个意思,但在内存操作相关的错误里,完全可能代表另一个风马牛不相及的意思。
所以,要让 ec == errc::file_exists 这样的比较真正可靠,光靠"把 errc 转换成某个数字"是不够的,还必须知道这个数字属于哪个错误分类家族——这正是 error_category(错误类别)机制存在的意义。
8.2 判断到底需要两步
要让 ec == errc::file_exists 这样的等价性比较正确工作,标准库需要走两个步骤:
第一步:判断 errc 这个枚举,到底应该被当成"具体的错误码类型"还是"通用的错误分类类型"。
这一步是通过两个类型特征(type trait)来实现的:is_error_code_enum<E> 和 is_error_condition_enum<E>。默认情况下,对任意类型 E,这两个特征的 ::value 都是 false。而标准库专门针对 errc 做了一个特化:
// 标准库内部的特化,声明errc属于"错误分类"这一类枚举
template <>
struct is_error_condition_enum<errc> : true_type {};
有了这个特化,标准库就"知道"了:errc 应该被当作 error_condition 的一种表现形式,而不是 error_code 的表现形式。
第二步:把具体的值,关联到一个 error_category 上。
error_condition 有一个模板化的构造函数(类似 template <class E> error_condition(E e)),只有当 is_error_condition_enum<E>::value 为 true 的时候,编译器才会找到并使用这个构造函数,把一个枚举值转换成一个真正的 error_condition 对象。
所以,当你写 ec == errc::file_exists 时,编译器实际上是这样处理的:因为 errc 满足 is_error_condition_enum 这个条件,编译器就能找到"从 errc 构造 error_condition"的转换路径,于是选中了 bool operator==(const error_code&, const error_condition&) 这个重载版本来完成比较。
8.3 关联 error_category 的具体机制
这一步的转换背后,实际调用的是一个叫 make_error_condition 的自由函数,它同样针对 errc 做了重载:
// 标准库内部实现,用来把errc枚举值转换成真正的error_condition对象
error_condition make_error_condition(errc e) noexcept {
return error_condition(
static_cast<int>(e), // 把枚举值转换成对应的整数
generic_category()); // 关联到"通用错误类别"
}
// 类似地,io_errc和future_errc也各自有对应的重载版本
error_condition make_error_condition(io_errc c) noexcept /* ... */;
error_condition make_error_condition(future_errc c) noexcept /* ... */;
这里的关键就是 generic_category()——它返回一个 error_category 实例的引用,专门负责"解释这一类通用错误码"。可以把它理解成一个单例(singleton):全局只有一份,专门用来标识"这是一个符合 POSIX 风格的通用错误分类"。
8.4 反过来,也能主动生成系统相关的错误码
除了前面这套"把枚举转成通用分类"的机制,标准库还提供了对应的 make_error_code() 系列函数,方便你在写跨平台代码时,根据不同平台生成不同的系统相关错误码:
// https://godbolt.org/z/WYGTrzGja
// 完整可运行代码:演示如何在跨平台代码里生成系统相关的错误码
#include <system_error>
#include <string>
#include <iostream>
void create_dir(const std::string& pathname, std::error_code& ec) {
#if defined(_WIN32)
// Windows平台专属实现,生成Windows风格的错误码
// (这里省略具体的Windows API调用细节)
#elif defined(linux)
// Linux平台专属实现,生成Linux风格的错误码
// (这里省略具体的POSIX API调用细节)
#else
// 既不是Windows也不是Linux的通用兜底情况
// 用make_error_code生成一个"不支持此操作"的通用错误码
ec = std::make_error_code(std::errc::not_supported);
#endif
}
int main() {
std::error_code ec;
create_dir("/some/path", ec);
if (ec) {
std::cout << "错误: " << ec.message() << "\n";
} else {
std::cout << "成功\n";
}
return 0;
}
代码关键点说明:
- 这段代码用预处理宏
#if defined(_WIN32)/#elif defined(linux)来区分平台,在真正的实现里,各平台分支会调用各自对应的系统 API,拿到系统原生的错误码。 - 兜底分支(既不是 Windows 也不是 Linux)用
std::make_error_code(std::errc::not_supported)生成一个具有"不支持此操作"语义的、通用的错误码,即便脱离了具体平台,代码依然能给出一个合理的、可解释的错误结果。
九、两大错误类别:generic_category 与 system_category
标准库预定义了两个最主要的"错误类别":
| 类别 | 说明 |
|---|---|
generic_category | 符合POSIX标准的通用错误信息,既可以在POSIX系统上直接使用,也可以在其他系统上被"模拟POSIX行为的函数"使用(比如Windows上的fopen) |
system_category | 留给POSIX标准之外、或者其他系统专属的错误码扩展使用的空间,比如Windows上GetLastError()返回的那些错误 |
可以把这两者的关系理解为:generic_category 负责"大家都能听懂的通用语言",而 system_category 负责"某个特定系统的方言"。
用一张图来梳理一下这几个概念之间的关系:
十、自定义错误码:怎么给自己的库设计一套错误体系
如果你不想直接借用标准库已有的错误码(比如担心语义不完全贴合自己的场景),推荐按照下面这几个步骤,给自己的项目设计一套专属的错误码体系:
- 定义一个枚举,装下你自己项目专属的错误码。
- 在你的库里实现一个
make_error_code函数,接收你刚定义的枚举作为参数。 - 针对你的错误码枚举,重载一个
make_error_category(这一步是可选的,如果你的使用方不需要"更粗粒度的通用分类",可以跳过),负责把你的具体错误码映射到某个可移植的error_category元素上。 - 对模板
std::is_error_condition_enum<>(或者对应的is_error_code_enum<>)做特化,内容设为true_type,这样标准库才能识别到"你的枚举也支持这套错误分类机制"。
下面是一段完整的、可以直接编译运行的自定义错误码示例:
// https://godbolt.org/z/Kv7brrEoE
// 完整可运行代码:自定义一套错误码体系
#include <system_error>
#include <iostream>
using std::error_code;
using std::system_category;
namespace mylib {
// 第一步:定义自己项目专属的错误码枚举
enum class errc {
LOAD_ERR = 1, // 加载失败
UNLOAD_ERR = 2, // 卸载失败
OTHER_ERR = 3 // 其他错误
};
// 第二步:实现make_error_code,把自定义枚举转换成error_code
// 这里选择关联到system_category,表示这是"系统相关"的错误码
error_code make_error_code(errc ec) {
switch (ec) {
case errc::LOAD_ERR:
return error_code((int)ec, system_category());
case errc::UNLOAD_ERR:
return error_code((int)ec, system_category());
case errc::OTHER_ERR:
return error_code((int)ec, system_category());
}
// 理论上不会走到这里,但为了避免编译器警告,加一个兜底返回
return error_code((int)ec, system_category());
}
// 模拟一个可能失败的操作,返回error_code而不是抛异常
error_code run(int arg) {
if (arg == 667) {
return make_error_code(errc::OTHER_ERR);
}
return error_code{}; // 默认构造代表"一切正常"
}
} // namespace mylib
int main() {
std::error_code ec = mylib::run(667);
if (!ec) {
std::cout << "运行成功!\n";
} else if (ec == mylib::make_error_code(mylib::errc::OTHER_ERR)) {
// 这里比较的是两个error_code是否代表同一个具体错误
std::cout << "遇到了OTHER_ERR这个错误\n";
} else {
std::cout << "遇到了别的未预期错误: " << ec.message() << "\n";
}
return 0;
}
代码关键点说明:
mylib::errc是完全独立于标准库errc的一个新枚举,专属于mylib这个命名空间,不会和标准库的std::errc产生冲突。make_error_code(errc ec)是一个自由函数(不是成员函数),负责把枚举值包装成一个真正的error_code对象。这里把它关联到system_category(),意味着这些错误码被视为"和系统相关"的一类。mylib::run(667)模拟了一个"传入特定参数就会失败"的函数,失败时返回一个携带OTHER_ERR语义的error_code,而不是抛异常。- 在
main函数里,ec == mylib::make_error_code(mylib::errc::OTHER_ERR)这一行是在比较两个error_code是不是代表同一个具体错误(这里比较的双方都是error_code类型,走的是operator==(const error_code&, const error_code&)这个重载版本)。
如果你还想支持"更粗粒度、更可移植"的错误分类判断(也就是让别人可以用类似errc::file_exists那样的方式,通过error_condition来判断你的自定义错误),可以按照同样的思路,再实现一版对应error_condition的make_error_condition和is_error_condition_enum特化,流程和前面讲的标准errc完全一致,这里不重复展开。
十一、system_error 异常类:走异常路线时怎么用
如果某个库函数更倾向于用异常来传播错误,标准库提供了一个统一的异常类 system_error(定义在同名头文件 <system_error> 里),它内部携带两样东西:
- 一个
error_code(本身又由一个整数细节码和一个error_category组成); - 一段描述性的错误信息文字。
这个设计的好处是:用一个统一的异常类型,就能覆盖各种各样的系统级错误场景,而不用像很多库那样,搞出一大堆层次复杂、而且在不同系统/实现上可能还长得不一样的专属异常类。用户只需要捕获这一个system_error,然后通过里面携带的error_code进一步细分具体情况即可。
举个例子,某个库内部尝试"分离一个线程"的操作失败了,库代码内部大概会这样抛异常:
// 这是库内部代码的示意(不是完整可运行程序,用于说明原理)
int osSpecificCode = pthread_detach(thread, 0);
if (osSpecificCode != 0) {
throw std::system_error(
std::error_code(osSpecificCode, std::system_category()),
"thread::detach failed"
);
}
用户这边捕获并处理这个异常的完整代码如下:
// 完整可运行代码:捕获标准库抛出的system_error异常
#include <thread>
#include <iostream>
#include <system_error>
int main() {
try {
// 对一个默认构造(也就是"空的"、没有关联任何执行线程)的thread对象
// 调用detach(),这是一个无效操作,会失败并抛出system_error异常
std::thread().detach();
} catch (std::system_error& e) {
// system_error 提供了 code() 和 what() 两个方法
// code() 返回内部携带的 error_code 对象
// what() 继承自std::exception,返回一段可读的错误描述文字
std::cout
<< "system_error with code:" << e.code()
<< " message:" << e.what()
<< '\n';
}
return 0;
}
代码关键点说明:
std::thread().detach():std::thread()默认构造出一个"空的"thread对象,它没有关联任何真正在运行的线程。对这样一个空对象调用detach()(分离线程)是一个无效操作,标准库会检测到这一点并抛出异常。catch (std::system_error& e):捕获这个统一的异常类型。e.code():拿到异常内部携带的error_code对象,可以进一步做前面讲过的那些判断(比如和某个error_condition比较)。e.what():这是从std::exception继承来的标准方法,返回一段人类可读的错误描述。
这段代码实际运行大致会输出类似这样的内容:
system_error with code:generic:22 message:Invalid argument
这里 code 部分显示的 generic:22,前面的 generic 表示这个错误码属于 generic_category(也就是通用的、POSIX 风格的分类),22 就是前面提到的 osSpecificCode,也就是具体的原始错误数值。
11.1 标准库内部其他地方也会抛这个异常
system_error 并不只是用在线程相关的场景,标准库很多地方都可能抛出这个异常。下面是另一个例子——处理输入流(std::cin)在某些错误条件下抛出的异常:
// 完整可运行代码:捕获流操作触发的system_error(实际是ios::failure)
#include <iostream>
#include <system_error> // std::make_error_condition, std::io_errc
int main() {
// 让cin在遇到failbit或badbit这两种状态时,改为抛出异常
// (默认情况下,流操作出错通常只是悄悄设置一个状态位,不会抛异常)
std::cin.exceptions(std::ios::failbit | std::ios::badbit);
try {
// 把cin底层关联的缓冲区设置成nullptr
// 这会导致后续对cin的某些操作触发failbit/badbit,从而抛出异常
std::cin.rdbuf(nullptr);
// 正常情况下这里可能会尝试读取输入,但因为底层缓冲区已经是nullptr
// 实际执行到某个读取动作时就会因为流状态异常而抛出
} catch (std::ios::failure& e) {
// std::ios::failure 是从 system_error 派生而来的异常类
// 所以既能用code()/what(),也能和error_condition比较
std::cerr << "错误: ";
if (e.code() == std::make_error_condition(std::io_errc::stream)) {
std::cerr << "流相关错误\n";
} else {
std::cerr << "其他类型错误\n";
}
}
return 0;
}
代码关键点说明:
std::cin.exceptions(std::ios::failbit | std::ios::badbit):默认情况下,输入输出流遇到错误通常只是悄悄设置一个内部状态标志位(比如failbit),并不会抛异常,你得自己检查cin.fail()之类的方法。调用exceptions()之后,一旦触发这几种指定的状态位,流就会主动抛出异常,这样你可以用try/catch的方式处理,而不是每次操作后都手动检查状态。std::ios::failure这个异常类,本质上是从system_error派生出来的,所以它一样拥有code()方法。e.code() == std::make_error_condition(std::io_errc::stream):这一行再次体现了前面讲过的核心思路——用error_code和一个具体的error_condition(这里是io_errc::stream,代表"流相关的错误")做比较,而不是直接比较原始数字,从而写出一段可移植、意图清晰的判断逻辑。
十二、整体知识地图
下面用一张图,把这一节涉及的所有核心概念和它们之间的关系串起来:
十三、小结
| 想做的事 | 该怎么做 |
|---|---|
| 判断一个操作是否成功 | if(!ec) 或 if(ec) |
| 判断具体是哪种错误(跨平台安全) | ec == std::errc::某个枚举值(比较error_condition,不要比较value()) |
| 获取人类可读的错误描述 | ec.message() 或异常的 e.what() |
| 不用异常、只用返回值处理错误 | 函数参数里传一个error_code&作为输出参数 |
| 用异常传播系统级错误 | 抛出system_error,携带error_code和描述文字 |
| 设计自己项目专属的错误码 | 定义枚举 + 实现make_error_code + (可选)关联error_category |
一句话总结:error_code 负责装下"这个平台原始、具体的错误数字",error_condition 负责提供一套"不管在哪个平台,语义判断都一样"的可移植标准,两者靠 error_category 这座桥梁连接起来;system_error 则是在你确实想用异常传播错误时,用同一个类型统一携带这些信息的容器。整套设计的核心目标只有一个——既不丢失底层的排查细节,又能让上层代码写出可移植、可读性强的错误处理逻辑。
运行时类型信息:<typeinfo> 与 <typeindex> 详解
1. <typeinfo> 头文件里到底有什么
<typeinfo> 这个头文件的内容其实非常精简,加起来主要就三样东西:
typeid()运算符:一个长得像函数、实际上是运算符的东西,用来获取某个类型的唯一标识信息;type_info类:typeid()运算之后返回的结果类型;bad_cast和bad_typeid这两个异常:在处理动态类型时可能会抛出的异常。
先纠正一个容易产生的误解:typeid()看起来很像函数调用,但它其实不是函数,而是一个运算符——这一点跟sizeof()很像,typeid本身甚至是 C++ 的一个关键字。不过有意思的是,虽然typeid是关键字,用它之前你依然必须先#include <typeinfo>,不然编译会报错。所以在实际使用体验上,把typeid()当成"这个头文件里定义好的一个函数"来理解,也完全没问题。
2. typeid() 能拿到什么信息,怎么用
typeid() 可以用来查询某个类型的信息,你传给它的参数可以是两种形式之一:
- 直接写类型名,比如
typeid(int)、typeid(std::string); - 写一个表达式,这种情况下
typeid()返回的是这个表达式的类型信息,比如typeid(someVariable)。
需要特别注意的限制是:这个表达式不能包含实际的运算过程,比如a+b*c这种带计算的表达式是不允许的,函数调用或方法调用也不行。
typeid()返回的结果,本质上是一个指向type_info实例的const引用。为了方便叙述,下面把这个引用记作ti。拿到ti之后,能做这些事情:
| 用法 | 说明 |
|---|---|
ti.hash_code() | 返回一个 size_t 类型的哈希值,常用来快速判断两个类型"是否不同" |
ti.name() | 返回一个 const char*,是该类型的文字描述,但这个格式并没有统一标准 |
type_index(ti) | 来自 <typeindex> 头文件,是判断两个类型是否相等的可靠方式 |
关于 ti.name() 需要特别提醒:它返回的文字描述没有标准格式,不同编译器、不同平台会给出完全不同的字符串;标准也不保证同一个程序里不同类型对应的字符串一定互不相同;甚至不保证同一个类型在多次调用中返回的字符串完全一样。所以,name() 虽然能提供一些信息,用来辅助调试还算方便,但不能拿它做任何可靠性要求高的判断,比如不能用它的返回值去比较两个类型是否相同。
3. 比"能做什么"更重要的是"不能做什么"
关于 type_info,有几条限制必须牢记:
- 你不能创建一个
type_info对象,也不能拷贝它——它没有公开的构造函数和拷贝构造函数,只能通过typeid()间接拿到它的引用; - 不应该直接用
==去比较两个type_info。虽然type_info确实重载了operator==,但这里有个很微妙的坑:标准并不保证两次分别调用typeid(A)得到的ti1和ti2,一定是同一个type_info实例,甚至连ti1 == ti2这个比较结果本身是否可靠都没有保证。
那如果确实需要判断"ti1和ti2代表的是不是同一个类型",正确的做法是什么呢?答案是:不要直接比较type_info,而是先把它们分别包装成type_index,再用type_index去比较:
type_index(ti1) == type_index(ti2) \texttt{type\_index(ti1) == type\_index(ti2)} type_index(ti1) == type_index(ti2)
type_index是从<typeindex>头文件里来的一个专门为了解决"类型比较"这个问题而设计的类。它还有一个额外的好处:type_index可以直接作为map之类关联容器的键(key)来用,而type_info本身是做不到这一点的(因为它禁止拷贝,也没有可靠的比较方式)。这样一来,你就可以搭建一个"类型 → 可读名字"的映射表,用起来非常方便。
4. 用代码实践:搭建一个"类型 → 可读名字"的映射表
下面这段代码演示了如何用 type_index 作为 map 的键,为常见类型建立一份可靠的、人类可读的名字对照表:
// https://godbolt.org/z/vT6fE4fTY
// 完整可运行代码
#include <iostream>
#include <typeinfo>
#include <typeindex>
#include <map>
#include <string>
// 定义一个基类和两个派生类,用来演示"动态类型"的效果
struct Base {
virtual ~Base() {} // 有虚函数,才具备多态性,typeid才能获取"动态类型"
};
struct Derived_One : public Base {};
struct Derived_Two : public Base {};
int main() {
using std::string; using std::cout; using std::type_index;
// 用type_index作为map的键,建立"类型 -> 可读名字"的映射表
std::map<std::type_index, string> names {
{ type_index(typeid(int)), "int" },
{ type_index(typeid(double)), "double" },
{ type_index(typeid(Base)), "Base" },
{ type_index(typeid(Derived_One)), "Derived_One" },
{ type_index(typeid(Derived_Two)), "Derived_Two" },
{ type_index(typeid(string)), "string" },
{ type_index(typeid(string::const_iterator)), "string" }, // 迭代器类型也归为"string"
};
// map本身的类型,也可以作为一个键加进去
names[type_index(typeid(names))] = "names-map";
int integer;
double floating;
Base base{};
Base *one = new Derived_One{}; // 静态类型是Base*,实际指向的对象是Derived_One
Base *two = new Derived_Two{}; // 静态类型是Base*,实际指向的对象是Derived_Two
// typeid(...).name() 的返回值跟具体的编译器/运行环境有关,不同机器结果可能不一样
cout << typeid(integer).name() << '\n';
cout << typeid(floating).name() << '\n';
cout << typeid(base).name() << '\n';
cout << typeid(*one).name() << '\n'; // 注意:这里是*one,取的是指针指向的对象
cout << typeid(*two).name() << '\n';
cout << typeid(string).name() << '\n';
cout << typeid(string{"World"}.begin()).name() << '\n';
cout << typeid(names).name() << '\n';
// 用type_index去查表,得到的是可靠、可读的名字
cout << names[type_index(typeid(integer))] << '\n';
cout << names[type_index(typeid(floating))] << '\n';
cout << names[type_index(typeid(base))] << '\n';
cout << names[type_index(typeid(*one))] << '\n';
cout << names[type_index(typeid(*two))] << '\n';
cout << names[type_index(typeid(string))] << '\n';
cout << names[type_index(typeid(names))] << '\n';
delete one;
delete two;
}
代码逐段讲解:
- 建立映射表部分:
std::map<std::type_index, string> names {...}这一段,把常见类型(int、double、Base及其派生类、string等)分别包装成type_index,作为键存进map,值就是我们希望展示的、可读性好的名字字符串。 Base *one = new Derived_One{};这一句是理解后面内容的关键。one这个指针变量的静态类型是Base*(编译期就能确定,写在代码里明摆着),但它实际指向的对象,动态类型却是Derived_One(运行期才真正创建出来的对象)。Base类里之所以要故意写一个虚析构函数virtual ~Base() {},就是为了让Base具备"多态"的能力——只有具备多态性的类,typeid()才能在运行时准确获取到对象的动态类型。- 直接打印
typeid(...).name()的部分:这一段展示的是name()方法返回的"原始"文字描述。这些描述跟具体的编译器实现、平台强相关,格式非常不友好(比如int在某些编译器上会显示成简短的i,Derived_One可能显示成11Derived_One这种带数字前缀的"编译器内部修饰名")。在下面实测环境下运行,实际得到的输出是(不同机器上很可能不一样,这正说明了name()不可靠这一点):
i
d
4Base
11Derived_One
11Derived_Two
NSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
N9__gnu_cxx17__normal_iteratorIPcNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEE
St3mapISt10type_indexNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESt4lessIS0_ESaISt4pairIKS0_S6_EEE
可以看到,int 显示成了 i,Base 显示成了 4Base(这个 4 是"Base"这个名字的字符长度,属于编译器内部的"名字修饰"规则),至于 string 和 map 这种模板类型,输出更是又长又难读,完全没法直接拿给人看。
4. 通过 type_index 查表的部分:这段代码走的是完全不同的路线——不再依赖 name() 这种不可靠的原始文字,而是拿 type_index(typeid(...)) 去查我们前面手动建好的 names 这张表,得到的就是我们自己指定好的、清晰可读的名字。运行结果如下:
int
double
Base
Derived_One
Derived_Two
string
names-map
对比一下就能直观地感受到差别:同样是查询类型,name() 给出的是杂乱且不可靠的编译器内部名字,而通过 type_index 查预先建好的表,得到的是干净、可控、可读性好的结果。这正是 type_index 存在的意义。
5. 一个容易被忽略但很重要的细节:表达式不会被真正执行
typeid() 的参数虽然可以是一个表达式,但你要清楚一件事:这个表达式本身并不会真的被执行,编译器只关心它的类型。这一点在大多数情况下无关紧要,但涉及"除以零"这种会导致程序崩溃的操作时,差别就非常明显了。
来看下面这个小实验:
//https://godbolt.org/z/fce168YTK
// 完整可运行代码
#include <iostream>
#include <typeinfo>
int main() {
// 666/0 这个除法运算本身不会被真正计算,
// typeid只是需要知道"这个表达式的结果类型是什么"(这里是int)
// 所以程序不会因为除以0而崩溃
std::cout << typeid(666/0).name() << '\n';
}
实际运行结果(已验证,程序正常运行,没有崩溃):
i
编译器会给出一个"除以零"的警告提示,但程序依然能正常运行并输出结果——因为 666/0 这段代码在 typeid() 里根本没有被真正求值执行,编译器只是在编译期就已经推导出"这个表达式的类型是 int",仅此而已。
但这里有一个例外情况需要特别小心: 当 typeid() 的参数是一个"具备多态性"的表达式时(也就是引用或指针指向的是一个带虚函数的类),情况就不一样了——编译器必须在运行时真正执行这个表达式,才能知道对象的动态类型到底是什么。回顾前面示例中的 typeid(*one) 和 typeid(*two):one 和 two 静态类型都是 Base*,但因为 Base 类里带了虚函数(虚析构函数),所以编译器知道,光靠静态类型信息是不够的,必须在运行时"打开"这个对象看一眼,才能确定它到底是 Derived_One 还是 Derived_Two。所以对于多态类型的表达式,*one 和 *two 这样的解引用操作,是会被真正执行的。
用一张图梳理一下这个判断逻辑:
6. 当你没法提前把所有类型都写进映射表时怎么办
前面示例里那种"提前把想要的类型都手动写进 names 这张表"的做法,在实际项目里未必总是可行——很多时候你根本不知道运行过程中会遇到哪些类型。这种情况下,基本上只剩两条路:
- 将就着用
type_info::name(),接受它不够可移植、不够可读的缺点; - 借助 Boost 库提供的
demangled_name。
Boost 库提供了boost::core::demangled_name(...)这个函数,能返回一份可读性好得多的类型信息。使用时要注意:不能直接用标准的typeid,而要改用BOOST_CORE_TYPEID这个宏来生成demangled_name需要的参数。虽然 Boost 官方并不能百分之百保证这份名字一定可读、一定稳定,但这个库维护得相当扎实,实际效果基本上可以放心使用。即便是模板类型,输出结果也不会太好看,但至少能做到"看得懂",这已经比标准库自带的name()强出不少。
// 示例代码(依赖Boost库,需要安装boost并链接boost_core,本地环境未安装Boost无法直接测试,
// 但语法和用法与Boost官方文档一致)
#include <iostream>
#include <typeinfo>
#include <string>
#include <map>
#include <boost/core/typeinfo.hpp>
int main() {
using std::string; using std::cout;
int i;
double f;
std::map<int, string> names; // 这里用一个map举例,配合下面查询它自身的类型
using boost::core::demangled_name;
// demangled_name 配合 BOOST_CORE_TYPEID 宏使用,得到可读的类型名
cout << demangled_name(BOOST_CORE_TYPEID(i)) << '\n';
// 输出: int
cout << demangled_name(BOOST_CORE_TYPEID(f)) << '\n';
// 输出: double
cout << demangled_name(BOOST_CORE_TYPEID(string)) << '\n';
// 输出: std::string
cout << demangled_name(BOOST_CORE_TYPEID(string{}.begin())) << '\n';
// 输出: __gnu_cxx::__normal_iterator<char*, std::string>
cout << demangled_name(BOOST_CORE_TYPEID(names)) << '\n';
// 输出类似: std::map<int, std::string, std::less<int>,
// std::allocator<std::pair<int const, std::string> > >
cout << demangled_name(BOOST_CORE_TYPEID(666/0)) << '\n';
// 输出: int (除法表达式同样不会被真正执行)
}
说明: 这段代码依赖 Boost 库(具体是 Boost.Core 组件),需要在自己的开发环境中安装 Boost 并正确配置头文件路径才能编译运行,用法上跟标准库的 typeid 类似,只是把宏换成了 BOOST_CORE_TYPEID,把打印函数换成了 demangled_name。核心区别就是:标准库的 name() 给出的是编译器内部的"修饰过的名字",而 Boost 的 demangled_name 会帮你把这个修饰名字"翻译"回人类容易阅读的形式,比如把一长串带数字前缀的内部符号,还原成 int、std::string 这种直观的写法。
7. 小结
| 概念/工具 | 作用 | 可靠性 |
|---|---|---|
typeid(x) | 获取表达式或类型 x 的类型信息,返回 const type_info& | 结果本身可靠,但直接拿 type_info 做比较不可靠 |
type_info::name() | 返回一段文字描述该类型 | 不可靠,不同编译器/平台格式不同,不保证唯一或稳定 |
type_info::hash_code() | 返回类型的哈希值 | 适合快速做"不等"判断,用于一般辅助场景 |
type_index(ti) | 把 type_info 包装成可比较、可作为map键的类型 | 可靠,推荐用于类型比较和构建类型映射表 |
boost::core::demangled_name | 把编译器内部的修饰名"翻译"成可读名字 | 依赖第三方库Boost,可读性明显优于标准库自带的 name() |
总结一下这一节的核心思路:typeid() 能让你在运行时拿到类型信息,但直接打印 name() 或者直接用 == 比较 type_info 都是不靠谱的。要想可靠地判断"两个类型是不是同一个",或者想把类型信息当成 map 的键来用,正确的做法是先包一层 type_index。如果还嫌 name() 打印出来的内容太丑、看不懂,那就考虑借助 Boost 提供的 demangled_name,能把这些"天书"一样的内部符号还原成正常人能看懂的类型名字。
C++ 函数对象工具箱:<functional> 详解
一、<functional> 是干什么用的
<functional> 这个头文件很少会被单独拿出来用,它更多的是给标准库其他部分(比如 <algorithm>、各种容器)打配合的。
一个典型的使用场景是:如果你想对一个容器里的所有元素求和,可以用 <numeric> 头文件里的 accumulate;但如果你想做的不是"求和"而是"求积"(也就是把 + 换成 *),你有两种选择:
- 自己写一个 lambda 表达式:
[](auto a, auto b) { return a*b; } - 直接用
<functional>里现成的multiplies,它干的事情和上面这个 lambda 一模一样。
<functional>里的很多工具都是这个套路——帮你把"常见的小操作"预先包装好,省得每次都手写 lambda。
在这一节里,把<functional>提供的东西大致分成三类来讲:
| 分类 | 作用 |
|---|---|
| 函数工厂 | function(万能容器)、bind(固定参数生成新函数)、mem_fn(把成员方法变成普通函数)等 |
| 具体的函数对象 | 针对算术、比较、逻辑、位运算这几类操作,预先写好的现成"小函数",可以代替简单的lambda |
| 专用哈希函数 | 无序容器需要的哈希函数对象,比如hash<int>这类针对内置类型的特化版本 |
哈希函数这部分内容比较直白(本质上就是给 int、double 这些内置类型准备好的 std::hash 特化版本),不需要额外展开讲解,重点放在前两类上。
二、具体的函数对象:把常见运算符包装成"可调用对象"
标准库把四大类常见运算(算术、比较、逻辑、位运算)分别包装成了对应的函数对象,可以直接拿来当作参数传给算法或者容器,效果和你自己写一个对应的 lambda 完全一样。
2.1 算术运算符对应的函数对象
| 函数对象 | 行为 |
|---|---|
plus | a + b |
minus | a - b |
multiplies | a * b |
divides | a / b |
modulus | a % b |
negate | -a |
2.2 比较运算符对应的函数对象
| 函数对象 | 行为 |
|---|---|
equal_to | a == b |
not_equal_to | a != b |
greater | a > b |
less | a < b |
greater_equal | a >= b |
less_equal | a <= b |
2.3 逻辑运算符对应的函数对象
| 函数对象 | 行为 |
|---|---|
logical_and | a && b |
logical_or | a || b |
logical_not | !b |
2.4 位运算符对应的函数对象
| 函数对象 | 行为 |
|---|---|
bit_and | a & b |
bit_or | a | b |
bit_xor | a ^ b |
bit_not | ~b |
这些函数对象最直接的用法,就是配合算法(比如前面提到的 accumulate)来临时替换默认的运算符:
// https://godbolt.org/z/jhebrYYv4
// 完整可运行代码:用 multiplies 代替 lambda 计算容器元素的乘积
#include <numeric> // accumulate
#include <functional> // multiplies
#include <vector>
#include <iostream>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
// accumulate 的第三个参数是初始值,第四个参数是"要用什么方式累积"
// 这里用 multiplies<int>{} 代替默认的加法,实现"求乘积"而不是"求和"
// multiplies<int>{} 中的 {} 是用花括号进行值初始化,构造出一个可调用的函数对象
int product = std::accumulate(v.begin(), v.end(), 1, std::multiplies<int>{});
std::cout << "1*2*3*4*5 = " << product << '\n'; // 输出: 120
return 0;
}
代码关键点说明:
std::multiplies<int>{}构造出一个"函数对象"(也叫仿函数,functor)——它本身是一个类的实例,但重载了operator(),所以可以像调用函数一样调用它(比如multiplies<int>{}(3, 4)会返回12)。accumulate的初始值这里改成了1而不是求和常用的0——因为求乘积的"单位元"是1(任何数乘以1都不变),如果还用0当初始值,最终结果就永远是0了。这是一个容易踩的坑。
三、用函数对象取代重载运算符:以 set 的排序规则为例
这些函数对象不仅能配合算法用,还能用来"定制"容器的行为。一个很典型的例子是 std::set——它的模板参数里,默认就用到了 <functional> 里的 less:
// 这是标准库内部set的声明(简化版),用来说明less的默认用法
namespace std {
template<class Key, class Compare = less<Key>, class Allocator = allocator<Key>>
class set;
}
也就是说,set 内部排序用的比较规则,默认就是 less<Key>,本质上就是调用 Key 类型的 operator<。
那如果你有一个自定义类型,想让它能放进 set 里、并且自动按某种规则排序,通常的做法是给这个类型重载 operator<。但除此之外,还有另一条路:直接给 std::less 这个模板类,针对你的类型做一个特化,效果是一样的——这跟给 unordered_set 提供自定义哈希函数(特化 std::hash)的思路是完全一致的。
// https://godbolt.org/z/Pjc1q4zM1
// 完整可运行代码:通过特化std::less,让自定义类型能放进set里自动排序
#include <set>
#include <string>
#include <iostream>
// 自定义的类型:龙
struct Dragon {
std::string name_;
};
// 在std命名空间里,对less模板类针对Dragon类型做特化
namespace std {
template<> struct less<Dragon> {
// 重载operator(),定义"两个Dragon该怎么比较大小"
// 这里选择按名字的字典序来比较
bool operator()(const Dragon &lhs, const Dragon &rhs) const {
return lhs.name_ < rhs.name_;
}
};
}
int main() {
// set<Dragon> 默认使用 less<Dragon> 作为比较方式
// 因为我们已经特化了 less<Dragon>,所以这里不需要额外传第二个模板参数
std::set<Dragon> dragons{
Dragon{"Smaug"}, Dragon{"Glaurung"},
Dragon{"Ancalagon"}, Dragon{"Scatha"}
};
// 遍历打印,因为set内部按less<Dragon>排序,所以会按名字字典序输出
for (const auto& d : dragons) {
std::cout << d.name_ << '\n';
}
return 0;
}
代码关键点说明:
template<> struct less<Dragon> { ... };是一个模板特化:告诉编译器,“当less被用在Dragon类型上时,请使用我这个专门写的版本,而不是默认的通用版本”。- 特化出来的
less<Dragon>里必须实现operator(),接收两个const Dragon&参数,返回一个bool,语义是"第一个参数是否应该排在第二个参数前面"。 - 这样一来,
std::set<Dragon>不需要额外传第二个模板参数(也就是不需要写std::set<Dragon, MyCompare>),因为它默认用的less<Dragon>就已经是我们特化过的版本了。 - 这种做法适合"不方便或者不想给
Dragon类型本身重载operator<"的场景——比如Dragon是别人写的类,你没法去改它的源码,但依然想让它能放进set里排序。
程序实际运行的输出结果(按名字字典序排列):
Ancalagon
Glaurung
Scatha
Smaug
四、综合实战:一个完全用 <functional> 构建的计算器
下面来看一个比较大的综合例子——用 <functional> 从头到尾构建一个简易计算器。这个计算器和常见的"用一个大 switch 语句处理每种运算符"的写法不同,它把所有可能出现的字符当作 map 的键,根据这个字符具体该怎么操作栈,分别放进四个不同的 map 里。
4.1 四种操作分类
| 操作类型 | 行为 | 该计算器中的例子 |
|---|---|---|
binOps(二元操作) | 从栈里弹出两个元素,把函数应用在它们身上,再把结果压回栈 | 加法、减法、乘法、除法、取模 |
unOps(一元操作) | 从栈里弹出一个元素,把函数应用在它身上,再把结果压回栈 | 这个计算器没有用到 |
zeroOps(零元操作) | 执行一个不需要参数的函数,把结果压入栈 | 数字字符(比如’3’代表压入数字3) |
stackOps(栈操作) | 不是"弹出再压入"这种固定模式,而是把整个栈直接交给函数自由处理 | 清空栈、打印栈、交换栈顶两个元素 |
4.2 完整可运行代码
// https://godbolt.org/z/3rb1csqaE
// 完整可运行代码:一个完全基于<functional>构建的简易计算器
#include <string>
#include <vector>
#include <iostream>
#include <map>
#include <functional>
// binOps: 二元运算符表
// key是字符(比如'+'),value是一个"接收两个int、返回一个int"的可调用对象
// std::function<int(int,int)> 是一个"万能容器",可以装下任何符合这个签名的可调用对象
std::map<char, std::function<int(int, int)>> binOps{
{'+', std::plus<int>{}},
{'-', std::minus<int>{}},
{'*', std::multiplies<int>{}},
{'/', std::divides<int>{}},
{'%', std::modulus<int>{}},
};
// unOps: 一元运算符表,这个计算器里没有用到,所以是空的
std::map<char, std::function<int(int)>> unOps{};
// val 是一个"返回lambda的lambda":
// val(5) 会返回一个新的lambda,这个新lambda不接收任何参数,调用时直接返回5
// 这种写法体现了"lambda是一等公民":lambda本身可以像普通值一样被创建、传递、返回
auto val = [](auto n) {
return [n]() { return n; };
};
// zeroOps: 零元运算符表,对应数字字符'0'到'9'
// 每个数字字符映射到一个"不需要参数、调用后返回对应数字"的函数对象
std::map<char, std::function<int()>> zeroOps{
{'0', val(0)}, {'1', val(1)}, {'2', val(2)}, {'3', val(3)}, {'4', val(4)},
{'5', val(5)}, {'6', val(6)}, {'7', val(7)}, {'8', val(8)}, {'9', val(9)},
};
// stackOps: 栈操作表,这类函数直接拿到整个栈的引用,自己决定怎么操作
std::map<char, std::function<void(std::vector<int>&)>> stackOps{
{' ', [](auto& stack) { /* 空格:什么都不做 */ }},
{'c', [](auto& stack) {
// 'c':清空整个栈
stack.clear();
}},
{':', [](auto& stack) {
// ':':交换栈顶的两个元素
auto top = stack.back(); stack.pop_back();
auto second = stack.back(); stack.pop_back();
stack.push_back(top);
stack.push_back(second);
}},
{'=', [](auto& stack) {
// '=':打印整个栈的内容
for (int elem : stack) {
std::cout << elem;
}
std::cout << "\n";
}},
};
// 计算器主函数:逐个字符解析输入的算式,模拟一个基于栈的计算过程
void calculator(std::string input) {
std::vector<int> stack{};
for (char c : input) {
int top, second;
// 依次尝试在四个map里查找这个字符对应的操作
// 用find返回的迭代器和end()比较,判断这个字符是否存在于该map里
if (auto it = unOps.find(c); it != unOps.end()) {
// 如果是一元操作符……
auto func = it->second;
top = stack.back(); stack.pop_back(); // 弹出栈顶一个元素
stack.push_back(func(top)); // 应用函数,把结果压回栈
} else if (auto it = binOps.find(c); it != binOps.end()) {
// 如果是二元操作符……
auto func = it->second;
top = stack.back(); stack.pop_back(); // 弹出栈顶两个元素
second = stack.back(); stack.pop_back();
stack.push_back(func(second, top)); // 注意顺序: second在前, top在后
// 这样减法这类"顺序敏感"的运算才不会算反
} else if (auto it = zeroOps.find(c); it != zeroOps.end()) {
// 如果是零元操作符(也就是数字字符)……
auto func = it->second;
stack.push_back(func()); // 调用函数(不需要参数),把结果压入栈
} else if (auto it = stackOps.find(c); it != stackOps.end()) {
// 如果是栈操作符……
auto func = it->second;
func(stack); // 直接把整个栈交给函数自由处理
} else {
// 四个map都没找到,说明这个字符不认识
std::cout << "\n'" << c << "' I don't understand.\n";
}
}
}
int main(int argc, const char* argv[]) {
if (argc > 1) {
// 如果有命令行参数,就用参数作为算式输入
calculator(argv[1]);
} else {
// 否则运行几个预设的算式作为演示
// "345*+6+=" 表示: 3 4 5 * + 6 + =
// 也就是逆波兰表达式(后缀表达式): (3 + (4*5)) + 6 = 3 + 20 + 6 = 29
calculator("345*+6+="); // 3+4*5+6,乘法优先于加法,结果是29
calculator("93-="); // 9-3=6
calculator("82/="); // 8/2=4
calculator("92%="); // 9%2=1
}
return 0;
}
4.3 代码逐段讲解
binOps、zeroOps、stackOps 这几个 map 的定义方式:它们的键都是 char(对应算式里的字符),值都是 std::function<某种签名>。std::function 是一个非常关键的类型——它可以装下任何符合指定"函数签名"的可调用对象,不管这个对象具体是一个普通函数、一个 lambda,还是像 std::plus<int>{} 这样的函数对象,只要参数和返回值类型对得上,都能塞进同一个 std::function 变量里。这就是为什么 binOps 里能同时放 std::plus<int>{}(一个函数对象)而不需要操心它具体的类型细节。
val 这个"返回lambda的lambda":auto val = [](auto n) { return [n]() { return n; }; }; 这行代码稍微有点绕,拆开来看:
- 外层
[](auto n) { ... }是一个 lambda,接收一个参数n。 - 它的返回值又是另一个 lambda:
[n]() { return n; },这个内层 lambda 通过捕获列表[n]把外层的n拷贝了一份保存起来,自己本身不接收任何参数,调用时直接返回保存的n。
所以val(5)这个调用,返回的是一个"不需要任何参数、调用后固定返回5"的函数对象。这正好符合zeroOps这个map需要的签名——std::function<int()>(不接收参数,返回一个int)。这种"函数返回函数"的写法,是"lambda 作为一等公民"这个概念的一个直观体现:lambda 可以像普通的整数、字符串一样被创建、存进变量、作为返回值传出去,不需要任何特殊处理。
calculator函数里binOps分支的求值顺序:
top = stack.back(); stack.pop_back();
second = stack.back(); stack.pop_back();
stack.push_back(func(second, top));
这里特意先弹出的叫 top(栈最上面的、也就是最后被压入的元素),再弹出的叫 second(次新的元素)。调用函数的时候写的是 func(second, top),也就是"较早压入的那个在前,较晚压入的那个在后"。这个顺序对于加法、乘法这种"顺序无关"的运算没有影响,但对于减法、除法这种顺序敏感的运算就至关重要了——如果不小心写反成 func(top, second),减法算出来的结果就会正负颠倒,这是编写基于栈的计算器时非常容易踩的一个坑。
4.4 手动推算一遍第一个算式
输入是 "345*+6+=",这是一个后缀表达式(逆波兰表达式)的写法,我们一步步跟踪栈的变化:
读到 '3': zeroOps命中, 压入3 栈: [3]
读到 '4': zeroOps命中, 压入4 栈: [3, 4]
读到 '5': zeroOps命中, 压入5 栈: [3, 4, 5]
读到 '*': binOps命中, 弹出5和4
计算 4*5=20, 压入20 栈: [3, 20]
读到 '+': binOps命中, 弹出20和3
计算 3+20=23, 压入23 栈: [23]
读到 '6': zeroOps命中, 压入6 栈: [23, 6]
读到 '+': binOps命中, 弹出6和23
计算 23+6=29, 压入29 栈: [29]
读到 '=': stackOps命中, 打印栈内容 输出: 29
用数学公式表达这整个计算过程:
3+(4×5)+6=3+20+6=29
3 + (4 \times 5) + 6 = 3 + 20 + 6 = 29
3+(4×5)+6=3+20+6=29
这跟代码注释里写的结果完全吻合。
4.5 实际运行结果
29
6
4
1
对应关系是:"345*+6+=" 算出 29;"93-=" 算出 9-3=6;"82/=" 算出 8/2=4;"92%=" 算出 9%2=1。
4.6 这种写法的优缺点
用一堆 map 加函数对象来代替 switch 语句,并不是说这样写就"绝对更好"——它跟传统的 switch 写法相比是各有取舍的。但如果你特别看重可扩展性(比如希望以后能在运行时动态注册新的运算符,或者希望代码结构更"数据驱动"),这种写法会是更巧妙的选择。
如果要把这个例子改造成生产级别的代码,至少有几个地方值得改进:
std::cout被当作全局变量直接写死在代码里,更好的做法是把输出流作为参数传进来(比如用std::ostream&类型的参数),这样测试和替换输出目标都会更灵活。- 代码里对四个
map依次调用find,这在很多分支下其实是"没必要的重复查找",实际项目中应该想办法减少这种不必要的查找次数。 - 这个例子里,数字是"一个字符一个字符"处理的(比如输入
"12"会被当成先压入1、再压入2,而不是数字12),这只是为了演示方便,实际计算器应该支持多位数字,用空格等分隔符区分不同的数字。
五、函数生成器:function、bind、bind_front、bind_back
5.1 std::function:几乎所有"可调用对象"的容器
前面的计算器例子里已经大量用到了 std::function。简单来说,它就是 C++ 里"能装下几乎任何可调用对象"的一种通用类型——但严格来讲,它并不是字面意义上的"万能",它的核心能力是:能隐式转换成一个C语言风格的函数指针,并且支持多种不同的初始化方式,从而在"各种各样的可调用对象"和"统一的函数签名接口"之间架起一座桥梁。
5.2 用 bind 固定住某个参数
如果你有一个接收两个参数的函数,但其中一个参数的值你已经提前确定了,bind 可以帮你把它变成一个只需要一个参数的新函数:
// https://godbolt.org/z/v8aGPYEPs
// 完整可运行代码:用bind固定函数的某个参数
#include <functional> // bind, minus, placeholders
#include <iostream>
using std::cout;
// 一个普通的两参数函数
int subtract(int a, int b) { return a - b; }
int main() {
// 引入placeholders命名空间,这样才能直接写 _1, _2 而不用写全名
using namespace std::placeholders;
cout << subtract(9, 3) << '\n'; // 直接调用,输出: 6
// bind(subtract, _1, 3) 的意思是:
// 生成一个新的可调用对象,它的第一个参数(_1)由调用时传入,
// 而第二个参数固定死为3
// 也就是说 minus3(x) 等价于 subtract(x, 3)
auto minus3 = std::bind(subtract, _1, 3);
cout << minus3(9) << '\n'; // minus3(9) = subtract(9,3) = 6
// from9(x) 的意思正好相反: 第一个参数固定为9, 第二个参数由调用时传入(_1)
// from9(x) 等价于 subtract(9, x)
auto from9 = std::bind(subtract, 9, _1);
cout << from9(3) << '\n'; // from9(3) = subtract(9,3) = 6
// bind不仅能用在普通函数上,也能用在<functional>提供的函数对象上
// std::minus<int>{} 本身接收两个参数(a,b)返回a-b,和subtract语义一致
auto againMinus3 = std::bind(std::minus<int>{}, _1, 3);
cout << againMinus3(9) << '\n'; // 同样是6
return 0;
}
代码关键点说明:
_1、_2这些"占位符",定义在std::placeholders命名空间里,是bind机制的核心。你可以把它们理解成"这个位置的参数值,等真正调用的时候再告诉我"——写占位符的地方,就是留给"未来调用者"去填的空。- 如果你生成的新函数只有一个参数(比如
minus3),你只需要用到_1;如果新函数还需要两个参数,就要同时用上_1和_2,以此类推。 bind(subtract, _1, 3)和bind(subtract, 9, _1)展示了占位符可以放在参数列表的任意位置——它不一定非要是第一个参数,具体放哪个位置,取决于你想固定哪个参数、留出哪个参数给未来调用。
5.3 如果一个函数不需要任何参数,bind 也能生成
如果你想用 bind 生成一个完全不需要传参的函数(也就是说,原函数需要的所有参数,都在 bind 的时候一次性固定死了),那就不需要写任何占位符。一个典型的应用场景是"随机数生成器":
// 完整可运行代码:用bind把随机数引擎和分布固定成一个"零参数"的生成器
#include <random>
#include <vector>
#include <iostream>
#include <functional>
void rollDice() {
// default_random_engine: 默认的随机数引擎(负责产生原始的伪随机数序列)
std::default_random_engine engine{};
// 用来统计骰子1到6各出现了多少次(下标0对应点数1,以此类推)
std::vector<size_t> counts{0, 0, 0, 0, 0, 0};
// uniform_int_distribution: 均匀分布,指定范围是[0,5],对应骰子的6个面
std::uniform_int_distribution<int> d6{0, 5};
// bind(d6, engine) 把"分布对象d6"和"随机数引擎engine"这两者绑定在一起
// 正常情况下需要写 d6(engine) 才能生成一个随机数
// 绑定之后,直接调用 d() 就等价于 d6(engine),不需要再传任何参数
auto d = std::bind(d6, engine);
// 掷骰子120万次,统计每个点数出现的次数
for (auto i = 1200 * 1000; i > 0; --i) {
++counts[d()]; // 每次调用d()都会生成一个0到5之间的随机数
}
// 打印统计结果
for (auto c : counts) {
std::cout << " " << c;
}
std::cout << '\n';
}
int main() {
rollDice();
return 0;
}
代码关键点说明:
std::uniform_int_distribution<int> d6{0, 5}本身是一个"分布对象",它需要配合一个随机数引擎才能真正生成随机数——正常写法是d6(engine),每次调用都会用engine产生的原始随机数,转换成[0,5]范围内的一个具体整数。std::bind(d6, engine)把这两者打包绑定在一起,生成的新对象d不再需要任何额外参数,直接d()就相当于d6(engine)。这样在后面的循环里,调用起来就简洁多了。- 循环里
++counts[d()]:每次调用d()得到一个 0 到 5 之间的随机数,把counts数组里对应下标的计数加一。跑够多次之后,因为是均匀分布,理论上每个点数出现的次数应该大致相等(各占总次数的 16\frac{1}{6}61 左右)。
用数学公式表达一下"理论期望":如果一共掷了 NNN 次骰子,每个点数理论上应该出现大约
N6 \frac{N}{6} 6N
次。比如 N=1,200,000N = 1{,}200{,}000N=1,200,000 时,每个点数理论上大约出现 200,000200{,}000200,000 次左右(实际运行会因为随机性有所浮动)。
5.4 C++20 的 bind_front 与 C++23 的 bind_back
bind 的写法(尤其是占位符 _1、_2 这种)对不少人来说不太直观,读起来容易绕。从 C++20 开始有了 bind_front,C++23 又补上了 bind_back,让某些常见的场景写起来更简单。
先对比一下用 lambda 和用 bind 实现同一个效果的写法差异:
// https://godbolt.org/z/49n4ebc4P
// 完整可运行代码:对比 lambda、bind、bind_back 三种写法
#include <functional> // bind, bind_back
#include <iostream>
using std::cout;
using namespace std::placeholders;
int add(int a, int b) { return a + b; } // 一个简单的两参数加法函数
int main() {
// ---------- 固定第一个参数为9 ----------
// 用lambda实现: 固定第一个参数为9, 只保留第二个参数x
auto add_to_9_lambda = [](auto x) { return add(9, x); };
// 用bind实现: 效果一样, 但要靠占位符_1标记"哪个位置留给调用者"
auto add_to_9_bind = std::bind(add, 9, _1);
cout << add_to_9_lambda(3) << '\n'; // 9+3=12
cout << add_to_9_bind(3) << '\n'; // 9+3=12
// ---------- 固定第二个参数为3 ----------
// 用bind_back实现: 固定的是"从后往前数"的参数, 也就是最后一个参数
// bind_back(add, 3) 表示 add的最后一个参数固定为3, 其余参数留给调用者
auto add_to_3_bind_back = std::bind_back(add, 3);
// 用lambda实现同样的效果
auto add_to_3_lambda = [](auto x) { return add(x, 3); };
cout << add_to_3_lambda(9) << '\n'; // 9+3=12
cout << add_to_3_bind_back(9) << '\n'; // 9+3=12
return 0;
}
代码关键点说明:
add_to_9_lambda和add_to_9_bind做的是同一件事:把add函数的第一个参数固定为9,剩下一个参数留给以后调用时再传。这两种写法效果完全一致,但bind版本需要理解占位符_1的含义,而 lambda 版本更接近日常读代码的直觉。add_to_3_bind_back用的是bind_back,它绑定的是从参数列表末尾数起的参数,这里add只有两个参数,"最后一个参数"自然就是第二个参数,所以bind_back(add, 3)相当于把第二个参数固定成3,第一个参数留给调用者。这跟add_to_3_lambda里[](auto x) { return add(x, 3); }的效果是完全一样的。
程序实际运行结果:
12
12
12
12
四行都输出 12,说明这几种写法(lambda 固定第一参数、bind 固定第一参数、bind_back 固定最后一个参数、lambda 固定第二参数)确实都达到了各自预期的效果。
5.5 什么时候该用 bind/bind_front/bind_back,而不是 lambda
既然 lambda 往往看起来更直观,那为什么标准库还要专门提供 bind 这一整套机制呢?
一个重要的原因是:在泛型编程场景下(也就是写模板代码,需要兼容各种不同类型的时候),lambda 在处理 noexcept(异常规范)和参数完美转发(forward)这类细节时会遇到一些困难,写起来比较别扭,甚至有些场景根本没法用 lambda 优雅地表达出来。这种时候,bind、bind_front、bind_back 这几个工具就成了更合适的选择。
也就是说:日常业务代码里,能用 lambda 就优先用 lambda,因为它更直观、更容易读懂;但如果你是在写高度通用的模板代码,遇到 lambda 处理起来比较吃力的场景,可以考虑切换到 bind 系列工具。
六、mem_fn:把"成员方法/成员变量"变成"普通函数"
mem_fn 做的事情,是把一个类的成员方法或者数据成员,转换成一个可以独立调用的"自由函数"形式。
正常情况下,访问成员方法或成员变量的写法是这样的:
instance.method(arg); // 调用成员方法
instance.data; // 访问成员变量
用 mem_fn 转换之后,写法会变成:
method(instance, arg); // instance变成了第一个参数
data(instance); // 同样,instance变成了参数
也就是说,原本"依附在某个对象上"的方法/变量访问方式,被 mem_fn 转换成了"独立的、把对象实例当作第一个参数"的函数调用方式。
来看一个完整的例子:
// https://godbolt.org/z/6abh4v5xW
// 完整可运行代码:用mem_fn把成员方法和成员变量变成独立函数
#include <functional>
#include <iostream>
struct Numbers {
// 一个不需要额外参数的成员方法,固定返回42
int theNumber() {
return 42;
}
// 一个需要一个参数的成员方法,返回参数值加上成员变量data
int more(int n) {
return n + data;
}
int data = 7; // 一个普通的成员变量
};
int main() {
// 用mem_fn分别把成员方法theNumber、成员方法more、成员变量data
// 都转换成独立的、可调用的函数对象
auto func = std::mem_fn(&Numbers::theNumber);
auto func2 = std::mem_fn(&Numbers::more);
auto access = std::mem_fn(&Numbers::data);
Numbers numbers; // 创建一个Numbers类型的实例,data默认是7
// 调用转换后的函数时,原来的"实例"变成了第一个参数
std::cout << func(numbers) << '\n'; // 等价于 numbers.theNumber(),输出42
std::cout << func2(numbers, 66) << '\n'; // 等价于 numbers.more(66),输出66+7=73
std::cout << access(numbers) << '\n'; // 等价于 numbers.data,输出7
return 0;
}
代码关键点说明:
std::mem_fn(&Numbers::theNumber):注意这里传入的是&Numbers::theNumber,也就是这个成员方法的指针,而不是直接调用它。mem_fn接收这个成员方法指针,返回一个可以独立调用的函数对象。func(numbers)调用的时候,第一个参数numbers会被当成"这个方法应该作用在哪个对象上",等价于写numbers.theNumber()。func2(numbers, 66):第一个参数依然是"作用的对象",第二个参数才是more方法本身需要的参数n,整体等价于numbers.more(66)。std::mem_fn(&Numbers::data):mem_fn不仅能包装成员方法,还能包装数据成员。access(numbers)等价于直接访问numbers.data,返回这个成员变量当前的值。
实际运行结果:
42
73
7
对应验证:theNumber() 固定返回 42;more(66) 计算的是 66+data=66+7=7366 + data = 66 + 7 = 7366+data=66+7=73;data 本身的值是 7。
6.1 mem_fn 与 bind 的分工
大多数情况下,mem_fn 能实现的效果,bind 也能做到(把成员方法指针和一个对象绑在一起)。但有一点 bind 做不到、只有 mem_fn 能直接支持:把一个数据成员转换成一个可调用的"访问器函数"(就像上面例子里的 access)。bind 本身没有这种"魔法"。
不过和前面的结论一样:如果 lambda 表达式已经能够清晰、直观地表达你想要的效果,那么很多时候lambda 依然是更具表达力、更容易读懂的选择,mem_fn 更多是在某些特定场景(比如需要把成员访问方式统一包装成标准的可调用对象接口时)才会显得特别方便。
七、整体知识地图
用一张图把这一节涉及的所有工具串起来:
八、小结
| 想做的事 | 该用什么 |
|---|---|
| 代替简单的算术/比较/逻辑/位运算lambda | plus、less、logical_and、bit_xor等现成函数对象 |
不想给自定义类型重载operator< | 特化std::less<你的类型> |
| 把不同类型的可调用对象统一存起来 | std::function<签名> |
| 固定住某个函数的部分参数(占位符标记位置任意) | std::bind + std::placeholders |
| 固定函数最前面的几个参数(C++20起) | std::bind_front |
| 固定函数最后面的几个参数(C++23起) | std::bind_back |
| 把成员方法/成员变量变成独立可调用的函数 | std::mem_fn |
| 泛型模板代码里lambda处理noexcept/forward比较吃力 | 优先考虑bind系列工具 |
| 日常业务代码,逻辑简单直观 | 优先考虑lambda,通常更易读 |
一句话总结:<functional> 提供的这些工具,核心目的都是把"某种操作"打包成一个统一的、可传递、可存储的"可调用对象"——不管这个操作原本是一个运算符、一个普通函数、一个带参数的函数、还是一个类的成员方法/成员变量。日常写业务代码时,lambda 往往已经够用而且更易读;但当你需要更强的通用性(比如定制容器行为、或者写高度泛型的模板代码)时,<functional> 里这些现成的工具能帮你省下不少重复造轮子的功夫。
C++ 标准库拾遗:optional、variant、any 与特殊数学函数
1. optional<T>:表示"可能有值,也可能没有值"
1.1 跟 Java 里的 Optional 有什么不一样
如果你之前接触过 Java,可能会觉得 optional<T> 这个名字似曾相识。但要提醒一下:C++ 里的 optional<T> 跟 Java 的 Optional<T> 含义并不完全一样。
在 Java 里,Optional<T> 是流式 API(streaming API)体系的一部分,本质上代表一个"大小为 0 或 1 的容器",官方甚至并不推荐把它当作方法参数来使用。
而在 C++ 里,情况完全不同:
- 第一,
optional<T>不是一个能配合算法使用的容器,它没有begin()、end()这些迭代器接口,标准库的那些算法函数(比如sort、copy)也用不上它; - 第二,
optional<T>的设计目标非常明确:专门用来表达"这个位置的值可能存在,也可能不存在"这种语义,既可以用在函数参数、返回值上,也可以放进容器里存放。
标准库文档对它的建议使用场景描述得很清楚:适合用在"有且仅有一个明确的理由导致某个T类型的值可能不存在,并且这种’不存在’的状态跟正常持有一个值一样自然"的场景里。说得更通俗、但没那么精确一点:凡是你以前习惯用nullptr来表示"这里没有值"的地方,现在应该考虑用optional<T>来代替。
1.2 optional 的常见用法
一个 optional<T> 类型的变量 opt,通常会这样使用:
- 创建:可以通过构造函数创建一个空的
optional,也可以直接用一个正常的值来创建,或者用特殊值nullopt表示"空",此外还可以用make_optional来构造; - 判断是否有值:可以利用
optional到bool的隐式转换,直接写if (opt);也可以显式调用has_value(); - 取值:用
value()、value_or(fallback)、*opt,或者opt->这几种方式访问里面存的值; - 替换或清空:用赋值、
swap,或者调用reset()把值清空; - 比较:可以用
==、<=等运算符比较,甚至可以直接跟"空状态"进行比较; - 链式操作(C++23起):可以用
transform、and_then、or_else这几个新方法,把多个处理步骤串联起来。
1.3 用代码把这些用法过一遍
// https://godbolt.org/z/9hWxjo7K4
// 完整可运行代码
#include <optional>
#include <iostream>
#include <string>
// 在字符串中查找某个字符第一次出现的位置
// 找到了返回位置索引,找不到就返回"空"——用optional<int>来表达这种"可能没有结果"的语义
std::optional<int> find_index(const std::string& text, char target) {
for (size_t i = 0; i < text.size(); ++i) {
if (text[i] == target) return i; // 一个普通的size_t值会隐式转换成optional<int>
}
return std::nullopt; // 表示"没有找到",用nullopt来表达空状态
}
int main() {
auto r1 = find_index("hello", 'l'); // 能找到,'l'在下标2的位置
auto r2 = find_index("hello", 'z'); // 找不到,因为"hello"里没有'z'
// 隐式转换到bool来判断是否有值,if(opt)是最常见的写法
if (r1) {
std::cout << "找到了,位置是: " << *r1 << "\n"; // *opt 直接取出里面的值
}
// 也可以显式调用has_value()来判断
if (!r2.has_value()) {
std::cout << "没找到\n";
}
// value_or:如果optional里没有值,就返回一个你指定的默认值,而不会抛异常
std::cout << "r2的值(带默认值): " << r2.value_or(-1) << "\n";
// 比较:可以直接跟nullopt比较,判断是否为空状态
std::optional<int> empty{};
std::cout << "empty是否等于nullopt: " << std::boolalpha
<< (empty == std::nullopt) << "\n";
// C++23链式操作:transform会在optional有值时对里面的值做变换,
// 如果optional本身是空的,transform直接跳过,结果依然是空的optional
auto doubled = r1.transform([](int x) { return x * 2; });
if (doubled) {
std::cout << "变换后的值: " << *doubled << "\n";
}
}
代码逐段讲解:
find_index函数的返回类型是std::optional<int>。当循环找到目标字符时,直接return i;——这里i是一个普通的size_t,C++ 会自动把它包装成一个"有值"的optional<int>。如果循环结束都没找到,就return std::nullopt;,nullopt是标准库专门定义的一个特殊值,代表"这个 optional 里什么都没装"。if (r1) { ... *r1 ... }——这里利用了optional到bool的隐式转换:当optional里确实装了一个值时,条件为真;如果是空的,条件为假。判断为真之后,用*r1这种"解引用"的方式(跟操作指针很像)取出里面真正的值。r2.has_value()——跟if(opt)效果类似,只是写法更明确一些,语义上更直白地表示"我在检查这个 optional 是否持有值"。r2.value_or(-1)——value_or是一个非常实用的方法:如果optional有值,就返回这个值;如果没有值,就返回你传进去的默认值(这里是-1),全程不会抛出异常,非常适合"取不到值就用兜底方案"的场景。empty == std::nullopt——optional支持直接跟nullopt做比较,用来判断它当前是不是处于"空"状态,写法非常直观。r1.transform([](int x) { return x * 2; })——这是 C++23 新增的链式方法。如果r1当前有值,transform就会拿这个值去调用你传入的函数,并把返回结果重新包装成一个新的optional;如果r1本身是空的,transform什么都不做,直接返回一个空的optional,不会尝试对"不存在的值"进行运算。除了transform,C++23 还提供了and_then(用来串联"返回 optional 的函数")和or_else(用来在为空的时候提供另一套处理逻辑),三者组合起来可以写出很流畅的链式处理代码,不需要写一堆if判断。
程序实际运行结果(已验证):
找到了,位置是: 2
没找到
r2的值(带默认值): -1
empty是否等于nullopt: true
变换后的值: 4
1.4 optional 不太适合存放的类型
有两种类型不太适合直接塞进 optional 里:
bool:如果你需要表达"真、假、未知"这种三态逻辑,optional<bool>用起来会有点别扭,更推荐使用类似boost::tribool这种专门为三态逻辑设计的类型;- 指针:指针本身已经有一个天然的"空值"可以用,那就是
nullptr,所以再套一层optional<T*>通常是多此一举。
2. variant:在多个类型之间安全地"二选一"
2.1 为什么不直接用 union
variant 这个模板,可以用来实现一种类似传统 union(联合体)的效果,但比 union 安全得多。传统 union 在"安全编程"这个层面上有个老大难问题:它没法可靠地帮你调用其中存放对象的构造函数和析构函数——你得自己手动管理这些生命周期细节,一不小心就容易出错。
variant 能存放"若干种类型中的其中一种"。一旦你给它赋了某种类型的值,variant 内部的状态就会切换成这个类型;如果之后你想按另一种类型去读取它,就会失败——正是这种"状态切换 + 类型受控"的机制,让 variant 的使用变得安全可靠。
2.2 基础用法示例
// https://godbolt.org/z/Y4vxTo9qW
// 完整可运行代码
#include <variant>
#include <iostream>
using std::get;
int main() {
std::variant<int, float> v{}; // v可以存放int或float之一
v = 12; // 赋值为int,v的状态变成"持有int"
auto i = get<int>(v); // 用get<类型>取出当前存放的值
std::cout << i << '\n'; // 输出: 12
v = 3.456f; // 重新赋值为float,v的状态变成"持有float"
std::cout << get<float>(v) << '\n'; // 输出: 3.456
// get<double>(v); // 编译错误:variant<int,float>根本不认识double这个类型
// get<3>(v); // 编译错误:索引3超出了variant只有2个候选类型(0和1)的范围
std::variant<int, float> w{};
w = get<float>(v); // 按"类型"访问v当前的值,再赋给w
w = get<1>(v); // 按"索引"访问也是可以的:0对应int,1对应float
w = v; // 甚至可以直接把整个variant赋值过去
try {
get<int>(w); // w当前实际存放的是float,尝试按int读取会抛异常
} catch (std::bad_variant_access&) {
std::cout << "捕获到bad_variant_access异常\n";
}
}
代码逐句讲解:
std::variant<int, float> v{};声明了一个variant,它只能在int和float这两种类型之间"二选一"存放数据,不能存放其他任何类型。v = 12;之后v内部的状态变成"当前持有一个int"。get<int>(v)就是按照"我认为里面存的是int"这个假设去取值,因为这个假设跟v的实际状态一致,所以取值成功。v = 3.456f;之后,v的状态又切换成了"当前持有一个float"。这时候如果还想用get<int>(v)去取值,就会失败,因为状态已经变了。get<double>和get<3>这两行被注释掉了,因为它们根本无法通过编译:variant<int,float>在类型层面上就只认识int和float两种类型,模板参数里写的类型或者索引一旦超出这个范围,编译器直接就会报错,这跟运行时的"类型不匹配"是两码事——一个是编译期就能拦下来的错误,一个是运行期才会暴露的问题。get<1>(v)除了按类型访问,variant还支持按"索引"访问:声明variant<int, float>时,int排在第 0 位,float排在第 1 位,所以get<1>(v)效果跟get<float>(v)完全一样。w = v;说明variant之间也可以直接整体赋值,赋值完之后w会拥有跟v一样的类型状态和值。try { get<int>(w); } catch (std::bad_variant_access&) { ... }——这段展示的正是"运行时类型不匹配"会发生什么:w此时实际上存放的是一个float,你却尝试用get<int>去读取,这在语法上完全合法(int确实是variant<int,float>认识的类型之一),但运行时会因为"当前实际状态跟你请求的类型对不上"而抛出std::bad_variant_access异常,需要用try/catch来捕获处理。
实际运行结果(已验证):
12
3.456
(异常被正确捕获,程序未崩溃,正常执行完毕。)
2.3 用 visit 优雅地处理不同类型的情况
除了用 get 手动去尝试某个特定类型之外,variant 还提供了一种更优雅的处理方式:std::visit。你给它传一个"访问者"(可以是一个泛型 lambda,也可以是一个带多个重载的函数对象),visit 会自动根据 variant 当前实际持有的类型,调用相应的处理逻辑。
// https://godbolt.org/z/MGdec8eK9
// 完整可运行代码
#include <variant>
#include <iostream>
using std::cout;
// 定义一个带多个operator()重载的函数对象,
// 分别针对int和float写不同的处理逻辑
struct TypeGreeting {
void operator()(int) const { cout << "Hello int"; }
void operator()(float) const { cout << "Hello float"; }
};
int main() {
std::variant<int, float> var{};
var = 12; // 当前状态: int
std::visit([](auto a) { cout << a; }, var); // 用泛型lambda处理,不管实际是什么类型都能打印
cout << std::endl; // 输出: 12
var = 3.456f; // 当前状态: float
std::visit(TypeGreeting{}, var); // 用带重载的函数对象处理
cout << std::endl; // 输出: Hello float
}
代码讲解:
- 第一次调用
visit:传入的是[](auto a) { cout << a; }——这是一个"泛型 lambda"(参数类型写成auto),意味着不管variant当前实际存放的是int还是float,这个 lambda 都能接受并处理,因为它对参数类型没有要求,只要求这个类型能被输出到流里就行。 - 第二次调用
visit:传入的是TypeGreeting{}这个函数对象,它内部针对int和float分别写了不同的operator()重载。visit会根据var当前实际持有的类型(这里是float),自动挑选并调用对应的那个重载版本——所以打印出来的是"Hello float",而不是"Hello int"。这种写法的好处在于,你可以针对不同类型编写完全不同的处理逻辑,而不用像用get那样写一堆if/else去手动判断当前到底是哪种类型。
实际运行结果(已验证):
12
Hello float
3. any:能装下"任意类型"的容器
如果你觉得 variant 还是不够灵活——毕竟声明 variant 的时候必须提前把所有候选类型都列出来——那么 any 可能更适合你。any 可以存放任意类型的一个值,取值的时候必须按照"存进去时的那个真实类型"去取,取错类型会抛出异常。不过 any 依然支持随时重新赋值,并且新赋的值可以是完全不同的类型。
// https://godbolt.org/z/TenW8nP98
// 完整可运行代码
#include <any>
#include <iostream>
#include <vector>
#include <string>
int main() {
std::any a = 5; // 先存一个int
std::cout << std::any_cast<int>(a) << '\n'; // 输出: 5
a = 3.456; // 重新赋值,变成一个double
std::cout << std::any_cast<double>(a) << '\n'; // 输出: 3.456
using namespace std::literals;
// 一个存放any的vector,三个元素各自是完全不同的类型:int、double、string
std::vector<std::any> data { 4, 8.976, "Geronimo"s };
std::cout << std::any_cast<double>( data[1] ) << '\n'; // 按类型取出第二个元素,输出: 8.976
std::cout << data[1].type().name() << '\n'; // 打印当前存放的类型信息(不保证跨平台一致)
}
代码讲解:
std::any a = 5;把一个int存进any变量a里,std::any_cast<int>(a)用来按照"我认为里面存的是int"这个假设取值。这里跟variant的get<T>有点像,但机制略有不同——any_cast是专门为any设计的取值方式。a = 3.456;之后,a里存放的类型已经从int变成了double,这是完全合法的:any不像variant,它没有"事先约定好哪几种类型"这个限制,可以随时切换成任意新的类型。std::vector<std::any> data { 4, 8.976, "Geronimo"s };展示了any可以放进容器里使用——data这个vector的三个元素,初始化完成后分别是int、double、std::string三种完全不同的类型,但它们都能统一存放在vector<any>里,这是variant做不到的(variant必须提前声明好候选类型集合)。data[1].type().name()——type()方法能告诉你当前any里实际存放的是什么类型,但要注意,跟前面讲过的type_info::name()一样,这个信息只适合用来辅助调试,不保证在不同平台或编译器上格式一致,不能作为可靠的判断依据。
程序实际运行结果(已验证):
5
3.456
8.976
d
(最后一行 d 是当前测试环境下 double 类型对应的编译器内部名字,不同机器上很可能显示为别的字符串。)
使用 any 时需要记住的几个要点:
any不是模板类——它本身是一个具体的类,之所以能存放任意类型,靠的是内部的类型擦除(type erasure)机制,而不是像vector<T>那样通过模板参数固定类型;- 创建
any可以用构造函数(可以带初始值,也可以不带),或者用make_any; - 判断
any当前是否为空,用has_value(); - 改变
any里存放的值,可以用赋值、emplace、reset,或者swap; - 想要类型安全地取出值,用
any_cast<T>; any没有提供一种"不抛异常就能检查当前是否是某个特定类型"的方式——如果你需要这种能力,那说明你的场景可能更适合用variant(variant有holds_alternative<T>这种不抛异常的检查方式)。
4. variant、any、optional 三者该怎么选
这三个工具乍看有点像,容易搞混,用一张对比表梳理一下各自的定位:
| 工具 | 能装多少种类型 | 典型使用场景 |
|---|---|---|
optional<T> | 只有一种类型 T,外加"空"这个状态 | 表达"这个值可能不存在",比如查找可能失败的函数返回值 |
variant<T1,T2,...> | 若干种预先声明好的候选类型之一 | 需要在几种已知类型之间安全切换,替代不安全的 union |
any | 理论上任意类型(无需提前声明) | 类型完全不确定、或者需要兼容各种未知类型的通用容器场景 |
用一张流程图来帮助梳理选择思路:
5. 特殊数学函数:不用第三方库也能算的"硬核数学"
对于经常跟数学、物理打交道的开发者来说,有个好消息:标准库里已经内置了不少相当"硬核"的特殊数学函数,不需要再额外引入第三方数学库。这些函数大多支持 float、double、long double 三种精度,全部放在 <cmath> 头文件里。有些函数还有多个命名变体,这里只列出常用的那一个代表。
| 函数名 | 作用 |
|---|---|
laguerre、assoc_laguerre | 计算(关联)拉盖尔多项式 |
legendre、assoc_legendre | 计算(关联)勒让德多项式(球函数) |
hermite | 计算埃尔米特多项式 |
beta、expint | 计算欧拉贝塔函数、指数积分函数 |
comp_ellint_1/2/3、ellint_1/2/3 | 计算完全或不完全的第一、二、三类椭圆积分 |
cyl_bessel_i/j/k | 计算正则、非正则、变形的圆柱贝塞尔函数 |
sph_bessel、sph_legendre、sph_neumann | 计算球贝塞尔函数、球勒让德函数、球诺伊曼函数 |
cyl_neumann | 计算圆柱诺伊曼函数(也叫第二类贝塞尔函数) |
riemann_zeta | 计算黎曼ζ函数,常用在与素数相关的研究中 |
拿黎曼ζ函数(Riemann zeta function)举例,它的数学定义是:
ζ(s)=∑n=1∞1ns
\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s}
ζ(s)=n=1∑∞ns1
比如当 s=2s=2s=2 时,这个级数有一个非常经典的封闭形式解(欧拉在很早以前就证明了这一点):
ζ(2)=π26≈1.644934
\zeta(2) = \frac{\pi^2}{6} \approx 1.644934
ζ(2)=6π2≈1.644934
用代码实际调用一下 riemann_zeta 和贝塞尔函数,验证结果是否符合数学理论:
// https://godbolt.org/z/o9Tx1GhKa
// 完整可运行代码
#include <cmath>
#include <numbers>
#include <iostream>
int main() {
// 黎曼zeta函数:理论上 zeta(2) = pi^2/6 ≈ 1.644934
std::cout << "riemann_zeta(2) = " << std::riemann_zeta(2.0) << "\n";
// 圆柱贝塞尔函数:第一类,阶数为0,自变量为1.0
std::cout << "cyl_bessel_j(0, 1.0) = " << std::cyl_bessel_j(0.0, 1.0) << "\n";
}
实际运行结果(已验证):
riemann_zeta(2) = 1.64493
cyl_bessel_j(0, 1.0) = 0.765198
对照一下:π2/6≈1.644934\pi^2/6 \approx 1.644934π2/6≈1.644934,跟程序算出来的 1.64493 完全吻合(受限于输出精度显示了 6 位有效数字),说明标准库的实现是正确的。
6. C++20 新增:内置的数学常量
从 C++20 开始,标准库在 <numbers> 头文件、std::numbers 命名空间下,直接内置了一批常用的数学常量,都是 inline constexpr double 类型,包括:e(自然对数的底)、log2e、log10e、pi(圆周率 π\piπ)、inv_pi(1/π1/\pi1/π)、inv_sqrtpi、ln2、ln10、sqrt2(2\sqrt{2}2)、sqrt3、inv_sqrt3、egamma(欧拉-马歇罗尼常数)、以及 phi(黄金分割比)。
如果你想要 float 或者 long double 精度版本的常量,只需要在名字后面加上 _v 后缀,把它当成一个模板来用,比如:
pi_v<long double>
\texttt{pi\_v<long double>}
pi_v<long double>
来实际验证一下:
// https://godbolt.org/z/9hvzMo8EG
// 完整可运行代码
#include <numbers>
#include <iostream>
int main() {
std::cout << "pi = " << std::numbers::pi << "\n"; // double精度
std::cout << "e = " << std::numbers::e << "\n"; // double精度
std::cout << "pi_v<float> = " << std::numbers::pi_v<float> << "\n"; // float精度
std::cout << "pi_v<long double> = " << std::numbers::pi_v<long double> << "\n"; // long double精度
}
实际运行结果(已验证):
pi = 3.14159
e = 2.71828
pi_v<float> = 3.14159
pi_v<long double> = 3.14159
有了这些内置常量,就不用再自己手写 #define PI 3.14159265358979... 这种容易出错、精度也未必够用的写法了,直接引用标准库提供的、经过充分测试的常量即可。
7. 小结
| 主题 | 核心要点 |
|---|---|
optional<T> | 表达"值可能不存在",替代用 nullptr 表示缺失值的老套路 |
variant<Ts...> | 安全版的 union,只能在预先声明好的几种类型间切换 |
any | 能装任意类型,取值需要按类型any_cast,取错类型会抛异常 |
| 特殊数学函数 | <cmath> 内置了贝塞尔、勒让德、黎曼ζ等一批专业数学函数,免去引入第三方库的麻烦 |
| 数学常量 | C++20起 <numbers> 提供了 pi、e 等常用常量,支持不同精度的 _v 模板版本 |
总的来说,optional、variant、any 这三个工具,解决的都是"如何安全地表达不确定性"这个共同的问题,只是不确定性的种类不同:optional 面对的是"有没有值"的不确定,variant 面对的是"这几种类型里到底是哪一种"的不确定,而 any 面对的则是"类型完全没有边界"的不确定。搞清楚自己面对的到底是哪一种不确定性,就能很快选出最合适的工具。
C++ 多线程编程:核心概念入门
一、先建立整体印象:这一章要学哪些概念
在正式讲每个概念之前,先把这一章会反复出现的几个术语摆出来,建立一个整体框架,后面详细展开的时候就不会迷路。
| 术语 | 说明 |
|---|---|
| 线程(Thread) | 程序执行的一条"分支",可以和其他线程同时运行 |
| 信号量(Semaphore) | 用来限制"同时能有多少人访问某个共享资源"的机制,每个共享资源(或一组资源)配一个信号量 |
| 互斥锁(Mutex) | 一种特殊的信号量,同一时刻只允许恰好一个线程访问 |
| 锁(Lock) | 一组类,用来标记"这段代码需要用互斥锁保护起来"的范围,每个线程、每个资源都会有自己的锁 |
| 临界区(Critical section) | 被锁和互斥锁保护起来的、不允许多个线程同时执行的那一段代码 |
| 数据竞争(Data race) | 多个线程同时读写同一份数据,导致结果变得不可预测的情况 |
| 死锁(Deadlock) | 两个线程各自持有一把锁,同时又在等待对方手里的锁,谁都动不了的僵局 |
| 承诺与期约(Promise and future) | 线程之间"传递结果"的通信模型:promise是"我保证会给你一个结果"的承诺,future是"到时候拿这个结果"的凭证 |
| 内存序(Memory order) | 规定不同线程对同一个对象做读写操作时,这些操作在时间上必须遵循什么样的先后关系 |
这一章的目标,就是把标准库里跟多线程相关的部分讲清楚,同时也会顺带讲一些并发编程本身的原理,这样才能真正明白"为什么要这样用",而不只是死记硬背 API。
二、从"顺序执行"到"并行执行":思维方式的转变
2.1 我们以前的程序是怎么跑的
到目前为止,可以把一个程序理解成一串按顺序排好的指令:计算机从头开始,一条一条地执行。虽然有分支(if)和函数调用,计算机会在代码里"跳来跳去",但骨子里依然是一条指令执行完了,才轮到下一条——自始至终都只有"一条时间线"在推进。
2.2 现在的计算机可以"同时做好几件事"
现代计算机通常配备了多个处理单元(也就是常说的"多核"),这意味着一个程序完全可以同时处理好几个部分的工作,而不是死板地排队等待。这种"程序内部可以并行工作"的能力,靠的就是**线程(thread)**这个机制。
一个程序可以创建多个线程,每个线程各自独立地往前推进,多个线程可以在同一时刻真正地同时运行(如果 CPU 核心数够多的话)。
2.3 关键点:所有线程共享同一块内存
这里有一个非常重要、也是后面所有麻烦的根源所在的特性:同一个程序里的所有线程,共享的是同一份内存。
也就是说:
- 每个线程都能看到其他所有线程能看到的所有变量和数据;
- 更麻烦的是,每个线程不仅能"看",还能随意修改这些共享的数据。
这就好比几个人同时在用同一张办公桌上的同一份文件——如果没有约定好"谁在什么时候能动这份文件",很容易出现"你写到一半,我把内容改了""你以为你看到的是最新版本,其实已经被别人偷偷改过"这类混乱局面。多线程编程真正的难点,从来不是"怎么创建线程",而是"怎么有序地共享数据、避免数据变得一团糟"。
用一张图直观展示这种"共享内存"的结构:
图里的双向箭头表示:每个线程既能读取内存里的数据,也能修改它——所有线程"共用一间屋子",谁都能进去动东西。
三、并行的其他形式:这一章不涉及的内容
在正式深入之前,有必要说明一下这一章的"边界"——并行计算的方式其实有很多种,并不是只有"多线程"这一种,先简单区分一下,避免混淆。
3.1 SIMD:一条指令同时处理多份数据
有一种并行方式是:用同一套指令,同时对多份数据做同样的操作,这种方式在专业上叫做 SIMD(Single Instruction, Multiple Data,单指令多数据)。
一个常见的例子是显卡(GPU)——它天生就是为了"用同一套操作,批量处理大量像素/数据"而设计的。
CPU 层面也有类似的能力:有些特殊的 CPU 指令(比如 MMX、SSE、AVX 这几种指令集),可以让一条指令同时对好几对数字做乘法运算,而不是一次只处理一对数字。这也是一种并行,只不过它不是靠"开多个线程"实现的,而是靠 CPU 硬件本身的特殊指令能力。
3.2 分布式并行:把任务拆到多台电脑上
还有一种并行方式是:把一个程序拆分成好几份,分别放到一台或多台不同的电脑上运行,让它们之间互相通信、协作完成任务。实现这种模式常见的技术手段包括进程(processes)、网络套接字(sockets)、MPI(一种专门用于高性能计算集群通信的标准)等等。
3.3 这一章只讲"单机、共享内存、多线程"这一种模式
上面提到的这些并行方式(SIMD、跨机器的分布式并行),目前都还没有被纳入 C++ 标准的范畴,所以这本书(以及这一章)也不会涉及它们。
这一章明确聚焦的场景是:在同一台机器上,只有一块共享内存,用多个线程去并行处理任务。这是最基础、也是标准库直接提供支持的并行模型。
不过有一点例外值得一提:从 C++17 开始,很多算法已经支持某种程度的 SIMD 加速,这是通过一个叫 parallel_unsequenced_policy 的执行策略实现的,使用时需要额外指定 par_unseq 这个参数。这部分内容属于"算法的并行执行"这个话题(在讲算法的章节里已经详细讨论过),如果感兴趣,可以自己去查一查目前所用的编译器具体支持到什么程度、能带来多大的实际效果——不同编译器、不同版本的支持情况差异是比较大的。
四、几个核心概念逐一拆解
前面表格里列出的那几个术语,看起来概念不少,但如果按照"问题出现的顺序"来理解,其实是一条很自然的逻辑链条。下面按照这条链条逐一展开。
4.1 线程(Thread):并行的基本单位
线程就是"程序执行的一条分支",可以和其他分支同时运行。可以把它想象成:原本一条笔直的时间线,现在分叉成了好几条,每条分支都在独立地往前走,谁也不用等谁。
4.2 数据竞争(Data race):多线程共享内存带来的核心风险
前面已经提到,所有线程共享同一份内存,这意味着多个线程完全有可能在同一时刻,同时去读写同一个变量。
如果没有任何保护措施,这种"同时读写"会导致结果变得不可预测——这就是"数据竞争"。之所以说"不可预测",是因为最终的结果会依赖于一些细节,比如"哪个线程的哪条指令具体在哪个时刻真正执行",而这些细节在不同次运行之间可能完全不一样,甚至同一份代码,这次跑出来是对的,下次跑就出错了,调试起来会非常痛苦。
4.3 信号量(Semaphore):给共享资源"设置访问名额"
为了避免数据竞争,需要有一种机制来"管控"到底能有多少个线程同时访问某个共享资源。信号量就是干这个的:每一个共享资源(或者一组资源)会配一个信号量,这个信号量本质上像一个"名额计数器"——线程想访问资源之前,得先跟信号量"申请一个名额",用完了再"归还名额"。如果名额已经用光,后来的线程就得排队等着。
4.4 互斥锁(Mutex):名额只有一个的特殊信号量
互斥锁(Mutex,全称 mutual exclusion,"互相排斥"的意思)是信号量的一个特殊情况:它的"名额"永远只有一个——也就是说,同一时刻,只允许恰好一个线程能够访问被它保护的资源。
这是多线程编程里最常用的保护机制:如果一块数据同时只能被一个线程读写,用互斥锁把这块数据"锁住",其他线程想访问就必须先排队等待,等当前持有锁的线程用完释放了,下一个线程才能拿到锁继续。
4.5 锁(Lock):管理互斥锁使用范围的辅助工具
单纯有一个互斥锁对象还不够方便——你需要一种简洁、不容易出错的方式,去标记"从这里到这里,这段代码需要被这把互斥锁保护起来"。**锁(Lock)**指的就是标准库里的一组类,专门负责干这件事:定义"访问应该被互斥锁保护起来"的具体范围。每个线程、针对每个具体资源,通常都会有一个属于自己的锁对象。
(后面在具体讲解标准库 API 的章节里,会看到诸如 lock_guard、unique_lock 这类具体的类,它们就是"锁"这个概念在标准库中的实际体现,这里先建立一个整体印象即可。)
4.6 临界区(Critical section):被保护起来、不能被多线程同时闯入的代码段
临界区指的是那段"被锁和互斥锁保护起来、不允许被多个线程同时执行"的程序代码。可以理解成:互斥锁负责"发放通行证",锁负责"标记出通行证生效的范围",而这段被标记出来的范围,就是临界区——任何时刻最多只能有一个线程身处其中。
用一张图梳理一下互斥锁、锁、临界区三者的关系:
4.7 死锁(Deadlock):两个线程互相卡住,谁都动不了
用锁和互斥锁虽然能避免数据竞争,但如果用得不小心,会引出一个新的麻烦——死锁。
死锁指的是这样一种情形:两个线程各自持有一把(或几把)互斥锁,同时又在等待对方手里持有的另一把锁,结果谁都没办法继续往下执行,整个程序就这样"卡死"在那里,永远动不了。
举一个经典的场景帮助理解:假设线程A先拿到了锁1,正打算去拿锁2;而线程B恰好先拿到了锁2,正打算去拿锁1。这时候:
- 线程A:手里有锁1,等着拿锁2——但锁2被线程B拿着,不会自己松手;
- 线程B:手里有锁2,等着拿锁1——但锁1被线程A拿着,同样不会自己松手。
两边都在"傻等对方先放手",但谁都不会主动放手,于是就永远僵持在那里了。用一个 ASCII 图直观展示一下这个互相卡住的循环等待关系:
线程A 持有 --> 锁1 线程B 持有 --> 锁2
线程A 等待 --> 锁2 (被B占用)
线程B 等待 --> 锁1 (被A占用)
锁1 ----被A持有---> 线程A ----等待---> 锁2
^ |
| v
线程B <----等待----- 锁2 <----被B持有--- (循环等待, 无法推进)
这种"循环等待"正是死锁的本质特征——沿着"谁持有什么、谁在等什么"这条链条画一圈,如果能画出一个闭环,那就说明存在死锁的风险。
4.8 承诺与期约(Promise and future):线程之间传递结果的"快递单"
前面讲的信号量、互斥锁、锁、死锁,处理的都是"多个线程怎么安全地共享同一份数据"这个问题。但线程之间还有另一种常见的需求:A线程把一个任务交给B线程去算,A线程希望将来能拿到B线程算出来的结果。
promise(承诺)和 future(期约)就是为了描述这种"线程间传递结果"的场景而设计的一套模型,可以理解成一种通信管道:
promise(承诺):代表的是"我答应你,将来会通过某个future把结果交给你"这样一个承诺。持有promise的那一方(通常是负责计算的线程),最终会把计算出的结果"塞进"这个承诺里。future(期约):代表的是"将来可以凭这个东西去取结果"的一张凭证。持有future的那一方(通常是发起请求、等待结果的线程),可以随时去问"结果好了吗",如果对方还没算完,它就等着,一旦对方(promise那边)真的把结果准备好了,这里就能立刻拿到。
可以把这个关系想象成寄快递:promise就是快递员承诺"这个包裹我一定会送到",而future就是你手里那张快递单——你可以随时凭这张单子去查快递到了没有,等快递真的送到了,你就能收货。
用一张图示意这个"通信管道"的概念:
4.9 内存序(Memory order):多线程读写同一个对象时,"先后顺序"该怎么保证
这是这一章里相对最抽象、也最容易被忽视的一个概念。
内存序描述的问题是:当不同的线程,对同一个对象(比如同一个变量)分别做读操作和写操作时,这些读写操作之间,在"时间先后"这个维度上,必须遵循什么样的规则?
这个问题之所以存在,是因为现代计算机的硬件和编译器,为了追求性能,经常会对指令的执行顺序做各种"优化重排"——只要这种重排不影响单个线程"自己看自己"时的逻辑结果,编译器和 CPU 就有权利打乱指令原本在代码里的先后顺序。但这种"单线程内部看起来没问题"的重排,一旦放到多线程场景下,别的线程可能会观察到一种"违反直觉"的顺序,从而引发难以察觉的 bug。
内存序就是用来明确规定:“在不同线程之间,某个变量的读写操作,到底允许以什么样的顺序被观察到”,从而在"允许编译器和硬件做性能优化"与"保证多线程程序结果依然正确"之间,划出一条明确的界线。
这是一个相当深入的话题,具体的规则和用法会在后续详细讲解标准库原子操作相关内容的部分里展开,这里先知道"存在这样一个问题、以及为什么会有这个问题"就足够了。
五、用一个最小可运行的例子,直观感受"数据竞争"
在深入学习具体的标准库 API 之前,先用一段简单到可以直接编译运行的代码,直观地感受一下"多线程共享数据、但没有做任何保护"到底会出什么问题。这有助于理解为什么后面要学互斥锁这些东西。
// https://godbolt.org/z/Yns8EcnsE
#include <iostream>
#include <thread>
#include <vector>
// 一个全局的共享计数器,所有线程都能看到、都能修改它
// 这里故意不加任何保护措施,用来演示数据竞争会带来什么后果
long counter = 0;
// 每个线程要执行的任务:把counter加1,重复执行很多次
void increment_many_times() {
for (int i = 0; i < 100000; ++i) {
// 这一行看起来是"一步",但实际上底层至少要经过三步:
// 1. 读取counter当前的值
// 2. 把读到的值加1
// 3. 把加1后的结果写回counter
// 如果两个线程"读取"这一步刚好在几乎同一时刻发生,
// 它们可能会读到同一个旧值,各自加1后再写回,
// 结果就相当于只加了一次,而不是两次——这就是数据竞争的典型后果
counter = counter + 1;
}
}
int main() {
// 创建4个线程,每个线程都会把counter加10万次
// 如果没有任何数据竞争问题,理论上最终counter应该等于 4 * 100000 = 400000
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
// emplace_back直接在vector内部构造一个thread对象
// 构造thread时传入要执行的函数,线程会立刻开始运行
threads.emplace_back(increment_many_times);
}
// 必须等所有线程都执行完毕,才能安全地读取counter的最终值
// join()会阻塞当前线程,直到目标线程执行完毕
for (auto& t : threads) {
t.join();
}
// 理论上应该是400000,但由于数据竞争,实际运行结果往往会小于这个数
// 而且很可能每次运行的结果都不一样
std::cout << "counter最终的值: " << counter << '\n';
std::cout << "理论上应该是: " << 4 * 100000 << '\n';
return 0;
}
代码关键点说明:
long counter = 0;:这是一个全局变量,所有线程都能直接访问、修改它,这正是前面讲的"所有线程共享同一份内存"的具体体现。counter = counter + 1;看起来简单,实际上不是"原子操作":所谓"原子操作",是指一个操作要么完整地发生,要么完全不发生,中间不会被别的线程"插队"打断。但counter = counter + 1这行代码,在底层至少拆解成"读取旧值→计算新值→写回新值"这三个步骤,这三步之间是有间隙的。如果两个线程的这三步操作恰好交错在一起(比如线程A读了旧值还没来得及写回,线程B也读了同一个旧值),最终就会导致"本应该加两次,实际只生效了一次"这样的结果丢失。std::thread的构造:threads.emplace_back(increment_many_times)会创建一个新的线程对象,并且立刻开始执行increment_many_times这个函数,不需要额外调用什么"启动"方法。t.join()的作用:join()会让当前线程(这里是main函数所在的主线程)阻塞等待,直到t这个线程真正执行完毕为止。如果不调用join()就直接读取counter,很可能这时候其他线程还没跑完,读到的值会是一个"跑到一半"的中间状态,结果更加不可预测。必须等所有子线程都真正结束之后,主线程才能放心地去读取最终的counter值。
5.1 实际会发生什么
如果你实际编译运行这段代码(编译时记得加上链接线程库的选项,比如用 g++ 编译时加 -pthread),会发现:counter 最终打印出来的值,通常会小于理论上的 400000,而且很可能每次运行的结果都不完全一样。
这正是数据竞争造成的直接后果:由于多个线程在"读取-计算-写回"这个过程中互相交错、互相覆盖,一部分"加1"的操作实际上丢失了,没有真正反映到最终结果里。而且因为这种交错的具体情况取决于操作系统怎么调度线程、CPU 具体的执行时机这些"不确定因素",所以这个 bug 表现得时好时坏,可能大多数时候看起来"差不多是对的",但绝不能保证每次都精确等于 400000——这也是数据竞争类 bug 最难缠的地方:它不是"必现"的错误,而是一种"看运气"的错误,很难通过简单的测试稳定复现,排查起来格外费劲。
如果要修复这个问题,就需要用到前面提到的互斥锁(或者更轻量的原子操作),把 counter = counter + 1; 这段代码变成一个真正不会被打断的"临界区"。具体怎么用互斥锁和锁来解决这个问题,会在后续详细讲解标准库线程相关 API 的部分具体展开,这里的重点是先直观感受"问题长什么样"。
六、整体知识框架回顾
用一张图把这一节讲到的所有核心概念串联起来,方便建立整体印象:
七、小结
| 概念 | 一句话理解 |
|---|---|
| 线程 | 程序里能和别的部分同时跑的一条执行分支 |
| 数据竞争 | 多线程没保护地同时读写同一份数据,结果变得不可预测 |
| 信号量 | 给共享资源设置"同时能进几个人"的名额限制 |
| 互斥锁 | 名额永远是1的特殊信号量,同一时刻只准一个线程进入 |
| 锁 | 标记出"这段代码需要用互斥锁保护"的范围 |
| 临界区 | 被锁保护起来、任意时刻最多一个线程能执行的代码段 |
| 死锁 | 多个线程互相攥着对方需要的锁,谁都动不了 |
| 承诺与期约 | 线程之间"我保证给你结果/我随时能来取结果"的通信约定 |
| 内存序 | 规定不同线程之间,读写同一个对象时必须遵守的先后顺序规则 |
这一节主要是打基础、建立整体框架,还没有具体讲解 std::thread、std::mutex、std::promise 这些标准库类的具体用法——那些内容会在后续小节里结合完整代码逐一展开。这里最重要的是先在脑子里建立起一条清晰的逻辑链条:因为线程共享内存,所以会有数据竞争的风险;为了避免数据竞争,需要用信号量/互斥锁配合锁来划出临界区;但使用锁又可能带来死锁这个新问题;除了保护共享数据,线程之间还经常需要传递结果,这就是promise和future要解决的问题;而所有这些机制底层,都建立在"内存序"这套规则之上。 把这条逻辑链理清楚了,后面学习具体的 API 就会容易理解得多,不至于"只记得住怎么写代码,却不知道为什么要这样写"。
C++ 多线程基础:thread 与 jthread 入门
1. 你其实早就"用过"线程了
这句话听起来可能有点意外,但确实是事实:你写的每一个 C++ 程序,从一开始就至少存在一个线程——那就是主线程(main thread)。当程序执行流进入 main 函数的那一刻,你其实就已经身处"第一个线程"之中,这个线程是操作系统在程序启动时自动帮你创建好的。等到执行流离开 main 函数,主线程会先完成一些必要的清理工作,然后才真正终止。
所谓"多线程编程",指的就是在这个主线程之外,再额外启动更多的线程,让它们并发地执行任务。每个新线程都跟 main 函数类似,需要有一个明确的"入口点"和"出口点"。能够充当这个入口的东西非常灵活,包括:普通函数、函数对象(functor)、lambda 表达式、绑定过参数的成员方法、std::function<> 的实例——总之,任何能够隐式转换成函数指针的东西,理论上都可以作为线程的入口。
2. thread 类型:C++ 里线程的具体载体
在 C++ 里,一个线程对应的是 <thread> 头文件里定义的 std::thread 类型的一个实例。拿到这个实例之后,你能对它做一些操作,比如:
- 等待它执行完毕,这个操作专门有个名字,叫作"合入"(join);
- 如果你既没有等它结束(join),线程却还在运行,那么等到这个
thread对象被销毁时,程序会调用terminate(),也就是直接粗暴地终止整个程序; - 另一种选择是调用
detach(),让这个线程"脱离"(detach)出去,独立继续运行,不再受这个thread对象的管辖——但这时候整个程序本身依然会照常结束。
这里要特别提醒一句:"脱离之后线程继续运行、但主程序已经结束"这种状态,通常并不是你真正想要的效果。所以,在结束程序之前,一般都应该主动等待线程执行完毕,而不是任由它被强制终止或者"孤零零地"脱离出去。
3. jthread:C++20 引入的"会自动等待"的线程
正是因为"忘记 join 就会导致程序崩溃"这个问题实在太容易踩坑了,C++20 引入了一个新的类型:jthread(“joining thread”,即"会自动合入的线程")。
jthread 有一个很贴心的特性:它的析构函数会自动尝试请求线程终止,并且等待它结束,也就是说,你不再需要手动记得调用 join()——这一步已经被封装进了它的生命周期管理里。
从现在开始,本章内容原则上对 thread 和 jthread 都是通用的,绝大多数场景下,你应该优先使用 jthread。只有在某些特殊情况下(比如你的项目还没能升级到 C++20,标准库里也还没有 jthread),才需要退回去用 thread,并且这种情况下务必要记得手动调用 join(),绝对不能遗漏。如果实在很想用 jthread 但环境暂时用不了 C++20,也可以考虑使用 jthread 原作者 Nico Josuttis 维护的一个兼容 C++17 的开源实现库。
不过在正式开始用线程之前,有个前提是显而易见的:你必须先启动一个新线程或者新的 jthread,之后才谈得上等它结束。
4. 启动一个最基础的线程
启动一个线程,你需要准备两样东西:一个"函数对象"(这里泛指任何能被线程执行的可调用对象),以及一个"已经存在的、能用来启动新线程的线程"——而这第二样东西,你天然就有,那就是 main 函数所在的主线程。
// https://godbolt.org/z/4bd44Pje7
// 完整可运行代码
#include <iostream>
#include <thread>
using std::cout; using std::endl;
// 计算斐波那契数列第n项(递归实现,故意写得比较低效,用来模拟耗时任务)
long fib(long n) { return n<=1 ? n : fib(n-1)+fib(n-2); }
void task1() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
void task2() { auto r = fib(31); cout << "fib(31)=" << r << endl; }
void task3() { auto r = fib(32); cout << "fib(32)=" << r << endl; }
// 定义一个函数对象,重载了operator(),依次执行三个耗时任务
struct BackgroundTask {
void operator()() const {
task1();
task2();
task3();
}
};
int main() {
BackgroundTask backgroundTask{}; // 初始化,此时还没有开始任何计算
std::jthread myThread{ backgroundTask }; // 传入构造函数,计算才真正开始
}
代码逐句讲解:
long fib(long n) { return n<=1 ? n : fib(n-1)+fib(n-2); }——这是一个非常经典但效率很低的递归斐波那契计算,特意选它是因为计算量足够大,能明显感受到"这确实是在另一个线程里认真跑了一段时间"。struct BackgroundTask里重载了operator(),这样这个结构体的实例就变成了一个"可调用对象"——你可以像调用函数一样直接用括号去"调用"这个实例(比如backgroundTask()),它内部会依次执行task1()、task2()、task3()。BackgroundTask backgroundTask{};——这一步只是创建了这个函数对象的实例,这一步本身并不会触发任何计算,fib函数完全没有被调用。std::jthread myThread{ backgroundTask };——这一步才是关键:把backgroundTask作为构造函数参数传给jthread,这一刻,一个新的线程才真正被启动,operator()里的三个任务开始在新线程中并发执行(跟主线程"并发",也就是同时进行)。
程序实际运行结果(这里为了能在合理时间内跑完测试,把原本较大的fib(40)/(41)/(42)换成了更小的fib(30)/(31)/(32),逻辑完全一样):
fib(30)=832040
fib(31)=1346269
fib(32)=2178309
需要特别注意的一点: 传给新线程的那个可调用对象,总是会被拷贝一份给新线程使用。这就要求这个可调用对象必须能被合理地拷贝。还有一个容易被忽略的细节:如果你用同一个 backgroundTask 变量去启动了好几个不同的 jthread,这几个线程各自持有的其实是各自独立的拷贝,彼此并不共享同一份实例(除非你专门做了额外处理,让它们共享某些数据)。
4.1 一种更常见的简化写法:直接传临时对象
实际编程中,更常见的写法是直接在 jthread 的构造函数调用里创建这个可调用对象,不用先声明一个变量:
std::jthread myThread{ BackgroundTask{} }; // 用临时对象作为构造函数的参数
这里有个容易踩的坑需要特别小心:千万不要写成 BackgroundTask()——因为这种写法在 C++ 里会被解析成"声明一个函数"(这是 C++ 里经典的"最令人苦恼的解析"问题,“most vexing parse”),而不是创建一个临时对象,这样一来代码根本不会按你预期的方式工作,甚至可能编译报错或者行为完全不对。用花括号 {} 而不是圆括号 () 就能完全避开这个陷阱。
4.2 更简单的写法:直接用 lambda
如果任务本身比较简单,不值得专门定义一个结构体,可以直接在初始化 jthread 的时候写一个 lambda 表达式:
// https://godbolt.org/z/jj7xE54bj
// 完整可运行代码
#include <iostream>
#include <thread>
using std::cout; using std::endl;
long fib(long n) { return n<=1 ? n : fib(n-1)+fib(n-2); }
void task1() { auto r = fib(28); cout << "fib(28)=" << r << endl; }
void task2() { auto r = fib(29); cout << "fib(29)=" << r << endl; }
void task3() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
int main() {
std::jthread myThread{ [] {
task1();
task2();
task3();
} };
}
代码讲解: 这里直接把一个不带任何参数、不捕获任何外部变量的 lambda 表达式 [] { task1(); task2(); task3(); } 传给 jthread 的构造函数。这个 lambda 会在新线程里被执行,效果跟前面用 BackgroundTask 结构体是完全一样的,只是写法更精简——不需要专门定义一个类型。myThread 启动的这个新线程会跟主线程并行运行,在这个新线程内部,task1、task2、task3 三个函数是按顺序依次执行的("并行"指的是这个新线程整体跟主线程同时进行,但线程内部的这三个任务本身仍然是顺序执行,不是并发执行)。
实际运行结果(已验证):
fib(28)=317811
fib(29)=514229
fib(30)=832040
5. 提前终止一个线程:stop_token 的用法
很多时候,你并不希望一个线程非要"自然运行到结束"不可。举个具体的例子:假设某个线程正在从磁盘读取一个巨大的文件,这时候用户点击了"取消"按钮——如果线程还傻乎乎地把整个文件读完,那明显是在浪费时间和资源,因为读出来的数据早就没用了。
解决这个问题的机制叫 stop_token(停止令牌),这个功能已经内置在 jthread 里了;而老式的 thread 类型并没有这个功能,如果你需要类似效果,得自己动手实现一套。
stop_token 本质上是一个对象,你可以从 jthread 那里拿到它,把它当作一条"沟通渠道",让外部世界能给线程内部"捎话"。在线程内部,你需要时不时主动检查一下:外面是不是已经请求这个线程提前结束了。
// 完整可运行代码
#include <iostream>
#include <thread>
#include <chrono>
using std::cout; using std::endl;
// 用比较耗时的fib计算来模拟真实的长时间任务,方便观察stop_token生效的效果
long fib(long n) { return n<=1 ? n : fib(n-1)+fib(n-2); }
void task1() { auto r = fib(35); cout << "fib(35)=" << r << endl; }
void task2() { auto r = fib(40); cout << "fib(40)=" << r << endl; }
void task3() { auto r = fib(42); cout << "fib(42)=" << r << endl; }
struct BackgroundTask {
// 注意这里operator()多了一个std::stop_token参数,用于跟外部通信
void operator()(std::stop_token st) const {
task1();
if(st.stop_requested()) return; // 检查外部是否已经请求停止
task2();
if(st.stop_requested()) return; // 再次检查
task3();
}
};
int main() {
BackgroundTask backgroundTask{};
std::jthread myThread{ backgroundTask };
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 主线程等待100毫秒
myThread.request_stop(); // 请求这个线程尽快停止
}
代码逐句讲解:
void operator()(std::stop_token st) const——注意这次的operator()多了一个std::stop_token类型的参数st。这个参数就是外部跟这个线程沟通的"渠道"。if(st.stop_requested()) return;——这一行会检查外部是否已经通过某种方式请求线程停止。如果返回true,说明外部希望线程尽快结束,这时候就应该主动return,放弃执行后续的任务,而不是硬着头皮把task2、task3也跑完。myThread.request_stop();——主线程这边,在启动新线程之后先睡眠 100 毫秒(模拟"过了一小段时间"),然后调用request_stop(),通过这个调用,把"请求停止"这个消息传递给新线程内部的stop_token。
关于这个例子的实际运行结果需要说明一下: 因为线程调度和计算耗时都跟具体运行环境有关,实际输出会因为机器性能不同而有所差异。在测试环境里实际运行的结果是:
fib(35)=9227465
fib(40)=102334155
也就是说,task1(计算 fib(35))很快就跑完了,跑完之后检查 stop_requested(),此时主线程的 100 毫秒还没睡够,所以还没收到停止请求,于是继续执行 task2(计算 fib(40));而 fib(40) 计算耗时明显更长,等它算完再去检查 stop_requested() 时,主线程那边的 request_stop() 已经生效了,于是线程主动放弃执行 task3(原本要计算耗时最久的 fib(42)),直接返回。这正好演示了 stop_token 发挥作用的过程:任务并没有被"暴力打断",而是在两个任务的间隙,主动、体面地检查并响应了停止请求。
用一张流程图梳理一下这个"检查-响应"的过程:
这套机制底层是怎么运作的: 每次创建一个 jthread 时,这个类内部总会同时创建一个 stop_source(“停止源”)。如果你传给 jthread 的这个可调用对象(比如上面的 operator())恰好带有一个 std::stop_token 类型的参数,那么系统就会自动把对应的 stop_token 作为参数传进去;如果你的函数根本没有这个参数,那这一步就直接跳过,不会强行传参数进去导致编译错误。所以可以把它理解成线程函数的一个"可选参数"——你想要,它就在;你不想要,也完全没问题。
从外部想要发起停止请求,标准的做法是先用 get_stop_source() 拿到对应的 stop_source 对象,再调用它的 request_stop();不过更方便的写法,是像上面例子那样,直接在 jthread 对象本身上调用 request_stop(),这是一个提供出来的快捷方式。
jthread 有一个很人性化的设计:它能同时兼容"带 stop_token 参数"和"不带 stop_token 参数"这两种线程函数,不会因为参数不匹配而报错。但要提醒一句:如果你的线程函数压根没有接收 stop_token 参数,那你就没有办法在函数内部检查"是否该提前终止"了——毕竟连"沟通渠道"都没接进来,线程内部自然无从得知外面发生了什么。一般来说,只要你写的线程要运行的时间超过几毫秒,就应该认真考虑加上 stop_token 这个参数,这样才能保证在需要时能够体面地提前结束任务。
6. 等待线程结束
正常情况下,你并不希望一个线程被"提前粗暴打断",而是希望耐心等它自然完成。一旦启动了一个线程,接下来你必须在下面两条路里选择一条:
| 处理方式 | 具体做法 | 效果 |
|---|---|---|
| 等待线程完成 | 调用 join() | 阻塞当前线程,直到目标线程执行完毕。jthread 会在析构函数里自动做这件事 |
| 让线程独立运行 | 调用 detach() | 目标线程脱离出去独立运行,不再受这个线程对象的管理 |
如果你用的是老式的 thread(不是 jthread),而且在这个线程结束之前,你既没有调用 join(),也没有调用 detach() 做出明确选择——那么当这个 thread 对象的生命周期结束(比如离开了它所在的作用域)、析构函数被调用时,如果线程仍然在运行,析构函数会直接调用 std::terminate(),粗暴地终止整个程序。
来看一个用 thread(而不是 jthread)配合显式 join() 的例子:
// 完整可运行代码
#include <iostream>
#include <thread>
using std::cout; using std::endl;
long fib(long n) { return n<=1 ? n : fib(n-1)+fib(n-2); }
void task1() { auto r = fib(28); cout << "fib(28)=" << r << endl; }
void task2() { auto r = fib(29); cout << "fib(29)=" << r << endl; }
void task3() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
int main() {
std::thread myThread{ [] { // 这里用的是普通的thread,而不是jthread
task1();
task2();
task3();
} };
myThread.join(); // 显式等待这个线程执行完毕
cout << "主线程: 所有任务都已完成" << endl;
}
代码讲解:
std::thread myThread{...}创建了一个普通的thread(不是jthread),传入的同样是一个执行三个任务的 lambda。myThread.join();——因为用的是普通thread,没有jthread那种"析构自动等待"的便利,所以必须手动、显式地调用join(),主线程会在这里暂停下来,一直等到myThread对应的新线程执行完所有任务之后,才会继续往下走。
实际运行结果(已验证):
fib(28)=317811
fib(29)=514229
fib(30)=832040
主线程: 所有任务都已完成
可以看到,“主线程: 所有任务都已完成"这行输出,一定是排在三个 fib 计算结果之后打印出来的——这正是因为 join() 起了阻塞等待的作用,保证了新线程的任务确实全部完成之后,主线程才会继续往下执行。
再强调一遍这个非常关键的规则: 如果一个 thread 变量所在的作用域已经结束(比如 main 函数要返回了),但对应的线程还在运行,而你又既没有 join() 它,也没有 detach() 它——你的程序基本上就要崩溃了。join() 的作用正是"耐心等待,直到线程自然结束”。
在这个具体例子里,为什么必须用 join() 而不是 detach(): 因为如果这里改用 detach(),新线程会独立运行下去,但主程序(main 函数)几乎立刻就会执行完毕并退出——而一旦主程序退出,所有被 detach() 的线程也会跟着一起被强制终止,不管它们是不是已经算完了。这样一来,你根本看不到三个 fib 计算的输出结果,因为 main 函数早就在计算还没做完之前就已经结束了。
不过这不代表 detach() 就没有用武之地——如果是在一个更复杂的程序里,主程序后面还有大量其他工作要做、不会很快退出,那这种情况下用 detach() 让某个线程独立在后台运行,就是完全合理、甚至更合适的选择。
7. 整体流程总结
用一张图把这一节讲的"启动线程 - 等待或脱离 - 收尾"整个生命周期串起来:
8. 小结
| 概念 | 说明 |
|---|---|
| 主线程 | 程序进入 main 函数时就已经存在,是天然的第一个线程 |
std::thread | 传统线程类型,需要手动 join() 或 detach(),忘记处理会导致程序崩溃 |
std::jthread | C++20引入,析构函数自动请求停止并等待,通常应优先使用 |
stop_token | jthread 内置的通信机制,线程函数可选择性地接收该参数,用于检查是否该提前结束 |
request_stop() | 从外部调用,通知线程"希望它尽快结束" |
join() | 阻塞等待线程执行完毕 |
detach() | 让线程独立运行,脱离当前对象的管理,但主程序退出时它也会终止 |
总结一下这一节最核心的心法:启动一个线程很简单,难的是"负责任地收尾"。用 jthread 能帮你省去很多手动管理的心智负担(自动等待、可选的停止令牌),但无论用 thread 还是 jthread,都要清楚地想明白:"这个线程该怎么结束?是要等它跑完,还是允许它独立运行,又或者需要能随时喊停?"把这个问题想清楚了,多线程代码的可靠性就有了基本保障。
C++ 多线程基础:从零理解 std::thread 与 std::jthread
一、先搞清楚一个事实:你早就在用线程了
只要你写过 C++ 程序,你就已经用过线程了——只是你没意识到而已。每一个 C++ 程序,从进入 main 函数的那一刻起,系统就已经帮你启动了一个线程,这个线程叫"主线程"。当 main 函数结束(做完必要的清理工作后),主线程也就跟着结束了,整个程序也就退出了。
那什么叫"多线程"呢?很简单:在主线程之外,你自己再额外启动一个或多个线程,让它们和主线程同时运行,这就实现了多线程。
每个线程都有一个"入口点"和一个"出口点",就跟 main 函数一样。能拿来当作线程入口的东西非常宽泛,只要是"可调用的"(callable)都行,比如:
- 普通函数
- 函数对象(functor,也就是重载了
operator()的类/结构体实例) - lambda 表达式
- 绑定的成员方法
std::function<>的实例
一句话总结:任何能够隐式转换成函数指针(或者说能被调用)的东西,都可以作为线程的入口函数。
二、std::thread 和 std::jthread 是什么关系
在 C++ 里,一个线程被表示为 std::thread 类型的一个对象,定义在头文件 <thread> 中。有了这个对象,你就能对线程做一些操作,比如:
- 等待线程结束(这个动作叫"join",即"汇合、合并")
- 让线程脱离主线程独立运行(这个动作叫"detach",即"分离")
这里有个非常容易踩的坑:如果你既不 join,也不 detach,但线程对象的生命周期结束了(比如离开了作用域),程序会直接调用terminate(),然后整个程序崩溃退出。 所以,你必须在线程对象销毁之前,明确决定是 join 还是 detach,通常建议优先选择 join(等待线程结束)。
正因为这个"必须手动处理,否则崩溃"的坑太容易踩,C++20 引入了一个新的类型:std::jthread(joining thread,“会自动 join 的线程”)。它的特点是:当 jthread 对象的析构函数被调用时,会自动帮你请求线程停止,然后自动等待线程结束,你不需要自己操心。
简单记忆: std::thread:C++11 就有,需要你手动join()或detach(),忘了就崩溃。std::jthread:C++20 新增,析构时自动帮你收尾,更安全,推荐优先使用。
如果你的编译环境还没有 C++20(也就没有jthread),又不想自己手动管理,可以用 jthread 的原作者 Nico Josuttis 写的兼容库(兼容 C++17),地址是https://github.com/josuttis/jthread。
除了"自动收尾"这个区别外,这一章后面讲的绝大部分内容对thread和jthread都是通用的,后面会specifically指出它们的差异点。
三、启动一个纯线程(Starting Pure Threads)
要启动一个新线程,你需要两样东西:
- 一个"可调用对象"(线程要执行的任务)
- 一个已经存在的线程,用来"发号施令"启动新线程——这个现成的线程,通常就是
main所在的主线程
来看一段完整可运行的示例代码:
// https://godbolt.org/z/ejr3bc7Ks
// 头文件:iostream 用于输出,thread 用于线程支持
#include <iostream>
#include <thread>
using std::cout;
using std::endl;
// 计算斐波那契数列的第 n 项(递归写法,故意写得比较"重",
// 目的是模拟一个耗时的计算任务,方便观察多线程效果)
long fib(long n) {
// 如果 n <= 1,直接返回 n(斐波那契数列的终止条件)
// 否则递归计算 fib(n-1) + fib(n-2)
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
// 三个任务函数,分别计算不同规模的斐波那契数并打印结果
void task1() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
void task2() { auto r = fib(31); cout << "fib(31)=" << r << endl; }
void task3() { auto r = fib(32); cout << "fib(32)=" << r << endl; }
// 定义一个"函数对象"(functor):
// 通过重载 operator(),让这个类的实例可以像函数一样被调用
struct BackgroundTask {
// const 表示这个调用不会修改对象自身的状态
void operator()() const {
task1(); // 依次顺序执行三个任务
task2();
task3();
}
};
int main() {
// 第一步:创建 BackgroundTask 的实例。
// 注意:这一步只是"初始化"这个对象,此时并没有开始任何计算!
BackgroundTask backgroundTask{};
// 第二步:用这个可调用对象构造一个 std::jthread 对象。
// 从这一行代码执行的瞬间,新线程就已经启动,
// 并开始在后台并行执行 backgroundTask 里的代码。
std::jthread myThread{ backgroundTask };
// 因为 myThread 是 jthread 类型,当 main 函数结束、
// myThread 这个局部变量被销毁时,析构函数会自动等待
// 新线程执行完毕(自动 join),所以这里不需要手写 join()。
return 0;
}
关键点解析:
std::jthread myThread{ backgroundTask };这一行是核心:创建 jthread 对象的构造函数参数,就是你想让新线程执行的"可调用对象"。这行代码执行完,新线程就已经在跑了,跟主线程是并行关系。- 注意一个很容易忽略的细节:可调用对象在传给新线程时,会被"拷贝"一份。 也就是说,新线程里用的
backgroundTask其实是一份副本,不是原来那个对象本身。所以,如果你想拿同一个backgroundTask去启动好几个线程,它们用的是各自独立的副本,彼此不共享状态(除非你额外做了处理,比如传引用或指针)。
除了上面这种"先定义类型再实例化"的写法,还有一种更常见的简化写法:直接在构造 jthread 的时候,用临时对象:
// 直接用临时对象 BackgroundTask{} 作为构造参数
std::jthread myThread{ BackgroundTask{} };
这里要特别小心一个语法陷阱:千万不要写成 BackgroundTask(),因为在某些上下文里,BackgroundTask() 会被编译器解析成"声明一个函数",而不是"创建一个临时对象"(这就是经典的 C++ "最令人头疼的解析"问题,Most Vexing Parse)。用花括号 {} 就能完全绕开这个坑。
如果任务比较简单,还可以直接用 lambda 表达式,代码更紧凑:
// 用 lambda 表达式直接定义线程要执行的内容,
// 不需要额外定义一个 struct
std::jthread myThread{ [] {
task1();
task2();
task3();
} };
这行代码的效果和前面用 BackgroundTask 完全一样:myThread 启动一个新线程并行运行,在这个新线程里,task1、task2、task3 会按顺序依次执行(注意:三个任务在这一个新线程内部是"顺序"执行的,只是这个新线程和主线程之间是"并行"关系)。
四、提前终止一个线程:stop_token
4.1 为什么需要"提前终止"
很多时候,你不希望一个线程非要等到自然结束才停下来。举个例子:用户点击了"取消"按钮,但此时后台线程正在从磁盘读取一个巨大的文件——如果线程继续傻乎乎地把文件读完,那用户点"取消"就没有意义了,还白白浪费时间和资源。
4.2 stop_token 是什么
C++20 给 jthread 内置了一个解决方案,叫 stop_token(停止令牌)。在旧的 std::thread 里没有这个东西,需要你自己手动实现类似的机制(比如用一个原子布尔变量)。
stop_token 可以理解成一根"通信线":外部(比如主线程)通过它向内部(新启动的线程)发送"请停下来"的信号;而内部线程则需要主动、周期性地检查这根线上有没有信号传来。
完整可运行的示例:
// https://godbolt.org/z/e7qvj46Y1
#include <iostream>
#include <thread>
#include <chrono>
using std::cout;
using std::endl;
long fib(long n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
void task1() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
void task2() { auto r = fib(31); cout << "fib(31)=" << r << endl; }
void task3() { auto r = fib(32); cout << "fib(32)=" << r << endl; }
struct BackgroundTask {
// 注意这里的参数:std::stop_token st
// 这是 jthread 专门提供的一个"通信通道"参数。
// 如果你的可调用对象的 operator() 带有这样一个 stop_token 参数,
// jthread 在启动线程时会自动把内部的令牌传进来;
// 如果你的函数不需要这个参数,也完全可以不写,二者都支持。
void operator()(std::stop_token st) const {
task1();
// 每完成一个任务后,检查一下"外部是否要求停止"
// st.stop_requested() 返回 true,表示外部发来了停止请求
if (st.stop_requested()) return; // 收到停止信号,直接退出,不再往下执行
task2();
if (st.stop_requested()) return;
task3();
}
};
int main() {
BackgroundTask backgroundTask{};
std::jthread myThread{ backgroundTask };
// 主线程先睡眠 100 毫秒,给新线程一点时间去执行任务
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 主动请求新线程停止:这只是"发出请求",
// 新线程要靠自己内部检查 stop_requested() 才能真正响应并退出
myThread.request_stop();
return 0;
// myThread 离开作用域时,jthread 的析构函数会自动等待线程真正结束(自动 join)
}
关键点解析:
std::stop_token st这个参数是可选的:只要你把可调用对象的调用运算符(或函数)写成"带一个stop_token类型参数"的形式,jthread在创建的时候就会自动把内部生成的令牌传进去;如果你写的函数根本没有这个参数,jthread也照样能正常工作,只是那样你就没法在内部检查停止请求了。jthread在被创建的那一刻,内部总是会同时创建一个配套的stop_source(停止源)。这个stop_source就是"发号施令"的一方,而传给线程函数的stop_token,则是"接收命令"的一方,二者是一一对应、互相关联的。st.stop_requested()是在线程内部"查岗"用的:定期问一句"外面有没有让我停下来?"如果有,就自己主动清理、返回,从而结束线程。- 从外部(比如主线程)请求停止,有两种等价写法:
- 先拿到
stop_source对象:myThread.get_stop_source(),再对它调用request_stop(); - 直接走"快捷方式",在
jthread对象上直接调用myThread.request_stop()(示例代码用的就是这种简便写法)。
为什么不能从外部直接、暴力地把一个线程"杀死"? 因为 C++ 里遵循 RAII(资源获取即初始化)原则——对象的生命周期和资源管理是绑定在一起的,如果强行从外部杀掉一个正在运行的线程,那个线程里正在使用的对象的析构函数就没有机会被正常调用,会导致资源泄漏、状态不一致等各种问题。所以 C++ 的设计思路是:只能"温和地请求"线程停止,线程内部自己决定什么时候、以什么方式安全地退出。
一个使用建议:只要你写的线程运行时间会超过几毫秒,就应该带上stop_token参数,方便随时能够优雅地终止它。(本文档为了讲解简洁,后面的部分示例省略了它,但实际写代码时不建议省略。)
- 先拿到
五、等待线程结束:join 与 detach
线程一旦启动后,你必须在它自然结束之前,明确决定接下来要怎么处理它,一般是二选一:
- 等待线程执行完毕——这个动作叫 join(汇合)。对于
jthread来说,这一步会在析构函数里自动帮你完成,不需要手写。 - 让线程独立运行,不再管它——这个动作叫 detach(分离)。
如果你用的是旧的std::thread(不是jthread),而且在线程结束之前,你既没有 join 也没有 detach,那么当这个thread对象的析构函数被调用时(比如离开了它所在的作用域),程序会直接调用std::terminate(),也就是程序直接崩溃退出。
两种收尾方式的写法非常简单:
// 方式一:等待(阻塞当前线程,直到 myThread 执行完毕)
myThread.join();
// 方式二:分离(不再等待,myThread 独立于当前线程运行)
myThread.detach();
来看一个用 std::thread(而不是 jthread)、显式调用 join() 的完整例子:
// https://godbolt.org/z/b6ajvb357
#include <iostream>
#include <thread>
using std::cout;
using std::endl;
long fib(long n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
void task1() { auto r = fib(30); cout << "fib(30)=" << r << endl; }
void task2() { auto r = fib(31); cout << "fib(31)=" << r << endl; }
void task3() { auto r = fib(32); cout << "fib(32)=" << r << endl; }
int main() {
// 用 lambda 表达式直接创建一个"纯" std::thread(注意:不是 jthread)
std::thread myThread{ [] {
task1();
task2();
task3();
} };
// 因为用的是 std::thread,必须手动 join,
// 否则当 myThread 离开作用域(main 函数结束)时程序会直接崩溃
myThread.join(); // 阻塞在这里,直到新线程把三个任务全部执行完
return 0;
}
关键点解析:
- 再强调一次这个"生死攸关"的规则:如果一个
thread对象所在的作用域结束了、这个线程实际还在运行,并且你既没有调用join()也没有调用detach(),那程序会直接崩溃。join()的作用就是让当前(调用它的)线程原地等待,直到目标线程运行完毕为止。 - 在上面这个具体的例子里,
join()是唯一正确的选择。原因是:如果这里改用detach(),主线程(main)几乎立刻就会执行完毕并退出——而主程序一旦退出,所有被 detach 出去、还在后台运行的线程也会被强制终止。这意味着如果用detach(),你很可能什么输出都看不到,因为三个耗时的斐波那契计算还没算完,主程序就已经收工退出了。 detach()更适合用在什么场景?——比如在一个不会很快结束的更大的程序中(例如一个长期运行的服务、GUI 程序等),这类程序里通常还有很多其他的计算或事件在持续进行,这时候把一些后台任务 detach 出去、让它们独立运行,才是合理的用法。
六、两种收尾方式的直观对比
| 方式 | 所属类型 | 需要手动操作吗 | 不处理会怎样 | 适用场景 |
|---|---|---|---|---|
| join | thread / jthread(jthread自动完成) | thread 需要手动调用 | thread 忘记处理会导致程序崩溃 | 需要等待线程结果、结果影响后续逻辑 |
| detach | thread / jthread 都可以 | 需要手动调用 | 不会崩溃,但线程会脱离控制独立运行 | 长期运行的程序中的后台任务 |
七、整体流程图
八、ASCII 版本的类关系与生命周期示意
std::jthread(C++20 新增,推荐使用)
|
|-- 构造时:自动生成一对 stop_source / stop_token
|-- 析构时:自动 request_stop() + 自动 join()
|-- 内部可调用对象若带 stop_token 参数,可优雅终止
|
v
+------------------------------------+
| 一个可调用对象(函数/functor/lambda) |
+------------------------------------+
^
|
|-- 构造时:不自动处理停止逻辑
|-- 析构时:若未 join/detach,直接 terminate 崩溃
|
std::thread(C++11 起,需手动管理)
线程状态流转(以 jthread 为例):
[主线程创建 jthread 对象]
|
v
[新线程立即开始并行执行任务]
|
+----------------------------+
| |
[主线程继续自己的工作] [新线程执行 task1 -> task2 -> task3]
| |
v |
[主线程可选:调用 request_stop()] ------>|(新线程内部检查 stop_requested)
| |
v v
[jthread 对象析构:自动等待新线程结束] [新线程结束(正常完成或提前返回)]
九、几个容易混淆的知识点小结
- 构造
jthread/thread传入的对象是"可调用对象"本身,不是函数调用的结果。 也就是说std::jthread myThread{ backgroundTask }是对,std::jthread myThread{ backgroundTask() }是错——后者是先调用了backgroundTask()(在主线程里同步执行),再把返回值(可能是void,根本编译不过)传给构造函数,这完全不是我们想要的效果。 - 传给线程的可调用对象会被拷贝一份,新线程操作的是副本,不是原对象。如果需要在多个线程之间共享状态,要额外用引用包装(如
std::ref)或者指针/智能指针来传递。 BackgroundTask{}和BackgroundTask()不等价,在很多上下文(尤其是作为函数参数、局部变量初始化时)中,BackgroundTask()会被编译器当成"声明一个返回BackgroundTask类型、不接受参数的函数",而不是创建一个临时对象。用花括号可以完全避免这个"最令人头疼的解析"(Most Vexing Parse)问题。stop_token是"协作式"的停止机制,不是"强制"停止。 外部只能发出"请求",线程内部必须自己主动检查、自己决定何时响应,C++ 没有提供从外部强行杀死一个线程的标准手段(这是为了保证 RAII、避免资源泄漏)。jthread析构自动 join,thread析构不会自动做任何事——不处理就崩溃。 这是二者最本质的区别,也是官方建议"能用jthread就优先用jthread"的核心原因。
C++ 多线程编程:异常处理、参数传递与线程数量
这一部分讲三件事:第一,线程和异常混在一起时会发生什么危险的事情,以及 jthread 怎么让这件事变得更安全;第二,怎么把参数传给线程函数,拷贝、引用、移动三种方式各自的坑在哪里;第三,一个程序到底应该开多少个线程,以及线程如何知道"我是谁"。
1. 线程和异常搅在一起会发生什么
1.1 一个看似安全实则会崩溃的例子
先看一段代码。如果没有线程,这段代码是完全正常的:data.at(666) 越界访问会抛出 std::out_of_range 异常,这个异常不是 std::runtime_error(虽然听起来像,但 out_of_range 实际上派生自 std::logic_error,跟 runtime_error 是两条不同的继承分支),所以 catch(std::runtime_error&) 抓不住它,异常会继续往外传,最终被 main() 里的 catch(...) 兜底,打印出 “An error has occurred”。
但是一旦引入了线程,故事就完全不一样了:
// https://godbolt.org/z/accKGsa1h
#include <iostream>
#include <thread>
#include <vector>
#include <exception>
#include <stdexcept>
using std::cout;
using std::endl;
// 递归计算斐波那契数列第 n 项,写得很慢是故意的,用来模拟一个耗时的计算任务
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
// 线程要执行的任务函数:算出 fib(40) 并打印
void task1() {
auto r = fib(40);
cout << "fib(40)=" << r << endl;
}
void main_program() {
try {
std::thread th{ &task1 }; // 启动一个新线程去算 fib(40),主线程不等它,继续往下走
std::vector<int> data{ 0, 1, 2 };
data.at(666); // 越界访问,这里抛出 std::out_of_range 异常
th.join(); // 因为上一行抛异常了,这一行永远不会被执行到
}
catch (std::runtime_error &ex) {
// out_of_range 不是 runtime_error 的子类,类型对不上,这个 catch 抓不住它
// 所以异常会离开这个 try 块,继续往外传播
}
}
int main() {
try {
main_program();
}
catch (...) {
// 期望在这里兜底打印出错误信息
std::cout << "An error has occurred\n"; // 实际上你根本看不到这一行
}
}
实际运行这段代码会发现,程序直接崩溃退出(在 Linux 上退出码是 134,也就是被 SIGABRT 信号杀死),“An error has occurred” 根本没有打印出来。
为什么会这样? 关键在于 th 这个 std::thread 变量的生命周期。当 data.at(666) 抛出异常后,C++ 的异常处理机制要做"栈展开"(stack unwinding):把当前作用域里的局部变量按照构造顺序的反序依次析构掉。th 就是这个 try 块里的局部变量,所以它的析构函数会被调用。
问题是:std::thread 有一条非常严格的规则——如果一个 std::thread 对象在析构的时候,它所代表的线程还没有结束(也就是说 joinable() 返回 true,既没有 join() 也没有 detach()),那么析构函数会直接调用 std::terminate(),让整个程序立刻终止。
而这个终止发生在栈展开的过程中,也就是发生在异常真正到达 main() 里的 catch(...) 之前。所以不管你在外层写了多么完备的 catch(...),都来不及生效,程序已经被杀死了。
下面这张图展示了完整的时间线:
1.2 修复方法一:把 th 的生命周期和"可能出错的代码"分开
思路很简单:让 th 的作用域比 try 块更大,这样即使 try 块里出了异常需要栈展开,th 也不会被销毁;等到确实要处理异常、要离开 main_program() 的时候,先手动 join(),再把异常重新抛出去:
// https://godbolt.org/z/YfMjn88Yj
#include <iostream>
#include <thread>
#include <vector>
#include <exception>
#include <stdexcept>
using std::cout;
using std::endl;
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
void task1() {
auto r = fib(40);
cout << "fib(40)=" << r << endl;
}
void main_program() {
std::thread th{ &task1 }; // th的作用域不再被try块限制,它是函数体的局部变量
try {
std::vector<int> data{ 0, 1, 2 };
data.at(666); // 触发out_of_range
}
catch (std::runtime_error &ex) {
// 依然抓不住out_of_range,因为类型不匹配
// 这个catch专门用来处理"我明确知道、想在这里就地解决"的特定错误类型
}
catch (...) {
// 捕获所有其他没有预料到的异常
th.join(); // 关键:先等待线程结束,避免th析构时程序被terminate
throw; // 再把异常重新抛出去,交给main_program的调用者处理
}
th.join(); // 正常路径(没有异常发生)也必须记得join
}
int main() {
try {
main_program();
}
catch (...) {
std::cout << "An error has occurred\n";
}
}
这次运行会正确打印出:
fib(40)=102334155
An error has occurred
这里的设计思路是:用 catch (std::runtime_error &ex) 处理"我在这个位置就能明确知道该怎么处理"的错误,用 catch (...) 兜住其他所有没预料到的错误——对于这类错误,先 join() 保证线程对象析构安全,再用裸 throw;(不带任何参数)把原始异常原样重新抛给外层,让外层的代码继续处理。
之所以要把 join() 尽量往后放(放在异常处理的最后,或者函数快结束的地方),是因为 join() 是阻塞调用,它会一直等到线程执行完才返回。如果 join() 调用得太早,比如紧跟在线程启动后面,那就完全失去了"并行执行"的意义,等于变成了串行。
1.3 修复方法二:直接换成 jthread
C++20 引入的 std::jthread(“joining thread”)从设计上就解决了这个问题:它的析构函数自带 join() 逻辑,不需要程序员手动操心。
#include <iostream>
#include <thread>
#include <vector>
#include <stdexcept>
using std::cout;
using std::endl;
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
void task1() {
auto r = fib(40);
cout << "fib(40)=" << r << endl;
}
void mainProgram() {
std::jthread th{ &task1 }; // jthread的析构函数自带join逻辑
std::vector<int> data{ 0, 1, 2 };
data.at(666); // 触发out_of_range,栈开始展开
// 栈展开过程中th的析构函数被调用:
// 它会自动等待task1()跑完,而不会导致terminate
}
int main() {
try {
mainProgram();
}
catch (...) {
std::cout << "An error has occurred\n";
}
}
这段代码同样会输出:
fib(40)=102334155
An error has occurred
而且比前面那个版本简单得多——不需要手动挪动变量作用域,也不需要写 catch(...) { th.join(); throw; } 这种模板代码。
1.4 jthread 析构函数到底做了什么
jthread 的析构函数其实做了两件事:
需要特别注意的一点是:如果你的线程函数没有接收 std::stop_token 参数,那么线程会一直运行到自然结束,不会因为析构函数调用了 request_stop() 而提前停下。也就是说,request_stop() 只是发出一个"建议尽快停止"的信号,线程函数得自己主动去检查 stop_requested() 才会响应这个信号。所以,只要有可能,写线程函数时都应该带上 stop_token 参数,并且时不时检查一下 stop_requested()。哪怕你自己代码里从来没有主动调用过 request_stop(),jthread 的析构函数在销毁时也会自动帮你调一次。
下面这张表总结了 std::thread 和 std::jthread 的核心区别:
| 特性 | std::thread | std::jthread(C++20) |
|---|---|---|
| 析构时线程还在运行 | 调用 std::terminate(),程序直接终止 | 自动调用 request_stop() + join() |
| 停止机制 | 没有内置机制,需自己实现标志位 | 自带 stop_token,可配合 stop_requested() 使用 |
| 是否可拷贝 | 不可拷贝 | 不可拷贝,但可以移动(move) |
| 异常安全性 | 需要手动小心安排 join() 的调用时机 | 天然更安全,不容易漏掉 join() |
2. 怎么把参数传给线程函数
2.1 基本用法:直接在构造函数里加参数
如果每次要跑不同的计算,都得单独写一个函数,那也太麻烦了。其实可以直接在启动线程的时候,把参数跟在函数名后面一起传进去:
// https://godbolt.org/z/3657srz83
#include <iostream>
#include <thread>
using std::cout;
using std::endl;
// 递归计算斐波那契数
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
// 包一层,专门用来当线程函数:算完直接打印
void runFib(long n) {
auto r = fib(n);
cout << "fib(" << n << ")=" << r << endl;
}
// 阿克曼函数:两个参数,增长速度极快,常用来测试系统极限
long ack(long m, long n) {
if (m == 0) return n + 1;
if (n == 0) return ack(m - 1, 1);
return ack(m - 1, ack(m, n - 1));
}
void runAck(long m, long n) {
auto r = ack(m, n);
cout << "ack(" << m << ',' << n << ")=" << r << endl;
}
int main() {
// 花括号初始化:第一个元素是函数(或函数指针),后面依次是要传给函数的参数
std::jthread f30{ runFib, 30 };
std::jthread f31{ runFib, 31 };
std::jthread f32{ runFib, 32 };
f30.join(); f31.join(); f32.join(); // 显式等待这三个线程算完
// thread(不是jthread)必须自己记得join,否则析构时会terminate
std::thread a1{ runAck, 2, 3 };
std::thread a2{ runAck, 2, 4 };
a1.join();
a2.join();
}
阿克曼函数的数学定义是这样的:
A(m,n)={n+1m=0A(m−1, 1)m>0, n=0A(m−1, A(m, n−1))m>0, n>0
A(m, n) = \begin{cases} n+1 & m = 0 \\ A(m-1,\ 1) & m > 0,\ n = 0 \\ A(m-1,\ A(m,\ n-1)) & m > 0,\ n > 0 \end{cases}
A(m,n)=⎩⎨⎧n+1A(m−1, 1)A(m−1, A(m, n−1))m=0m>0, n=0m>0, n>0
它跟斐波那契数列一样,常被用作"故意写得很慢的递归函数"来演示多线程效果——区别是阿克曼函数只要把第一个参数 mmm 稍微调大一点,运算量就会爆炸式增长,比斐波那契数列更极端,但它算出来的数字本身没什么实际意义,纯粹是用来"占用 CPU 时间"的。
2.2 参数默认是拷贝传递
这一点很关键:启动线程时传进去的参数,会先被拷贝一份,再交给线程函数使用,而不是直接存一个引用。也就是说,即使线程函数的形参写的是 const std::string&(引用类型),实际调用的时候也会先在线程内部生成一份独立的拷贝:
// https://godbolt.org/z/hT481z16Y
#include <iostream>
#include <thread>
#include <chrono>
#include <string>
using namespace std::chrono; // 用来省去chrono::前缀,以及1s这种字面量后缀
// 注意:msg的形参类型是引用,但这不代表线程会共享调用者那边的字符串
void delayPrint(seconds s, const std::string& msg) {
std::this_thread::sleep_for(s); // 先睡s秒,模拟耗时任务
std::cout << msg << std::endl;
}
int main() {
std::jthread m1{ delayPrint, 1s, "On your marks" }; // 字符串字面量,会被转换成string再拷贝进线程
std::jthread m2{ delayPrint, 2s, std::string{"set"} }; // 临时对象,同样被拷贝
std::string go = "go";
std::jthread m3{ delayPrint, 3s, go }; // go本身是局部变量,一样会被拷贝一份进线程,不是引用
}
程序运行结束会按顺序打印 On your marks、set、go(因为等待时间分别是 1 秒、2 秒、3 秒)。这里的重点是:虽然 go 是 main() 里的一个局部变量,理论上可以按引用传递,但线程库依然会先把它拷贝一份,再交给 delayPrint。这样设计的好处是:就算你后面对 m3 调用了 detach()(让线程完全脱离主线程独立运行,不再受主线程管理),并且 main() 的作用域已经结束、go 这个变量已经被销毁了,线程内部持有的那份拷贝依然完好,不会出问题。
2.3 用裸指针传参要格外小心
上面说"参数会被拷贝",这句话对指针来说是有陷阱的:被拷贝的只是指针本身(也就是一个地址值),指针指向的那块内存并不会被拷贝。如果原始数据的生命周期比线程还短,线程里的指针就会变成悬垂指针(指向已经被销毁的内存):
#include <iostream>
#include <thread>
#include <chrono>
using namespace std::chrono;
// 危险信号:形参是裸指针const char*
void delayPrint(seconds s, const char* msg) {
std::this_thread::sleep_for(s);
std::cout << msg << std::endl; // 这里用到msg指向的内存,风险就在这里
}
void run() {
const char risk[] = "This won't end well..."; // risk是run()的局部数组
std::jthread t{ delayPrint, 1s, risk }; // 只拷贝了指针(地址),没拷贝数组内容
t.detach(); // 线程脱离管理,独立运行
} // <- risk在这里被销毁,但线程里的指针还指向这块已经失效的内存
int main() {
run();
std::this_thread::sleep_for(2s); // 主线程多等2秒
}
这段代码可以编译通过,但它是典型的未定义行为(undefined behavior):run() 函数一返回,局部数组 risk 就被销毁了,而线程里的 msg 指针仍然指向这块已经不属于程序的内存。之后线程尝试通过这个悬垂指针去读取内容,结果是不可预测的——可能崩溃,可能输出乱码,也可能"侥幸"正常运行(因为内存还没被覆盖),但这种"侥幸"是不能依赖的。
2.4 用 std::ref 强制传引用
有些场景确实需要多个线程共享同一份数据、互相能看到彼此的修改,这时候拷贝就不是想要的行为了。C++ 提供了 std::ref(在头文件 <functional> 里),可以强制让参数按引用传递而不是拷贝:
// https://godbolt.org/z/esxWxaKr5
#include <iostream>
#include <thread>
#include <chrono>
#include <functional>
using namespace std::chrono;
struct State {
int counter;
};
// 形参是引用,配合std::ref使用时,线程内部真正共享的是外部同一个对象
void showState(const State& state) {
for (auto i : { 5, 4, 3, 2, 1 }) {
std::cout << "counter: " << state.counter << std::endl;
std::this_thread::sleep_for(1s);
}
}
int main() {
State state{ 4 };
// std::ref(state)告诉线程库:不要拷贝,保留对state的引用
std::jthread th{ showState, std::ref(state) };
std::this_thread::sleep_for(1s);
state.counter = 501; // 修改主线程里的state,线程内部能看到这个变化
std::this_thread::sleep_for(1s);
state.counter = 87;
std::this_thread::sleep_for(1s);
state.counter = 2;
}
运行结果会是:
counter: 4
counter: 501
counter: 87
counter: 2
counter: 2
如果没有用 std::ref(也就是普通拷贝传递),输出会一直是 counter: 4,因为线程内部拿到的是启动那一刻的独立副本,跟外部的 state 已经没有任何关系了。正是因为看到了输出里 counter 的值随着主线程修改而变化,才能确认这里传递的确实是引用而不是拷贝。
使用 std::ref 时要特别当心一件事:一定要保证被引用的对象在线程运行期间始终有效。如果线程还没跑完,外部的 state 就被销毁了,那又会变成前面提到的悬垂引用问题。
2.5 用 std::move 转移资源所有权
还有一种情况:要传递的对象拷贝代价很大(比如一张占用大量内存的图片),或者这个对象根本不允许拷贝(比如 std::unique_ptr,它的设计就是独占所有权、禁止拷贝)。这时候应该用 std::move,把对象的资源"移动"进线程,而不是拷贝:
#include <iostream>
#include <thread>
#include <chrono>
#include <vector>
#include <memory>
using namespace std::chrono;
// 用vector存数据是因为vector支持高效的移动操作
struct Image {
std::vector<char> data_; // 拷贝代价大:可能有几百万字节
explicit Image() : data_(1'000'000) {} // 构造时分配100万字节
};
// 注意:这里形参是按值传递(Image img),配合std::move使用时,是"移动构造"而不是拷贝构造
void showImage(Image img) {
std::cout << img.data_.size() << '\n';
}
void showIptr(std::unique_ptr<int> iptr) {
std::cout << *iptr << '\n';
}
int main() {
Image image{};
std::cout << image.data_.size() << std::endl; // 输出: 1000000
// std::move告诉编译器:把image的资源"掏空"转移给线程,而不是拷贝一份
std::jthread th1{ showImage, std::move(image) }; // 线程里输出: 1000000
std::this_thread::sleep_for(200ms);
std::cout << image.data_.size() << std::endl; // 输出: 0 (image已经被掏空了)
th1.join();
// unique_ptr根本不支持拷贝,只能移动
auto iptr = std::make_unique<int>(657);
std::cout << (bool)iptr << std::endl; // 输出: 1 (true,指针有效)
std::jthread th2{ showIptr, std::move(iptr) }; // 线程里输出: 657
std::this_thread::sleep_for(200ms);
std::cout << (bool)iptr.get() << std::endl; // 输出: 0 (false,iptr已经空了)
}
实际运行输出:
1000000
1000000
0
1
657
0
image 在移动之前和之后的状态可以这样理解:
移动之前:
main()中的image ----> [完整的图片数据, 1,000,000字节]
std::move(image)之后:
main()中的image ----> [空的,0字节]
线程内的img ----> [完整的图片数据, 1,000,000字节]
这里三种传参方式的对比可以总结成下表:
| 传递方式 | 写法 | 适用场景 | 注意事项 |
|---|---|---|---|
| 拷贝(默认) | 直接传值 | 大多数简单类型、字符串等 | 每个线程拿到的是独立副本,互不影响,最安全 |
| 引用 | std::ref(变量) | 需要多个线程共享同一份数据、互相能看到修改 | 必须保证被引用对象在线程运行期间一直存活 |
| 移动 | std::move(变量) | 拷贝代价大的资源,或者本身不可拷贝的对象(如 unique_ptr) | 移动之后原变量会被清空,不能再依赖它原来的值 |
3. 移动 jthread 本身
3.1 线程对象可以作为函数返回值
jthread(以及 thread)虽然不能拷贝,但可以移动。所以完全可以把创建线程这件事包装进一个函数里,让函数把线程对象"移动"出来返回给调用者:
#include <iostream>
#include <thread>
#include <chrono>
#include <string>
using namespace std::chrono;
// 返回一个jthread对象;函数返回值本来就会自动应用移动语义,不需要写std::move
auto makeThread(std::string who) {
return std::jthread{ [who] { // lambda按值捕获who,线程内部有自己的一份拷贝
std::this_thread::sleep_for(200ms);
std::cout << "Good luck, " << who << std::endl;
} };
}
int main() {
auto th = makeThread("Jim"); // th接管了makeThread内部创建的那个线程对象
th.join();
}
3.2 把线程对象当参数传递:必须显式 move
jthread 既然不可拷贝,如果想把一个已经存在的线程对象传给另一个函数(比如转交管理权),就必须显式调用 std::move:
#include <thread>
#include <chrono>
#include <iostream>
using namespace std::chrono;
// 形参是按值接收一个jthread,调用者必须用std::move把线程"转让"过来
void yourMission(std::jthread job) {
job.join(); // 在这个函数里负责等待线程结束
}
int main() {
std::jthread th{ [] {
std::this_thread::sleep_for(200ms);
std::cout << "should you choose to accept it" << std::endl;
} };
yourMission(std::move(th)); // 显式移动,把管理责任转交给yourMission
// 移动之后,main()里的th已经不再持有任何线程了,即使就此让th离开作用域也没问题
}
std::move(th) 之后,th 在 main() 里已经变成一个"空壳",不再关联任何实际的线程。此时就算让 main() 直接结束、th 自然离开作用域,也不会有问题,因为责任已经被转交给 yourMission 里的 job 了(这个例子里 job 只是原地等待线程结束,但也完全可以对 job 调用 detach())。
3.3 把线程放进容器:动态管理任意数量的线程
jthread 最实用的一个特点是可以放进标准容器(比如 vector),这样就能用一个循环动态管理数量不固定的线程:
#include <iostream>
#include <thread>
#include <vector>
using std::cout;
using std::endl;
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
void runFib(long n) {
auto r = fib(n);
cout << "fib(" << n << ")=" << r << endl;
}
int main() {
std::vector<std::jthread> threads; // 用来存放一批线程对象
for (auto n : { 38, 39, 40, 41, 42 }) {
// emplace_back直接在vector内部就地构造jthread对象,相当于启动了一个新线程
threads.emplace_back(runFib, n);
}
// 不需要手动逐个join!
// 当threads这个vector在main()结束时被销毁,
// 它会依次销毁里面的每个jthread元素,每个元素的析构函数都会自动join
}
除了 emplace_back 直接构造,也可以先构造好线程对象再移动进去:
std::jthread th1{ runFib, n };
threads.push_back(std::move(th1)); // 移动一个已存在的线程变量
threads.push_back(std::thread{ runFib, n }); // 或者:临时对象本身就是右值,会被自动移动进容器
需要注意的是,如果容器里存的是普通 std::thread 而不是 jthread,那么在容器销毁之前必须自己写循环手动 join() 每一个线程,否则同样会触发 terminate()。
4. 一个程序到底应该开多少个线程
4.1 两种线程:等待型和计算型
线程大致可以分成两类:
- 等待型线程(waiting threads):大部分时间都在"傻等",比如等磁盘读数据、等网络响应。因为 CPU 的速度比硬盘、SSD、Wi-Fi 快太多了,这类线程实际占用 CPU 的时间其实很少。这种线程可以开很多,一般机器(台式机、笔记本、手机)上同时开 10 到 100 个通常都没问题,理论上限甚至可以到 1000~10000 个(每个线程大约占用 1MB 左右的内存开销)。不过线程数量太多时,用线程池往往是更好的选择。
- 计算型线程(computation threads):会真正持续占用 CPU 进行运算。这类线程如果开得比 CPU 核心数多太多,反而会因为频繁的线程切换(context switch)浪费额外的资源,得不偿失。
一个经验法则是:
计算型线程的最佳数量≈(1∼2)×CPU核心数 \text{计算型线程的最佳数量} \approx (1 \sim 2) \times \text{CPU核心数} 计算型线程的最佳数量≈(1∼2)×CPU核心数
C++ 标准库提供了一个函数可以查询系统信息:
std::thread::hardware_concurrency()
这个函数返回一个"提示值",大致代表系统能真正并行执行多少个线程。但标准并不保证这个值一定准确——如果信息不可用,它甚至可能返回 0。现代 CPU 存在超线程(hyperthreading)技术,比如一颗 4 核 CPU 配合超线程,hardware_concurrency() 可能会返回 8,但这并不意味着 8 个线程能像 8 个真正独立的核心那样跑得一样快,超过物理核心数之后的那部分线程,性能提升会打折扣。
4.2 实测:线程数量和耗时的关系
下面这段代码用不同数量的并行线程去反复计算 fib(n),通过测量耗时来直观感受"线程越多不一定越快"这个道理:
#include <thread>
#include <iostream>
#include <vector>
#include <chrono> // steady_clock,用来做计时,不受系统时间被人为调整的影响
using std::cout;
using std::endl;
using namespace std::chrono;
long fib(long n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
int main() {
// 先打印系统给出的并行线程数提示
cout << std::thread::hardware_concurrency() << '\n';
// 依次尝试用1到6个线程并行计算fib(40)
for (int nthreads : { 1, 2, 3, 4, 5, 6 }) {
cout << "Threads: ";
const auto start = steady_clock::now(); // 记录起始时间点
std::vector<std::jthread> threads;
for (int ti = 1; ti <= nthreads; ++ti) {
threads.emplace_back(std::jthread{ fib, 40 }); // 启动一个计算fib(40)的线程
cout << ti << "... "; cout.flush();
}
for (auto &th : threads) th.join(); // 显式等待所有线程算完,这样才能准确计时
const auto now = steady_clock::now();
cout << " Time: "
<< duration_cast<milliseconds>(now - start).count()
<< "ms\n";
}
}
代码里 vector<jthread> threads 用来收集这一轮启动的所有线程对象;启动完之后,显式调用 th.join() 是为了确保计时准确(如果依赖析构函数自动 join,那一轮的耗时就没法在循环内部准确测量了)。
在一台双核四超线程的机器(hardware_concurrency() 返回 4)上,示例中给出的实测结果大致是:
Threads: 1... Time: 727ms
Threads: 1... 2... Time: 758ms
Threads: 1... 2... 3... Time: 1113ms
Threads: 1... 2... 3... 4... Time: 1274ms
Threads: 1... 2... 3... 4... 5... Time: 1703ms
Threads: 1... 2... 3... 4... 5... 6... Time: 1955ms
| 并行线程数 | 耗时 | 相比1个线程串行执行N次的估算耗时 |
|---|---|---|
| 1 | 727ms | 727ms |
| 2 | 758ms | 1454ms |
| 3 | 1113ms | 2181ms |
| 4 | 1274ms | 2908ms |
| 5 | 1703ms | 3635ms |
| 6 | 1955ms | 4362ms |
从这组数据能看出几个道理:虽然 hardware_concurrency() 报告的是 4,但实际上只有 2 个线程能做到真正意义上的完全并行(1 个线程到 2 个线程耗时几乎没变化,728ms 到 758ms),从第 3 个线程开始,多个线程已经在互相"抢" CPU 资源了。不过即便如此,4 个线程并行跑(1274ms)依然比把 2 个线程的场景重复跑两遍(2×758ms=1516ms2 \times 758ms = 1516ms2×758ms=1516ms)要快,说明适度超过核心数开线程仍然有收益,只是收益会递减。
5. 我是哪个线程:this_thread::get_id
每个线程都有一个唯一标识,通过 std::this_thread::get_id() 可以获取当前线程的 ID(类型是 std::thread::id):
#include <thread>
#include <iostream>
int main() {
std::cout << "Main: " << std::this_thread::get_id() << '\n'; // 主线程自己的id
std::jthread th{ [] {
// lambda内部,this_thread指的就是这个新启动的线程本身
std::cout << "Thread: " << std::this_thread::get_id() << '\n';
} };
}
某一次运行的输出可能是:
Main: 139930192312128
Thread: 139930185496256
这个 ID 具体长什么样是由标准库实现决定的(不同编译器、不同系统可能输出格式完全不同,甚至每次运行都不一样),但它有几个很实用的特性:
- 可以用
==和<来比较两个thread::id是否相等或者排序; - 支持
std::hash,所以能放进std::map、std::unordered_set这类关联容器里; - 利用这个特性,可以让每个线程通过自己的 ID 去查表,找到"属于自己的那份数据",从而实现一种简单的线程本地数据关联机制。
小结
这一部分核心讲的是三件互相关联的事情:
- 线程和异常的生命周期陷阱:
std::thread析构时如果线程还没结束会直接terminate()整个程序,这个终止发生在栈展开过程中,比外层的catch(...)更早生效。要么手动管理好join()的时机(把线程变量的作用域和可能抛异常的代码分开),要么直接换用jthread,它的析构函数会自动完成request_stop()+join()。 - 参数传递的三种方式:默认是拷贝(最安全,每个线程独立),需要共享数据时用
std::ref强制引用(但要保证被引用对象存活够久),资源代价大或不可拷贝时用std::move转移所有权(移动后原变量会被清空)。裸指针作为参数传递时要格外小心,因为只有指针本身被拷贝,指向的内存不会。 - 线程数量与线程标识:等待型线程可以开很多,计算型线程数量最好控制在 CPU 核心数的 1 到 2 倍之间,可以用
hardware_concurrency()获取参考值(但它不保证绝对准确);每个线程可以通过this_thread::get_id()获得自己独一无二的标识。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)