现代C++杂项
1. std::apply
std::apply(f, t) 把 tuple 类对象的元素展开成参数去调用 f,等价于 f(std::get<0>(t), std::get<1>(t), ...)。它是 C++17 引入的,定义在 <tuple> 中。能展开的类型包括 std::tuple、std::pair、std::array,C++23 起还包括 std::ranges::subrange 等其他 tuple-like 类型。
auto t = ;
;
它的实现就是 index_sequence 加包展开:
constexpr decltype
常见用途:
| 场景 | 写法 |
|---|---|
| 把参数存起来,稍后再调用(线程池、任务队列) | [f, args = std::make_tuple(xs...)] { std::apply(f, args); } |
| 遍历 tuple 的每个元素 | std::apply([](auto&... xs) { ((std::cout << xs << ' '), ...); }, t); |
| 用 tuple 里的元素构造一个对象 | 用它的兄弟函数 std::make_from_tuple<T>(t) |
要注意的地方:
- 不能直接传重载函数或函数模板的名字,编译器不知道选哪个版本,要包一层 lambda。
- 普通结构体不能展开,它不是 tuple-like 类型。
- 想移动元素,要写
std::apply(f, std::move(t))。 - C++20 起 lambda 可以直接捕获参数包(
[...args = std::move(args)]),"先打包成 tuple、再 apply"这种写法的需求减少了。
2. C++17 结构化绑定
一次声明多个名字,分别绑定到一个对象的各个元素或成员上:
std::map<std::string, int> m;
for // 遍历 map
auto = m.; // 拆开 insert 的返回值
auto = ;
;
auto = Point;
能绑定三类对象:
- 数组。
- tuple-like 类型:特化了
std::tuple_size,并且能调用get<i>的类型。 - 聚合类或结构体:所有非静态数据成员都是 public,并且声明在同一个类里。
原理:先生成一个隐藏对象,名字是它的别名。
auto = p; // 相当于:auto e = p; a 是 e.first 的别名,b 是 e.second 的别名
auto& = p; // 相当于:auto& e = p; a、b 直接指向 p 的成员
auto [a, b] = p会拷贝整个 p,改a不影响p。要修改原对象,写auto&。const auto& [a, b] = f();会延长临时对象的生命周期。auto& [a, b] = f();编译不过,因为非 const 左值引用不能绑定到临时对象。- 对
a取decltype,得到的是成员声明时的类型。
限制和坑:
| 限制 | 什么时候解除 |
|---|---|
| 名字的个数必须和元素个数完全一致,不能跳过某个元素 | C++26 可以用 _ 占位(我的记忆) |
| lambda 不能捕获结构化绑定的名字 | C++20 |
不能声明为 static、thread_local |
C++20 |
不能声明为 constexpr、不能用参数包(auto [...xs] = t) |
C++26(我的记忆) |
| 不能给每个名字单独指定类型,不能嵌套拆解 | — |
3. std::optional
它表示"要么有一个 T,要么什么都没有"。值直接存放在 optional 对象内部,不在堆上分配,另外再用一个 bool 标记是否有值。
std::optional<int> ;
if ;
int v = .;
// C++23 的链式操作
auto r = .
.;
使用场景:
- 函数可能没有结果:查找、解析、从缓存里取值。用它取代 -1、
nullptr、npos这类特殊值,或者"输出参数 + 返回 bool"的写法。 - 可选的成员或配置项:
std::optional<Timeout> timeout; - 延迟构造:先声明,之后再
emplace(...)。
优点:
- "可能没有值"直接体现在类型上,调用方不会忘记检查。
- 值语义,不做堆分配,可以用在 constexpr 里。
T平凡可拷贝时,optional<T>也平凡可拷贝。
缺点和坑:
| 问题 | 说明 |
|---|---|
| 不能说明"为什么没有值" | 需要错误原因时用 C++23 的 std::expected<T, E> |
| 占用额外空间 | 多一个 bool,再加对齐填充:sizeof(optional<int>) 是 8,sizeof(optional<double>) 是 16(x86_64)。vector<optional<double>> 的内存占用直接翻倍 |
对空的 optional 使用 *opt 是 UB |
不做检查。value() 会检查,为空时抛 bad_optional_access |
| 移动之后仍然有值 | auto x = std::move(opt); 之后 opt.has_value() 仍为 true,里面是一个被移走后的 T |
optional<bool> 容易混淆 |
if (opt) 检查的是有没有值,if (*opt) 检查的是值本身 |
| 比较运算符的语义 | opt == 0 在有值时比较值,在无值时结果为 false |
不支持 optional<T&> |
C++26 才加入(我的记忆)。在那之前用 T* 或 optional<std::reference_wrapper<T>> |
各种替代方案对比:
| 方案 | 适合的场景 |
|---|---|
特殊值(-1、npos) |
值域里天然有一个不会用到的值 |
T* |
引用一个已经存在的对象 |
std::optional<T> |
返回一个新值,而且"没有值"是正常情况 |
std::expected<T, E> |
需要携带错误信息 |
| 异常 | 错误确实少见,并且需要跨越多层调用传递 |
4. std::format 和 std::print 的区别
std::format |
std::print / std::println |
|
|---|---|---|
| 版本和头文件 | C++20,<format> |
C++23,<print> |
| 做什么 | 格式化成一个 std::string 返回 |
格式化后直接写到 stdout、FILE* 或 ostream |
| 返回值 | std::string |
void |
| 中间字符串 | 必然构造一个 | 不需要构造 std::string,由实现内部缓冲 |
| Unicode 输出 | 只负责生成字符串,编码由你处理 | 输出到终端时会正确处理 UTF-8。在 Windows 控制台上,它把 UTF-8 转成 UTF-16 再调用 WriteConsoleW 输出,中文不会乱码,前提是源码字面量是 UTF-8 编码 |
| 换行 | 不加 | println 自动加换行 |
两者使用同一套格式语法,都来自 {fmt} 库。共同的优点:
- 类型安全,比
printf可靠。 - 格式串在编译期检查:
std::format_string的构造函数是 consteval 的,写成std::format("{:d}", "abc")会直接编译报错。 - 可以扩展:特化
std::formatter<T>,自己的类型就能被格式化。 - 默认不受 locale 影响,需要本地化格式时写
{:L}。 - 支持位置参数,比如
{1} {0}。
std::string s = ;
; // " 3.14|0xff"
; // 写进已有的缓冲区,省一次分配
std::print 和 std::cout << std::format(...) 相比:
- 少构造一个临时字符串,也绕开了 iostream 的开销。
- 一次调用把整条内容写出去。多线程时,不会像
cout << a << b那样在两个<<之间被别的线程插进来。
常见坑:
- 格式串必须是编译期常量。格式串来自配置文件或者是运行时变量时,要写
std::vformat(fmt, std::make_format_args(args...)),C++26 可以写std::format(std::runtime_format(fmt), ...)。运行时格式串写错时,抛出std::format_error。 - 编译器支持:大致是 GCC 13 开始支持
<format>,GCC 14 开始支持<print>;MSVC 和 libc++ 也是先支持 format、后支持 print。具体版本凭记忆,未核实。 - 需要把结果写进已有缓冲区、避免分配时,用
std::format_to或format_to_n。
两者都是 C++17 引入的"一个变量可以存放不同类型的值"的工具,区别在于类型集合是否在编译期确定:
std::variant<Ts...>:类型集合是封闭的,编译期就确定。它是类型安全的联合体。std::any:类型集合是开放的,什么类型都能放,要到运行时才知道里面放的是什么。
下面的代码和 sizeof 数值都没有实测,sizeof 以 libstdc++(x86_64)为准,属于实现细节。
一、std::variant
基本用法
std::variant<int, double, std::string> v = 42; // 当前存放的是 int
v = "hello"; // 现在存放的是 std::string
v.; // 当前是第几个类型:2
std::holds_alternative<std::string>; // true
std::get<std::string>; // 类型不对时抛 std::bad_variant_access
std::get<2>; // 按下标取
if // 类型不对时返回 nullptr,不抛异常
用 std::visit 分派(推荐)
;
-> ; // C++17 需要推导指引;C++20 起可以省略
;
- 漏写任何一种类型的处理分支都会编译失败,这是 variant 相比
if-else链最大的好处。 visit的实现通常是一张函数指针跳转表,按index()查表,时间复杂度 O(1)。- C++26 增加了成员函数写法
v.visit(...)(我的记忆)。
内存布局
值直接存放在 variant 对象内部,不做堆分配。大小等于最大的那个类型,加上一个类型下标,再加上对齐填充:
std::variant<int, std::string>,x86_64 libstdc++:
┌──────────────────────────────┬───────┬─────────┐
│ 存储区:32 B(按 std::string 的大小)│ index │ padding │
└──────────────────────────────┴───────┴─────────┘ sizeof == 40
sizeof(std::variant<int, double>)是 16。- libstdc++ 用能容纳全部类型个数的最小无符号整数作为下标类型,不超过 255 种类型时就是 1 字节。
使用场景
| 场景 | 例子 |
|---|---|
| 类型集合固定时,替代继承体系 | AST 节点、各种消息和事件:值语义,不做堆分配,也没有虚函数 |
| 状态机 | 每个状态是一个类型:std::variant<Idle, Connecting, Connected, Closed>,状态特有的数据直接存在对应的类型里 |
| JSON 这类动态值 | variant<nullptr_t, bool, double, string, vector<Json>, map<string, Json>>。这是递归定义,需要借助 vector、map 或 unique_ptr 间接存放 |
| 返回"值或者错误" | 在 C++23 的 std::expected 出现之前的替代写法 |
和虚函数相比,这是经典的表达式问题(expression problem):
- variant 容易加新操作:再写一个 visitor 就行。但加新类型时,所有 visit 都要改。
- 继承容易加新类型:再派生一个类就行。但加新操作时,所有类都要改。
常见坑
-
构造时类型选错(C++17 的经典问题):
std::variant<std::string, bool> v = "abc"; // C++17 选中的是 bool!const char* → bool 是标准转换,优先于 → std::string 这种用户定义转换 // C++20 的 P0608(作为 DR 回溯修正)禁止了窄化转换和到 bool 的转换,现在会选 std::string -
默认构造的是第一个类型。第一个类型不能默认构造时,variant 也无法默认构造。惯用法是把
std::monostate放在第一位,用来表示"空"。 -
valueless_by_exception:切换类型的赋值或emplace过程中抛出异常,variant 可能变成"没有值"的状态。这时index()返回variant_npos,visit会抛bad_variant_access。 -
大小由最大的类型决定:只要有一个大类型,每个 variant 对象都会变大。可以把大类型改成
unique_ptr<Big>存放。 -
允许出现重复的类型,但这时
get<T>会产生二义性,只能用下标访问。 -
不能存放引用、数组和
void。 -
同时 visit 多个 variant 时,要实例化的组合数是各自类型数的乘积,编译时间会暴涨。
二、std::any
基本用法
std::any a = 42;
a = ;
a.; // true
a. == typeid; // true
auto s = std::any_cast<std::string>; // 类型不对时抛 std::bad_any_cast
auto p = std::any_cast<int>; // 传指针的版本:类型不对时返回 nullptr
a.;
实现:类型擦除加小对象优化
- libstdc++ 的
std::any由两部分组成:一个指向"管理函数"的指针,加上一个指针大小的存储区,所以sizeof(std::any)是 16。 - 小类型直接放在这块存储区里。libstdc++ 的条件是:大小不超过一个指针,并且移动构造不抛异常。
- 更大的类型会在堆上分配。
- 这个大小是实现相关的,MSVC 的存储区更大,
sizeof(std::any)在 64 位下约为 64(未核实)。
使用场景(其实不多)
- 库不知道用户会放什么类型的"附加数据":比如给回调或连接对象挂一个 user data、插件系统、属性表。它可以取代
void*,并且多了类型检查。 - 异构的键值属性表:
std::map<std::string, std::any>。
大多数情况下都有更好的选择:
- 类型集合固定时,用 variant。
- 需要多态行为时,用虚函数接口。
- 编译期知道类型时,用模板。
常见坑
any_cast要求类型完全一致:不会做任何转换,也不认基类和派生类的关系。std::any_cast<long>(std::any(1))会抛异常。- 字符串字面量存进去的是
const char*:std::any a = "hi";,之后调用any_cast<std::string>会抛异常。 - 存放的类型必须可以拷贝,所以
unique_ptr放不进去。 - 大对象要堆分配,而且每次取值都要做类型检查,性能不如 variant。
- 你只能取出事先知道类型的值:一个 any 放进来之后,你没法对它做"它是什么类型就做什么操作",除非自己挨个类型去尝试
any_cast。
三、对比
union |
std::variant |
std::any |
void* |
虚函数继承 | |
|---|---|---|---|---|---|
| 类型集合 | 固定 | 固定 | 任意 | 任意 | 可以派生出新类型 |
| 知道当前是什么类型吗 | 不知道,要自己记录 | 知道(index()) |
知道(type()) |
不知道 | 知道(通过 vptr) |
| 类型安全 | 无 | 编译期加运行期都检查 | 运行期检查 | 无 | 有 |
| 存储位置 | 对象内部 | 对象内部 | 小对象在内部,大对象在堆上 | 外部 | 通常在堆上 |
| 非平凡类型(如 string) | C++11 起允许,但构造和析构要自己管 | 自动管理 | 自动管理(要求可拷贝) | 自己管 | 自动管理 |
| 取值 | 直接访问 | visit、get、get_if |
any_cast,类型必须完全一致 |
强制转换 | 虚函数调用 |
| 典型大小 | 最大成员 | 最大类型 + 下标 | 16(libstdc++) | 8 | 一个指针,加上堆上的对象 |
面试时可以这样回答:
- variant 是类型安全的联合体:类型集合在编译期固定,值存放在对象内部,用
visit分派,漏掉任何一种类型都会编译失败,适合替代封闭的继承体系和实现状态机。 - any 是类型擦除的容器:什么类型都能放,但取值时类型必须完全匹配,大对象要堆分配,适合"库不知道用户会放什么类型"的场景。
- 能用 variant 就不要用 any。
暂无评论,欢迎留下第一条评论。