C++ 17 結構化綁定

今天我想要介紹 C++ 17 新增的 Structured Binding(結構化綁定)。以 std::pair 為例,Structured Binding 能讓我們能直接將 std::pair 的內容綁定到我們指定的識別字:

auto [a, b] = std::make_pair("str", 0);

在這個例子𥚃,a 是型別為 const char *"str"b 是型別為 int0。換句話說,上面的程式碼相當於:

const char *a = "str";
int b = 0;

這讓我們能簡化「接收 std::pair」的程式碼。舉例來說,std::set::insert(const value_type &) 函式的回傳型別是 std::pair<iterator, bool>。回傳值分別代表插入元素的位置與是否已經有相同的元素。我們能使用以下程式碼接收回傳值:

void example(std::set<std::string> &s, const std::string &x) {
  auto [iter, inserted] = s.insert(x);
  // ...
}

在 C++ 11 要做類似的事可以使用 std::tie

void example(std::set<std::string> &s, const std::string &x) {
  std::set<std::string>::iterator iter;
  bool inserted;
  std::tie(iter, inserted) = s.insert(x);
  // ...
}

或者自行以 .first.second 存取回傳值:

void example(std::set<std::string> &s, const std::string &x) {
  std::pair<std::set<std::string>::iterator, bool> p = s.insert(x);
  std::set<std::string>::iterator iter = p.first;
  bool inserted = p.second;
  // ...
}

三者相比之下,可見 Structured Binding 能簡化不少程式碼。

std::tuple

C++ 11 的 std::tuplestd::pair 相似,兩者都能儲存不同型別的元素。兩者的差異是前者依據樣版參數能儲存 0 至 N 個元素,而後者只能儲存 2 個元素。二者比較如下表:

std::tuple std::pair 說明
std::make_tuple() std::make_pair() 以函式參數建構一個 tuple 或 pair
std::get<0>(tuple) pair.first 存取第一個元素
std::get<1>(tuple) pair.second 存取第二個元素
std::get<i>(tuple)   存取第 i 個元素

Structured Binding 當然也能綁定 std::tuple 的元素:

#include <tuple>

int main() {
  auto [x, y, z] = std::make_tuple("str", 0.5, 1);
}

Structured Binding 的識別字數量必需與 std::tuple 的元素數量相等,否則會產生編譯錯誤:

#include <tuple>

int main() {
  auto [x, y] = std::make_tuple("str", 0.5, 1);
  // test.cpp:4:8: error: only 2 names provided for structured binding
  // test.cpp:4:8: note: while ‘std::tuple<const char*, int, char>’ decomposes into 3 elements
}

初始化述句

Structured Binding 述句是一種初始化述句,所以能被用於 for 述句、if 述句與 switch 述句:

for (init-statement condition; step) statement

if (init-statement condition) statement

switch (init-statement condition) statement

它讓我們繞過初始化述句只能定義一種型別的限制:

#include <iostream>
#include <string>
#include <tuple>

std::tuple<int, std::string> test() {
  return std::make_tuple(1, "some failure message");
}

int main() {
  for (auto [i, tag] = std::make_tuple(0, "tag: ");
       i < 10; ++i) {
    std::cout << tag << i << std::endl;
  }

  if (auto [err, cause] = test(); err != 0) {
    std::cerr << "error: " << cause << std::endl;
  }
}

結構化綁定

這個小節我要深入介紹 Structured Binding(結構化綁定)的語法與語意。

語法

Structured Binding 的語法如下:

const-qualifier-opt auto ref-qualifier-opt [identifiers-list] = assignment-expression;

每個部分分述如下:

  • const-qualifier-optconst 關鍵字(可選擇)
  • ref-qualifier-opt&&&(可選擇)
  • identifiers-list 是以逗號分隔的關鍵字列表
  • assignment-expression 是會被綁定的值。assignment-expression 可以是任何「優先度大於或等於賦值運算子(=)」的運算子構成的表達式。除去逗號運算子與 throw 表達式,assignment-expression 幾乎涵蓋所有 C++ 的表達式。

語意

