Skip to content

结构化绑定:一行解包多个值

笔者写代码时总撞上一个别扭的场景:函数返回多个值,你得一个个拆开赋给变量。用 pairresult.firstresult.second,用 tuplestd::get<0>(t)——要么语义不明,要么写法丑陋。C++11 引入了 std::tie 缓解这事,但老实说那语法也不优雅:先声明好所有变量,再用 tie 往里塞。有没有那种跟 Python a, b = func() 一样爽的拆分写法?真有了,孩子们。

C++17 终于给了正经答案——结构化绑定(Structured Binding)。一行把 pairtuple、数组、结构体全拆开,直接拿到有名字的变量,语义清晰,零开销。


从 pair 和 tuple 讲起

pair:最常见的多返回值

std::pair 是标准库中最常见的"打包两个值"的方式。std::map::insert 返回一个 pair<iterator, bool>std::map::find 返回一个 pair<const Key, Value>&。在结构化绑定出现之前,我们只能这样写:

cpp
auto result = m.insert({1, "one"});
if (result.second) {
    std::cout << "Inserted: " << result.first->second << '\n';
}

result.second 是什么意思?不查文档你根本不知道。结构化绑定直接把语义写进变量名里:

cpp
auto [it, inserted] = m.insert({1, "one"});
if (inserted) {
    std::cout << "Inserted: " << it->second << '\n';
}

在范围 for 中遍历 map 的时候更是优雅到不行。以前写 it->firstit->second,现在直接 [key, value]

cpp
std::map<int, std::string> sensor_names = {
    {1, "Temperature"},
    {2, "Humidity"},
    {3, "Pressure"}
};

for (const auto& [id, name] : sensor_names) {
    std::cout << "Sensor " << +id << ": " << name << '\n';
}

这里有个细节:循环体里写的是 +id 而不是 id。原因在于 uint8_toperator<< 会把它当字符输出,而 + 会做整型提升(integral promotion),强制转换成 int 再打印。口说无凭,跑一下最直白(GCC 16.1.1,-O2):

text
without + (raw uint8_t): A
with +    (promoted)   : 65

同样是 id = 65,不加 + 打印出来是字符 A,加了才显示数字。

tuple:超过两个值的情况

当函数需要返回三个或更多值时,std::tuple 是自然的选择。结构化绑定的写法和 pair 完全一致:

cpp
std::tuple<int, std::string, double> query_database(int id) {
    return {id, "sensor_" + std::to_string(id), 23.5};
}

auto [record_id, name, value] = query_database(42);

与 std::tie 的对比

C++11 的 std::tie 也能做类似的事情,但体验差了不少。它要求先声明好所有变量,然后用 tie 往里面赋值:

cpp
int record_id;
std::string name;
double value;
std::tie(record_id, name, value) = query_database(42);

对比一下就很明显了:结构化绑定的变量声明和解包一步到位,而 std::tie 得拆成两步。虽然 std::tie 内部用的是引用,实际上它也能处理含有不可拷贝类型(如 std::unique_ptr)的 tuple——因为引用绑定不涉及拷贝。但结构化绑定的语法更简洁,而且支持按值、按引用、按转发引用等多种语义。


原生数组与结构体

原生数组

固定大小的原生数组也能直接解包。这在处理一些固定格式的数据时非常方便:

cpp
int rgb[3] = {255, 128, 0};
auto [r, g, b] = rgb;

二维数组的每一行也可以在循环中解包:

cpp
int matrix[2][3] = {
    {1, 2, 3}, {4, 5, 6}
};
for (auto& row : matrix) {
    auto [a, b, c] = row;
    std::cout << a << ' ' << b << ' ' << c << '\n';
}

注意结构化绑定只支持一维数组的直接解包。你不能写 auto [a, b, c, d, e, f] = matrix,因为 matrix 本质上是 int[2][3],大小是 2 而不是 6。

结构体和类

如果结构体的所有非静态数据成员都是 public 的,它就能直接被结构化绑定解包。编译器会按声明顺序依次绑定:

cpp
struct SensorReading {
    uint8_t sensor_id;
    float value;
    uint32_t timestamp;
    bool is_valid;
};

SensorReading reading{5, 23.5f, 1234567890, true};
auto [id, val, ts, valid] = reading;

不需要懂任何模板元编程,只要结构体成员是公有的就能用——这恐怕是结构化绑定最直观的用法了。

