C++ 结构化绑定

一、结构化绑定

1.1 引子

在 C++中,如果一个函数需要返回两个值,我们可以使用std::pair 。例如:有个函数查找并返回一个整数数组的最小值和最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::pair<int, int> findMinMax(const std::vector<int> &numbers)
{
if (numbers.empty()) return {0, 0};

int min = numbers[0];
int max = numbers[0];

for (int num: numbers)
{
if (num < min) min = num;

if (num > max) max = num;
}

return {min, max};
}

我们有多种方法可以获取std::pair中的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::vector<int> data = {42, 17, 89, 3, 56, 21};

// 方法一
auto r = findMinMax(data);
int min = r.first;
int max = r.second;

// 方法二
auto r = findMinMax(data);
int min = std::get<0>(r);
int max = std::get<1>(r);


// 方法三
int min, max;
std::tie(min, max) = findMinMax(data);

我们发现虽然有多种方法可以获取std::pair中的参数,但是都有如下问题:1. 代码不够简洁 2. 可读性较差。在C++ 17中可以通过结构化绑定一行代码搞定:

1
auto [min, max] = findMinMax(data);

1.2 结构化绑定是什么

C++17标准中引入了结构化绑定,即将指定的标识符绑定到初始化的子对象或元素上,相当于给初始化的子对象(或者元素)起了别名。注意别名不同于引用,后文会详细讲解它们区别。结构化绑定为我们提供了一种更简单、更直观的方式来操作复合数据类型。

语法:

1
2
3
decl-specifier ref-qualifier(optional) [ identifier-list ] = expression;
decl-specifier ref-qualifier(optional) [ identifier-list ] { expression };
decl-specifier ref-qualifier(optional) [ identifier-list ] ( expression );

各部分说明:

decl-specifier : 修饰符,可选的有constvolatilestaticthread_local,必选的为auto

ref-qualifier:&&&,可选,表示左值引用或万能引用

identifier-list:用逗号分隔的标识符列表,标识符在绑定语句所在的作用域内可见

expression:表达式

下面有一个嵌套std::map,外层表示学校与班级,内层表示班级与学生人数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 嵌套 map:学校 -> 班级 -> 学生人数
std::map<std::string, std::map<std::string, int>> schools{
{"HighSchoolA", {{"Class1", 35}, {"Class2", 30}}},
{"HighSchoolB", {{"Class1", 28}, {"Class2", 32}, {"Class3", 26}}}
};

// 外层遍历学校
for (const auto &[school, classes]: schools) // 将key绑定给标识符school, 将val绑定给标识符classes。由于不希望拷贝和修改,使用 const & 修饰
{
std::cout << "School: " << school << std::endl;

// 内层遍历班级
for (const auto &[className, studentCount]: classes) // 将key绑定给标识符className, 将val绑定给标识符studentCount。由于不希望拷贝和修改,使用 const & 修饰
{
std::cout << " " << className << " has " << studentCount << " students." << std::endl;
}
}

1.3 深入理解结构化绑定

1.3.1 绑定是什么

1.2节中我们提到结构化绑定就是将指定的标识符绑定到初始化的子对象上,等同于子对象的别名。下面通过两个示例来理解这句话。

示例一

1
2
3
4
5
6
7
8
9
10
std::pair<std::string, int> person = std::make_pair("小明", 18);

auto [name, age] = person;
std::cout << "person.age" << person.second << " &person.age=" << &person.second << std::endl;
std::cout << "age" << age << " &age=" << &age << std::endl;

age = 20;
std::cout << "修改后" << std::endl;
std::cout << "person.age" << person.second << " &person.age=" << &person.second << std::endl;
std::cout << "age" << age << " &age=" << &age << std::endl;

输出:

1
2
3
4
5
person.age18 &person.age=0x16d8bf048
age18 &age=0x16d8bf010
修改后
person.age18 &person.age=0x16d8bf048
age20 &age=0x16d8bf010