編譯器會先將 Structured Binding 改寫為:

const-qualifier-opt auto ref-qualifier-opt __internal_unique_id = assignment-expression;

其中 __internal_unique_id 是編譯器內部給予的獨特識別字(不會與其他變數撞名)。如果 ref-qualifier-opt 被省略,則會複製一份 assignment-expression 的計算結果。

接著,如果 assignment-expression 的型別遵循 Structured Binding Protocol(結構化綁定協議):

  • 特化 std::tuple_size<Type> 類別樣版以提供元素個數
  • 特化 std::tuple_element<Index, Type> 類別樣版以提供各個元素的型別
  • 定義 get 函式樣版作為存取元素的介面。

編譯器會優先採用這些資訊。不過這比較複雜,所以保留到下一節再介紹。

如果 assignment-expression 的型別是一般的 struct 或 class 且所有非靜態成員變數(Non-static Data Member)都是公開的(Public),則 identifiers-list 的識別字個數必須與成員變數的個數一致,各個識別字會依序對應到各個成員變數。例如:

struct Point {
  int x_;
  int y_;
};

int main() {
  auto [a, b] = Point{1, 2};
  return a + b;
}

相當於:

int main() {
  auto __internal_unique_id = Point{1, 2};
  return __internal_unique_id.x_ +
         __internal_unique_id.y_;
}

注意:Structured Binding 定義的識別字不是「參考(Reference)」。編譯器會把這些識別字對應為成員變數存取表達式。如果我們用 std::is_reference<decltype(a)>::value 測試 a 的型別,我們會得到 false。這是為了讓 Structured Binding 的結果和「成員變數存取表達式」的結果一致:

#include <iostream>
#include <type_traits>

struct Point {
  int x_;
  int y_;
};

int main() {
  Point p{1, 2};
  auto [a, b] = p;

  std::cout << std::is_reference<decltype(a)>::value << " "
            << std::is_reference<decltype(p.x_)>::value
            << std::endl;

  return 0;
}

如果 assignment-expression 的型別是陣列,則 identifiers-list 的識別字個數必須與陣列長度一致,各個識別字會依序對應到各個陣列元素

int main() {
  int my_array[] = {1, 2, 3};
  auto [a, b, c] = my_array;
  return a + c;
}

以上程式碼相當於:

int main() {
  int my_array[] = {1, 2, 3};

  int __internal_unique_id[3];
  std::copy(my_array, my_array + 3, __internal_unique_id);

  return __internal_unique_id[0] +
         __internal_unique_id[2];
}

備註:因為原生陣列沒有複製建構式,當 assignment-expression 是陣列型別時,C++ 語言另外規定應以元素對元素的方式複製陣列元素。

結構化綁定與參考

接著我們看看 const-qualifier-optref-qualifier-opt 對 Structured Binding 的影響。前面我們提到 Structured Binding 述句:

const-qualifier-opt auto ref-qualifier-opt [identifiers-list] = assignment-expression;

會先被改寫為:

const-qualifier-opt auto ref-qualifier-opt __internal_unique_id = assignment-expression;

這和 C++ 11 引入的 auto 型別推導是一樣的,const-qualifier-optref-qualifier-opt 的組合與效果分述如下表:

const-qualifier-opt ref-qualifier-opt 說明
常用 __internal_unique_id 會是 assignment-expression 計算結果的複本。對 identifiers-list 的改動只會反應於 __internal_unique_id 上。
& __internal_unique_id 會是指向 assignment-expression 計算結果的 Lvalue Reference(左值參考)。對 identifiers-list 的改動會反應於 assignment-expression 的計算結果上。因為 __internal_unique_id 是 Lvalue Reference,所以它不能綁定暫時物件。
最常用 && __internal_unique_id 會是 Forward Reference,亦稱為 Universal Reference。依據 assignment-expression 計算結果的型別,這可以是 Lvalue Reference(左值參考)、Rvalue Reference(右值參考)、Constant Reference to Lvalue(左值常數參考)、Constant Reference to Rvalue(右值常數參考)。這是用途最廣泛使用限制最少的組合。
const __internal_unique_id 會是 assignment-expression 計算結果的常數複本。
const & __internal_unique_id 會是指向 assignment-expression 計算結果的 Constant Reference to Lvalue(左值常數參考)。
const && __internal_unique_id 會是指向 assignment-expression 計算結果的 Constant Reference to Rvalue。這不是 Forward Reference 也不能綁定 Lvalue(右值)。

