🐳 矿坑系列 ── Structured binding declaration
tags: C++
此篇由我的 hackmd 搬運而來,下方這是我的 hackmd 目錄,有興趣的朋友也可以來逛逛。
点此回到 C++笔记 目录
这是矿坑系列的第一篇文章,希望能够每周都有一篇的产量,内容主要会是我详细去看某个 C++11 以后的语法的笔记。我平常主要是在 hackmd 上写笔记的,由于之前学习时受过了许多人的帮助,因此就想着也搬上 CSDN 来回馈一下大家,但还不太会操作,如果大家想看有CSS美美的版本可以到我的 hackmd 内查看,不过是繁体字就是了。
前言
Structured binding declaration 是C++17 加入的一个新特性,它让我们能够更简单地去处理多个回传值或多变数的情况,通常会在要接 tuple_like 的容器或Struct 的回传值时搭配auto来使用。
我尽量将原里理解并写在了「介绍及原理」的部分,有一大部分是翻译了官方的文件,原先只是想弄个翻译,结果翻着翻着发现官方有些地方讲的不是让人很明白,于是就又加上了自己的文字,渐渐地就变一篇文件了,如果只想知道如何使用的朋友可以直接跳到下方的应用部分。
另外有些地方我也不是百分之百的了解了,如果有大佬愿意帮忙补充亦或是纠正我的错误就太感谢了!
语法
主要有三种初始化的方式:
1. attr(opt) cv(opt) auto ref-qualifier(opt) [idendentifier-list] = expression;
2. attr(opt) cv(opt) auto ref-qualifier(opt) [idendentifier-list]{ expression };
3. attr(opt) cv(opt) auto ref-qualifier(opt) [idendentifier-list]( expression );
💡 opt 代表的是 optional,可加可不加的意思。
-
attr 🐳
指的是 attrubutes,可加可不加。 -
cv 🐳
可能是cv-qualifier,后方需加上auto,需要的话也可以加上static、thread_local 之类的储存类说明符,但不推荐使用到volatile。 -
ref-qualifier 🐳
&
或&&
,可加可不加,取决于你的需求。 -
identifier-list 🐳
这里放妳要使用的变数名称,他实际上不是变数而是标示符,它们之间需要以逗点,
隔开,后方会有例子。 -
expression 🐳
表达式,通常会放array、tuple-like 容器或是个没有union 成员的Class,语法上会是assignment-expression,它们不能是throw
表达式,并且在top-level 不能有逗号运算符,这里应该是指expression 能够有sub-expression,而它要的是最上层的那个(感谢marty大佬)。另外,expression 内的变数名不能和 identifier-list 内的变数名相同,简单来说就是不能重复宣告同样名字的变数。
介绍及原理
Structed binding 会在你现在的 scope 内采用你identifier-list 里给的标示符,并且将其连结到你expression 里写的元素或子物件。采用时它会先创造出一个特殊的变数来存取你的初始化叙述(initializer),型态取决于你的expression,这个变数的名称这里我们先取作__e
,由于__e
可能是个容器或参考,所以我们给他取叫 initializer,没看过这个词的朋友不用太担心,而 __e
在存取时有一些规则:
-
如果expression 是个A型态的array,而且你没有使用ref-qualifier,那么
__e
会是原先expression 计算结果的复本,型态会是cv A
,cv指的就是cv-qualifier 。而__e
内的元素会依据你使用的初始化方式(最上方写的三种方式)来初始化。如果你使用的是第一种(
=
号)方式,那么__e
内的元素会使用复制初始化来初始化为你expression 内相对应的元素;若你使用的是第二或第三种方法,那么
__e
内的元素会使用直接初始化来初始化为你expression内相对应的元素。 -
如果不是上面的那种情况,那么编译器先将 Structured Binding 改写,直接使用
__e
这个名称作为原先 expression 的复本,像这样:1. attr(opt) cv(opt) auto ref-qualifier(opt) __e = expression;
2. attr(opt) cv(opt) auto ref-qualifier(opt) __e{ expression };
3. attr(opt) cv(opt) auto ref-qualifier(opt) __e( expression );__e
会是个匿名的tuple-like 的容器(没ref-qualifier时) 或是tuple-like 的容器的参考(有ref-qualifier时),简短来說妳有写&
这个__e
就会是个参考,如果没写就是个容器。接着编译器会去看它是否符合Tuple-Like" Binding Protoco ( tuple-like 连结协定),简单来说会长这样:std::tuple_size<__E>::value
必须是个格式正确的整数常量表达式 (integer constant expression)。- identifier-list 内元素的数量必须与
std::tuple_size<__E>::value
相同。 - 如果上面两项有其中一项不符合,便去检查这个 Class 的成员变数是否都为 public,如果不是(有 private 的成员变数),则编译错误
接着identifier-list 内的元素便会「连结」到
__e
内相对应的元素,这也是妳有写&
时,对identifier-list 内的元素做改动就能改动到原容器的原因,因为__e
是个参考,举个例子:std::tuple<int,int> a{1,2} auto [x,y] = a; // __e 是个容器, x 与 std::get<0>(__e) 连结, y 与 std::get<1>(__e) 连结 auto &[x,y] = a; // __e 是 a 的左值参考, x 与 std::get<0>(__e) 连结, y 与 std::get<1>(__e) 连结 auto &&[x, y] = std::make_tuple( 1, 2 ); // __e 是右边那个tuple 的右值参考,x 与std::get<0>(__e) 连结, y 与std::get <1>(__e) 连结
Code 有点长,大家可以复制下来看,在网页上可能不太好阅读。可以看见内部是使用
std::get<>()
来存取元素的,因此妳的expression 必须是个回传tuple-like 容器的叙述,否则妳的__e
不会是个tuple-like 的容器(或容器的参考),那个也就无法使用std::get<>()
了。💡 这边只举了tuple-like 容器的例子,因为原生阵列没有复制建构子,也就是说他不能被改写成上面那三种样式,也就不能用那三种方法初始化,可以看下面这个例子,它会喷错:
int a[2]{ 1, 2 }; int b[2] = a;
所以会需要另外规定方式来初始化。
「连结」这个动作无法以C++ 语言描述,妳可以把他想像成参考,又或是宏定义,但要记得他不是,他是C++ 语言的本身,没办法用C++ 写出来,已经类似语言特性的概念了,就好像我们无法自己实作function-body 的大括号一样(感谢Cy解释)。
💡 官方文件是这么写的: Structured Binding 像是个参考,它是某个已经存在的物件的别名,但 Structured Binding 不是参考,它不需要是个引用类型。
挺玄学的,我自己是用「类似宏定义」来理解的,底下也会如此解释,但各位要记得它不是宏定义,也许是为了确保将标示符丢进std::remove_reference_t <decltype((标示符))>()
时型态要与连结到的元素丢进std::remove_reference_t<decltype((连结到的元素))>()
一样才如此设计的。
接下来我会详细的讲解一下内部的原理,这里用__E
来表示__e
的型态,也就是说__E
为初始化叙述(initializer) 的型态,另外我们也可以说__E
与std::remove_reference_t<decltype((__e))>
等价。
上述的初始化结束后,它会根据 __E
的状况来进行连结,会有三种情况:
-
如果
__E
是个 array 型态 ,那么 identifier-list 内的元素会与初始化叙述(initializer) 内相对应的元素连结这种情况下,每个identifier-list 内的标示符会是一个左值(lvalue),与初始化叙述(initializer) 内相应的元素连结,也因此,identifier-list 内的标示符数量需要与array 内的变数数量一样多,看一下下面这个例子:
int a[2] = {1,2}; auto [x,y] = a;
auto [x,y] = a;
会创建一个名字叫__e
的array__e[2]
,利用[复制初始化](https://en.cppreference.com/w/cpp/language/ copy_initialization)来初始化__e[2]
,之后x 与y 分别会与__e[0]
与__e[1]
连结,你可以把他们想像成参考,或是宏定义,但要记住它们实际上不是。如果有写 ref-qualifier 且 expression 回传的是 lvalue,则 identifier-list 内的元素会间接与 a 内的元素连结,对 identifier-list 内的元素的操作将会反应到 a 的元素上:
int a[2] = {1,2}; auto& [x,y] = a;
auto& [x,y] = a;
会创建一个名字叫__e
的参考引用expression 的计算结果,而identifier-list 内的元素则会透过__e
间接变为a 内元素的参考,可以把他想像成这样:int a[2] = {1,2}; auto & __e = a; // 等价于 int(&e)[2] = a; #define x __e[0] #define y __e[1]
而如果 expression 回传的是 rvalue,则 identifier-list 内的元素会与
__e
内的元素连结:auto && [ x, y, z ] = ( int[] ){ 1, 2, 3 };
可以把他想像成这样:
auto &&__e = ( int[] ){ 1, 2, 3 }; #define x __e[0] #define y __e[1] #define z __e[2]
当然上面这两个例子都是伪代码,内部当然不是这样的,连结无法以 C++ 语言来描述,x 与 y 仅仅是标示符,所以不会是上面这个样子,这只是个示意。
-
如果
__E
是个没有union 成员的Class 型态,而且 std::tuple_size<__E> 是个有成员的完全型(不用管这个成员的型态或可访问性如何),简单来说就是__e
能够做成tuple-like 的容器,符合 tuple-like 连结协定,那么就会使用 tuple-like 连结协定来进行连结与前面提到的一样,首先
std::tuple_size<__E>::value
必须是个格式正确的整数常量表达式(integer constant expression),并且identifier-list 内元素的数量必须与std::tuple_size <__E>::value
相同。再来对于每个标示符,都会连结一个元素(也就是
__e
内的元素),元素的型态会类似是「std::tuple_element<i,__E>::type
的"引用"」 ,注意它是「引用」,i
指的是__e
内第i 个元素,如果这个型态对应的初始化叙述(initializer) 是左值,那这个变数就会是左值引用,如果是右值那就是右值引用。连结到的第 i 个元素详细如下:
-
如果通过Class成员访问的方式在
__E
的范围内查找到至少一个函式模板,且这个函式模板的第一个模板参数是个non-type参数,那么第i 个元素的初始化叙述(initializer) 会是e.get<i>()
。 -
如果没有找到符合情况的函式模板,那么会使用argument-dependent lookup 的方式来呼叫
get<i>(__e )
,因此第i 个元素的初始化叙述(initializer) 会是e.get<i>(__e)
。
在这些初始化叙述中,如果
__e
是一个左值参考(这只会发生在你的ref-qualifier
是&
,或是你的初始化叙述是个左值而且ref-qualifier
是&&
,简单来说就是收合为&
时),那么你将expression 内相对应的元素会是一个左值(这听起来很废话,但重点在下一句)。否则expression 内相对应的元素会是一个消亡值(xvalue),因为内部实际上执行了一次完美转发(perfect-forwarding), 而
i
会是个型态为std::size_t
的纯右值(prvalue),因此<i>
会被转换(解释)为模板参数列表。💡 有三点提醒大家一下
- identifier-list 内的标示符、
__e
内的元素与 expression 内相对应的元素,这三个会有一样的生命周期。 - 我们通常会直接称 identifier-list 内的标示符为「变数」,尽管它不是,但它使用上与变数基本上一样,概念也类似。
- identifier-list 内第i个元素型态会是
std::tuple_element<i,E>::type
。
看一下这个例子:
float x{}; char y{}; int z{}; std::tuple<float&,char&&,int> tpl(x,std::move(y),z); const auto& [a,b,c] = tpl;
a 的名字叫做「Structured Binding」,连结到 tpl内第一个元素,
decltype(a)
为float&
b 的名字叫做「Structured Binding」,连结到 tpl内第二个元素,decltype(b)
为char&&
c 的名字叫做「Structured Binding」,连结到 tpl内第三个元素,decltype(c)
为const int
-
-
如果不是以上两种情况,则expression 内的每个non-static 成员变数都需要是个直接成员或是expression 的相同基类,而且Structured Binding 格式需要正确,让我们能够间接使用
__e.name
来呼叫变数。你的 expression 内不能有匿名或是 union 的成员,identifier-list 内的标示符数量需要与 non-static 的成员变数数量相同。每个 identifier-list 内的标示符都会连结到相对应的成员变数,实际上是连结到
__e.m_i
,m_i
表示第 i 个成员变数,另外 Structured Binding 支援 bit field 用法,看这个例子:
#include <iostream> struct S { mutable int x1 : 2; volatile double y1; int z1; }; int main() { using f = S; const auto [x, y, z] = f(); return 0; }
x 会是个整数左值标示符,连结到一个 2-bit 的整数元素 x1,y 会连结到 const volatile double 的元素 y1。
使用 Structured Binding
💡 记得要切换成 C++17 才能够使用。
现在我们举个简单的例子(来源),现在我要定义一个「人」的函式,人会有年龄、名字等等,因此它的回传型态会是一个 std::tuple<std::string, int>
:
std::tuple<std::string, int> CreatePerson() {
return { "Cherno", 24 };
}
而在 main
内我们需要用到资料时,过去需要像这样:
std::tuple<std::string, int> person = CreatePerson(); // 当然你可以用auto
std::string &name = std::get<0>( person );
int &age = std::get<1>( person );
而对 tuple 熟悉的朋友可能会使用 std::tie
:
std::string name;
int age;
std::tie( name, age ) = CreatePerson();
好多了,我们不需要为了赋值多个变数而额外创个person
,但它仍然需要3 行,又或许我们可以使用Struct,但在C++17 后多了一个新特性Structured Binding,现在我们只需要这样:
auto [name, age] = CreatePerson();
而它也不只限定 tuple-like 的容器,也可以与 Struct 和原生阵列连结,看一下这个例子(来源):
struct TeaShopOwner {
int id;
std::string name;
};
auto GetTeaShopOwner() -> TeaShopOwner {
TeaShopOwner owner { 1, "test"};
return owner;
}
auto [id, name] = GetTeaShopOwner(); // id = 1 , name = "test"
如此一来 id 便会等于 1,name 则会是 “test” 了。 Structured Binding 的另一个好处是可以搭配 Ranged-based for Loop
如此一来 id 便会等于 1,name 则会是 “test” 了。 Structured Binding 的另一个好处是可以搭配 Ranged-based for Loop 使用:
struct TeaShopOwner {
int id;
std::string name;
};
std::vector<TeaShopOwner> owners {{1, "COCO"}, {2, "1Shit"}};
// C++17 前的用法
for (const auto& owner : owners) {
printf("Owner id = %d\n", owner.id);
}
// 使用 Structured Binding
for (const auto& [id, _] : owners) {
printf("Owner id = %d\n", id);
}
留意上面的 Ranged-based for-loop 接住 owners
时,将第二个变数名称取为 _
,我们通常会利用这个手法来表示 name
在回圈里不受「重视」。
以往 C++ 函数的回传值多是单一型别,如 bool, int。有了 Structured Binding 再搭配其他技巧,在处理回传值时更有弹性。
补充
-
对成员的
get
进行查找时会忽略可访问性与非类型模板参数的确切类型。像是template<char*> void get();
,成员将导致使用成员解释,即使格式是错的。 -
有些
[
前方的声明仅适用于隐藏变数__e
,而不适用 identifier-list 内的元素:
#include <cassert>
#include <tuple>
int main() {
int a = 1, b = 2;
const auto &[x, y] = std::tie( a, b ); // x and y are of type int&
auto [z, w] = std::tie( a, b ); // z and w are still of type int&
assert( &z == &a ); // passes
return 0;
}
- tuple-like 的意思是使用
std::tuple_size<>
会是个完全型,即使这个可能导致格式错误:
#include <iostream>
#include <tuple>
struct A {
int x;
};
namespace std {
template <>
struct tuple_size<::A> {};
} // namespace std
int main() {
auto [x] = A{}; // error; the "data member" interpretation is not considered.
return 0;
}
参考资料
1. Structured binding declaration (since C++17) (文章部份来源、例子来源)
2. C 17嚐鮮:結構化繫結宣告(Structured Binding Declaration)
5. C++17尝鲜:结构化绑定声明(Structured Binding Declaration)
6. C++17新特性(1) – 结构化绑定初始化(Structured binding declaration)
7. When does an Incomplete Type error occur in C++
12. STRUCTURED BINDINGS in C++ (例子来源)
13. if-with-initializer in structured binding declaration example ill formed?
14. [C++] - 中的复制初始化(copy initialization)
15. Attribute specifier sequence(since C++11)
16. cv (const and volatile) type qualifiers
18. Is there a difference between copy initialization and direct initialization?
20. What is a sub-expression in C?
21. std::tuple_size<std::tuple>
23. Structured bindings implementation underground and std::tuple
25. Template non-type parameters
27. Template parameters and template arguments
29. Structured binding declarations [dcl.struct.bind]
30. Understand structured binding in C++17 by analogy
31. Structured binding on const
32. Is always the address of a reference equal to the address of origin?
33. C++ primary expressions - Is it primary expression or not?