结构化绑定

1. 结构化绑定基本语法

C++17 引入了一个非常有趣且实用的特性,叫做结构化绑定。简单来说,它让我们可以像给变量起名字一样,一次性把一个复杂对象(如数组、结构体、元组)里的多个值拆分出来,分别赋值给不同的变量

在 C++17 之前,如果我们想从一个包含两个值的对象里取值,代码可能会有点啰嗦。有了结构化绑定,代码瞬间变得像 Python 或 JavaScript 一样优雅。

结构化绑定就像是一个特殊的变量声明。它的基本长这样:

1
auto [变量1, 变量2, 变量3, ...] = 要绑定的对象;
  • auto:让编译器自动推导类型(就像你平时用 auto a = 10; 一样)。
  • [ ... ]:方括号里是你想要的新变量名。
  • =:赋值符号。

注意: 方括号里的变量名数量,必须和你要绑定的对象里的元素数量完全一致,否则会报错。

假设现在有一个函数,它返回了一个包含学生姓名分数的结构体:

  • C++17 之前(传统写法):

    1
    2
    3
    4
    Student result = getStudent();
    // 得这样写,很啰嗦
    std::string name = result.name;
    int score = result.score;
  • C++17 结构化绑定(新写法):

    1
    2
    // 一行搞定!直接“解包”
    auto [name, score] = getStudent();

    使用结构化绑定代码更少,逻辑更清晰,阅读的人一眼就能看出 name score 分别代表什么。

2. 结构化绑定的应用

2.1 绑定数组

绑定数组这是最简单的情况。假设我们有一个固定大小的数组,想直接把里面的元素赋值给变量。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
// 定义一个包含 3 个整数的数组
int my_array[3] = {10, 20, 30};

// 【核心代码】
// 我们创建名为 a, b, c 的三个变量,分别对应数组里的 0, 1, 2 号元素
auto [a, b, c] = my_array;

std::cout << "a: " << a << std::endl;
std::cout << "b: " << b << std::endl;
std::cout << "c: " << c << std::endl;

return 0;
}

运行结果:

1
2
3
a: 10
b: 20
c: 30

当我们写 auto [a, b, c] = my_array; 时,默认发生的是拷贝。也就是说,a、bcmy_array 里面元素的副本。修改 a 不会影响 my_array!如果想通过绑定后的变量直接修改原始对象,需要使用引用 &

  • 想要读数,默认直接写 auto [...]
  • 想要改原数据,一定要加 &,写成 auto& [...]
  • 防止误改并高效,写 const auto& [...]

2.2 绑定结构体

绑定结构体这是实际开发中最常用的场景。结构体通常用来把不同类型的数据打包在一起。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string>

// 定义一个简单的结构体
struct User
{
std::string name;
int age;
double height;
};

int main()
{
// 创建一个结构体实例
User u = {"小明", 18, 1.75};

// 【核心代码】
// 自动识别 u.name 是 string,u.age 是 int,u.height 是 double
// 并按顺序赋值给 name, age, height 三个新变量
auto [name, age, height] = u;

std::cout << "姓名: " << name << std::endl;
std::cout << "年龄: " << age << std::endl;
std::cout << "身高: " << height << std::endl;

return 0;
}.

在使用结构化绑定的时候,关于类成员权限的说明:如果结构体成员是 private(私有的),那么直接绑定会失败。结构化绑定要求成员必须是 public(公有的)或者提供了特殊的接口。这里的特殊接口指的是针对该结构体或类实现的元组式协议

简单来说,如果我们的结构体或者类成员是 private 的,不能直接使用默认的结构化绑定,但可以通过重载以下全局函数,让编译器知道如何解包对应的对象:

  1. std::tuple_size<S>::value(或者 std::tuple_size<S>::value 的特化): C++ 标准库中的一个工具,用于在编译期获取类型 S 中包含的元素个数。

    它的核心作用不是查看某个变量有多少个元素,而是查看某个数据类型定义了多少个成员。

  2. std::tuple_element<I, S>::type(特化):类型萃取工具,用于告知编译器第 I 个元素的类型。

    • I:元素的索引(从 0 开始)。
    • S:要查询的类型(如 tuple、pair、array 或支持结构化绑定的类)。
  3. get 函数:可以是成员函数 get<I>() 或者全局函数 get<I>(s),用于返回对应索引的值。