覺得上表太複雜了嗎?如果無法一次記住所有組合,只要記住最常用的分別是 auto && 與 auto。我之後會另外為文介紹 C++ 11 引入的 Rvalue Reference 與型別推導機制。

自訂 Structured Binding

如果想要自訂 Structured Binding 的行為,或者想要使用 Structured Binding 綁定帶有私有成員變數的物件,你必須:

  • 特化 std::tuple_size<Type> 類別樣版以提供元素個數。
  • 特化 std::tuple_element<Index, Type> 類別樣版以提供各個元素的型別。
  • 定義四個 get 函式樣版作為存取元素的介面。

本節將以 example::MyClass 為例,示範如何自行定義結構化綁定:

namespace example {

class MyClass {
private:
  const char *x_;
  int y_;
public:
  MyClass(const char *x, int y) : x_(x), y_(y) {}
};

}  // namespace example

第一步:我們必需特化 std::tuple_size 類別樣版:

#include <cstddef>
#include <tuple>
#include <type_traits>

namespace example {
class MyClass;  // Forward Declaration
}  // namespace example

namespace std {

template <>
class tuple_size<example::MyClass>
    : public integral_constant<size_t, 2> {}

}  // namespace std

因為 std::tuple_size 宣告於 <tuple> 標頭檔,所以我們必須先引入 <tuple>。為了讓我們的特化滿足 C++ 標準的要求,所以我們直接繼承 std::integral_constant,並分別以 std::size_t2 為樣版參數。這個定義能讓編譯器知道 Structured Binding 會需要 2 個識別字。

第二步:我們必須分別為 x_y_ 特化 std::tuple_element 類別樣版:

namespace std {

template <>
class tuple_element<0, example::MyClass> {
public:
  using type = const char *;
};

template <>
class tuple_element<1, example::MyClass> {
public:
  using type = int;
};

}  // namespace std

特化 std::tuple_element 的時候必需定義一個名稱為 type 的型別別名。這個型別通常就是成員變數的型別。

第三步:我們必須宣告四種 get 函式樣版,分別處理 example::MyClass 的左值參考、左值常數參考、右值參考與右值常數參考。另外,因為宣告 get 函式樣版時使用的 std::tuple_element_t 別名樣版(Alias Template)依賴前面定義的 std::tuple_element 類別樣版特化,所以 example::MyClass 在此之前都只有前置宣告。我們現在才要定義 example::MyClass

namespace example {

class MyClass {
private:
  const char *x_;
  int y_;

public:
  MyClass(const char *x, int y) : x_(x), y_(y) {}

  template <size_t Index>
  std::tuple_element_t<Index, MyClass> &get() &;

  template <size_t Index>
  const std::tuple_element_t<Index, MyClass> &get() const &;

  template <size_t Index>
  std::tuple_element_t<Index, MyClass> &&get() &&;

  template <size_t Index>
  const std::tuple_element_t<Index, MyClass> &&get() const &&;
};


template <>
std::tuple_element_t<0, MyClass> &MyClass::get<0>() & {
  return x_;
}

template <>
const std::tuple_element_t<0, MyClass> &MyClass::get<0>() const & {
  return x_;
}

template <>
std::tuple_element_t<0, MyClass> &&MyClass::get<0>() && {
  return std::move(x_);
}

template <>
const std::tuple_element_t<0, MyClass> &&MyClass::get<0>() const && {
  return std::move(x_);
}

template <>
std::tuple_element_t<1, MyClass> &MyClass::get<1>() & {
  return y_;
}

template <>
const std::tuple_element_t<1, MyClass> &MyClass::get<1>() const & {
  return y_;
}

template <>
std::tuple_element_t<1, MyClass> &&MyClass::get<1>() && {
  return std::move(y_);
}

template <>
const std::tuple_element_t<1, MyClass> &&MyClass::get<1>() const && {
  return std::move(y_);
}

}  // namespace example

