1. std::apply

std::apply(f, t) 把 tuple 类对象的元素展开成参数去调用 f,等价于 f(std::get<0>(t), std::get<1>(t), ...)。它是 C++17 引入的,定义在 <tuple> 中。能展开的类型包括 std::tuplestd::pairstd::array,C++23 起还包括 std::ranges::subrange 等其他 tuple-like 类型。

auto t = std::make_tuple(1, 2.5, "hi");
std::apply([](int a, double b, const char* c) { /* ... */ }, t);

它的实现就是 index_sequence 加包展开:

template <class F, class Tuple, std::size_t... I>
constexpr decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<I...>) {
    return std::invoke(std::forward<F>(f), std::get<I>(std::forward<Tuple>(t))...);
}

常见用途

场景 写法
把参数存起来,稍后再调用(线程池、任务队列) [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 (const auto& [key, value] : m) { ... }            // 遍历 map
auto [it, inserted] = m.insert({"a", 1});             // 拆开 insert 的返回值
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), n);

struct Point { int x, y; };
auto [x, y] = Point{1, 2};

能绑定三类对象

  1. 数组。
  2. tuple-like 类型:特化了 std::tuple_size,并且能调用 get<i> 的类型。
  3. 聚合类或结构体:所有非静态数据成员都是 public,并且声明在同一个类里。

原理:先生成一个隐藏对象,名字是它的别名。

auto [a, b] = p;    // 相当于:auto e = p;  a 是 e.first 的别名,b 是 e.second 的别名
auto& [a, b] = 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 左值引用不能绑定到临时对象。
  • adecltype,得到的是成员声明时的类型。

限制和坑

