本文聚焦 C++20 标准库中八组非常有代表性的能力:
std::jthread、std::osyncstream、指派初始化器、范围 for 中的初始化语句、Lambda 初始捕获中的包扩展、and/or/not替代标记、功能特性宏测试以及std::span。
目录
- 为什么这八组能力值得放在一起学习
std::jthread:自动 join 的协作式中断线程std::osyncstream:线程安全输出的终极方案- 指派初始化器:用名字而不是位置来初始化结构体
- 范围 for 循环中的初始化语句
- Lambda 初始捕获中的包扩展
and/or/not等替代标记- 功能特性宏测试
std::span:连续内存的轻量级视图- 八大特性之间的联系
- 版本边界与编译器支持总表
1. 为什么这八组能力值得放在一起学习
C++20 的标准库和语言层面有一个非常明显的方向:把程序员需要手动维护的、容易出错的隐含约定,变成由类型系统或标准库对象直接表达的显式信息。
std::jthread 解决的是:
线程对象析构时如果忘记
join()或detach()就会触发std::terminate(),并且std::thread没有原生的中断机制,怎样让线程管理更安全、更省心?
std::osyncstream 解决的是:
多个线程同时写
std::cout时输出内容交错混乱,加互斥锁又太重,怎样用一种轻量、原子性的方式完成多线程输出?
指派初始化器解决的是:
结构体成员越来越多时,用位置初始化
{1, "Alice", 75000.0, 2}很难看出哪个值对应哪个字段,怎样用.成员名 = 值的方式进行初始化让初始化一目了然
范围 for 中的初始化语句解决的是:
每次循环前需要先获取一个临时容器再遍历,在 C++20 之前必须在 for 外面单独声明变量,作用域容易泄漏,怎样把临时变量的声明限制在 for 循环内部?
Lambda 初始捕获中的包扩展解决的是:
在模板函数中,想把可变参数包按值或按引用捕获进 Lambda,C++20 之前只能用
[args...]的简单形式,无法对每个参数做移动或完美转发捕获,怎样让参数包的捕获更灵活?
and/or/not 替代标记解决的是:
某些键盘或编码环境下
&&、||、!这些符号不方便输入,C++20 之前需要包含<ciso646>才能使用and、or、not等单词形式,怎样让它们直接作为关键字使用?
功能特性宏测试解决的是:
不同编译器对 C++ 标准的支持进度不一,怎样在编译时检测某个特性是否可用,从而写出跨平台、跨编译器的可移植代码?
std::span 解决的是:
数组、
std::vector、std::array都是一段连续内存,但传参时需要为每种容器写不同签名的函数,C 风格数组传参后还丢失了大小信息,怎样用一种统一的轻量级类型来引用所有连续内存序列?
这八组工具看似彼此无关,实际上都在减少同一种危险模式:
text
程序员脑子里知道这段内存的生命周期、类型安全、线程安全
↓
但类型系统和标准库不知道
↓
于是必须靠约定、注释、裸指针、手动加锁、平台宏来维护
↓
一旦约定失配,就出现错误
C++20 把这些信息放进对象本身:
text
jthread
→ 析构时自动 join,自带 stop_token 协作中断
osyncstream
→ 类型本身就保证输出操作的原子性
designated initializer
→ 初始化表达式本身就标注了对应哪个成员
for init-statement
→ 语法本身就限制了临时变量的作用域
lambda pack capture
→ 捕获语法本身就能表达移动/转发语义
and/or/not
→ 语言关键字本身就能替代符号
feature test macro
→ 宏定义本身就能检测编译器能力
span
→ 类型本身就说明"我是一段连续内存的视图,我不拥有数据"
这就是理解这些工具时最值得抓住的主线。
2. std::jthread:自动 join 的协作式中断线程
一句话理解:
std::jthread=std::thread+ 析构时自动join()+ 内置stop_token协作中断机制。
std::jthread 定义在:
cpp
#include <thread>
它从 C++20 开始进入标准库,全称是 "joining thread",设计目标就是作为 std::thread 的更安全、更易用的替代品。它的核心接口设计和 std::thread 基本一致,但多了两个关键能力:自动 join 和协作中断。
2.1 std::thread 的两个老问题
要理解 std::jthread 的价值,先得搞清楚 std::thread 到底有什么问题。
问题一:析构时的危险行为
std::thread 的析构函数有一个非常严格的行为:如果一个 std::thread 对象在它被析构时仍然处于 joinable 状态(即既没有调用 join() 也没有调用 detach()),程序会直接调用 std::terminate() 终止运行。
这意味着程序员必须在所有可能的执行路径上------包括正常返回路径和异常处理路径------都正确地处理 std::thread 对象。一旦疏忽,程序就会崩溃。
cpp
#include <thread>
void risky_function() {
std::thread t([]{
// 做一些工作...
});
// ... 如果这里抛出了异常,t 的析构函数会被调用
// 因为 t 仍然是 joinable 的,程序会调用 std::terminate()!
// 我们必须记得在所有路径上调用 join
t.join();
}
上面这段代码看着没毛病,但如果在 t 创建之后、t.join() 之前有任何异常被抛出,t 的析构函数就会在栈展开时被调用,此时 t 仍然是 joinable 的,程序直接终止。
问题二:缺乏原生的中断机制
std::thread 没有提供停止线程的接口。想停止一个正在运行的线程,通常需要外部维护一个 std::atomic<bool> 标志位,线程循环检查这个标志位并主动退出。这种方式需要手动管理状态,不够直观也不够方便,而且每个需要中断的场景都得自己造一套轮子。
2.2 std::jthread 是什么
std::jthread 就是 C++20 对上述两个问题的回答。它同样定义在 <thread> 头文件中,和 std::thread 在同一个头文件里。
最基本的使用方式:
cpp
#include <thread>
#include <iostream>
void safe_function() {
std::jthread t([] {
std::cout << "Hello from jthread!\n";
});
// 不需要显式调用 t.join()
// 当 t 离开作用域时,会自动等待线程结束
} // <- 这里析构函数会自动调用 join(),确保线程执行完毕
int main() {
safe_function();
std::cout << "jthread has been automatically joined.\n";
return 0;
}
这段代码的核心在于:当 t 离开 safe_function 函数的作用域时,jthread 的析构函数会自动调用 join() 来等待线程执行完毕。你不再需要手动写 t.join(),也不需要担心异常导致 std::terminate()。
2.3 析构函数的行为
std::jthread 的析构函数行为可以用一句话概括:如果线程仍然 joinable,先发出停止请求(request_stop()),然后调用 join() 等待线程结束。
这个行为和 std::thread 形成鲜明对比:
text
std::thread 析构
→ 如果 joinable → std::terminate() → 程序崩溃
std::jthread 析构
→ 如果 joinable → request_stop() + join() → 优雅退出
这意味着即使你在 jthread 执行期间抛出异常,栈展开时 jthread 的析构函数也会安全地把线程收尾。
2.4 协作中断机制
协作中断是 jthread 的另一个核心功能,它通过一套 stop_token 机制来实现。这套机制涉及三个关键类型:
text
std::stop_source
→ 停止信号的"源",可以发出停止请求
→ 拥有 request_stop() 方法
std::stop_token
→ 停止信号的"令牌",只能查询是否被请求停止
→ 拥有 stop_requested() 方法
std::stop_callback
→ 停止信号的"回调",注册后在停止请求发出时自动调用
它们的关系可以这样理解:
text
stop_source 拥有停止状态
↓
可以产生多个 stop_token(共享同一个停止状态)
↓
线程函数通过 stop_token 查询 stop_requested()
↓
也可以在 stop_token 上注册 stop_callback
std::jthread 内部就持有一个 std::stop_source,并且在线程函数的第一个参数是 std::stop_token 类型时,会自动把内部的 stop_token 传进去。这个自动注入机制是 jthread 最巧妙的设计之一。
jthread 构造函数的自动注入原理
std::jthread 的构造函数在创建线程时,会用 if constexpr 检查传入的可调用对象的签名:
text
如果函数第一个参数是 std::stop_token 类型
→ 自动将内部的 stop_token 作为第一个参数注入
→ 线程函数后续参数紧随其后
如果函数第一个参数不是 std::stop_token 类型
→ 按普通方式传递参数,不注入 stop_token
用伪代码来展示 jthread 的内部结构:
cpp
// 伪代码,展示 jthread 的内部结构
class jthread {
private:
std::thread internal_thread_; // 底层的线程对象
std::stop_source stop_source_; // 内置的停止源
public:
template<typename Callable, typename... Args>
jthread(Callable&& func, Args&&... args) {
// 检查函数签名,如果第一个参数是 stop_token,就自动注入
if constexpr (std::is_invocable_v<Callable, std::stop_token, Args...>) {
// 自动将 stop_token 作为第一个参数传递
internal_thread_ = std::thread(
std::forward<Callable>(func),
stop_source_.get_token(), // 自动注入的 stop_token
std::forward<Args>(args)...
);
}
else {
// 普通函数,不注入 stop_token
internal_thread_ = std::thread(
std::forward<Callable>(func),
std::forward<Args>(args)...
);
}
}
std::stop_token get_stop_token() const { return stop_source_.get_token(); }
bool request_stop() { return stop_source_.request_stop(); }
};
这段伪代码的核心逻辑就是:编译器在编译期通过 if constexpr 判断传入的函数是否接受 std::stop_token 作为第一个参数。如果是,就自动把 stop_source_ 产生的 stop_token 注入进去;如果不是,就走普通的 std::thread 路径。这种设计让中断能力变成了一种"可选附加"------你想用就加个 std::stop_token 参数,不想用就不加,完全向后兼容。
2.5 协作中断的基本用法
来看一个完整的协作中断示例:
cpp
#include <thread>
#include <iostream>
#include <chrono>
using namespace std::chrono_literals;
// 线程函数接受一个 stop_token 参数
// jthread 会自动把内部的 stop_token 注入到这个位置
void interruptible_worker(std::stop_token token) {
for (int i = 0; i < 10; ++i) {
// 在每次循环开始时检查是否收到了中断请求
if (token.stop_requested()) {
// 收到中断请求,执行清理工作后退出
std::cout << "Thread is being requested to stop. Cleaning up...\n";
return; // 优雅退出
}
// 正常工作
std::cout << "Working... " << i << std::endl;
std::this_thread::sleep_for(500ms);
}
std::cout << "Work completed normally.\n";
}
int main() {
// 创建 jthread,传入 interruptible_worker
// 因为函数签名的第一个参数是 std::stop_token
// jthread 会自动注入内部的 stop_token
std::jthread worker(interruptible_worker);
// 让线程运行一会儿(2秒)
std::this_thread::sleep_for(2s);
// 主线程请求 worker 线程停止
std::cout << "Requesting stop from main...\n";
worker.request_stop(); // 发出中断信号
// jthread 析构时会自动 join,等待 worker 线程退出
return 0;
}
这段代码的执行流程是这样的:主线程创建 jthread 后,jthread 内部创建了一个 stop_source,并把它的 stop_token 注入到 interruptible_worker 的第一个参数。线程开始循环工作,每隔 500 毫秒检查一次 token.stop_requested()。2 秒后,主线程调用 worker.request_stop(),这个请求设置了停止状态。下次线程循环检查 token.stop_requested() 时返回 true,线程打印清理消息后退出。jthread 析构时自动 join()。
2.6 request_stop()
成员函数:
cpp
bool request_stop();
参数
无参数。
返回值
返回 bool 类型。如果本次调用成功发出了停止请求,返回 true;如果停止请求已经被发出过(即之前已经有人调用过 request_stop()),返回 false。
用法
request_stop() 是 jthread 对外暴露的停止请求接口。调用它后,与 jthread 内部 stop_source 关联的所有 stop_token 的 stop_requested() 都会返回 true。这个方法不会强制终止线程,它只是设置一个标志,线程需要自己检查并决定何时退出。这就是"协作式"中断的含义------外部请求停止,线程内部配合检查并退出。
重要细节
request_stop() 是线程安全的,可以同时从多个线程调用,只有一个调用会返回 true,其余返回 false。如果 jthread 不处于 joinable 状态(比如默认构造或已经 join 过),调用 request_stop() 不会有效果。
2.7 get_stop_source()
成员函数:
cpp
std::stop_source get_stop_source() const noexcept;
参数
无参数。
返回值
返回一个 std::stop_source 对象,它和 jthread 内部的停止状态关联。通过返回的 stop_source,你也可以调用 request_stop() 来发出停止请求。
用法
通常情况下,直接用 jthread 的 request_stop() 就够了。但如果你需要把停止能力传递给其他对象,或者需要在更复杂的场景中控制停止状态,就可以通过 get_stop_source() 拿到底层的 stop_source 对象来做更多操作。
2.8 get_stop_token()
成员函数:
cpp
std::stop_token get_stop_token() const noexcept;
参数
无参数。
返回值
返回一个 std::stop_token 对象,它和 jthread 内部的停止状态关联。通过这个 stop_token,你可以查询 stop_requested() 或注册 stop_callback。
用法
这个方法主要用于在 jthread 外部获取停止令牌。比如你想在另一个线程中注册一个 stop_callback,当 jthread 被请求停止时执行某些操作,就需要通过 get_stop_token() 获取令牌。
2.9 std::stop_source
std::stop_source 是停止状态的拥有者,定义在 <stop_token> 头文件中(虽然 jthread 在 <thread> 中,但 stop_token 系列类型在 <stop_token> 中)。
构造
cpp
std::stop_source source; // 默认构造,拥有一个停止状态
std::stop_source source(std::nostopstate); // 构造一个没有停止状态的 source
默认构造的 stop_source 拥有一个有效的停止状态。用 std::nostopstate 构造的 stop_source 没有停止状态,调用它的任何方法都不会有效果。
request_stop()
cpp
bool request_stop() noexcept;
参数
无。
返回值
bool。如果成功发出了停止请求返回 true;如果已经发出过返回 false;如果没有停止状态返回 false。
用法
这是发出停止请求的核心方法。调用后,所有关联的 stop_token 的 stop_requested() 都会变成 true。
stop_requested()
cpp
bool stop_requested() const noexcept;
参数
无。
返回值
bool。如果停止请求已被发出返回 true,否则返回 false。
用法
用于查询当前停止状态。和 stop_token 上的 stop_requested() 效果一样。
get_token()
cpp
std::stop_token get_token() const noexcept;
参数
无。
返回值
返回一个和当前 stop_source 关联的 std::stop_token 对象。一个 stop_source 可以产生多个 stop_token,它们共享同一个停止状态。
用法
这是把停止查询能力传递给其他代码的接口。通过 get_token() 产生的所有 stop_token 都指向同一个停止状态,任何一个 stop_source 调用 request_stop() 后,所有 stop_token 都能看到。
2.10 std::stop_token
std::stop_token 是停止状态的只读观察者,它只能查询停止状态,不能发出停止请求。
stop_requested()
cpp
bool stop_requested() const noexcept;
参数
无。
返回值
bool。如果关联的 stop_source 已经发出了停止请求返回 true,否则返回 false。
用法
这是线程函数内部最常用的方法。在线程的循环体或关键节点调用它来检查是否应该停止执行。如果返回 true,线程应该执行清理工作后退出。
stop_possible()
cpp
bool stop_possible() const noexcept;
参数
无。
返回值
bool。如果关联的 stop_source 存在(即停止状态有效),返回 true。如果 stop_token 是默认构造的(没有关联任何 stop_source),返回 false。
用法
这个方法用于判断一个 stop_token 是否有可能被请求停止。在将 stop_token 传递给某个函数之前,可以先检查它是否有效。
2.11 手动创建 stop_source 管理多个线程
一般情况下 std::stop_source 是 std::jthread 内部的成员,不需要手动管理。但在特殊场景下,你可以手动创建 stop_source,把 stop_token 传递给多个线程,实现"一个停止源控制多个线程"的效果。
cpp
#include <thread>
#include <iostream>
#include <chrono>
using namespace std::chrono_literals;
void multiple_consumers() {
// 手动创建 stop_source,不依赖 jthread 内部的 stop_source
std::stop_source source;
// 创建多个工作线程,共享同一个停止信号
// 注意这里用的是 jthread,但 stop_token 来自手动创建的 source
std::jthread worker1([token = source.get_token()]{
while (!token.stop_requested()) {
std::cout << "Worker 1 working..." << std::endl;
std::this_thread::sleep_for(200ms);
}
std::cout << "Worker 1 stopped" << std::endl;
});
std::jthread worker2([token = source.get_token()]{
while (!token.stop_requested()) {
std::cout << "Worker 2 working..." << std::endl;
std::this_thread::sleep_for(150ms);
}
std::cout << "Worker 2 stopped" << std::endl;
});
// 让两个线程运行 1 秒
std::this_thread::sleep_for(1s);
// 通过手动创建的 source 发出停止请求
// 两个 worker 都会收到停止信号
std::cout << "Stopping all workers..." << std::endl;
source.request_stop();
// 两个 jthread 析构时会自动 join
}
int main() {
multiple_consumers();
}
这段代码的关键点在于:两个 jthread 的 stop_token 都来自同一个手动创建的 stop_source。当 source.request_stop() 被调用后,两个 stop_token 的 stop_requested() 都返回 true,两个线程几乎同时收到停止信号。注意,这两个 jthread 自己内部也有 stop_source,但因为我们用的是手动创建的 source 的 token,所以停止信号来自手动 source 而不是 jthread 内部的。
2.12 std::stop_callback
std::stop_callback 是一个 RAII 对象,它在构造时注册一个回调函数到某个 stop_token 上,当停止请求被发出时,这个回调函数会被自动调用。
cpp
#include <stop_token>
构造函数
cpp
template<class C>
explicit stop_callback(const stop_token& st, C&& cb) noexcept(/* ... */);
模板参数 C
回调函数的可调用类型,可以是 Lambda、函数指针、函数对象等。
参数 st
关联的 stop_token,回调将注册在这个 stop_token 的停止状态上。
参数 cb
回调函数本身,当停止请求被发出时被调用。
返回值
构造函数返回一个 std::stop_callback 对象。当这个对象被析构时,如果回调还未被调用且停止请求还未发出,回调会被取消注册。如果回调已经被调用过了,析构什么也不做。
关键行为
- 如果在构造
stop_callback时,停止请求已经被发出,回调会在构造函数中同步调用。 - 如果停止请求在构造之后才发出,回调会在调用
request_stop()的那个线程中同步执行。 stop_callback析构时,如果回调正在执行,析构会等待回调执行完毕。这保证了回调不会在对象析构后还在运行。
用法:资源清理
stop_callback 主要用于在线程被请求停止时自动执行资源清理、状态保存等操作:
cpp
#include <fstream>
#include <vector>
#include <thread>
#include <iostream>
#include <chrono>
using namespace std::chrono_literals;
void resource_cleanup_example() {
// 创建 jthread,Lambda 接受 stop_token
std::jthread worker([](std::stop_token token) {
// 模拟需要清理的资源
std::ofstream logfile("worker_log.txt");
std::vector<int> temporary_buffer(1000);
// 注册清理回调
// 当 token 关联的 stop_source 发出 request_stop() 时
// 这个 Lambda 会被自动调用
std::stop_callback cleanup(
token,
[&logfile, &temporary_buffer]() {
std::cout << "Cleaning up resources..." << std::endl;
logfile << "Worker stopped unexpectedly\n";
logfile.close();
temporary_buffer.clear();
std::cout << "Cleanup completed" << std::endl;
}
);
// 主工作循环
for (int i = 0; i < 100; ++i) {
// 每次循环检查是否被请求停止
if (token.stop_requested()) {
std::cout << "Stop requested, exiting gracefully..." << std::endl;
return; // 退出后 cleanup 对象析构,但回调已经在请求停止时执行过了
}
// 模拟工作
logfile << "Working... " << i << std::endl;
temporary_buffer[i % temporary_buffer.size()] = i;
std::this_thread::sleep_for(100ms);
}
std::cout << "Work completed normally" << std::endl;
});
// 主线程等 2 秒后请求停止
std::this_thread::sleep_for(2s);
std::cout << "Requesting stop..." << std::endl;
worker.request_stop();
// worker 析构时自动 join
}
int main() {
resource_cleanup_example();
return 0;
}
这段代码的执行流程:jthread 创建后,线程内部打开了日志文件、分配了缓冲区,然后注册了一个 stop_callback。主线程等 2 秒后调用 worker.request_stop(),此时 stop_callback 注册的回调被自动调用,执行文件写入和缓冲区清理。然后线程的循环检查到 stop_requested() 返回 true,优雅退出。
2.13 joinable()
cpp
bool joinable() const noexcept;
参数
无。
返回值
bool。如果 jthread 对象关联了一个正在执行的线程返回 true,否则返回 false。
用法
和 std::thread::joinable() 的含义完全相同。默认构造的 jthread 不 joinable,已经 join() 或 detach() 过的也不 joinable。
2.14 join()
cpp
void join();
参数
无。
返回值
void,无返回值。
用法
手动等待线程结束。调用后 jthread 不再 joinable。通常不需要手动调用,因为析构时会自动 join()。但如果你需要在线程结束之前就等待它完成(比如在某个函数中间等待),可以手动调用。
2.15 detach()
cpp
void detach();
参数
无。
返回值
void,无返回值。
用法
将线程与 jthread 对象分离,线程继续在后台独立运行。调用后 jthread 不再 joinable,析构时不会 join() 也不会 request_stop()。注意:detach() 之后你就失去了对线程的控制,也无法再通过 jthread 请求停止。
2.16 get_id()
cpp
std::jthread::id get_id() const noexcept;
参数
无。
返回值
返回 std::jthread::id 类型(实际上是 std::thread::id 的别名),表示线程的唯一标识符。如果 jthread 不 joinable,返回默认构造的 id(即 std::jthread::id{})。
用法
用于获取线程的 ID,可以用于日志记录、调试、或者在 std::condition_variable 的等待谓词中判断线程是否完成。
2.17 hardware_concurrency()
cpp
static unsigned int hardware_concurrency() noexcept;
参数
无。这是一个静态成员函数。
返回值
返回 unsigned int,表示实现支持的并发线程数量。这个值通常等于 CPU 的逻辑核心数。如果无法检测,返回 0。
用法
和 std::thread::hardware_concurrency() 完全相同。用于在程序启动时决定创建多少个工作线程。
2.18 swap()
cpp
void swap(jthread& other) noexcept;
参数
other:另一个 jthread 对象的引用,当前对象将与它交换底层线程和停止状态。
返回值
void,无返回值。
用法
交换两个 jthread 对象的底层线程句柄和 stop_source。交换后,当前对象管理原来 other 管理的线程,other 管理原来当前对象管理的线程。这个操作不会影响正在运行的线程本身,只是交换了管理权。
2.19 operator=
cpp
jthread& operator=(jthread&& other) noexcept;
参数
other:另一个 jthread 对象的右值引用。
返回值
返回 *this,即当前对象的引用。
用法
移动赋值操作符。如果当前对象仍然 joinable,会先发出 request_stop() 再 join() 等待线程结束,然后接管 other 的线程和停止状态。赋值后 other 不再 joinable。
关键行为
这个操作符的设计保证了"移动后源对象安全"的原则。赋值后 other 变成默认构造的状态,可以安全析构。同时,如果当前对象原来有线程在运行,它会先优雅地停止并等待那个线程结束,而不是直接 terminate。
2.20 jthread 与 thread 的全面对比
理解 jthread 的最佳方式是把它和 thread 放在一起做全面对比:
text
对比维度 std::thread std::jthread
─────────────────────────────────────────────────────────────────
析构行为 joinable → terminate joinable → request_stop + join
自动 join 否 是
中断机制 无 有(stop_token 协作中断)
回调注册 无 有(stop_callback)
接口兼容 - 核心接口与 thread 一致
move 语义 支持 支持
copy 语义 不支持 不支持
头文件 <thread> <thread>
标准版本 C++11 C++20
核心区别就两条:析构行为和中断机制。jthread 在这两个维度上都比 thread 更安全。但 jthread 并非在所有场景都优于 thread------如果你需要 detach() 一个线程让它独立运行(虽然很少推荐这样做),jthread 的 detach() 会同时切断停止状态的关联,这时候和 thread 就没什么区别了。
2.21 停止状态的线程安全性
stop_source、stop_token 和 stop_callback 的所有操作都是线程安全的。这意味着:
request_stop()可以从多个线程同时调用,只有一个会成功发出请求。stop_requested()可以在线程运行的同时被外部调用,不会产生数据竞争。stop_callback的注册和触发都是线程安全的。如果在注册回调时停止请求已经发出,回调会在构造函数中同步执行。
这些保证是通过内部的原子操作和互斥量实现的,使用者不需要自己加锁。
2.22 jthread 的 RAII 理念
std::jthread 的设计完美体现了 RAII(Resource Acquisition Is Initialization)理念。资源(线程)的获取在构造时完成,资源的释放(join)在析构时自动完成。这意味着:
text
传统方式(手动管理):
{
std::thread t(work);
// ... 大量代码 ...
t.join(); // 如果中间忘了,或者异常抛出,就崩溃
}
jthread 方式(自动管理):
{
std::jthread t(work);
// ... 大量代码 ...
// 不需要做任何事,t 离开作用域时自动收尾
}
这种"构造即获取、析构即释放"的模式,让资源管理变得几乎不可能出错------只要 jthread 对象正确构造了,它的线程就一定会被正确收尾,无论中间发生了什么。
3. std::osyncstream:线程安全输出的终极方案
一句话理解:
std::osyncstream= 一个带独立缓冲区的输出流包装器,写入时无需加锁,析构时原子性地把缓冲区内容刷新到底层流。
std::osyncstream 定义在:
cpp
#include <syncstream>
它从 C++20 开始进入标准库。完整的类型是 std::basic_osyncstream<CharT, Traits, Allocator>,我们日常使用的是 std::osyncstream,也就是 std::basic_osyncstream<char> 的类型别名。
3.1 多线程输出的混乱问题
在 C++20 之前,当多个线程同时向 std::cout 写入数据时,输出的内容很容易交错在一起,变得完全无法阅读。
cpp
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
std::mutex cout_mutex;
void print_hello(int id) {
// 如果不锁,输出会交错混乱
// std::lock_guard<std::mutex> lock(cout_mutex);
std::cout << "Hello from thread: " << id << '\n';
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back(print_hello, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
上面这段代码如果取消互斥锁的注释,虽然能保证输出不交错,但每次输出都要加锁解锁,开销不小。而且加锁的粒度很难控制------锁太细,一个 << 操作就加一次锁,多次 << 之间还是可能交错;锁太粗,性能损失大。
std::osyncstream 就是为了解决这个问题而诞生的。
3.2 std::osyncstream 的工作原理
std::osyncstream 的原理可以用三步来概括:
text
第一步:写入
→ 每个 osyncstream 对象拥有自己独立的内部缓冲区
→ 不同线程向各自的 osyncstream 写入时,数据先进入各自的缓冲区
→ 期间不需要任何锁
第二步:刷新
→ 当 osyncstream 对象被销毁时(最常见是在语句结束时)
→ 它的析构函数会自动、原子性地将整个缓冲区的内容刷新到关联的输出流
→ 这个刷新操作是线程安全的
第三步:结果
→ 每个线程的输出在底层流上是完整、连续的
→ 不会出现半个输出被另一个线程打断的情况
3.3 构造函数
cpp
explicit osyncstream(streambuf_type* buf);
explicit osyncstream(ostream_type& os);
参数
最常用的形式是第二种,传入一个输出流引用(如 std::cout)。os 是底层输出流,osyncstream 会在自己的缓冲区操作完成后,把内容刷新到这个流中。
第一种形式传入一个 streambuf 指针,用于更底层的控制。
返回值
构造函数返回一个 std::osyncstream 对象,也算是我们平时使用的std::<<了。
用法
最常见的用法是:
cpp
std::osyncstream(std::cout) << "Hello from thread: " << id << '\n';
这行代码创建了一个临时的 osyncstream 对象,包装了 std::cout,然后向它写入数据。当这行语句结束时,临时对象析构,缓冲区内容原子性地刷新到 std::cout。
也可以给它起个名字,多次写入后再统一刷新:
cpp
std::osyncstream sync_cout(std::cout);
sync_cout << "Hello from thread: " << id << '\n';
// 在 sync_cout 析构时,整行内容会原子性地输出到 cout
3.4 基本用法
cpp
#include <iostream>
#include <thread>
#include <vector>
#include <syncstream> // 引入新的头文件
void print_hello(int id) {
// 使用 osyncstream 来包装 cout
// 每个线程的 osyncstream 对象都有自己独立的缓冲区
std::osyncstream sync_cout(std::cout);
sync_cout << "Hello from thread: " << id << '\n';
// 也可以写成一行:
// std::osyncstream(std::cout) << "Hello from thread: " << id << '\n';
// 当 sync_cout 析构时,整行内容会原子性地输出到 cout
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back(print_hello, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
这段代码的关键在于:每个线程创建自己的 osyncstream 对象,写入操作进入各自的缓冲区,没有任何锁竞争。当 sync_cout 离开作用域时,析构函数原子性地把缓冲区内容刷新到 std::cout。最终每个线程的输出在 std::cout 上都是完整的、连续的一行,不会交错。
3.5 emit()
cpp
void emit();
参数
无。
返回值
void,无返回值。
用法
手动将缓冲区内容刷新到底层流,同时保持 osyncstream 对象存活。调用 emit() 后,内部缓冲区会被清空,但 osyncstream 对象仍然可以继续使用------你可以继续往里写入新数据,等待下次析构或再次 emit() 时刷新。
这个方法适用于需要分批输出、或者需要在特定时刻确保数据已经写入底层流的场景。
cpp
#include <iostream>
#include <thread>
#include <vector>
#include <syncstream>
void print_hello(int id) {
std::osyncstream bout(std::cout);
bout << "This goes to the buffer." << id << '\n';
bout << " This too." << id << '\n';
// 到目前为止,cout 上还没有任何输出
// 数据都在 bout 的内部缓冲区里
bout.emit(); // 手动刷新,"This goes to the buffer.X\n This too.X\n" 原子性地输出到 cout
bout << "This is after emit." << id << '\n'; // 继续写入缓冲区
// 当 bout 析构时,"This is after emit.X\n" 会被原子性地输出到 cout
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back(print_hello, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
这段代码展示了 emit() 的效果:前两行写入缓冲区后调用 emit(),数据原子性地写入 cout。然后继续写入第三行,这行数据在 bout 析构时才会被刷新。
3.6 get_wrapped()
cpp
protected:
streambuf_type* get_wrapped() const noexcept;
参数
无。
返回值
返回底层 streambuf 指针,即被包装的流的缓冲区指针。
用法
这个方法是 protected 的,通常在自定义的 osyncstream 子类中使用。一般开发者不太需要直接使用它。它的作用是让你能访问到底层流的信息,比如判断底层流的状态。
3.7 最佳实践
不要长期持有 osyncstream 对象。 它的价值在于短生命周期的输出------在一个函数内部创建、写入、析构,完成一次原子输出。如果你长期持有一个 osyncstream 对象,就失去了"将多个输出操作打包成一个原子操作"的意义,而且内部缓冲区会持续占用内存。
优先使用临时对象语法。 对于单次输出,推荐直接写成一行:
cpp
std::osyncstream(std::cout) << "Thread " << id << ": " << message << '\n';
这行代码在语句结束时自动析构、自动刷新,最简洁也最安全。
需要多次输出时用命名对象 + emit()。 如果一个函数内需要多次输出且希望它们分批原子刷新,可以用命名对象配合 emit()。
3.8 osyncstream 的底层原理
std::osyncstream 的线程安全保证来自它包装的底层缓冲区类型 std::basic_syncbuf。整个机制可以用下面的流程来理解:
text
写入操作 (<<)
↓
进入 osyncstream 的内部缓冲区(basic_syncbuf)
↓
此时不需要任何锁,因为每个线程有自己的 osyncstream 实例
↓
析构或 emit() 被调用
↓
basic_syncbuf 把内部缓冲区的所有内容一次性、原子性地传输到底层流
↓
底层流(如 cout)的 mutex 被短暂锁定
↓
内容写入完成后立即释放锁
关键点在于"短暂锁定":锁只发生在最终刷新那一刻,而不是每次 << 操作时。这意味着即使有 100 次 << 操作,也只需要在最后刷新时锁一次底层流。
3.9 osyncstream 与互斥锁的性能对比
使用 std::mutex 保护输出的方式:
text
每次 << 操作
→ 加锁 → 写入 → 解锁
→ 加锁 → 写入 → 解锁
→ 加锁 → 写入 → 解锁
→ ... N 次
使用 std::osyncstream 的方式:
text
每次 << 操作
→ 写入内部缓冲区(无锁)
→ 写入内部缓冲区(无锁)
→ 写入内部缓冲区(无锁)
→ ... N 次
→ 析构时:锁定底层流 → 一次性写入 → 解锁
在高并发场景下,osyncstream 的优势非常明显------多个线程可以同时向各自的缓冲区写入,互不干扰,只在最终刷新时短暂竞争底层流的锁。
3.10 osyncstream 的适用场景
- 多线程日志输出 :多个工作线程向
std::cout或日志文件输出,每条日志需要完整不交错。 - 多线程进度报告:多个线程在处理不同任务时向控制台输出进度信息。
- 多线程结果输出:多个计算线程在完成后把结果输出到同一流。
不适合的场景:
- 单线程输出 :单线程不需要
osyncstream,直接用std::cout即可。 - 高频小输出 :如果每个线程只有一行输出且频率极低,
std::mutex也可以接受。osyncstream的优势在于需要连续多次<<的场景。
4. 指派初始化器:用名字而不是位置来初始化结构体
一句话理解:
指派初始化器 = 在初始化结构体时,用
.成员名 = 值的方式指定每个值对应哪个成员,而不是靠位置顺序。
指派初始化器是 C++20 引入的语言特性,不需要包含任何额外的头文件。
4.1 传统初始化的问题
在 C++20 之前,初始化结构体只能用位置初始化:
cpp
struct Employee {
int id = 100;
std::string name = "张三";
double salary;
int department;
};
// 传统初始化方式(按声明顺序)
Employee e1{ 101, "Alice", 75000.0, 2 };
当结构体成员较少时,这种方式还能接受。但当成员变多后,75000.0 是 salary 还是别的什么?2 是 department 还是工龄?读代码的人必须回到结构体定义去查每个成员的位置。
4.2 指派初始化器的语法
C++20 引入的指派初始化器语法:
cpp
Employee e2{
.id = 102,
.name = "Bob",
.salary = 68000.0,
.department = 3
};
每个初始化项前面加上 .成员名 =,清楚标明这个值赋给哪个成员。
4.3 可以跳过某些成员
未用指派初始化器指定的成员,会使用其默认成员初始化器(如果有的话)或进行值初始化(对内置类型就是零初始化):
cpp
// department 未指定,使用默认值 0(因为无默认成员初始化器,值初始化为 0)
Employee e3{
.id = 103,
.name = "Charlie",
.salary = 72000.0
};
// id 和 salary 未指定
// id 使用默认成员初始化器 100
// salary 值初始化为 0.0
Employee e4{
.name = "Charlie",
.department = 1
};
4.4 重要规则
规则一:顺序必须与声明顺序一致
C++ 的指派初始化器必须按照成员在结构体中的声明顺序使用,不能乱序。这一点和 C 语言不同。
cpp
// 错误示例:salary 在 name 之后声明,但这里把 salary 放在 name 前面
Employee e5{
.id = 104,
.salary = 80000.0, // 错误!salary 声明在 name 之后
.name = "David",
.department = 1
};
上面这段代码无法编译,因为 salary 在结构体中声明在 name 之后,初始化时必须保持 name 在 salary 之前。
规则二:不能与普通初始化混用
cpp
// 错误:不能混用指派初始化和普通位置初始化
Employee e6{
.id = 104,
"David", // 错误!不能混用
.salary = 80000.0
};
规则三:不能嵌套
cpp
struct Inner { int a; int b; };
struct Outer { Inner inner; int x; };
// 错误:C++ 不支持嵌套指派初始化器
// Outer o{ .inner.a = 1, .x = 2 };
// 正确写法
Outer o{ .inner = {1, 2}, .x = 3 };
// 或者
Outer o{ .inner = {.a = 1, .b = 2}, .x = 3 };
规则四:不能用于数组
C 语言的指派初始化器支持 [index] = value 来初始化数组的特定元素,但 C++ 不支持这种语法。
规则五:禁止窄化转换
cpp
struct S { int x; double y; };
// 错误:1.5 到 int 是窄化转换
// S s{ .x = 1.5, .y = 2.0 };
4.5 适用于联合体
指派初始化器可以用来初始化联合体的非第一个成员:
cpp
union U {
int a;
const char* b;
};
U u1 = {.a = 1}; // OK,初始化第一个成员
U u2 = {.b = "hello"}; // OK,初始化第二个成员(C++20 之前不行)
U u3 = {.a = 1, .b = "hello"}; // 错误,联合体只能初始化一个成员
4.6 特性测试宏
cpp
__cpp_designated_initializers // 值为 201707L 表示支持
这个宏可以用来在编译时检测编译器是否支持指派初始化器。在 #if 条件中使用:
cpp
#if __cpp_designated_initializers >= 201707L
// 使用指派初始化器
Point p{.x = 1, .y = 2};
#else
// 回退到位置初始化
Point p{1, 2};
#endif
4.7 指派初始化器与 C 语言的差异
C 语言的指派初始化器比 C++ 灵活得多,支持以下 C++ 不允许的特性:
text
C 语言支持但 C++ 不支持的:
→ 乱序初始化:{ .y = 1, .x = 2 } (C++ 要求严格按声明顺序)
→ 数组指派:{ [2] = 5 } (C++ 不支持数组指派)
→ 嵌套指派:{ .inner.x = 1 } (C++ 不支持嵌套指派)
→ 混合使用:{ .x = 1, 2 } (C++ 不支持混用指派和位置初始化)
C++ 选择更严格的规则是为了保持初始化的确定性和可预测性。乱序初始化在 C 语言中虽然方便,但容易引入难以发现的 bug------结构体成员顺序变了,初始化的含义就变了。C++ 要求严格按声明顺序使用,使初始化行为更加明确。
4.8 指派初始化器的实际优势
- 自文档化 :
.id = 102比102更清晰地表达了"102 是 id 的值"这个意图。 - 防错:如果结构体新增了成员,位置初始化可能悄悄地把值赋给错误的位置,而指派初始化器会明确地只赋值指定的成员。
- 可读性:对于成员超过 3 个的结构体,指派初始化器显著提升代码可读性。
- 部分初始化:可以只初始化关心的成员,其余使用默认值,语义更清晰。
5. 范围 for 循环中的初始化语句
一句话理解:
C++20 允许在范围 for 循环的圆括号里加一个初始化语句,用分号和循环变量声明隔开,语法形式类似
for (init; declaration : range) { ... }。
这个特性不需要包含额外头文件,是语言层面的增强。
5.1 语法
cpp
// C++11 引入的范围 for 基本形式
for (declaration : range_expression) {
// 循环体
}
// C++20 允许在范围 for 中添加初始化语句
for (init-statement; declaration : range_expression) {
// 循环体
}
init-statement 是在循环开始前执行的语句,通常是一个变量声明。这个变量的作用域被限制在 for 循环内部,循环结束后不可见。
5.2 为什么需要这个特性
考虑一个场景:函数返回一个容器,你想遍历它。在 C++20 之前:
cpp
std::vector<int> getData() {
return {1, 2, 3, 4, 5};
}
int main() {
// C++20 之前需要这样写
{
auto vec = getData(); // 先获取数据,临时变量 vec 的作用域在这个大括号内
for (int x : vec) {
std::cout << x << " ";
}
std::cout << "\n";
}
// vec 在这里已经不可见,但如果不用大括号包裹,vec 会泄漏到外面
}
C++20 可以合并为一行:
cpp
int main() {
// C++20:初始化语句和循环合并
// auto vec = getData() 是 init-statement
// int x 是 declaration
// vec 是 range_expression
for (auto vec = getData(); int x : vec) {
std::cout << x << " ";
}
std::cout << "\n";
// vec 在这里不可见,作用域被限制在 for 循环内
}
5.3 另一个常见场景:遍历 map 的副本
cpp
#include <map>
#include <iostream>
int main() {
std::map<int, std::string> m = {
{1, "one"},
{2, "two"},
{3, "three"}
};
// C++20 之前
{
auto tmp = m;
for (auto& [key, val] : tmp) {
val = "new " + val; // 修改临时副本而不是原始 map
std::cout << key << ": " << val << "\n";
}
}
// C++20 带初始化语句的版本
for (auto tmp = m; auto& [key, val] : tmp) {
val = "new " + val; // 修改临时副本
std::cout << key << ": " << val << "\n";
}
}
这个场景的好处是:tmp 的作用域被限制在 for 循环内,不会泄漏到外层作用域。
5.4 主要特点
- 初始化语句在循环开始前执行一次。
- 作用域限制:初始化语句中声明的变量只在 for 循环内可见,循环结束后自动销毁。
- 兼容性:不影响原有的范围 for 循环语法,两种形式可以共存。
5.5 实际应用场景
场景一:避免临时变量的作用域泄漏
在 C++20 之前,如果在遍历前需要先获取一个临时容器(比如函数返回值),就必须在外层作用域声明变量。这个变量在 for 循环之后仍然可见,可能会导致误用。C++20 的初始化语句把变量的作用域限制在 for 循环内部。
场景二:避免悬垂引用
当范围表达式返回一个临时对象时,如果不把它绑定到一个变量上,在某些情况下可能产生悬垂引用。初始化语句确保临时对象的生命周期覆盖整个循环:
cpp
// 安全:getData() 返回的临时对象被 vec 持有
// vec 的生命周期覆盖整个 for 循环
for (auto vec = getData(); int x : vec) {
// ...
}
场景三:带锁的遍历
cpp
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> shared_data;
void safe_iterate() {
// 先加锁,再遍历
// 锁的作用域限制在 for 循环内
for (std::lock_guard<std::mutex> lock(mtx); int x : shared_data) {
// 遍历期间持有锁
// 循环结束后锁自动释放
}
}
这个用法把锁和遍历的生命周期绑定在一起,遍历一结束锁就释放,不会多锁也不会少锁。
6. Lambda 初始捕获中的包扩展
一句话理解:
C++20 允许在 Lambda 的初始化捕获中使用
...语法来直接捕获和展开参数包,支持按值、按引用、完美转发和移动捕获。
这个特性不需要包含额外头文件,是语言层面的增强。
6.1 C++20 之前的包捕获
在 C++20 之前,Lambda 可以捕获可变参数包,但方式比较有限:
cpp
// [args...] 按值捕获包
// [&args...] 按引用捕获包
示例一:简单包捕获
cpp
#include <iostream>
template<typename... Args>
void process(Args&&... args) {
// [args...] 按值捕获整个参数包
auto lambda = [args...]{
// 使用折叠表达式展开包
((std::cout << args << " "), ...);
std::cout << "\n";
};
lambda();
}
示例二:引用捕获并修改
cpp
template<typename... Args>
void process_by_ref(Args&&... args) {
// [&args...] 按引用捕获整个参数包
auto lambda = [&args...]{
// 修改通过引用捕获的变量
((args *= 2), ...);
};
std::cout << "Before: ";
((std::cout << args << " "), ...);
std::cout << "\n";
lambda(); // 执行 Lambda,会修改外部变量
std::cout << "After: ";
((std::cout << args << " "), ...);
std::cout << "\n";
}
6.2 C++20 的新语法
C++20 增强了 Lambda 的初始化捕获能力,允许在初始化捕获中使用 ... 语法来展开参数包:
cpp
[...pack_name = expression] // 按值捕获包(每个参数都拷贝)
[&...pack_name = expression] // 按引用捕获包(每个参数都引用)
其中 pack_name 是捕获的参数包名称,expression 是生成参数包的表达式,... 放在包名称前面表示这是一个包扩展捕获。
按值捕获
cpp
#include <iostream>
template<typename... Args>
void process(Args&&... args) {
// [...captured_args = args] 按值捕获所有 args
// 每个 args 元素都被拷贝到 Lambda 的捕获中
// captured_args 成为 Lambda 内部的参数包名称
auto lambda = [...captured_args = args]{
((std::cout << captured_args << " "), ...);
std::cout << "\n";
};
lambda();
}
按引用捕获
cpp
template<typename... Args>
void process_by_ref(Args&&... args) {
// [&...captured_refs = args] 按引用捕获所有 args
// 每个 args 元素都以引用方式被 Lambda 捕获
auto lambda = [&...captured_refs = args]{
((captured_refs *= 2), ...);
};
std::cout << "Before: ";
((std::cout << args << " "), ...);
std::cout << "\n";
lambda(); // 执行 Lambda,会修改外部变量
std::cout << "After: ";
((std::cout << args << " "), ...);
std::cout << "\n";
}
6.3 完美转发和移动捕获
C++20 的包扩展初始化捕获最强大的地方在于:可以对每个参数执行完美转发或移动捕获。
完美转发捕获
cpp
#include <iostream>
#include <vector>
#include <string>
template<typename... Args>
void process(Args&&... args) {
// [...captured = std::forward<Args>(args)]
// 对每个参数执行完美转发
// 如果原始参数是左值,则按值拷贝捕获
// 如果原始参数是右值,则按移动捕获
auto lambda = [...captured = std::forward<Args>(args)]{
((std::cout << captured << " "), ...);
std::cout << "\n";
};
}
移动捕获
cpp
template<typename... Args>
auto capture_with_forward_or_move(Args&&... args) {
// [&...captured = std::forward<Args>(args)] // 编译错误!不能对引用捕获做转发
// [&...captured = std::move(args)] // 编译错误!不能对引用捕获做移动
// [...moved_args = std::forward<Args>(args)] // 正确:按值捕获,对每个参数完美转发
auto lambda = [...moved_args = std::forward<Args>(args)]{
std::vector<std::string> v;
((v.push_back(moved_args)), ...);
return v;
};
return lambda();
}
注意这里的语法差异:
text
[...name = expr] → 按值捕获包(可以配合 std::forward 做转发或移动)
[&...name = expr] → 按引用捕获包(不能配合 std::forward 或 std::move)
[&...captured = std::forward<Args>(args)] 之所以报错,是因为按引用捕获意味着每个元素都是引用类型,而 std::forward 返回的是值或右值引用,无法绑定到引用捕获上。
构造元组捕获
C++20 的包扩展捕获还支持更复杂的初始化方式,比如把参数包捕获为元组:
cpp
#include <iostream>
#include <tuple>
template<typename... Args>
auto create_tuple_capture(Args&&... args) {
// 将参数包完美转发后构造一个 tuple
// items 是一个 std::tuple,作为 Lambda 的单个捕获
return [items = std::tuple(std::forward<Args>(args)...)]{
// std::apply 把 tuple 的元素展开传给 Lambda
return std::apply([](auto&&... elements) {
return (elements + ...); // 折叠表达式求和
}, items);
};
}
int main() {
auto func = create_tuple_capture(1, 2, 3, 4);
std::cout << func() << std::endl; // 输出: 10
return 0;
}
这段代码的逻辑:create_tuple_capture 接收 4 个整数,用 std::tuple 完美转发后捕获进 Lambda。Lambda 执行时,用 std::apply 把 tuple 的元素展开传给内部 Lambda,内部 Lambda 用折叠表达式 (elements + ...) 对所有元素求和。
6.4 C++20 之前 vs C++20 的对比
text
C++20 之前:
[args...] → 按值捕获,无法对每个参数做初始化
[&args...] → 按引用捕获,无法对每个参数做初始化
C++20 新增:
[...name = expr] → 按值捕获,可以对每个参数做初始化(如 forward、move)
[&...name = expr] → 按引用捕获,可以对每个参数做初始化
核心区别在于 C++20 的语法允许你为每个包元素指定一个初始化表达式,这就打开了完美转发和移动捕获的大门。
6.5 语法细节详解
C++20 的包扩展初始化捕获语法有几种变体,每种都有不同的语义:
[...name = expr] 按值捕获
... 放在包名前面,= expr 是每个包元素的初始化表达式。这意味着对于参数包中的每个元素,都会在 Lambda 的捕获中创建一个对应的拷贝。name 在 Lambda 内部成为一个参数包,可以用折叠表达式展开。
[&...name = expr] 按引用捕获
&... 表示按引用捕获。每个包元素都以引用方式绑定到 Lambda 捕获中。注意,按引用捕获时不能配合 std::forward 或 std::move,因为引用捕获的元素本身就是引用,对引用做 forward 或 move 会导致编译错误。
[...name = std::forward<Args>(args)] 完美转发捕获
这是最强大的形式。std::forward<Args>(args) 对每个参数做完美转发:如果原始参数是左值,则拷贝捕获;如果原始参数是右值,则移动捕获。这种写法在需要把参数传递给异步操作或延迟执行的场景中特别有用。
[name = std::tuple(std::forward<Args>(args)...)] 元组捕获
这不是包扩展捕获,而是把整个参数包打包成一个 std::tuple 作为单个捕获。Lambda 内部用 std::apply 展开 tuple。这种写法适用于需要把参数包作为一个整体保存并在之后一次性展开使用的场景。
6.6 实际应用:异步任务包装器
C++20 的包扩展捕获最常见的实际应用是创建异步任务包装器:
cpp
#include <iostream>
#include <utility>
#include <future>
// 异步执行函数并捕获参数
template<typename F, typename... Args>
auto async_invoke(F&& f, Args&&... args) {
// 完美转发捕获函数和所有参数
// 返回一个可调用对象,可以在之后调用
return [f = std::forward<F>(f),
...captured = std::forward<Args>(args)]() mutable {
return f(captured...);
};
}
int main() {
int x = 10;
int y = 20;
// 捕获 add 函数和参数 x, y
auto task = async_invoke([](int a, int b) {
return a + b;
}, x, y);
// 稍后执行
std::cout << task() << std::endl; // 输出: 30
return 0;
}
这段代码的关键在于 [...captured = std::forward<Args>(args)]:它完美转发捕获所有参数。如果某个参数是右值(如临时对象或 std::move 的结果),它会被移动到 Lambda 的捕获中,而不是拷贝。这在处理大型对象(如 std::string、std::vector)时能显著提升性能。
7. and/or/not 等替代标记
一句话理解:
C++20 把
and、or、not等单词形式的逻辑运算符从"需要包含头文件才能用的替代标记"提升为"语言关键字",可以直接使用。
7.1 替代标记列表
C++ 支持以下替代标记:
text
逻辑运算符:
and 代替 &&
or 代替 ||
not 代替 !
xor 代替 ^
位运算符:
bitand 代替 &
bitor 代替 |
compl 代替 ~
xor_eq 代替 ^=
其他:
and_eq 代替 &=
or_eq 代替 |=
not_eq 代替 !=
7.2 C++20 的变化
在 C++20 之前,使用这些替代标记在 MSVC 上需要包含头文件 <ciso646>。这是因为 C++ 标准把这些标记定义为"替代标记"(alternative tokens),而不是关键字,某些编译器需要头文件来引入它们的定义。
C++20 开始,这些替代标记正式成为关键字,不包含任何头文件也可以直接使用。
7.3 使用示例
cpp
// C++17 及之前:在 MSVC 上可能需要 #include <ciso646>
// C++20 开始:直接可用
#include <iostream>
int main() {
int a = 10, b = 20;
// 使用 and, or, not 代替 &&, ||, !
if (a > 0 and b < 30) { // 等价于 if (a > 0 && b < 30)
std::cout << "Both conditions are true\n";
}
if (not(a == b)) { // 等价于 if (!(a == b))
std::cout << "a is not equal to b\n";
}
bool flag1 = true, flag2 = false;
if (flag1 or flag2) { // 等价于 if (flag1 || flag2)
std::cout << "At least one flag is true\n";
}
return 0;
}
7.4 实际建议
虽然 and、or、not 在 C++20 后可以直接使用,但在实际项目中,符号形式 &&、||、! 仍然是更主流的写法。替代标记的主要价值在于某些特殊编码环境或可读性要求较高的场景。
7.5 完整替代标记参考
为了方便查阅,这里列出所有 C++ 替代标记及其对应的符号:
text
关键字类替代标记(C++20 后成为关键字):
────────────────────────────────────────
and → && (逻辑与)
or → || (逻辑或)
not → ! (逻辑非)
xor → ^ (按位异或)
bitand → & (按位与)
bitor → | (按位或)
compl → ~ (按位取反)
and_eq → &= (按位与赋值)
or_eq |= (按位或赋值)
xor_eq ^= (按位异或赋值)
not_eq != (不等于)
这些替代标记在语义上和对应的符号完全等价,编译后产生完全相同的机器码。选择哪种形式纯粹是风格问题,不影响程序行为。
7.6 not_eq 的特殊用法
not_eq 替代 != 时有一个需要注意的地方:它是一个二元运算符,必须写成 a not_eq b,不能像 not 那样作为前缀使用:
cpp
int a = 5, b = 3;
// 正确用法
if (a not_eq b) { /* ... */ } // 等价于 if (a != b)
// 错误用法 - not_eq 不是前缀运算符
// if (not_eq(a, b)) { /* ... */ } // 编译错误
// not 才是前缀运算符
if (not(a == b)) { /* ... */ } // 等价于 if (!(a == b))
8. 功能特性宏测试
一句话理解:
特性测试宏是标准库提供的一组以
__cpp_开头的宏,用于在编译时检测编译器或标准库是否支持某个特定的 C++ 功能,从而编写可移植的代码。
8.1 为什么需要特性测试宏
C++ 标准在不断发展,但不同编译器对标准的支持进度各不相同。GCC 12 可能已经支持了 C++20 的某个特性,但 Clang 15 可能还不支持,或者 MSVC 19.35 才开始支持。特性测试宏就是用来在编译时检测这种差异的。
它们的主要作用:
- 检查编译器是否支持某个 C++ 新标准的特性。
- 检测标准库是否提供某个标准库组件(如
<filesystem>、<optional>等)。 - 编写跨平台、跨编译器的可移植代码,避免因编译器差异导致编译错误。
8.2 三类特性测试工具
__has_include
检测某个头文件是否可用:
cpp
#ifdef __has_include
# if __has_include(<optional>)
# include <optional>
# elif __has_include(<experimental/optional>)
# include <experimental/optional>
# elif __has_include(<boost/optional.hpp>)
# include <boost/optional.hpp>
# else
# error "Missing <optional>"
# endif
#endif
__has_include 是一个预处理器宏,返回 1 表示指定的头文件存在,0 表示不存在。它的语法是 __has_include(<头文件名>)。
__has_cpp_attribute
检测某个属性是否可用:
cpp
#ifdef __has_cpp_attribute
# if __has_cpp_attribute(deprecated)
# define DEPRECATED(msg) [[deprecated(msg)]]
# endif
#endif
#ifndef DEPRECATED
# define DEPRECATED(msg)
#endif
DEPRECATED("foo() has been deprecated") void foo();
__has_cpp_attribute 返回一个整数,表示属性的支持版本。返回 0 表示不支持,非零值表示支持的版本号。
__cpp_* 特性宏
这是最核心的一组宏。每个 C++ 特性都有一个对应的 __cpp_ 前缀宏,如果编译器支持该特性,宏会被定义为表示该特性被采纳时的年月值(如 201304L 表示 2013 年 4 月)。
cpp
// 检查 constexpr 的版本
#if __cpp_constexpr >= 201304 // 201304 表示 C++14 的 constexpr 改进
# define CONSTEXPR constexpr
#else
# define CONSTEXPR inline
#endif
CONSTEXPR int bar(unsigned i) {
return i * 2;
}
cpp
// 检查二进制字面量
#if __cpp_binary_literals // 如果定义了,表示支持二进制字面量
unsigned mask1 = 0b11000000;
unsigned mask2 = 0b00000111;
#else
unsigned mask1 = 0xC0;
unsigned mask2 = 0x07;
#endif
8.3 常见的特性测试宏
更多的特性测试宏可以去标准库文档查看。以下是一些常见的:
text
语言特性宏:
__cpp_constexpr constexpr 支持(201304L = C++14 改进版)
__cpp_binary_literals 二进制字面量(201414L = C++14)
__cpp_designated_initializers 指派初始化器(201707L = C++20)
__cpp_init_captures Lambda 初始化捕获(201304L = C++14)
__cpp_generic_lambdas 泛型 Lambda(201304L = C++14, 201707L = C++20 改进)
__cpp_fold_expressions 折叠表达式(201603L = C++17)
标准库特性宏:
__cpp_lib_jthread std::jthread 支持(201911L = C++20)
__cpp_lib_span std::span 支持(202002L = C++20)
__cpp_lib_syncbuf std::osyncstream 支持(201802L = C++20)
__cpp_lib_optional std::optional 支持(202106L = C++20 改进)
__cpp_lib_filesystem std::filesystem 支持(201703L = C++17)
8.4 使用模式
特性测试宏的典型使用模式是用 #if 检查宏是否定义及其值:
cpp
#if defined(__cpp_lib_span) && __cpp_lib_span >= 202002L
# include <span>
void process(std::span<int> data) { /* 使用 span */ }
#else
// 回退方案
void process(int* data, size_t n) { /* 使用裸指针 */ }
#endif
这种模式让你的代码能根据编译器的实际能力自动选择最佳实现,是编写跨平台 C++ 代码的标准做法。
8.5 __has_include 的深入理解
__has_include 是一个预处理器运算符,用于检测某个头文件是否可以被包含。它的语法是:
cpp
__has_include(<header>) // 检测标准库头文件
__has_include("header") // 检测用户自定义头文件
参数
<header> 或 "header":要检测的头文件名。尖括号形式检测标准库和系统头文件路径,双引号形式检测用户自定义头文件路径。
返回值
返回整数 1(存在)或 0(不存在)。
用法
__has_include 常用于在不确定某个库是否可用的场景下做条件包含。比如你想使用 std::optional,但不确定编译器是否支持 C++17:
cpp
#if __has_include(<optional>)
# include <optional>
using MaybeInt = std::optional<int>;
#elif __has_include(<experimental/optional>)
# include <experimental/optional>
using MaybeInt = std::experimental::optional<int>;
#else
// 自定义实现
struct MaybeInt { /* ... */ };
#endif
注意事项
__has_include 只检测头文件是否存在,不检测头文件中是否包含特定的功能。一个头文件可能存在但不完整(某些功能可能被编译器标记为未实现)。因此,__has_include 应该和 __cpp_* 宏配合使用。
8.6 __has_cpp_attribute 的深入理解
__has_cpp_attribute 是一个预处理器运算符,用于检测某个属性是否被支持。语法:
cpp
__has_cpp_attribute(attribute_name)
参数
attribute_name:要检测的属性名,如 deprecated、nodiscard、maybe_unused 等。
返回值
返回一个整数,表示属性的支持版本。0 表示不支持,非零值表示支持的年份月份值。例如 __has_cpp_attribute(deprecated) 在支持 C++14 [[deprecated]] 时返回 201309L。
用法
cpp
#if __has_cpp_attribute(nodiscard)
# define NODISCARD [[nodiscard]]
#else
# define NODISCARD
#endif
NODISCARD int compute() { return 42; }
8.7 __cpp_* 宏的命名规则
特性测试宏的命名遵循统一规则:
text
语言特性宏:
__cpp_特性名
例:__cpp_constexpr、__cpp_fold_expressions
标准库特性宏:
__cpp_lib_特性名
例:__cpp_lib_span、__cpp_lib_jthread
宏的值是一个长整数字面量,格式为 YYYYMML,表示该特性被采纳的年月。例如 201911L 表示 2019 年 11 月被采纳。
版本比较规则
用 >= 比较宏值来判断是否满足最低版本要求:
cpp
#if __cpp_lib_span >= 202002L
// 确保是 C++20 最终版,而不是早期实验版
#endif
有时候同一个特性在不同版本有改进,通过比较版本值可以选择使用最新功能:
cpp
#if __cpp_constexpr >= 201304L
// C++14 改进版 constexpr:支持循环、局部变量
constexpr int factorial(int n) {
int result = 1;
for (int i = 1; i <= n; ++i) result *= i;
return result;
}
#elif __cpp_constexpr
// C++11 版本 constexpr:只能用单 return 语句
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
#endif
8.8 特性测试宏 vs 编译标准版本
一个常见的误区是认为只要设置了 -std=c++20,所有 C++20 特性就都可用了。实际上:
text
-std=c++20
→ 告诉编译器使用 C++20 标准的语法规则
→ 但不保证所有标准库功能都已实现
特性测试宏
→ 检测编译器和标准库的实际实现状态
→ 即使设置了 -std=c++20,某些库特性可能还未实现
因此,正确的做法是:设置编译标准 + 用特性测试宏做细粒度检测。这样即使编译器声称支持 C++20,你的代码也能优雅地处理那些尚未实现的部分。
9. std::span:连续内存的轻量级视图
一句话理解:
std::span= 一段连续内存的轻量级、非拥有型视图,包含一个指针和一个大小,能统一接收 C 数组、std::vector、std::array等各种连续内存容器。
std::span 定义在:
cpp
#include <span>
它从 C++20 开始进入标准库。
9.1 为什么需要 std::span
在 C++20 之前,处理连续内存序列时需要为不同容器写不同签名的函数:
cpp
#include <iostream>
#include <vector>
// 处理 C 数组:需要指针 + 大小
void process_array(int* arr, std::size_t size) {
for (std::size_t i = 0; i < size; ++i) {
// 处理 arr[i]
}
}
// 处理 vector:需要 const 引用
void process_vector(const std::vector<int>& vec) {
for (auto& elem : vec) {
// 处理 elem
}
}
int main() {
int a[] = { 1, 2, 3, 4, 5 };
process_array(a, sizeof(a) / sizeof(int)); // 数组传参后丢失大小信息
std::vector<int> v = { 1, 2, 3, 4, 5 };
process_vector(v);
return 0;
}
这段代码的问题在于:process_array 和 process_vector 做的事情完全一样------遍历一段 int 序列,但函数签名必须不同。而且 C 风格数组传参后退化成指针,丢失了大小信息,还无法使用范围 for 循环。
std::span 解决了这个问题:用一个统一的类型来接收所有连续内存序列。
9.2 主要特点
text
非占有性
→ 它不管理所指向内存的生命周期
→ 你需要确保在 span 使用期间,底层数据始终有效
轻量级
→ 通常只包含指针和大小两个成员
→ 动态扩展 span 通常占 2 个指针大小(16 字节 on 64-bit)
→ 静态扩展 span 只包含指针(大小编码在类型中)
边界安全
→ operator[] 通过 assert 做调试期边界检查(release 模式下不检查)
兼容性
→ 可以方便地与 C 风格数组和 STL 容器交互
→ 不能用于非连续容器(如 std::list、std::map 等)
9.3 模板参数
cpp
template<class T, std::size_t Extent = std::dynamic_extent>
class span;
模板参数 T
元素类型。表示 span 引用的序列中每个元素的类型。例如 std::span<int> 引用的是 int 序列。
模板参数 Extent
范围的大小。如果是一个编译时常量(如 5),则是静态扩展(Fixed-size extent)。如果是 std::dynamic_extent(默认值),则是动态扩展(Dynamic-size extent)。
9.4 构造 span
cpp
#include <span>
#include <vector>
#include <array>
#include <iostream>
void print(std::span<int> s) {
for (auto elem : s) {
std::cout << elem << ' ';
}
std::cout << '\n';
}
int main() {
// 从 C 数组创建 span
int arr[] = { 1, 2, 3, 4, 5 };
std::span<int> s1(arr); // CTAD 可以推导出 span<int, 5>
print(s1); // 输出: 1 2 3 4 5
// 从 vector 创建 span
std::vector<int> vec = { 6, 7, 8, 9, 10 };
std::span<int> s2(vec); // 推导出 span<int>(动态扩展)
print(s2); // 输出: 6 7 8 9 10
// 从 array 创建 span
std::array<int, 5> arr2 = { 11, 12, 13, 14, 15 };
std::span<int> s3(arr2); // 推导出 span<int, 5>(静态扩展)
print(s3); // 输出: 11 12 13 14 15
// 固定大小的 span
std::span<int, 3> s4(arr, 3); // 只包含前 3 个元素
print(s4); // 输出: 1 2 3
return 0;
}
这段代码展示了 std::span 的多种构造方式。最关键的一点是:print 函数接受 std::span<int> 参数,但实参可以是 C 数组、vector、array------只要元素类型是 int 且内存连续,span 都能接收。
9.5 静态扩展与动态扩展
静态扩展 span
cpp
std::span<int, 5> // 大小在编译时已知为 5
特点:
- 大小在编译时已知,是类型的一部分。
- 不需要存储大小信息,所以更轻量------只占一个指针的大小。
- 只能从大小完全匹配的数据源构造。如果你声明
span<int, 3>但传入的数组有 5 个元素,无法构造。
动态扩展 span
cpp
std::span<int> // 等价于 span<int, std::dynamic_extent>
std::span<int, std::dynamic_extent> // 显式写法
特点:
- 大小在运行时确定。
- 需要存储大小信息,通常占两个指针大小。
- 更加灵活,可以从任何大小的连续序列构造。
cpp
#include <span>
#include <vector>
#include <array>
#include <iostream>
// 动态大小的 span
void process_dynamic(std::span<int> sp) {
// sizeof(sp) 在 64 位系统上通常是 16 字节(指针 + 大小)
std::cout << sizeof(sp) << '\n';
}
// 静态大小的 span(固定为 3 个元素)
void process_static(std::span<int, 3> sp) {
// sizeof(sp) 在 64 位系统上通常是 8 字节(只有指针,大小编码在类型中)
std::cout << sizeof(sp) << '\n';
}
int main() {
std::vector<int> vec = { 1, 2, 3, 4, 5 };
std::array<int, 3> arr = { 6, 7, 8 };
int c_array[] = { 9, 10, 11 };
// 动态大小 span - 可以接受任意大小的容器
process_dynamic(vec); // OK - 5 个元素
process_dynamic(arr); // OK - 3 个元素
process_dynamic(c_array); // OK - 3 个元素
// 静态大小 span - 只能接受恰好 3 个元素的容器
// process_static(vec); // 编译错误! vec 大小不是 3
process_static(arr); // OK - arr 正好 3 个元素
process_static(c_array); // OK - c_array 正好 3 个元素
// 静态 span 可以隐式转换为动态 span
std::span<int, 3> sp1(c_array);
process_dynamic(sp1); // OK - 静态 span 可以传给动态 span 参数
}
9.6 迭代和访问元素
通过 span 的 operator[] 或迭代器访问数据,也可以修改 span 指向的数据。当 span 的模板参数是 const T 时不可修改,或者 const span 也不能修改。
operator[]
cpp
constexpr reference operator[](size_type idx) const;
参数
idx:要访问的元素的索引,从 0 开始。
返回值
返回指定位置元素的引用。引用类型取决于 T:如果 T 是 int,返回 int&;如果 T 是 const int,返回 const int&。
用法
operator[] 在调试模式下(如 _DEBUG 宏定义时)会通过 assert 做边界检查。但在 Release 模式下不做检查,越界访问是未定义行为。注意:C++26 才会引入 at() 方法做带异常的边界检查。
front()
cpp
constexpr reference front() const;
参数
无。
返回值
返回 span 中第一个元素的引用。
用法
等价于 operator[](0)。如果 span 为空(大小为 0),行为是未定义的。
back()
cpp
constexpr reference back() const;
参数
无。
返回值
返回 span 中最后一个元素的引用。
用法
等价于 operator[](size() - 1)。如果 span 为空,行为是未定义的。
data()
cpp
constexpr pointer data() const noexcept;
参数
无。
返回值
返回指向底层连续内存起始位置的原始指针。
用法
当你需要把 span 的数据传给 C API 或需要原始指针时使用。
size()
cpp
constexpr size_type size() const noexcept;
参数
无。
返回值
返回 size_type(通常是 std::size_t),表示 span 中元素的数量。对于静态扩展 span,返回编译时常量;对于动态扩展 span,返回运行时存储的值。
size_bytes()
cpp
constexpr size_type size_bytes() const noexcept;
参数
无。
返回值
返回 span 中所有元素占用的总字节数,等于 size() * sizeof(T)。
empty()
cpp
constexpr bool empty() const noexcept;
参数
无。
返回值
bool。如果 span 大小为 0 返回 true,否则返回 false。
完整示例
cpp
#include <span>
#include <vector>
#include <array>
#include <iostream>
void print(std::span<const int> s) {
// 范围 for 迭代遍历
for (auto elem : s) {
std::cout << elem << ' ';
}
std::cout << '\n';
}
int main() {
int arr[] = { 1, 2, 3, 4, 5 };
std::span<int> s(arr);
// 迭代器遍历 + 修改
std::span<int>::iterator it = s.begin();
while (it != s.end()) {
// 修改 span 数据,本质修改了 arr 的数据
// 因为 span 是一个视图,不拥有数据
*it += 1;
++it;
}
print(s); // 输出: 2 3 4 5 6
print(arr); // 输出: 2 3 4 5 6(arr 也被修改了!)
// 使用 operator[]
s[0]++;
int first = s[0];
int last = s[s.size() - 1];
// 使用 front() 和 back()
int f = s.front(); // 获取第一个元素
int b = s.back(); // 获取最后一个元素
// 使用 data() 获取原始指针
int* p = s.data();
// 类型转换:创建只读视图
std::vector<int> vec = { 1, 2, 3 };
std::span<const int> const_view{ vec }; // 只读视图
print(const_view);
return 0;
}
这段代码的核心要点:通过迭代器修改 span 的数据,本质修改了底层的 arr 数组,因为 span 只是一个视图。如果把 span 的模板参数写成 const int(如 std::span<const int>),就只能读不能改。
9.7 子视图操作
std::span 提供了三个方法来获取子视图。
first(count)
cpp
constexpr span<element_type, see_below> first(size_type count) const;
参数
count:要获取的前 N 个元素的数量。
返回值
返回一个新的 span,包含当前 span 的前 count 个元素。如果原始 span 是静态扩展的且 count 是编译时常量,返回的 span 也是静态扩展的;否则是动态扩展的。
用法
cpp
auto first3 = s.first(3); // 获取前 3 个元素
last(count)
cpp
constexpr span<element_type, see_below> last(size_type count) const;
参数
count:要获取的后 N 个元素的数量。
返回值
返回一个新的 span,包含当前 span 的最后 count 个元素。
用法
cpp
auto last2 = s.last(2); // 获取后 2 个元素
subspan(offset, count)
cpp
constexpr span<element_type, see_below> subspan(
size_type offset = 0,
size_type count = std::dynamic_extent
) const;
参数 offset
子视图的起始位置,从 0 开始计数。
参数 count
子视图的元素数量。如果传入 std::dynamic_extent(默认值),则取从 offset 到末尾的所有元素。
返回值
返回一个新的 span,从 offset 位置开始,包含 count 个元素。如果 count 是 std::dynamic_extent,则包含从 offset 到末尾的所有元素。
用法
cpp
auto sub = s.subspan(1, 3); // 从索引 1 开始,取 3 个元素
auto sub2 = s.subspan(2); // 从索引 2 开始,取到末尾
完整示例
cpp
#include <span>
#include <iostream>
void print(std::span<int> s) {
for (auto elem : s) {
std::cout << elem << ' ';
}
std::cout << '\n';
}
int main() {
int arr[] = { 1, 2, 3, 4, 5 };
std::span<int> s(arr);
// 获取前 N 个元素
auto first3 = s.first(3);
print(first3); // 输出: 1 2 3
// 获取后 N 个元素
auto last2 = s.last(2);
print(last2); // 输出: 4 5
// 获取子范围 [offset, offset + count)
auto sub = s.subspan(1, 3); // 从索引 1 开始,取 3 个元素
print(sub); // 输出: 2 3 4
// 从索引 2 开始取到末尾
auto sub2 = s.subspan(2);
print(sub2); // 输出: 3 4 5
return 0;
}
9.8 迭代器
std::span 提供了完整的迭代器支持,包括 begin()、end()、rbegin()、rend(),以及对应的 const 版本 cbegin()、cend()、crbegin()、crend()。
begin() / end()
cpp
constexpr iterator begin() const noexcept;
constexpr iterator end() const noexcept;
参数
无。
返回值
begin() 返回指向第一个元素的迭代器,end() 返回指向最后一个元素之后位置的迭代器(past-the-end iterator)。迭代器类型是连续迭代器(contiguous iterator),可以像普通指针一样使用。
用法
配合 while 循环或 STL 算法使用。span 的迭代器实际上就是对底层指针的包装。
rbegin() / rend()
cpp
constexpr reverse_iterator rbegin() const noexcept;
constexpr reverse_iterator rend() const noexcept;
参数
无。
返回值
返回反向迭代器,rbegin() 指向最后一个元素,rend() 指向第一个元素之前的位置。
用法
用于反向遍历 span 中的元素。
9.9 CTAD(类模板参数推导)
std::span 支持类模板参数推导(CTAD),这意味着你在构造 span 时不一定需要显式写出模板参数:
cpp
int arr[] = { 1, 2, 3, 4, 5 };
std::span s1(arr); // 推导出 span<int, 5>(静态扩展)
std::vector<int> vec = { 6, 7, 8, 9, 10 };
std::span s2(vec); // 推导出 span<int>(动态扩展)
std::array<int, 5> arr2 = { 11, 12, 13, 14, 15 };
std::span s3(arr2); // 推导出 span<int, 5>(静态扩展)
CTAD 的规则是:如果数据源的大小在编译时已知(如 C 数组、std::array),推导出静态扩展 span;如果大小在运行时才知道(如 std::vector),推导出动态扩展 span。
9.10 最佳实践
函数参数优先用 std::span<const T>。 如果函数只需要读取数据,用 const 版本的 span,这样既安全又通用。如果需要修改数据,用 std::span<T>。
不要把 span 存储起来。 span 是非拥有的视图,如果底层数据被销毁或重新分配(如 vector 扩容),span 就会变成悬垂引用。span 的正确用法是:作为函数参数,在函数调用期间使用,函数返回后就丢弃。
注意 const span 和 span<const T> 的区别。 const span<int> 表示 span 对象本身不可修改(不能重新指定指向的内存),但可以修改指向的数据。span<const int> 表示指向的数据不可修改,但可以重新指定 span 指向别的内存。
9.11 span 作为函数参数的统一接口
span 最常见的使用场景就是函数参数。它能统一接收所有连续内存容器,让你不需要为每种容器写一个重载:
cpp
#include <span>
#include <vector>
#include <array>
#include <iostream>
// 一个函数就能接收 C 数组、vector、array
void process(std::span<const int> data) {
// 可以用范围 for
for (int x : data) {
std::cout << x << ' ';
}
std::cout << '\n';
// 可以用下标访问
if (!data.empty()) {
std::cout << "First: " << data.front()
<< ", Last: " << data.back() << '\n';
}
// 可以获取大小
std::cout << "Size: " << data.size() << '\n';
std::cout << "Bytes: " << data.size_bytes() << '\n';
// 可以获取原始指针传给 C API
const int* raw = data.data();
}
int main() {
int c_arr[] = {1, 2, 3, 4, 5};
std::vector<int> vec = {10, 20, 30};
std::array<int, 4> arr = {100, 200, 300, 400};
// 同一个函数,接收三种不同的容器
process(c_arr); // OK
process(vec); // OK
process(arr); // OK
// 也可以传子视图
process(std::span{vec}.subspan(1)); // 从第 1 个元素到末尾
}
如果没有 span,你需要为 int* + size、const std::vector<int>&、const std::array<int, N>& 分别写不同签名的函数,或者在函数内部做类型分发。span 把这些统一起来了。
9.12 span 与 string_view 的关系
std::span 和 std::string_view(C++17)在设计理念上非常相似:
text
string_view
→ 字符序列的轻量级视图
→ 非拥有
→ 指针 + 大小
span
→ 任意类型连续序列的轻量级视图
→ 非拥有
→ 指针 + 大小
可以把 span 理解为 string_view 的泛化版本:string_view 只能引用 char 序列,而 span 可以引用任意类型 T 的序列。事实上,span<const char> 在功能上和 string_view 几乎等价。
两者共享的核心原则是:不要拥有数据,只提供访问接口;不要存储,只在函数调用期间使用。
9.13 span 的常见陷阱
陷阱一:悬垂引用
cpp
std::span<int> bad_practice() {
std::vector<int> local = {1, 2, 3};
return std::span<int>(local); // 危险!local 析构后 span 悬垂
}
span 不拥有数据,返回 span 指向局部变量会导致悬垂引用。
陷阱二:vector 扩容后 span 失效
cpp
std::vector<int> vec = {1, 2, 3};
std::span<int> s(vec);
vec.push_back(4); // vector 可能重新分配内存
// s 现在可能指向已释放的内存!
// std::cout << s[0]; // 未定义行为
vector 扩容后,原来 span 指向的内存可能已经失效。
陷阱三:误用 const span
cpp
std::span<int> s(arr);
const std::span<int> cs(arr);
s[0] = 10; // OK,修改 span 指向的数据
cs[0] = 10; // 也 OK!const span<int> 仍可修改数据
// const 只是限制不能对 span 本身做赋值或 subspan
如果想要只读 span,必须用 span<const int>:
cpp
std::span<const int> readonly(arr);
// readonly[0] = 10; // 编译错误!const int 不可修改
10. 八大特性之间的联系
这八组 C++20 特性虽然看似各自独立,但它们之间存在深层的联系。
text
std::jthread 解决线程生命周期安全
↓
但多线程环境下,线程间输出会交错
↓
std::osyncstream 解决输出原子性
↓
而多线程环境下,线程函数可能需要处理数组数据
↓
std::span 提供统一的连续内存访问接口
↓
span 可以用在函数参数中,函数内部可以用范围 for 遍历
↓
范围 for 的初始化语句让临时变量不泄漏
↓
而遍历过程中可能需要用 Lambda 处理数据
↓
Lambda 的包扩展捕获让模板代码更灵活
↓
初始化结构体时用指派初始化器更清晰
↓
逻辑判断用 and/or/not 更可读
↓
所有这些特性的可用性都可以通过功能特性宏来检测
更具体地说:
jthread内部使用的stop_callback就是一个可调用对象,它的使用场景和 Lambda 密切相关。osyncstream的输出操作经常在多线程函数中使用,而这些线程函数可能由jthread管理。span作为函数参数统一接收各种连续容器,而函数内部可能用范围 for 遍历 span,for 循环中可能用到初始化语句。- 指派初始化器常用于初始化配置结构体,这些结构体可能包含 span 或线程相关的配置。
- 特性测试宏可以检测以上所有特性是否可用,是编写可移植代码的基础设施。
11. 版本边界与编译器支持总表
| 特性 | 标准 | 头文件 | 特性测试宏 | GCC | Clang | MSVC |
|---|---|---|---|---|---|---|
std::jthread |
C++20 | <thread> |
__cpp_lib_jthread (201911L) |
10.0 | 10.0 | 19.28+ |
std::stop_token |
C++20 | <stop_token> |
__cpp_lib_jthread (201911L) |
10.0 | 10.0 | 19.28+ |
std::stop_callback |
C++20 | <stop_token> |
__cpp_lib_jthread (201911L) |
10.0 | 10.0 | 19.28+ |
std::osyncstream |
C++20 | <syncstream> |
__cpp_lib_syncbuf (201802L) |
11.0 | 9.0 | 19.27+ |
| 指派初始化器 | C++20 | 无(语言特性) | __cpp_designated_initializers (201707L) |
8.0 | 6.0 | 19.26+ |
| 范围 for 初始化语句 | C++20 | 无(语言特性) | __cpp_range_based_for (201603L) |
9.0 | 5.0 | 19.14+ |
| Lambda 包扩展捕获 | C++20 | 无(语言特性) | __cpp_init_captures (201803L) |
9.0 | 8.0 | 19.22+ |
and/or/not 关键字 |
C++20 | 无(语言特性) | 无(语言变更) | 全版本 | 全版本 | 19.0+ |
std::span |
C++20 | <span> |
__cpp_lib_span (202002L) |
10.0 | 9.0 | 19.27+ |
__has_include |
C++17 | 无(预处理器) | 无 | 5.0 | 2.9 | 19.0+ |
__has_cpp_attribute |
C++17 | 无(预处理器) | 无 | 5.0 | 2.9 | 19.0+ |
常见误区
| 误区 | 正确理解 |
|---|---|
jthread 析构时调用 detach() |
析构时先 request_stop() 再 join(),不是 detach() |
stop_requested() 会阻塞等待 |
不会阻塞,只查询状态,立即返回 |
osyncstream 会锁住整个 cout |
只在析构/emit() 刷新时短暂锁住底层流,写入缓冲区时无锁 |
| 指派初始化器可以乱序 | C++ 要求严格按声明顺序,和 C 语言不同 |
span 可以用于 std::list |
不行,span 只适用于连续内存序列 |
span 拥有数据 |
span 不拥有数据,是视图,底层数据销毁后 span 变悬垂 |
const span<int> 不能改数据 |
可以改数据,const 只限制 span 对象本身。span<const int> 才不能改数据 |
[&...name = std::forward<T>(args)] 正确 |
编译错误,引用捕获不能配合 forward/move,只能用 [...name = std::forward<T>(args)] |
and/or/not 在 C++20 前所有编译器都能用 |
MSVC 在 C++20 前需要包含 <ciso646> |
编译选项
要使用这些 C++20 特性,需要设置正确的编译标准:
text
GCC/Clang: -std=c++20 或 -std=gnu++20
MSVC: /std:c++20 或 /std:c++latest
CMake: set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
特性测试宏使用建议
在实际项目中,推荐用特性测试宏来保护对新特性的使用:
cpp
#if defined(__cpp_lib_jthread) && __cpp_lib_jthread >= 201911L
#include <thread>
// 使用 std::jthread
std::jthread worker([]{ /* ... */ });
#else
#include <thread>
// 回退到 std::thread
std::thread worker([]{ /* ... */ });
worker.join(); // 必须手动 join
#endif
这种写法确保你的代码在不支持 C++20 的编译器上也能编译通过,只是回退到旧实现。
结语:
多的不说,少的不叙,我们后会有期,c++的故事,一直在继续~~