以上程式碼有兩個值得注意的地方:

  1. get<Index> 成員函式之後分別有 &const &&&const &&。這是分別代表 this 指標指向的物件是左值、左值常數、右值與右值常數。
  2. get<0>get<1> 的函式樣版特化必須寫在類別宣告之外。這是 C++ 語言的規定。

第四步:使用 Structured Binding 綁定 example::MyClass 物件:

#include <iostream>

int main() {
  auto [x, y] = example::MyClass("example", 42);

  std::cout << x << std::endl;
  std::cout << y << std::endl;

  return 0;
}

上面的程式碼相當於以下示意程式碼:

int main() {
  auto __internal_unique_id = example::MyClass("example", 42);
  /* auto && */x = __internal_unique_id.template get<0>();
  /* auto && */y = __internal_unique_id.template get<1>();

  std::cout << x << std::endl;
  std::cout << y << std::endl;

  return 0;
}

編譯器會將 Structured Binding 述句翻譯為數個 get<Index> 函式呼叫,然後將 identifiers-list 裡面的識別字對應到 get<Index> 函式的回傳值。雖然這個對應關係有點像是參考(Reference),但是這些識別字本身不是參考(Reference)。識別字與回傳值的對應關係記錄於編譯器內部的資料結構。它無法以 C++ 語言描述。上面的程式碼只是示意偽代碼。

第三步也可以將 get<Index> 從成員函式改寫為 Free Function(自由函式)[1]。不過因為 get<Index> 要存取的變數是私有成員變數,所以我們要先將這些 Free Function 宣告為 Friend Function(友誼函式):

namespace example {

template <size_t Index>
std::tuple_element_t<Index, MyClass> &get(MyClass &);

template <size_t Index>
const std::tuple_element_t<Index, MyClass> &get(const MyClass &);

template <size_t Index>
std::tuple_element_t<Index, MyClass> &&get(MyClass &&) ;

template <size_t Index>
const std::tuple_element_t<Index, MyClass> &&get(const MyClass &&);

class MyClass {
private:
  const char *x_;
  int y_;

public:
  MyClass(const char *x, int y) : x_(x), y_(y) {}

  template <size_t Index>
  friend std::tuple_element_t<Index, MyClass> &
  get(MyClass &);

  template <size_t Index>
  friend const std::tuple_element_t<Index, MyClass> &
  get(const MyClass &);

  template <size_t Index>
  friend std::tuple_element_t<Index, MyClass> &&
  get(MyClass &&) ;

  template <size_t Index>
  friend const std::tuple_element_t<Index, MyClass> &&
  get(const MyClass &&);
};


template <>
std::tuple_element_t<0, MyClass> &get<0>(MyClass &obj) {
  return obj.x_;
}

template <>
const std::tuple_element_t<0, MyClass> &get<0>(const MyClass &obj) {
  return obj.x_;
}

template <>
std::tuple_element_t<0, MyClass> &&get<0>(MyClass &&obj) {
  return std::move(obj.x_);
}

template <>
const std::tuple_element_t<0, MyClass> &&get<0>(const MyClass &&obj) {
  return std::move(obj.x_);
}

template <>
std::tuple_element_t<1, MyClass> &get<1>(MyClass &obj) {
  return obj.y_;
}

template <>
const std::tuple_element_t<1, MyClass> &get<1>(const MyClass &obj) {
  return obj.y_;
}

template <>
std::tuple_element_t<1, MyClass> &&get<1>(MyClass &&obj) {
  return std::move(obj.y_);
}

template <>
const std::tuple_element_t<1, MyClass> &&get<1>(const MyClass &&obj) {
  return std::move(obj.y_);
}

}  // namespace example