限制 什么时候解除
名字的个数必须和元素个数完全一致,不能跳过某个元素 C++26 可以用 _ 占位(我的记忆)
lambda 不能捕获结构化绑定的名字 C++20
不能声明为 staticthread_local C++20
不能声明为 constexpr、不能用参数包(auto [...xs] = t C++26(我的记忆)
不能给每个名字单独指定类型,不能嵌套拆解

3. std::optional

它表示"要么有一个 T,要么什么都没有"。值直接存放在 optional 对象内部,不在堆上分配,另外再用一个 bool 标记是否有值。

std::optional<int> parse_int(std::string_view s);

if (auto n = parse_int(s)) use(*n);
int v = parse_int(s).value_or(0);

// C++23 的链式操作
auto r = parse_int(s).transform([](int x) { return x * 2; })
                     .or_else([] { return std::optional<int>{0}; });

使用场景

  • 函数可能没有结果:查找、解析、从缓存里取值。用它取代 -1、nullptrnpos 这类特殊值,或者"输出参数 + 返回 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 返回 格式化后直接写到 stdoutFILE*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 = std::format("{} + {} = {}", 1, 2, 3);
std::println("{:>8.2f}|{:#x}", 3.14159, 255);        // "    3.14|0xff"
std::format_to(std::back_inserter(buf), "{}", x);    // 写进已有的缓冲区,省一次分配

std::printstd::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_toformat_to_n

两者都是 C++17 引入的"一个变量可以存放不同类型的值"的工具,区别在于类型集合是否在编译期确定

  • std::variant<Ts...>:类型集合是封闭的,编译期就确定。它是类型安全的联合体。
  • std::any:类型集合是开放的,什么类型都能放,要到运行时才知道里面放的是什么。

下面的代码和 sizeof 数值都没有实测,sizeof 以 libstdc++(x86_64)为准,属于实现细节。

一、std::variant

基本用法

#include <variant>

std::variant<int, double, std::string> v = 42;   // 当前存放的是 int
v = "hello";                                      // 现在存放的是 std::string

v.index();                                  // 当前是第几个类型:2
std::holds_alternative<std::string>(v);     // true
std::get<std::string>(v);                   // 类型不对时抛 std::bad_variant_access
std::get<2>(v);                             // 按下标取
if (auto p = std::get_if<int>(&v)) { ... }  // 类型不对时返回 nullptr,不抛异常

std::visit 分派(推荐)

template <class... Fs> struct overloaded : Fs... { using Fs::operator()...; };
template <class... Fs> overloaded(Fs...) -> overloaded<Fs...>;   // C++17 需要推导指引;C++20 起可以省略

std::visit(overloaded{
    [](int i)                { std::println("int {}", i); },
    [](double d)             { std::println("double {}", d); },
    [](const std::string& s) { std::println("string {}", s); },
}, v);
  • 漏写任何一种类型的处理分支都会编译失败,这是 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 都要改。
  • 继承容易加新类型:再派生一个类就行。但加新操作时,所有类都要改。

常见坑

  1. 构造时类型选错(C++17 的经典问题):

    std::variant<std::string, bool> v = "abc";
    // C++17 选中的是 bool!const char* → bool 是标准转换,优先于 → std::string 这种用户定义转换
    // C++20 的 P0608(作为 DR 回溯修正)禁止了窄化转换和到 bool 的转换,现在会选 std::string
    
  2. 默认构造的是第一个类型。第一个类型不能默认构造时,variant 也无法默认构造。惯用法是把 std::monostate 放在第一位,用来表示"空"。

  3. valueless_by_exception:切换类型的赋值或 emplace 过程中抛出异常,variant 可能变成"没有值"的状态。这时 index() 返回 variant_nposvisit 会抛 bad_variant_access

  4. 大小由最大的类型决定:只要有一个大类型,每个 variant 对象都会变大。可以把大类型改成 unique_ptr<Big> 存放。

  5. 允许出现重复的类型,但这时 get<T> 会产生二义性,只能用下标访问。

  6. 不能存放引用、数组和 void

  7. 同时 visit 多个 variant 时,要实例化的组合数是各自类型数的乘积,编译时间会暴涨。

二、std::any

基本用法

#include <any>

std::any a = 42;
a = std::string("hi");
a.has_value();                           // true
a.type() == typeid(std::string);         // true

auto s = std::any_cast<std::string>(a);   // 类型不对时抛 std::bad_any_cast
auto p = std::any_cast<int>(&a);          // 传指针的版本:类型不对时返回 nullptr
a.reset();

实现:类型擦除加小对象优化

  • libstdc++ 的 std::any 由两部分组成:一个指向"管理函数"的指针,加上一个指针大小的存储区,所以 sizeof(std::any) 是 16。
  • 小类型直接放在这块存储区里。libstdc++ 的条件是:大小不超过一个指针,并且移动构造不抛异常。
  • 更大的类型会在堆上分配
  • 这个大小是实现相关的,MSVC 的存储区更大,sizeof(std::any) 在 64 位下约为 64(未核实)。

使用场景(其实不多)

  • 库不知道用户会放什么类型的"附加数据":比如给回调或连接对象挂一个 user data、插件系统、属性表。它可以取代 void*,并且多了类型检查。
  • 异构的键值属性表std::map<std::string, std::any>

大多数情况下都有更好的选择:

  • 类型集合固定时,用 variant。
  • 需要多态行为时,用虚函数接口。
  • 编译期知道类型时,用模板。

常见坑

  1. any_cast 要求类型完全一致:不会做任何转换,也不认基类和派生类的关系。std::any_cast<long>(std::any(1)) 会抛异常。
  2. 字符串字面量存进去的是 const char*std::any a = "hi";,之后调用 any_cast<std::string> 会抛异常。
  3. 存放的类型必须可以拷贝,所以 unique_ptr 放不进去。
  4. 大对象要堆分配,而且每次取值都要做类型检查,性能不如 variant。
  5. 你只能取出事先知道类型的值:一个 any 放进来之后,你没法对它做"它是什么类型就做什么操作",除非自己挨个类型去尝试 any_cast

三、对比

union std::variant std::any void* 虚函数继承
类型集合 固定 固定 任意 任意 可以派生出新类型
知道当前是什么类型吗 不知道,要自己记录 知道(index() 知道(type() 不知道 知道(通过 vptr)
类型安全 编译期加运行期都检查 运行期检查
存储位置 对象内部 对象内部 小对象在内部,大对象在堆上 外部 通常在堆上
非平凡类型(如 string) C++11 起允许,但构造和析构要自己管 自动管理 自动管理(要求可拷贝) 自己管 自动管理
取值 直接访问 visitgetget_if any_cast,类型必须完全一致 强制转换 虚函数调用
典型大小 最大成员 最大类型 + 下标 16(libstdc++) 8 一个指针,加上堆上的对象

面试时可以这样回答:

  • variant 是类型安全的联合体:类型集合在编译期固定,值存放在对象内部,用 visit 分派,漏掉任何一种类型都会编译失败,适合替代封闭的继承体系和实现状态机。
  • any 是类型擦除的容器:什么类型都能放,但取值时类型必须完全匹配,大对象要堆分配,适合"库不知道用户会放什么类型"的场景。
  • 能用 variant 就不要用 any。