首页 热点 业界 科技快讯 数码 电子消费 通信 前沿动态 电商

结构化绑定详解

2023-08-31 07:02:35 来源 : 哔哩哔哩

结构化绑定是C++17新增的语法,适当使用能极大地提升编程体验。结构化绑定将引入的标识符绑定到对象的元素或成员上。很多人将结构化绑定视为引用的语法糖,诚然它们有许多相似之处,但二者在语义上还是有很多不同的地方。

对象、引用、结构化绑定是C++中三个相互关联但是彼此并列的概念,它们都是程序中的实体。

1. 绑定到数组的元素


(资料图片仅供参考)

首先,结构化绑定能够绑定到数组的元素上:

在这段代码中,定义了 abc三个结构化绑定。通过 printf可以观察到它们的值分别是 123,对应数组arr的三个元素。

实际上在这个过程中编译器帮我们干了下面的事:首先,引入一个匿名变量,在这里我们叫它_unnamed_,它的各个元素从 arr复制初始化。然后,将结构化绑定引入的三个名字分别绑定到这个匿名数组的三个元素上。

需要注意的是,这里的引用仅仅表示一种绑定关系,即 a绑定到_unnamed_[0],并不代表 a是个引用。例如,我们直接定义一个引用 int &ra = arr[0],那么 decltype(ra)会得到 int&,而结构化绑定的声明类型是不带引用的:decltype(a)得到的是 int

那么如何证明上述匿名变量的存在,并且真的发生了复制呢?一个简单的办法是修改 a的值,arr[0]并不会随之变化,说明 a和 arr[0]指代的两个不同的对象。当然还有更直观的办法,那就是自定义类的复制构造函数。

运行上述代码,我们很能够观察到程序输出下列内容:

分割线之前是数组 arr的三个元素从 int直接初始化的输出,分割线之后是结构化绑定过程中匿名数组各个元素的复制构造的输出。并且复制的对象的地址也能一一对应。

但是,这只能证明发生了复制,还不足以证明这个匿名变量的存在。编译器完全可以省略匿名变量,直接从数组的三个元素复制初始化三个变量,并且还可以避免前文提到的看起来像引用,却又不是引用的绑定。

2. 绑定到数据成员

让我们带着这个问题来看看下面这段代码:

程序的输出如下:

可以看到,这里只调用了一次B的复制构造,说明这个匿名变量确实存在。

和数组类似,结构化绑定可以绑定到类的非静态数据成员。当然并不是每一种类都可以被结构化绑定,它必须有如下性质:

它所有的非静态数据成员在当前语境中可访问。

它所有的非静态数据成员都是它自己,或者同一个基类的直接成员。

结构化绑定并不要求成员必须有 public访问权限,只要在当前语境中可以访问所有成员即可。

第二点似乎不太直观,我们通过两个例子来说明一下:

我们从 A派生出 B和 C两个类。其中 B没有增加任何数据成员,它可能只添加了一些成员函数扩展了 A的功能,这在开发中也是很常见的手法。那么对于 B来说,它所有的非静态数据成员都是从基类 A继承而来的,因此 B符合结构化绑定的要求。而 C则增加了一个数据成员,因此不满足第二条性质。

3. 结构化绑定中的限定符

左值引用限定符

刚才我们见到的结构化绑定都有一个复制的过

程,会产生一个匿名对象。有时候复制的开销会比较大,我们当然想避免不必要的复制。于是我们可以为结构化绑定添加一个引用限定符,以引用的方式绑定到相应的对象上。

还记得刚刚说过结构化绑定过程中的匿名变量吗?它再一次派上大用场了。如果结构化绑定声明中包含引用限定符,那么这个引入的匿名变量就是一个引用!

引用 _unnamed_绑定到 arr,而 a又绑定到 _unnamed_[0],也就是说 a直接绑定到了 arr[0]上。b和 c同理。再一次强调,即使添加了引用限定符,结构化绑定也不是引用,decltype(a)仍然是 int而不是 int&。这里的引用只是为了表达绑定关系。

定义引用不会产生可观察的副作用,我们也就无法直接证明这个匿名变量确实是引用。当然我们还是可以从侧面来应证它,比如说左值引用不能绑定到右值。

cv限定符

很自然地,你会想到用右值引用来绑定到右值表达式,但别忘了const限定的左值引用,它们也可以绑定到右值。

