第20章 结构化绑定(C++17 C++20)

熟悉Python的读者应该知道,Python函数可以有多个返回值,例如:

def return_multiple_values():
    return 11, 7

x, y = return_multiple_values()

在上面的代码中函数return_multiple_values返回的是一个元组(tuple)(11, 7),在函数返回后元组中元素的值被自动地分配到了xy上。回过头来看C++,我们惊喜地发现在C++11标准中同样引入了元组的概念,通过元组C++也能返回多个值,但使用方法却不如Python那般简洁:

#include <iostream>
#include <tuple>

std::tuple<int, int> return_multiple_values()
{
  return std::make_tuple(11, 7);
}

int main()
{
  int x = 0, y = 0;
  std::tie(x, y) = return_multiple_values();
  std::cout << "x=" << x << " y=" << y << std::endl;
}

可以看到,这段代码和Python完成了同样的工作,但代码却要麻烦许多。其中一个原因是C++11必须指定return_multiple_values函数的返回值类型,另外,在调用return_multiple_values函数前还需要声明变量xy,并且使用函数模板std::tiexy通过引用绑定到std::tuple<int&, int&>上。对于第一个问题,我们可以使用C++14中auto的新特性来简化返回类型的声明(可以回顾第3章):

auto return_multiple_values()
{
  return std::make_tuple(11, 7);
}

重点来了,要想解决第二个问题就必须使用C++17标准中新引入的特性——结构化绑定。所谓结构化绑定是指将一个或者多个名称绑定到初始化对象中的一个或者多个子对象(或者元素)上,相当于给初始化对象的子对象(或者元素)起了别名,请注意别名不同于引用,这一点会在后面详细介绍。首先让我们看一看结构化绑定是如何化腐朽为神奇的:

#include <iostream>
#include <tuple>

auto return_multiple_values()
{
  return std::make_tuple(11, 7);
}

int main()
{
  auto[x, y] = return_multiple_values();
  std::cout << "x=" << x << " y=" << y << std::endl;
}

在上面这段代码中,auto[x, y] = return_multiple_values()是一个典型的结构化绑定声明,其中auto是类型占位符,[x, y]是绑定标识符列表,其中xy是用于绑定的名称,绑定的目标是函数return_multiple_values()返回结果副本的子对象或者元素。用支持C++17标准的编译器编译运行这段代码会正确地输出:

x=11 y=7

请注意,结构化绑定的目标不必是一个函数的返回结果,实际上等号的右边可以是任意一个合理的表达式,比如:

#include <iostream>
#include <string>

struct BindTest {
  int a = 42;
  std::string b = "hello structured binding";
};

int main()
{
  BindTest bt;
  auto[x, y] = bt;
  std::cout << "x=" << x << " y=" << y << std::endl;
}

编译运行这段代码的输出如下:

x=42 y=hello structured binding

可以看到结构化绑定能够直接绑定到结构体上。将其运用到基于范围的for循环中会有更好的效果:

#include <iostream>
#include <string>
#include <vector>

struct BindTest {
  int a = 42;
  std::string b = "hello structured binding";
};

int main()
{
  std::vector<BindTest> bt{ {11, "hello"},  {7, "c++"},  {42, "world"} };
  for (const auto& [x, y] : bt) {
       std::cout << "x=" << x << " y=" << y << std::endl;
  }
}

请注意以上代码的for循环部分。在这个基于范围的for循环中,通过结构化绑定直接将xy绑定到向量bt中的结构体子对象上,省去了通过向量的元素访问成员变量ab的步骤。

在阅读了前面的内容之后,读者是否有这样的理解。

1.结构化绑定的目标就是等号右边的对象。

2.所谓的别名就是对等号右边对象的子对象或者元素的引用。

如果确实是这么理解的,请忘掉它们,因为上面的理解是错误的。真实的情况是,在结构化绑定中编译器会根据限定符生成一个等号右边对象的匿名副本,而绑定的对象正是这个副本而非原对象本身。另外,这里的别名真的是单纯的别名,别名的类型和绑定目标对象的子对象类型相同,而引用类型本身就是一种和非引用类型不同的类型。在初步了解了结构和绑定的“真相”之后,现在我将使用伪代码进一步说明它是如何工作起来的。对于结构化绑定代码:

BindTest bt;
const auto [x, y] = bt;

编译器为其生成的代码大概是这样的:

BindTest bt;
const auto _anonymous = bt;
aliasname x = _anonymous.a
aliasname y = _anonymous.b