可以看到修改age并不会影响person.age,而且它们内存地址是不一样。即编译器发现auto后面没有&&&时,会创建一个person对象的副本(匿名对象),然后将name与age绑定到这个匿名对象上。伪代码如下:

1
2
3
4
5
std::pair<std::string, int> person = std::make_pair("小明", 18);

auto anonymous = person; // 参考person创建的匿名对象
aliasname name = anonymous.name; // aliasname这里表示别名,而不是引用
aliasname age = anonymous.age;

示例二

1
2
3
4
5
6
7
8
9
10
std::pair<std::string, int> person = std::make_pair("小明", 18);

auto &[name, age] = person;
std::cout << "person.age" << person.second << " &person.age=" << &person.second << std::endl;
std::cout << "age" << age << " &age=" << &age << std::endl;

age = 20;
std::cout << "修改后" << std::endl;
std::cout << "person.age" << person.second << " &person.age=" << &person.second << std::endl;
std::cout << "age" << age << " &age=" << &age << std::endl;

输出:

1
2
3
4
5
person.age18 &person.age=0x16b8c3048
age18 &age=0x16b8c3048
修改后
person.age20 &person.age=0x16b8c3048
age20 &age=0x16b8c3048

可以看到修改age会影响person.age且它们内存地址一样。即编译器发现auto后面有&&&时,不会创建匿名对象而是直接将name与age绑定到person对象上。伪代码如下:

1
2
3
4
std::pair<std::string, int> person = std::make_pair("小明", 18);

aliasname name = person.name; // aliasname这里表示别名,而不是引用
aliasname age = person.age;

1.3.2 子对象别名是什么

对于标识符等同于子对象别名又该如何理解呢?可以看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::pair<std::string, int> person = std::make_pair("小明", 18);
auto &[name, age] = person;

std::cout << "&person.name=" << &person.first << " &person.age" << &person.second << " &name="
<< &name << " &age=" << &age << std::endl;

std::cout << "std::is_same_v<std::string, decltype(person.name)="
<< std::is_same_v<std::string, decltype(person.first)> << std::endl;
std::cout << "std::is_same_v<int, decltype(person.age)="
<< std::is_same_v<int, decltype(person.second)> << std::endl;


std::cout << "std::is_same_v<std::string, decltype(name)="
<< std::is_same_v<std::string, decltype(name)> << std::endl;
std::cout << "std::is_same_v<int, decltype(age)="
<< std::is_same_v<int, decltype(age)> << std::endl;


std::cout << "std::is_same_v<std::string&, decltype(name)="
<< std::is_same_v<std::string &, decltype(name)> << std::endl;
std::cout << "std::is_same_v<int&, decltype(age)="
<< std::is_same_v<int &, decltype(age)> << std::endl;

输出:

1
2
3
4
5
6
7
&person.name=0x16b977030 &person.age0x16b977048 &name=0x16b977030 &age=0x16b977048
std::is_same_v<std::string, decltype(person.name)=1
std::is_same_v<int, decltype(person.age)=1
std::is_same_v<std::string, decltype(name)=1
std::is_same_v<int, decltype(age)=1
std::is_same_v<std::string&, decltype(name)=0
std::is_same_v<int&, decltype(age)=0

可以看到这里的别名就是单纯别名,别名的类型和绑定目标对象的子对象类型相同,而不是子对象的引用类型。

1.3.3 绑定不能忽略部分子对象

学生信息由学号、姓名、性别、年龄、班级五部分组成,并通过std::tuple存储,现需要打印学号和姓名,可以使用std::tie配合std::ignore轻松完成,如果使用结构化绑定,则不支持忽略部分子对象。

1
2
3
4
5
6
7
std::tuple<int, std::string, char, int, std::string> student = {202501, "张三", 'M', 18, "class A"};
int id;
std::string name;
std::tie(id, name, std::ignore, std::ignore, std::ignore) = student;
std::cout << "id=" << id << " name=" << name << std::endl;