要注意的是這些 get 函式樣版必需定義於 example::MyClass 類別所在的命名空間。這樣編譯器在翻譯 Structured Binding 述句時,才能使用「類似」Argument Dependent Lookup 的方法找到對應的 get<Index> 函式。以下程式碼:

int main() {
  auto [x, y] = example::MyClass("example", 42);

  std::cout << x << std::endl;
  std::cout << y << std::endl;

  return 0;
}

會被翻譯為:

int main() {
  auto __internal_unique_id = example::MyClass("example", 42);
  /* auto && */x = example::get<0>(__internal_unique_id);
  /* auto && */y = example::get<1>(__internal_unique_id);

  std::cout << x << std::endl;
  std::cout << y << std::endl;

  return 0;
}

附帶一提,前面我們說到編譯器在翻譯 Structured Binding 述句時,會使用「類似」Argument Dependent Lookup 的方式,從參數型別的命名空間尋找 get 函式樣版。然而這和真正的 Argument Dependent Lookup 有一個細微的差異:Argument Dependent Lookup 並不會將函式樣版(Function Template)納入候選清單。C++ 語言規格在描述 Structured Binding 的時候,有特別指明兩者的差異。

如果我們自己要呼叫 get 函式樣版,我們不能依賴 Argument Dependent Lookup。我們必需要以範疇解析運算子(Scoped Resolution Operator)指稱命名空間或者事先以 using 述句引入 get 函式樣版:

void test() {
  auto my = example::MyClass("example", 42);
  auto [x, y] = my;

  std::cout << x << " " << example::get<0>(my) << std::endl;

  {
    using example::get;
    std::cout << y << " " << get<1>(my) << std::endl;
  }
}

小結

本節我們花費很長的篇幅介紹如何自訂 Structured Binding。即便只想要綁定二個成員變數,為了處理所有情況,我們必需編寫近百行程式碼。自訂 Structured Binding 有時候反而會讓你的程式碼更為複雜。從我個人的經驗來看,std::tuple 足以應付日常需求。即使 std::tuple 不足以處理你面對的情況,在自訂 Structured Binding 之前仍應深思熟慮。

本節將程式碼拆解為四個步驟。如果需要完整的程式碼,請下載 myclass-member-function-get.cppmyclass-free-function-get.cpp

其他細節

在 C++ 11 使用 std::tie 綁定回傳值的時候,如果想要忽略特定元素,我們可以使用 std::ignore

double x, z;
std::tie(x, std::ignore, z) = std::make_tuple(1.0, 2.0, 3.0);

然而 Structured Binding 沒有與 std::ignore 對應的表示法。即使你不使用該元素,你還是必需提供一個識別字:

auto [x, unused, z] = std::make_tuple(1.0, 2.0, 3.0);

一些編譯器會因為「後面的程式碼」沒有使用該關鍵字而印出警告。如果要忽略那些警告,可以在 Structured Binding 述句之後加上(將下方 unused 識別字代換為你選用的識別字):

(void)unused;

雖然在 C++ 17 我們能以 [[maybe_unused]] 屬性忽略該警告,然而在制定 C++ 17 的時候沒有處理到 Structured Binding。這個問題已經被回報到語言瑕疪報告 CWG#2360。C++ 20 的草稿已經更新文字敘述。在 C++ 20 編譯器只會在所有識別字都沒有被使用時才會印出警告:

// Has warning: g++ -std=c++17 -Wall
// No warning: clang -std=c++17 -Wall
double example_no_warn_partial_usages() {
  auto [x, unused] = std::make_tuple(1.0, 2.0);
  return x;
}

// Has warning: g++ -std=c++17 -Wall
// No warning: clang -std=c++17 -Wall
void example_no_warn_maybe_unused() {
  [[maybe_unused]] auto [unused1, unused2] =
      std::make_tuple(1.0, 2.0);
}

// Must see warnings
void example_warn() {
  auto [unused1, unused2] = std::make_tuple(1.0, 2.0);
}

參考資料


[1]Free Function(自由函式)指 C++ 程式碼中不是成員函式的函式。典型的 Free Function 包函 main 函式或者 C 標準函式庫的 getenv 函式等等。

Note

如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。