在上面的伪代码中,_anonymous是编译器生成的匿名对象,可以注意到const auto [x, y] = btauto的限定符会直接应用到匿名对象_anonymous上。也就是说,_anonymousconst还是volatile完全依赖auto的限定符。另外,在伪代码中xy的声明用了一个不存在的关键字aliasname来表达它们不是_anonymous成员的引用而是_anonymous成员的别名,也就是说xy的类型分别为const intconst std:: string,而不是const int&const std::string&。为了证明以上两点,读者可以尝试编译运行下面这段代码:

#include <iostream>
#include <string>

struct BindTest {
  int a = 42;
  std::string b = "hello structured binding";
};

int main()
{
  BindTest bt;
  const auto[x, y] = bt;

  std::cout << "&bt.a=" << &bt.a << " &x=" << &x << std::endl;
  std::cout << "&bt.b=" << &bt.b << " &y=" << &y << std::endl;
  std::cout << "std::is_same_v<const int, decltype(x)>=" 
       << std::is_same_v<const int, decltype(x)> << std::endl;
  std::cout << "std::is_same_v<const std::string, decltype(y)>=" 
       << std::is_same_v<const std::string, decltype(y)> << std::endl;
}

编译运行的结果如下:

&bt.a=0x77fde0 &x=0x77fd80
&bt.b=0x77fde8 &y=0x77fd88
std::is_same_v<const int, decltype(x)>=1
std::is_same_v<const std::string, decltype(y)>=1

正如上文中描述的那样,别名x并不是bt.a,因为它们的内存地址不同。另外,xy的类型分别与const intconst std::string相同也证明了它们是别名而不是引用的事实。由此可见,如果在上面这段代码中试图使用xy去修改bt的数据成员是无法成功的,因为一方面xy都是常量类型;另一方面即使xy是非常量类型,改变的xy只会影响匿名对象而非bt本身。当然了,了解了结构化绑定的原理之后,写一个能改变bt成员变量的结构化绑定代码就很简单了:

int main()
{
  BindTest bt;
  auto&[x, y] = bt;

  std::cout << "&bt.a=" << &bt.a << " &x=" << &x << std::endl;
  std::cout << "&bt.b=" << &bt.b << " &y=" << &y << std::endl;

  x = 11;
  std::cout << "bt.a=" << bt.a << std::endl;
  bt.b = "hi structured binding";
  std::cout << "y=" << y << std::endl;
}

虽然只是将const auto修改为auto&,但是已经能达到让bt数据成员和xy相互修改的目的了:

BindTest bt;
auto &_anonymous = bt;
aliasname x = _anonymous.a
aliasname y = _anonymous.b

关于引用有趣的一点是,如果结构化绑定声明为const auto&[x, y] = bt,那么x = 11会编译失败,因为x绑定的对象是一个常量引用,而bt.b = "hi structured binding"却能成功修改y的值,因为bt本身不存在常量问题。

请注意,使用结构化绑定无法忽略对象的子对象或者元素:

auto t = std::make_tuple(42, "hello world");
auto [x] = t;

以上代码是无法通过编译的,必须有两个别名分别对应bt的成员变量ab。熟悉C++11的读者可能会提出仿照std::tie使用std::ignore的方案:

auto t = std::make_tuple(42, "hello world");
int x = 0, y = 0;
std::tie(x, std::ignore) = t;
std::tie(y, std::ignore) = t;

虽然这个方案对于std::tie是有效的,但是结构化绑定的别名还有一个限制:无法在同一个作用域中重复使用。这一点和变量声明是一样的,比如:

auto t = std::make_tuple(42, "hello world");
auto[x, ignore] = t;
auto[y, ignore] = t;    // 编译错误,ignore无法重复声明

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

我们在上面的示例代码中并没有见到过这种类型,它是3种情况中最简单的一种。绑定到原生数组即将标识符列表中的别名一一绑定到原生数组对应的元素上。所需条件仅仅是要求别名的数量与数组元素的个数一致,比如:

#include <iostream>

int main()
{
  int a[3]{ 1, 3, 5 };
  auto[x, y, z] = a;
  std::cout << "[x, y, z]=[" 
       << x << ", " 
       << y << ", " 
       << z << "]" << std::endl;
}

以上代码很好理解,别名xyz分别绑定到a[0]a[1]a[2]所对应的匿名对象上。另外,绑定到原生数组需要小心数组的退化,因为在绑定的过程中编译器必须知道原生数组的元素个数,一旦数组退化为指针,就将失去这个属性。

