C++ 结构化绑定过程

起因

同学给出如下代码,指出 std::forward_as_tuple() 的返回值不能被 auto 结构化绑定:

#include <tuple>

int main() {
    auto t1 = std::tuple{0};
    auto [a0] = t1;                      // ok
    auto t2 = std::forward_as_tuple(0);
    auto [a1] = t2;                      // error
}

修改代码尝试了几次发现,t2 只是不能被 auto 结构化绑定,可以被 auto &auto &&(万能引用)结构化绑定。查阅资料得知 std::forward_as_tuple() 返回的 std::tuple 的参数类型都是完美转发后的类型,t2 的类型是 std::tuple<int &&> 而不是 std::tuple<int>

将代码修改如下,果然 t1 也无法被结构化绑定了。

#include <tuple>

int main() {
-   auto t1 = std::tuple{0};
-   auto [a0] = t1;                      // ok
+   auto t1 = std::tuple<int &&>{0};     // don't use CTAD
+   auto [a0] = t1;                      // error
    auto t2 = std::forward_as_tuple(0);
    auto [a1] = t2;                      // error
}

所以说,std::forward_as_tuple() 不应该背这个锅。

std::tuple<T&&> 的复制构造函数被删除了

从上面代码编译时的错误来看,结构化绑定在尝试调用 std::tuple 的复制构造函数,但是当有一个模板参数为右值引用类型时,std::tuple 的复制构造函数就会被删除,导致编译失败。

做以下测试:

struct A {
  int &&x;
};
struct B {
  int &x;
};
static_assert(std::is_move_constructible_v<A>);
static_assert(!std::is_copy_constructible_v<A>);
static_assert(std::is_move_constructible_v<B>);
static_assert(std::is_copy_constructible_v<B>);

这说明右值引用成员的存在会导致类的移动构造函数被删除。std::forward_as_tuple() 在转发纯右值的时候,将纯右值转换成了右值引用,从而返回的 std::tuple 中包含右值引用成员,因而不能用 auto 结构化绑定(auto & 可以)。

另一个问题是:为什么上述代码的结构化绑定会调用 std::tuple 的复制构造函数呢?

结构化绑定过程

https://en.cppreference.com/w/cpp/language/structured_binding

<attr> auto <C++20:static/thread_local> <cv> <ref> [<id-list>] = <initializer>
<attr> auto <C++20:static/thread_local> <cv> <ref> [<id-list>] { <initializer> }
<attr> auto <C++20:static/thread_local> <cv> <ref> [<id-list>] ( <initializer> )
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 这一块在 cppreference 写的是 cv-auto

C++20 起,可以增加 static 或者 thread_local 修饰符,同时 volatile 修饰符在结构化绑定中的使用被标记为过时。

步骤 1: 创建临时变量 e

结构化绑定时会创建一个临时变量 e 用来存储初始化器(initializer)的值。初始化的方式如下:

  1. 如果表达式是数组类型 A,而且没有引用限定符,那么 e 的类型是 <cv> A,其中 <cv> 表示结构化绑定声明时的 cv 限定符。e 的每个元素会被复制构造或者直接构造。(对于基本类型数组来说,就是拷贝了一遍数组。)
  2. 否则,e 按照取代 [<id-list>] 的方式定义。

举个例子来说明第 2 点:

- auto const &[x, y] = std::pair{3, 4};
+ auto const &e      = std::pair{3, 4};

在初始化器不为数组类型的情况下,就好像把变量名替换成临时变量 e 一样。这个时候 e 的类型为 std::pair<int, int> const &

Clangd 的类型提示