加上 const限定之后,我们就不能修改这些结构化绑定的值了。在需要的时候加上 const能让我们的程序更加安全。

既然是cv限定符,自然还有 volatile。我们稍微提一下,这个限定符实际上很少用到,甚至在C++20中弃用了大部分语境中的 volatile限定,包括结构化绑定。你仍然可以写,但编译器可能会发出警告。volatile是另一个很大的话题,并且涉及到很多实现上的细节,这里就不展开讲了。

右值引用限定符

实际上在结构化绑定中说“右值引用”限定符并不准确,毕竟前面还有一个 auto占位符呢。auto&&是不是右值引用可说不准,让我们来复习一下。

在上面的示例代码中,auto&& lref = i会进行类型推导,由于初始化器i是个左值,推导出 auto -> int&再经过引用折叠 int& && -> int&最终得到 lref是个左值引用。

结构化绑定引入的匿名变量也是如此,如果引用限定符是 &&那么匿名变量的类型就会根据这一规则自动推导,这也是 auto&&被称为万能引用的原因。

存储类说明符(C++20起)

从C++20开始,你可以为结构化绑定加上 static或者 thread_local这两个存储类说明符,它们同样是作用在引入的匿名变量上。

使用这两个说明符的时候要注意,如果再加上引用限定符,绑定到某个局部变量上,很容易产生悬垂引用。这与普通的静态变量规则是相同的。

4. 初始化器

上面的代码中我们一直都是使用等于号形式的初始化器。实际上结构化绑定还允许花括号和圆括号初始化。大多数情况下,它们区别不大。唯一的区别在于初始化匿名变量的时候,等于号的形式使用复制初始化,而花括号或者圆括号的形式使用直接初始化。复制初始化不考虑 explicit构造函数。

5. 绑定到元组式类型的元素

结构化绑定还能绑定到例如 std::tuple或者 std::pair,甚至 std::array这些类型上。但仔细想想,就会发现事情并没有那么简单。pair还能用下面这个形式强行解释一下,结构化绑定是绑定到它的两个数据成员上。

但 tuple呢?它有公开可访问的数据成员吗?标准库似乎没有提供给我们。访问 tuple的元素必须通过 std::get函数。如果你了解一点模板元编程,那你应该知道 tuple通常是用模板递归继承的方式实现的,它的数据成员分布在一层一层的基类里。这明显是不符合结构化绑定绑定到数据成员的要求的。

再说说 std::array,虽然它名字就叫 array,长的像数组,用起来也像数组,但它毕竟不是数组,std::is_array<std::array<int,3>>::value肯定是 false

那么结构化绑定是如何实现的呢?其实,C++为我们提供了一套精fù妙zá的机制,可以自定义结构化绑定规则,我们通常叫它元组式绑定。

如果你只是使用标准库提供的这些元组式类型,那么不必担心:就把std::pairstd::tuple当成所有成员都能公开访问的结构体,把std::array当成普通的数组。标准库已经给你实现好了相关的细节,不了解这套机制的工作原理也不影响你使用。

使用例:

程序输出:

如果你对其中的细节感兴趣,或者想要给你写的类实现自定义结构化绑定,就让我们开始吧。

首先,编译器会检查结构化绑定的初始化表达式的类型,我们暂时称它为 T。如果 T是数组类型,那就按照前文所述的规则绑定到数组元素。否则,编译器就会检查 std::tuple_size<T>::value是否是一个合法整数类型的常量表达式。如果它是,那就进行元组式绑定。否则,就按照前文所述的规则绑定到 T的数据成员。

std::tuple_size是标准库中声明的一个类模板,此外,标准库还提供了针对 std::pairstd::tuplestd::array的特化。

上述代码只是实现 std::tuple_size特化的方式之一,仅作为示例。如果你要给自定义类型实现结构化绑定,第一步就是写一个相应的 std::tuple_size特化。它必须包含一个静态的整数常量成员,名字为 value。它的值必须是正整数,表示可以结构化绑定的元素的数量,如果它的值和[ 标识符列表 ]的数量不相等,则编译器会报错。惯例上将它的类型设定为 size_t,但任意整数类型都是可以的。

然后,编译器同样会引入一个匿名变量来保存初始化表达式的值。我们以std::tuple为例看看下面的代码:

auto [i,c,d] = std::make_tuple(1, '2', );auto _unnamed_ = std::make_tuple(1, '2', );

为了将结构化绑定中的标识符绑定到某个对象,编译器还会为每一个标识符引入一个新的变量。它的类型是 std::tuple_element<0, T>::type的引用。如果它对应的初始化表达式的值类别是左值,那么它是左值引用,否则,它是右值引用。它对应的初始化表达式的形式见后文详述。

这也就意味着,为了实现自定义结构化绑定,我们还需要自己实现相应的 std::tuple_element特化。它有两个模板参数,第一个参数是一个整数,表示结构化绑定的标识符的序号,从0开始递增;第二个参数是你的自定类型。以 std::pair为例,看看 std::tuple_element的自定义特化要怎么写:

针对 std::tuple的特化实现起来较为复杂,需要用到模板递归继承,这里就不作展示了。感兴趣的读者可以自行查找资料,或者翻看STL的源代码。

总结来说,std::tuple_element<I, T>::type表示了类型 T的第 I个可绑定元素的类型。

有了类型,为每个结构化绑定引入了额外的引用变量之后,接下来就要对这些变量进行初始化了,毕竟引用必须在定义的时候就初始化。首先,编译器会去找类型 T是否有名为 get的成员函数模板,并且 get的第一个模板参数是非类型模板参数。如果找到这样的成员,那么就调用 _unnamed_.get<I>()来初始化第I个变量。如果没有这样的成员,就调用 get<I>(_unnamed_)来初始化,并且查找 get的过程只进行实参依赖查找(ADL, Argument Dependent Lookup),不考虑其他形式。

另外,在调用 get的时候,如果匿名变量 _unnamed_的类型是左值引用,则调用过程中它保持为左值;否则将它转换到亡值再调用。也就是说如果get同时存在接受左值引用和右值引用的重载时,前者调用左值引用的版本,而后者调用右值引用的版本,这实际上类似于完美转发。

最后,将结构化绑定引入的标识符绑定到额外引入的这些变量所指代的对象上。

最后,我们通过一个例子来看看完整的自定义结构化绑定过程。考虑如下场景:标准库在常用数学函数库中提供了 div_t div(int, int)函数。它计算两个整数相除得到的商和余数,并通过一个结构体返回。但是标准并未规定结构体 div_t两个成员的顺序,因此直接绑定到数据成员可能会导致顺序不对,于是我们可以为它定义一套元组式的绑定方式,让第一个变量始终绑定到商,而第二个变量始终绑定到余数。

首先,我们需要为 std::tuple_size<T>写一个特化。此处的 std::tuple_size<div_t>::value即结构化绑定能绑定的成员的数量,因此我们将它设置为2。

然后,我们需要为 std::tuple_element这个模板类写一些特化,用于确定各个元素的类型。div_t只有两个成员,我们直接写两个全特化即可。

最后,我们需要写一个 get函数,用来绑定匿名变量的各个元素。此处的constexpr if也是C++17的新特性,它的条件表达式必须是一个编译期常量,因此它会在编译期就能根据条件选择相应的分支,直接将另一个分支删除,有点类似于预处理指令 #ifdef-#else-#endif的效果。

对于我们这个简单的例子constexpr if并不是必须的,因为这里的if两个分支返回的类型是相同的。如果if两个分支返回不同的类型,就可以通过constexpr if消除不需要的分支,保证编译能够通过。

完整的示例代码如下:

完整语法

存储类说明符 :(C++20起)

staticthread_local

cv限定符 :

constvolatile(C++20起弃用)const volatile(C++20起弃用)

引用限定符 :

&&&

初始化器 :

=初始化表达式{初始化表达式}(初始化表达式)

结构化绑定声明 :

存储类说明符ₒₚₜ cv限定符ₒₚₜ auto引用限定符ₒₚₜ [标识符列表]初始化器 ;

最后,欢迎来到QQ频道交流讨论。频道名称:std::forward编程社区搜索频道号直达:wxj6l1350o

标签:

相关文章

最近更新
结构化绑定详解 2023-08-31 07:02:35
梦见吃肉什么意思 2023-08-31 06:33:48
卫龙港股涨9.57% 2023-08-31 06:26:30
迎开学 2023-08-31 06:23:35