接下来我们对上面的代码做出如下的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <iostream>
#include <string>
#include <tuple> // 必须包含

// 修改后的 User 类
class User
{
private:
std::string name;
int age;
double height;

public:
// 修改 1: 添加公有构造函数,允许外部初始化
User(std::string n, int a, double h) : name(n), age(a), height(h) {}

// 修改 2: 实现 get 接口 (返回对应索引的成员)
// 注意:这里必须返回引用或 const 引用,才能对原对象读取(如果是引用绑定)
template <std::size_t I>
auto& get() &
{
if constexpr (I == 0) return name;
else if constexpr (I == 1) return age;
else if constexpr (I == 2) return height;
}

// const 版本的 get,用于 const 对象
template <std::size_t I>
const auto& get() const &
{
if constexpr (I == 0) return name;
else if constexpr (I == 1) return age;
else if constexpr (I == 2) return height;
}
};

// 修改 3: 特化 std::tuple_size,告知编译器 User 有 3 个元素
template<>
struct std::tuple_size<User> : std::integral_constant<std::size_t, 3> {};

// 修改 4: 特化 std::tuple_element,告知编译器每个位置的类型
template<std::size_t I>
struct std::tuple_element<I, User>
{
// 通过 decltype 获取 get() 返回的类型
using type = decltype(std::declval<User>().get<I>());
};

int main()
{
// 创建实例 (现在的初始化调用的是我们添加的构造函数)
User u = {"小明", 18, 1.75};

// 【核心代码】
// 编译器发现 User 实现了 tuple-like 协议,
// 它会调用 u.get<0>(), u.get<1>(), u.get<2>() 来获取值,
// 从而绕过了成员是 private 的限制。
auto [name, age, height] = u;

std::cout << "姓名: " << name << std::endl;
std::cout << "年龄: " << age << std::endl;
std::cout << "身高: " << height << std::endl;

return 0;
}

这种机制实际上是把我们的类伪装成了一个 std::tuple。如果使用了这种方式,结构化绑定就不会去关心成员是不是 public 的,它只会调用提供的 get 方法来获取数据。std::pairstd::tuple 之所以能支持结构化绑定,就是内部实现了这一套接口。

2.3 绑定 std::pair 和 std::map

在 C++ 中,std::map(映射容器)存储的是一对一对的值(键值对)。比如存储学号对应姓名。以前我们要遍历 map 很麻烦,现在非常爽。关于std::map有一个小细节就是它的每个元素其实是一个 std::pair

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <map>
#include <string>

int main()
{
// 创建一个 map:存储 ID 和 姓名
std::map<int, std::string> students =
{
{101, "李华"},
{102, "韩梅梅"}
};

// 传统写法要用 iter->first 和 iter->second
// 【核心代码】使用结构化绑定遍历
// key 代表键(id),value 代表值
for (const auto& [key, value] : students)
{
std::cout << "ID: " << key << ", 姓名: " << value << std::endl;
}

// 也可以单独绑定一个 pair
std::pair<int, std::string> p = {202, "王刚"};
auto [id, name] = p;
std::cout << "单独 Pair -> ID: " << id << ", 姓名: " << name << std::endl;

return 0;
}

2.4 绑定 std::tuple

std::tuple 是 C++11 引入的一个非常有用的标准库容器,它可以看作是 std::pair 的泛化版本。std::pair 只能存两个元素,而 std::tuple 可以存储任意数量、任意类型的元素。 我们也可以将它看作是超级结构体,因为它不需要预先定义结构体类型就可以把任意类型的数据打包在一起

以下是 std::tuple 的常见用法详解:

  1. 头文件:使用前需要包含头文件#include <tuple>

  2. 创建和初始化

    • 直接构造:最直接的方式是在尖括号中指定类型,圆括号中指定值。

      1
      2
      // 存储 int, double, string
      std::tuple<int, double, std::string> myTuple(10, 3.14, "Hello");
    • 使用 std::make_tuple (推荐)

      1
      auto myTuple = std::make_tuple(10, 3.14, "Hello"); 
    • 空元组

      1
      std::tuple<> emptyTuple;