结构化绑定要求数据成员按声明顺序绑定,且完全支持位域(bit field)。如果结构体里有 mutable 成员,行为可能需要注意:绑定到的"匿名变量"可能被 const 修饰,但 mutable 成员不受此限制,仍可修改。


三种绑定语义

结构化绑定并不总是拷贝。实际上,auto 前面的修饰符决定了底层匿名变量的类型:

  • auto [...]——按值拷贝。绑定变量引用的是这个拷贝。
  • auto& [...]——绑定到左值引用。可以修改原对象。
  • const auto& [...]——绑定到 const 左值引用。只读访问,不拷贝。
  • auto&& [...]——转发引用。既能绑定左值也能绑定右值。

用一个例子区分它们:

cpp
std::pair<int, int> range{1, 10};

// 拷贝:r1、r2 引用的是匿名拷贝,不影响 range
auto [r1, r2] = range;

// 引用:直接操作原对象
auto& [r3, r4] = range;
r3 = 5;  // range.first 变成 5

跑一下就能看到 auto& 改的是原对象、auto 改的是拷贝:

text
range.first  after auto& mutation: 5
r1 (copy,    unaffected)         : 1

底层机制是这样的:编译器先声明一个匿名变量(类型由 auto/auto&/const auto&/auto&& 决定),用右侧表达式初始化它。然后每个绑定变量都是这个匿名变量的成员的引用(或者对于按值情况,是指向拷贝的成员的引用)。

cpp
// auto [x, y] = get_point(); 大致等价于:
auto __anonymous = get_point();
auto& x = __anonymous.first;   // 引用匿名变量的成员
auto& y = __anonymous.second;

这意味着绑定变量本身永远是引用——它们引用的是那个隐藏的匿名对象的成员。你没法拿到"绑定变量本身"的地址,只能拿到它所引用的子对象的地址。

⚠️ 注意:auto& 要求右侧是左值。如果右侧是临时对象(比如 std::make_pair(1, 2) 的返回值),auto& 会编译失败,因为非 const 引用不能绑定到右值。这时应该用 const auto& 或直接 auto 按值拷贝。

cpp
// 错误:auto& 不能绑定到临时对象
auto& [x, y] = std::make_pair(1, 2);

// 正确:const 引用可以延长临时对象生命周期
const auto& [x, y] = std::make_pair(1, 2);

// 或者直接拷贝
auto [x, y] = std::make_pair(1, 2);

让自定义类型支持绑定:Tuple-Like Protocol

如果你的类有私有成员,不能直接用结构体方式解绑。但 C++ 提供了另一条路:让编译器把你的类当作 "tuple-like" 类型来处理。只需要三样东西:

  1. 特化 std::tuple_size<YourType>,告诉编译器有多少个元素。
  2. 特化 std::tuple_element<I, YourType>,告诉编译器第 I 个元素的类型。
  3. YourType 的命名空间中提供 get<I>() 函数,返回第 I 个元素。
展开代码 (共 34 行)收起代码
cpp
#include <utility>
#include <cstdint>

class SensorData {
public:
    SensorData(uint8_t id, float value) : id_(id), value_(value) {}

    template<std::size_t I>
    auto& get() {
        if constexpr (I == 0) return id_;
        else if constexpr (I == 1) return value_;
    }

    template<std::size_t I>
    const auto& get() const {
        if constexpr (I == 0) return id_;
        else if constexpr (I == 1) return value_;
    }

private:
    uint8_t id_;
    float value_;
};

// 特化 tuple_size:告诉编译器有 2 个元素
template<>
struct std::tuple_size<SensorData> : std::integral_constant<std::size_t, 2> {};

// 特化 tuple_element:告诉编译器每个元素的类型
template<>
struct std::tuple_element<0, SensorData> { using type = uint8_t; };

template<>
struct std::tuple_element<1, SensorData> { using type = float; };

配合 get<I> 的 ADL 重载,现在就可以愉快地解包了:

cpp
SensorData data{5, 23.5f};
auto [id, value] = data;    // id = 5, value = 23.5

实跑确认(注意 id 又得加 + 才打印成数字):

text
id = 5, value = 23.5

这里的关键是 get<I>() 函数必须定义在类所在的命名空间中(ADL 规则),这样编译器才能找到它。对于标准命名空间 std 中的特化,你需要在 std 命名空间中写 tuple_sizetuple_element 的特化,但 get 函数放在类所在的命名空间即可。

这套机制被称为 "tuple-like protocol",标准库的 std::pairstd::tuplestd::array 都是靠它实现结构化绑定支持的。


C++20 的变化