如果用 Clangd 作为 LSP,把鼠标指到 [ 的位置上去,会看到:

variable (anonymous)
Type: const std::pair<int, int> & (aka const pair<int, int> &)

这个 anonymous 变量应该就是指的临时变量 e

一开始结构化绑定测试代码中的 auto [a1] = t2 会尝试调用 std::tuple 的复制构造函数也是因为临时变量 e 是用 auto 定义的。

步骤 2:决定绑定方式

e 的无引用类型 Estd::remove_reference_t<decltype((e))>

  1. 如果 E 是数组类型,使用数组绑定方式。
  2. 如果 E 是非 union 的类类型,且 std::tuple_size<E> 有定义且有一个成员名为 value,使用元组绑定方式。
  3. 如果 E 是非 union 的类类型,且 std::tuple_size<E> 无定义,使用数据成员绑定方式。

步骤 3:绑定

数组绑定

int bind_array() {
  int a[2] = {1, 2};
  auto [x, y] = a;    // creates e[2], copies a into e,
                      // then x refers to e[0], y refers to e[1]
  auto &[xr, yr] = a; // xr refers to a[0], yr refers to a[1]
  static_assert(std::is_same_v<decltype(x), int>);
  static_assert(std::is_same_v<decltype(y), int>);
  static_assert(std::is_same_v<decltype(xr), int>);
  static_assert(std::is_same_v<decltype(yr), int>);
}

可以看到 auto & 只是作用于临时值 e 上面的,从绑定中获得的元素并不是引用类型的。

元组绑定

元组绑定规则:对于 tuple-like 类型,std::tuple_size<E>::value 必须是整数常量表达式,结构化绑定中每个变量的类型按顺序则由 std::tuple_element<i, E>::type 决定。决定了每个变量的类型之后,就要用 e.get<i>()(优先考虑)或者 get<i>(e)(只考虑 ADL,不考虑非 ADL,比如说 std::tuple 就考虑 std::get)来逐一初始化它们。

这说明 auto &[...] = ...&(ref-qualifier)只会影响临时匿名变量的类型,不会影响被绑定标识符的类型。如果想要在结构化绑定时建立引用关系,则需要 std::tuple 的参数类型(或者 std::tuple_element<i, E>::type)为引用类型。

void bind_with_ref() {
  float x{};
  char y{};
  int z{};

  std::tuple<float &, char &&, int> tpl(x, std::move(y), z);
  {
    const auto &[a, b, c] = tpl;
    static_assert(std::is_same_v<decltype(a), float &>);
    static_assert(std::is_same_v<decltype(b), char &&>);
    static_assert(std::is_same_v<decltype(c), int const>);
  }

  {
    // c 没有引用 z,而是复制了一份。
    auto &[a, b, c] = tpl;
    static_assert(std::is_same_v<decltype(a), float &>);
    static_assert(std::is_same_v<decltype(b), char &&>);
    static_assert(std::is_same_v<decltype(c), int>);
    c = 5;
    std::cout << "z: " << z << "\n"; // prints "z: 0"
  }
}

虽然 ref-qualifier 没有作用在绑定后的变量上,但是 cv-qualifier 作用了在绑定后的变量上。第一个例子中 c 的类型为 int const 就是证明。

数据成员绑定

  1. 所有数据成员都要参与绑定,少了一个都不行。
  2. 必须有所有数据成员的访问权限。
void foo() {
  struct C {
    int x;
  
  private:
    int y;
  };
  auto [x, y] = C{}; // error: Cannot decompose private member 'y' of 'C'
  // error: Type 'C' decomposes into 2 elements, but only 1 name was provided
  auto [x1] = C{};
}

结构化绑定的顺序

以下由 C++ 语言规范保证:

  1. 临时变量的创建在所有标识符绑定之前。
  2. 位次小的变量绑定总是在位次大的变量绑定之前。

其他注意点

Cppreference 上写了不少注意点,这里只抄了一部分我觉得容易忽略的。(网页上最后的 Notes 是需要非常关注的,里面讲了不少额外信息。)

🚩 The portion of the declaration preceding [ applies to the hidden variable e, not to the introduced identifiers:

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

🚩 结构化绑定创建的变量在 C++20 之前不能被 lambda 捕获。