将标识符列表中的别名分别绑定到结构体和类的非静态成员变量上,这一点在之前的例子中已经见到了。但是我们之前没有提过关于这种绑定的限制条件,实际上这种情况的限制条件要比原生数组复杂得多。首先,类或者结构体中的非静态数据成员个数必须和标识符列表中的别名的个数相同;其次,这些数据成员必须是公有的(C++20标准修改了此项规则,详情见20.5节);这些数据成员必须是在同一个类或者基类中;最后,绑定的类和结构体中不能存在匿名联合体:

class BindTest {
  int a = 42;        // 私有成员变量
public:
  double b = 11.7;
};

int main()
{
  BindTest bt;
  auto[x, y] = bt;
}

以上代码会编译错误,因为BindTest成员变量a是私有的,违反了绑定结构体的限制条件:

class BindBase1 {
public:
  int a = 42;
  double b = 11.7;
};

class BindTest1 : public BindBase1 {};

class BindBase2 {};

class BindTest2 : public BindBase2 {
public:
  int a = 42;
  double b = 11.7;
};

class BindBase3 {
public:
  int a = 42;
};

class BindTest3 : public BindBase3 {
public:
  double b = 11.7;
};

int main()
{
  BindTest1 bt1;
  BindTest2 bt2;
  BindTest3 bt3;
  auto[x1, y1] = bt1;    // 编译成功
  auto[x2, y2] = bt2;    // 编译成功
  auto[x3, y3] = bt3;    // 编译错误
}

在上面这段代码中,auto[x1, y1] = bt1auto[x2, y2] = bt2可以顺利地编译,因为类BindTest1BindTest2的非静态数据成员要么全部在派生类中定义,要么全部在基类中定义。BindTest3却不同,其中成员变量a的定义在基类,成员变量b的定义在派生类,这一点违反了绑定结构体的限制条件,所以auto[x3, y3] = bt3会导致编译错误。最后需要注意的是,类和结构体中不能出现匿名的联合体,而对于命名的联合体则没有限制。

绑定到元组就是将标识符列表中的别名分别绑定到元组对象的各个元素。绑定到类元组又是什么意思呢?要解释这个概念就要从绑定的限制条件讲起。实际上,绑定元组和类元组有一系列抽象的条件:对于元组或者类元组类型T

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

2.类型T还需要保证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的实例,get<i>()get<i>(t)返回的是实例t中第i个元素的值。

理解上述条件会发现,它们其实比较抽象。这些条件并没有明确规定结构化绑定的类型一定是元组,任何具有上述条件特征的类型都可以成为绑定的目标。另外,获取这些条件特征的代价也并不高,只需要为目标类型提供std::tuple_sizestd::tuple_element以及get的特化或者偏特化版本即可。实际上,标准库中除了元组本身毫无疑问地能够作为绑定目标以外,std::pairstd::array也能作为结构化绑定的目标,其原因就是它们是满足上述条件的类元组。说到这里,就不得不进一步讨论std::pair了,因为它对结构化绑定的支持给我们带来了一个不错的惊喜:

#include <iostream>
#include <string>
#include <map>

int main()
{
  std::map<int, std::string> id2str{ {1, "hello"}, 
  {3, "Structured"}, {5, "bindings"} };

  for (const auto& elem : id2str) {
       std::cout << "id=" << elem.first
            << ", str=" << elem.second << std::endl;
  }
}

上面这段代码是一个基于范围的for循环遍历std::map的例子,其中elemstd::pair<const int, std::string>类型,要在循环体中输出keyvalue的值就需要访问成员变量firstsecond。这个例子中使用基于范围的for循环已经比使用迭代器遍历std::map简单了很多,但是加入结构化绑定后代码将被进一步简化。我们可以将std::pair的成员变量firstsecond绑定到别名以保证代码阅读起来更加清晰:

for (const auto&[id, str]:id2str) {
  std::cout << "id=" << id 
       << ", str=" << str << std::endl;
}

我们已经知道了通过满足类元组的限制条件让任何类型支持结构化绑定的方法,现在是时候实践一下了。以上一节中提到的BindTest3为例,我们知道由于它的数据成员分散在派生类和基类之中,因此无法使用结构化绑定。下面将通过让其满足类元组的条件,从而达到支持结构化绑定的目的:

#include <iostream>
#include <tuple>

class BindBase3 {
public:
  int a = 42;
};

class BindTest3 : public BindBase3 {
public:
  double b = 11.7;
};

namespace std {
  template<>
  struct tuple_size<BindTest3> {
       static constexpr size_t value = 2;
  };