C++20 对结构化绑定做了几处调整,主要与 constexpr 上下文相关。

结构化绑定可以在 constexpr 函数内部使用,这意味着编译期计算函数也能返回多值并用结构化绑定接收:

cpp
constexpr auto get_point() {
    return std::make_pair(3, 4);
}

constexpr bool test_structured_binding() {
    auto [x, y] = get_point();
    return x == 3 && y == 4;
}

static_assert(test_structured_binding());

不过要注意,你不能在命名空间作用域直接声明 constexpr 的结构化绑定(比如 constexpr auto [x, y] = get_point(); 是编译错误的)。这是因为结构化绑定本质上是一组引用变量的声明,而不是单个变量声明。

在 lambda 捕获方面,C++17 其实就支持直接捕获结构化绑定变量。下面的代码在 C++17 中就能工作:

cpp
std::map<int, std::string> m = {{1, "one"}, {2, "two"}};

for (const auto& [k, v] : m) {
    auto callback = [k, v] {  // C++17 就支持直接捕获
        std::cout << k << ": " << v << '\n';
    };
    callback();
}

C++20 新增的是初始化捕获语法(key = k),这在某些情况下更灵活。但需要注意,[=] 默认捕获不会自动捕获结构化绑定变量,你需要显式列出它们。


性能:零开销的语法糖

结构化绑定本身没有任何运行时开销。它纯粹是编译期的语法变换——编译器会在幕后创建匿名变量,然后让绑定变量引用匿名变量的成员。

cpp
// 这两种写法生成的汇编代码完全一样
auto [x, y] = get_point();

// 等价于
auto __tmp = get_point();
auto x = __tmp.first;
auto y = __tmp.second;

"汇编完全一样"这种话不能空口说。拿 GCC 16.1.1 实测,两种写法各 g++ -std=c++17 -O2 -S,再 diff

bash
g++ -std=c++17 -O2 -S sb_structured.cpp
g++ -std=c++17 -O2 -S sb_manual.cpp
diff sb_structured.s sb_manual.s

diff 输出只有一行——.file 那行的源文件名不同,实际指令完全一致:

text
_Z1fv:                  # f(),两个版本一模一样
    movl    $7, %eax    # 直接返回 3 + 4 = 7
    ret

编译器把 get_point() 内联后直接常量折叠成 movl $7, %eax,结构化绑定没留下任何痕迹。所以性能建议很简单:大结构体用 const auto& 避免拷贝,小类型(内置类型、小 struct)直接 auto 按值拷贝。auto&& 在泛型代码里有用,但具体类型已知时,明确写 autoconst auto& 更清晰。


常见的坑

生命周期问题

auto&& 绑定到临时对象时,匿名变量的生命周期会被延长到绑定变量的作用域结束,所以用 auto&&const auto& 是安全的。但如果你拿到了绑定变量的指针或引用然后传出去,就有悬空风险:

cpp
const auto& [x, y] = std::make_pair(1, 2);
// x, y 在这个作用域内有效,安全
// 但如果 &x 被存到外部,作用域结束后就悬空了

不能直接当返回值

结构化绑定的变量名不能直接用于函数返回。如果你想返回解包后的值,需要重新打包:

cpp
auto [x, y] = get_point();
// 不能 return x, y; 必须重新打包
return std::make_pair(x, y);

// 或者直接返回函数结果
return get_point();

不能用于类成员声明

你不能在类的成员声明中使用结构化绑定:

cpp
class MyClass {
    auto [x, y] = get_point();  // 编译错误
};

如果你需要存储解包后的值,用结构体或者 pair/tuple 成员代替。


在线运行

在线运行结构化绑定示例,体验 pair、tuple、数组和结构体的解包:

Compiler Explorer

结构化绑定:pair、tuple、数组与结构体解包

在线运行并观察结构化绑定在 pair、tuple、数组和结构体上的解包效果。

code/examples/vol2/11_structured_bindings.cpp

收尾

结构化绑定能覆盖的类型就这些:pairtuple、原生数组、公有成员结构体,外加实现了 tuple-like protocol 的自定义类型。语义全由 auto 前面的修饰符决定——auto 拷贝、auto& 引用、const auto& 只读引用、auto&& 转发引用。

笔者日常用得最多的还是范围 for 遍历 map(for (const auto& [k, v] : m))和接多返回值函数。下一章的 if/switch 初始化器跟它配套,两者一起用,代码能再瘦一圈。

参考资源

v0.7.1-1-gb1e27bb · b1e27bb · 2026-07-05