由于 tuple 内部的类型可能不同,不能像 vector 那样用下标 [i] 访问(因为编译器不知道 [i] 返回的是什么类型)。必须使用编译期索引。

  1. 使用 std::get (索引):索引从 0 开始。必须在编译期知道索引值

    1
    2
    3
    4
    5
    auto t = std::make_tuple(10, 3.14, "Test");

    int i = std::get<0>(t); // 获取第一个元素 (int)
    double d = std::get<1>(t); // 获取第二个元素
    std::string s = std::get<2>(t); // 获取第三个元素

    注意:如果索引越界,编译器会直接报错。

  2. 使用 std::get (类型):如果 tuple 中没有重复的类型,也可以通过类型来获取元素。

    1
    2
    3
    4
    auto t = std::make_tuple(10, 3.14, "Test");

    double d = std::get<double>(t); // 直接获取 double 类型的元素
    // int i = std::get<int>(t); // 如果有两个 int,这行会报错
  3. 结构化绑定 (C++17,最推荐):这是目前最优雅的方式,可以将 tuple 解包到独立的变量中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include <iostream>
    #include <tuple>
    #include <string>

    int main()
    {
    // 定义一个元组:包含 整数, 浮点数, 字符串
    auto my_data = std::make_tuple(1, 3.14, "hello");

    // 【核心代码】
    // 按顺序解开元组
    auto [num, pi, text] = my_data;

    std::cout << "整数: " << num << std::endl;
    std::cout << "圆周率: " << pi << std::endl;
    std::cout << "文本: " << text << std::endl;

    return 0;
    }

最后再讲一下如何操作元组:

  1. 获取元素数量 (std::tuple_size)

    1
    2
    using MyTuple = std::tuple<int, double, char>;
    constexpr size_t size = std::tuple_size<MyTuple>::value; // 值为 3
  2. 修改元素:std::get 返回的是引用,所以可以直接修改:

    1
    2
    auto t = std::make_tuple(10, 3.14);
    std::get<0>(t) = 20; // t 的第一个元素变成了 20
  3. 元组拼接 (std::tuple_cat):将两个或多个元组拼接成一个新元组。

    1
    2
    3
    4
    auto t1 = std::make_tuple(1, 2);
    auto t2 = std::make_tuple("Hello", 3.5);
    auto t3 = std::tuple_cat(t1, t2);
    // t3 的类型是 tuple<int, int, const char*, double>

2.5 忽略某些值

有时候对象里有 3 个元素,但我们只关心第 1 个和第 3 个,中间的不想要。C++ 允许你使用变量名 _(下划线)来占位。_ 只是一个普通的变量名(约定俗成用来代表丢弃),并不是特殊的关键字。这在接收函数返回值时非常有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <tuple>

// 返回一个文件的操作结果:是否成功, 错误码, 错误信息
std::tuple<bool, int, std::string> open_file()
{
return {true, 0, "Success"};
}

int main()
{
auto [success, code, msg] = open_file();

// 我只关心是否成功,不关心错误码和错误信息
// 使用 _ 来忽略后面两个
// 实际上 code 和 msg 变成了 _ 变量,我们不再使用它们
auto [ok, _, _msg] = open_file();

if (ok)
{
std::cout << "文件打开成功!" << std::endl;
}

return 0;
}

在 C++ 中,_ 只是一个普通的合法变量名,并不是一个用于“丢弃”的神奇关键字。因此,在同一个作用域内,你不能声明多个名字都叫 _ 的变量,否则编译器会报重定义错误。

1
2
// 这里的两个 _ 在同一个作用域内,编译器会认为你多次定义了同一个变量
auto [ok, _, _] = open_file(); // 错误!

如果你的目的是忽略末尾的所有变量,C++ 的结构化绑定不支持像 Python 那样写成 [ok, *_]C++ 要求方括号内的变量数量必须和元组/结构体的大小完全一致。你必须一个一个地把不想用的位置用不同的名字填满。比如:

1
2
// 假设函数返回 5 个值,我只想要第 1 个
auto [val, _1, _2, _3, _4] = get_five_things();