  template<>
  struct tuple_element<0, BindTest3> {
       using type = int;
  };

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

template<std::size_t Idx>
auto& get(BindTest3 &bt) = delete;

template<>
auto& get<0>(BindTest3 &bt) { return bt.a; }

template<>
auto& get<1>(BindTest3 &bt) { return bt.b;}

int main()
{
  BindTest3 bt3;
  auto& [x3, y3] = bt3;
  x3 = 78;
  std::cout << bt3.a << std::endl;
}

在上面这段代码中,我们为BindTest3实现了3种特性以满足类元组的限制条件。首先实现的是:

template<>
struct tuple_size<BindTest3> {
  static constexpr size_t value = 2;
};

它的作用是告诉编译器将要绑定的子对象和元素的个数,这里通过特化让tuple_size<BindTest3>::value的值为2,也就是存在两个子对象。然后需要明确的是每个子对象和元素的类型:

template<>
struct tuple_element<0, BindTest3> {
  using type = int;
};

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

这里同样通过特化的方法指定了两个子对象的具体类型。最后需要实现的是get函数,注意,get函数的实现有两种方式,一种需要给BindTest3添加成员函数;另一种则不需要,我们通常会选择不破坏原有代码的方案,所以这里先展示后者:

template<std::size_t Idx>
auto& get(BindTest3 &bt) = delete;

template<>
auto& get<0>(BindTest3 &bt) { return bt.a; }

template<>
auto& get<1>(BindTest3 &bt) { return bt.b;}

可以看到函数模板get也特化出了两个函数实例,它们分别返回bt.abt.b的引用。之所以这里需要返回引用,是因为我希望结构化绑定的别名能够修改BindTest3的实例,如果需要的是一个只读的结构化绑定,则这里可以不必返回引用。最后template<std::size_t Idx> auto& get(BindTest3 &bt) = delete可以明确地告知编译器不要生成除了特化版本以外的函数实例以防止get函数模板被滥用。

正如上文强调的,我不推荐实现成员函数版本的get函数,因为这需要修改原有的代码。但是当我们重新编写一个类,并且希望它支持结构化绑定的时候,也不妨尝试实现几个get成员函数:

#include <iostream>
#include <tuple>

class BindBase3 {
public:
  int a = 42;
};

class BindTest3 : public BindBase3 {
public:
  double b = 11.7;
  template<std::size_t Idx> auto& get() = delete;

};

template<> auto& BindTest3::get<0>() { return a; }
template<> auto& BindTest3::get<1>() { return b; }

namespace std {
  template<>
  struct tuple_size<BindTest3> {
       static constexpr size_t value = 2;
  };

  template<>
  struct tuple_element<0, BindTest3> {
       using type = int;
  };

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

int main()
{
  BindTest3 bt3;
  auto& [x3, y3] = bt3;
  x3 = 78;
  std::cout << bt3.a << std::endl;
}

这段代码和第一份实现代码基本相同,我们只需要把精力集中到get成员函数的部分:

class BindTest3 : public BindBase3 {
public:
  double b = 11.7;
  template<std::size_t Idx> auto& get() = delete;

};

template<> auto& BindTest3::get<0>() { return a; }
template<> auto& BindTest3::get<1>() { return b; }

这段代码中get成员函数的优势显而易见,成员函数不需要传递任何参数。另外,特化版本的函数get<0>get<1>可以直接返回ab,这显得格外简洁。读者不妨自己编译运行一下这两段代码,其输出结果应该都是78,修改bt.a成功。

前面提到过,当在结构体或者类中使用结构化绑定的时候,需要有公开的访问权限,否则会导致编译失败。这条限制乍看是合理的,但是仔细想来却引入了一个相同条件下代码表现不一致的问题:

struct A {
  friend void foo();
private:
  int i;
};

void foo() {
  A a{};
  auto x = a.i; // 编译成功
  auto [y] = a; // 编译失败
}

在上面这段代码中,foo是结构体A的友元函数,它可以访问A的私有成员i。但是,结构化绑定却失败了,这就明显不合理了。同样的问题还有访问自身成员的时候:

class C {
  int i;
  void foo(const C& other) {
       auto [x] = other; // 编译失败
  }
};

为了解决这类问题,C++20标准规定结构化绑定的限制不再强调必须为公开数据成员,编译器会根据当前操作的上下文来判断是否允许结构化绑定。幸运的是,虽然标准是2018年提出修改的,但在我实验的3种编译器上,无论是C++17还是C++20标准,以上代码都可以顺利地通过编译。

本章介绍的结构化绑定是新特性中比较有趣的一个,使用该特性可以直接绑定数据对象的内部成员,函数返回多个值就是其中一个应用。另外,自定义支持结构化绑定的类型也并不困难,代码库作者不妨为库中的类型添加类元组方法,让它们支持结构化绑定。