const auto& [id, name, std::ignore, std::ignore, std::ignore] = student; // 编译失败

我们可以通过给不需要的子对象用”占位标识符“来模拟忽略效果,但需确保这个“占位标识符”在当前作用域不会再被使用到。注意:这种方式也是将子对象绑定到标识符上,只是后面不使用这个标识符,并且在当前作用域中“占位标识符”不能相同。

1
const auto& [id, name, _p1, _p2, _p3] = student;

二、可绑定的类型

结构化绑定可以作用于3种类型,包括原生数组、结构体和类对象、元组和类元组的对象。

2.1 绑定到原生数组

绑定到原生数组所需条件最简单,标识符的数量等于数组的元素个数。需要注意的是,编译器必须知道原生数组的元素个数,如果数组名退化为指针,则会绑定失败。

1
2
3
4
5
int arr[3] = {10, 20, 30};
auto [x, y, z] = arr;

int *p = arr;
auto [a, b, c] = p; // 编译错误,编译器无法获取到p指向的数组元素个数

2.2 绑定到结构体和类对象

绑定到结构体和类对象有如下限制条件:

  1. 结构体(或类)中的非静态成员变量个数必须和标识符列表中标识符的个数相同

  2. 非静态成员变量访问权限必须为public (C++20 放宽了该限制规则)

  3. 非静态成员变量必须在当前类或基类中

  4. 结构体(或类)中不能有匿名联合体

1
2
3
4
5
6
7
8
9
10
11
12
13
class A
{
int _a;
int _b;
};

int main()
{
A obj;
auto &[x, y] = obj;

return 0;
}

上面代码会编译失败,因为类A的非静态成员变量为private,违反限制条件2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A
{
public:
int _a;
int _b;

private:
static const int _num = 2;
};

int main()
{
A obj;
auto &[x] = obj;

return 0;
}

上面代码会编译失败,因为类A的非静态成员变量有2个,而标识符只有1个,违反限制条件1。

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
class Base1
{
public:
int _a;
double _b;
};

class Derived1 : public Base1
{
};


class Base2
{
};

class Derived2 : public Base2
{
public:
int _a;
double _b;
};


class Base3
{
public:
int _a;
};

class Derived3 : public Base3
{
public:
double _b;
};

int main()
{
Derived1 d1;
Derived2 d2;
Derived3 d3;

auto[x1, y1] = d1;
auto[x2, y2] = d2;
auto[x3, y3] = d3; // 编译失败

return 0;
}

上面代码中,d1d2结构化绑定成功,因为非静态成员变量都在当前类或基类中。d3结构化绑定失败是因为非静态成员变量在当前类与基类中都有定义,违反限制条件3。

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
class A
{
public:
int common_data;

// 匿名联合体
union
{
int int_value;
double double_value;
char char_value;
};

std::string name;
};

int main()
{
A obj;
obj.common_data = 2025;
obj.int_value = 100;
obj.name = "test";

auto [common, value, name] = obj; // 编译错误

return 0;
}

上面代码会编译失败,因为类A中有匿名联合体,违反限制条件4。

2.3 绑定到元组和类元组

在上文的示例中已经演示过结构化绑定到std::tuple中 ,即将标识符列表中的标识符分别绑定到元组对象的各个子元素中。那么绑定到类元祖是什么意思呢?有些细心读者注意到上文中已使用结构化绑定到std::pair,但std::pair并不是元祖类型。接下来让迷雾揭晓。

类元祖是指那些不是元组类型,但实现了像std::tuple中为支持结构化绑定一系列限制条件的类型。例如std::pair就是一个类元祖。

限制条件:对于元祖或类元祖类型T,需要满足以下条件

  1. std::tuple_size<T>::value是一个符合语法的表达式,并且该表达式获得的整数值与标识符列表中的标识符个数相同

  2. std::tuple_element<i, T>::type是一个符合语法的表达式,其中i是小于std::tuple_size<T>::value的整数,表示类型T中第i个元素的类型

  3. 类型T必须存在合法的成员函数模板get<i>()或者函数模板get<i>(t),其中i是小于std::tuple_size<T>::value的整数,t是类型T的实例化对象,返回的是实例t中第i个元素的值。

2.2节中对于Derived3,由于它的非静态成员变量分散在基类与派生类之间,所以不支持结构化绑定,下面将通过让其满足类元组的条件,从而达到支持结构化绑定的目的:

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
class Base3
{
public:
int _a;
};

class Derived3 : public Base3
{
public:
double _b;
};

namespace std
{
// 通过模版特化告诉编译器将要绑定的子对象个数,满足条件1
template<>
struct tuple_size<Derived3>
{
static constexpr size_t value = 2;
};

// 通过模版特化告诉编译器每个子对象类型,满足条件2
template<>
struct tuple_element<0, Derived3>
{
using type = int;
};

template<>
struct tuple_element<1, Derived3>
{
using type = double;
};
}


// 获取第i个元素的值。满足条件3
template<std::size_t Idx>
auto &get(Derived3 &obj) = delete;

template<>
auto &get<0>(Derived3 &obj) { return obj._a; }

template<>
auto &get<1>(Derived3 &obj) { return obj._b; }

int main()
{
Derived3 d3;
auto &[x3, y3] = d3;

return 0;
}

get函数实现有两种,一种是作为类的成员函数,一种是函数摸板。上文中是通过函数摸板方式,并且返回的是子对象的引用,所以需要使用auto &[x3, y3] = d3;,如果使用auto [x3, y3] = d3;则会编译错误。并通过=delete明确告诉编译器不要生成除了特化版本以外的函数,防止get函数模板被滥用。下文展示将get作为类的成员函数的方案。

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
class Base3
{
public:
int _a;
};

class Derived3 : public Base3
{
public:
double _b;

// 获取第i个元素的值。满足条件3
template<std::size_t Idx> auto& get() = delete;
template<> auto& get<0>() { return _a; }
template<> auto& get<1>() { return _b; }
};

namespace std
{
// 通过模版特化告诉编译器将要绑定的子对象个数
template<>
struct tuple_size<Derived3>
{
static constexpr size_t value = 2;
};

// 通过模版特化告诉编译器每个子对象类型
template<>
struct tuple_element<0, Derived3>
{
using type = int;
};

template<>
struct tuple_element<1, Derived3>
{
using type = double;
};
}

三、C++ 20

2.2节中提到,非静态成员变量访问权限必须为public 。这个限制乍看是合理的,但是在某些场景下是不合理的。例如下面代码中,test函数是类A的友元函数,可以访问类A私有成员变量_a,但是不能结构化绑定,这个就不合理了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
friend void test();

private:
int _a;
};

void test()
{
A a;
auto x = a._a;
auto [y] = a; // 违反限制条件
}

另外,同样问题还出现在类的成员函数想结构化绑定自身的时候:

1
2
3
4
5
6
7
8
9
10
class A
{
private:
int _a;

void test()
{
auto [x] = *this; // 违反限制条件
}
};

为了解决这类问题,C++20标准规定结构化绑定的限制不再强调类的非静态成员变量必须为public访问权限,而是由编译器根据当前上下文判断是否允许绑定

说明: 在clang 编译器中测试不管是使用C++ 17还是C++ 20标准,上述代码都能编译运行成功。

总结:结构化绑定在许多情况下为我们编写代码提供了极大的便利,通过结构化绑定可以使代码更简洁、可读性更高,很方便从复杂类型中提取子对象并赋予有意义标识符名称。但是它也有一些缺点,例如无法应用于动态数据结构,如std::vector。并且可能带来命名空间污染,不支持忽略部分子对象等。

参考

谢丙堃 <<现代C++语言核心特性解析>>