摘要訊息 : 使用 auto 一次性宣告多個不同型別的變數.

0. 前言

C++ 11 之後, 若某個函式有多個回傳值, 那麼我們可以使用 std::tuple. 例如,

#include <tuple>

template <typename T1, typename T2, typename T3, typename T4, typename T5>
constexpr std::tuple<T1, T2, T3, T4, T5> default_values() {
    return {T1 {}, T2 {}, T3 {}, T4 {}, T5 {}};
}

如果我們要使用回傳值, 則需要借助 std::get 或者 std::tie. 但是, 我們都需要額外宣告一個型別為 std::tuple<T1, T2, T3, T4, T5> 甚至五個型別分別為 T1, T2, T3, T4T5 的變數. 如果這裡面存在某個變數對應的型別並非內建型別, 而且預設的建構子十分複雜, 那麼這個建構子可能需要運作兩次 :

#include <tuple>

template <typename T1, typename T2, typename T3, typename T4, typename T5>
constexpr std::tuple<T1, T2, T3, T4, T5> default_values() {
    return {T1 {}, T2 {}, T3 {}, T4 {}, T5 {}};
}
T3 v3;      // 如果 T3 的建構十分複雜, 那麼此處要浪費時間進行一次無用的建構
v3 = get<2>(default_values<T1, T2, T3, T4, T5>());

為了主要解決這個問題, C++ 17 引入了結構化繫結.

本文的目錄中的標題過長, 可能影響前面章節的閱讀體驗, 故本篇文章的目錄預設為隱藏不展開狀態, 需要閣下手動展開.

更新紀錄 :

  • 2022 年 6 月 16 日進行第一次更新和修正.

1. 結構化繫結

如果閣下只是希望簡單了解結構化繫結, 那麼只需要閱讀本節內容即可.

1.1 基本概念

C++ 17 提案 P0144R2《Structured bindings》提出結構化繫結有三種方式 :

  1. [[attribution]] const volatile auto reference-qualifier [variable-list] = {expression};
  2. [[attribution]] const volatile auto reference-qualifier [variable-list] {expression};
  3. [[attribution]] const volatile auto reference-qualifier [variable-list](expression).

其中, [[attribution]] 是可選的屬性, 使用的頻率極少, 一般可以直接忽略; constvolatile 限定都是可選的; reference-qualifier 是參考限定, 包含 &&&, 也是可選的; variable-list 是變數列表; expression 是表達式.

我們可以看到, 由於變數的型別可能各不相同, 因此使用結構化繫結宣告變數時, 無法指定具體的型別, 必須使用 auto 進行宣告.

1.2 對類別的結構化繫結

現在我們寫一個最簡單的結構化繫結 :

#include <tuple>
#include <string>

std::tuple<int, char, const char *> t {42, 'L', "hello structured binding!"};
auto [a, b, str] {s};

Code 2 相當於

#include <string>

struct {
    int a;
    char b;
    string str;
} s;
int a;
char b;
string str;
a = s.a;
b = s.b;
str = s.str;

結構化繫結中的 auto 不像普通的 auto, 對於多個型別不同的變數, 只要它被寫在 [] 中, 那麼 auto 就會對裡面的變數分別進行推倒, 而不像 auto a {0}, b {1}; 這樣要求變數 ab 的型別一定要相同. 另外, 是否帶有 const, volatile 和參考限定的規則和普通的變數宣告是一樣的. 如果不帶有參考限定, 那麼初始化時會採用複製操作. 因此, 如果某些型別不可進行複製, 那麼結構化繫結就會擲出編碼錯誤.

除了最簡單的應用之外, 還有一個應用比較多的地方是 Range-For, 特別是針對 std::map. std::mapvalue_type是一個 std::pair, 如果我們要使用 Range-For 對 std::map 進行尋訪, 那麼還需要通過 std::pair 的兩個成員 firstsecond 來獲取鍵和值. 現在有了結構化繫結, 就可以直接使用下面的方式 :

#include <map>
#include <string>

int main(int argc, char *argv[]) {
    std::map<int, std::string> m;
    for(auto &[key, value] : m) {
        //...
    }
}

對於類別的結構化繫結還有兩個需要注意的地方 : 結構化繫結中的變數數量必須等同於類別中成員的數量; 類別中的所有成員都必須是 public 成員. 這兩個條件任意一個不滿足, 就會導致編碼錯誤.

std::pairstd::tuple 本質上就是全部成員都是外部可存取的類別, 因此特別適用於使用結構化繫結. 另外, 對於位元欄位也可以使用結構化繫結 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    struct {
        int a : 4;
        int b : 2;
        int c : 2;
    } s {6, 0, 1};
    auto [size_4_bit, size_2_bit_1, size_2_bit_2] {s};
    cout << size_4_bit << endl;     // 輸出結果 : 6
    cout << size_2_bit_1 << endl;       // 輸出結果 : 0
    cout << size_2_bit_2 << endl;        // 輸出結果 : 1
}

1.3 對陣列的結構化繫結

結構化繫結除了可以用在類別之外, 還可以用在陣列中. 和類別的結構化繫結一樣, 陣列的結構化繫結中的變數數量必須和陣列的大小是一樣的 :

int arr[] {1, 2, 3};
auto [x, y, z] = arr;
auto &[x2, y2, z2] = arr;
auto &[x3, y3, z3] {arr};
auto [x4, y4, z4] {arr};        // Error : cannot initialize an array element of type 'int' with an lvalue of type 'int [3]'

上面產生編碼錯誤的那一行程式碼, 編碼器錯誤地認為我們是需要使用 arr 去初始化一個陣列 [x4, y4, z4], 於是擲出了編碼錯誤. 另外, 針對動態記憶體配置的陣列也是無法直接使用結構化繫結的 :

auto arr {new int[3] {1, 2, 3}};
auto &[x, y, z] = arr;      // Error : cannot decompose non-class, non-array type 'int *'

2. 進階

2.1 結構化繫結的本質

結構化繫結的本質和 Range-For 其實是類似的, 都是由編碼器自動幫我們生成對應的程式碼. 在結構化繫結中, 編碼器實際上仍然使用 std::get 函式來對類似於 tuple 的物件進行結構化繫結. 這就使得我們自訂的任何型別 T, 只要多載一個適用於 Tget 函式樣板, 再特製化 std::tuple_sizestd::tuple_element, 就可以對 T 使用結構化繫結的語法.

Tip : std::tuple_size 用於查看一個 std::tuple 總共帶有幾個變數; std::tuple_element 用於查看一個 std::tuple 的某一個變數的型別.

#include <iostream>
#include <tuple>

struct s {};

template <int>
int get(s) {
    return 0;
}

namespace std {
    template <>
    class tuple_size<s> : public std::integral_constant<int, 1> {};
    template <>
    class tuple_element<0, s> {
    public:
        using type = int;
    };
}

int main(int argc, char *argv[]) {
    s a;
    auto [x] {a};       // 相當於 auto x {get<0>(a)};
    std::cout << x << std::endl;    // 輸出結果 : 0
}

Code 8 中, 我們打開了名稱空間 std, 並且對 std::tuple_sizestd::tuple_element 針對型別 s 進行了特製化. 這一步是不可少的, 否則就會產生編碼錯誤.

2.2 為動態記憶體配置的陣列進行結構化繫結

第 2.1 節中我們知道了結構化繫結的本質, 這就為我們自訂結構化繫結語法提供了基礎. 在第 1.3 節中我們曾經提到, 動態記憶體配置的陣列是無法直接使用結構化繫結的, 原因便是沒有為其特製化的函式樣板 get, std::tuple_sizestd::tuple_element. 那麼, 如果我們主動編寫相關的程式碼, 就可以使得結構化繫結用於動態記憶體配置的陣列甚至 std::vector 上 :

#include <tuple>

template <int I>
int &get(int *arr) {
    return arr[I];
}
namespace std {
    template <>
    struct tuple_size<int *> : std::integral_constant<int, array_size> {};
    template <>
    struct tuple_element<0, int *> {
        using type = int;
    };
    template <>
    struct tuple_element<1, int *> {
        using type = int;
    };
    template <>
    struct tuple_element<2, int *> {
        using type = int;
    };
    template <>
    struct tuple_element<3, int *> {
        using type = int;
    };
}
int main(int argc, char *argv[]) {
    auto arr {new int[array_size] {1, 2, 3}};
    auto [x1, x2, x3, x4] {arr};        // Error : use of undeclared identifier 'get'
    delete[] arr;
}

Code 9-1 會產生編碼錯誤, 因為編碼器在查找 get 這個名稱的過程中會忽略掉外面的函式樣板 get. 因此, 我們需要建立一個中介者 :

#include <iostream>
#include <tuple>

template <int N>
struct dynamic_array_binder {
    int *arr;
    operator int *() {
        return this->arr;
    }
};

template <int I>
int &get(int *arr) {
    return arr[I];
}

namespace std {
    template <int N>
    struct tuple_size<dynamic_array_binder<N>> : std::integral_constant<int, N> {};
    template <int I, int N>
    struct tuple_element<I, dynamic_array_binder<N>> {
        using type = int;
    };
}

template <int N>
auto dissect(int *arr) {
    return dynamic_array_binder<N> {arr};
}

int main(int argc, char *argv[]) {
    auto arr {new int[100] {1, 2, 3}};
    auto [x1, x2, x3, x4] = dissect<4>(arr);
    std::cout << x1 << std::endl;       // 輸出結果 : 1
    std::cout << x2 << std::endl;       // 輸出結果 : 2
    std::cout << x3 << std::endl;       // 輸出結果 : 3
    std::cout << x4 << std::endl;       // 輸出結果 : 0
    delete[] arr;
}

Code 9-2 提供了通用解決方案, 通過修改程式碼配接至任何容器. 而且與前面那一份產生編碼錯誤的程式碼不同, 上述程式碼對於具體的元素數量進行自動調整, 而不像之前那一份程式碼那樣需要手動給出 array_size.

Tip : 此處的名稱查找涉及極其複雜的規則, 我們暫時不講.

3. 雜項

3.1 某些不支援的結構化繫結語法

結構化繫結可以一定程度上簡化程式碼, 但是一切使用 auto 在某些時候可能有些不太方便, 因此可能會有以下程式碼 :

struct {
    int a;
    int b;
    int c;
} s;
int [a, b, c] {s};      // Error : 結構化繫結中不能明確宣告型別, 只能夠使用 auto
auto [a, long b, c] {s};        // Error : 結構化繫結中的變數不支援宣告為指定的型別
auto [a, &b, c] {s};        // Error : 目前結構化繫結只能統一進行參考限定的宣告, 而不能單一對其中的某個變數進行宣告

Code 10 會產生三個編碼錯誤, 目前 C++ 的結構化繫結暫時還不支援這樣做.

std::tie 可以結合 std::ignore 來忽略某些值, 但是在結構化繫結中不支援這樣做 :

#include <tuple>

struct {
    int a;
    int b;
    int c;
} s;
auto [a, std::ignore, c] {s};       // Error : expected ',' or ']' in lambda capture list

從編碼錯誤的提示中, 我們可以看到, 由於 std::ignore 是一個變數, 因此編碼器會將上面這個結構化繫結錯誤地識別為 Lambda 表達式.

3.2 存在非樣板成員函式 get 的情形

結構化繫結的過程中, 編碼器並不是直接去外部的名稱空間中找到合適的 get, 而是首先查看變數對應的型別中是否存在一個名稱為 get 的成員函式. 如果確實存在這樣的成員函式, 那麼編碼器會優先匹配這個成員函式, 而並非外部的 get 函式. 這就導致類似於 std::shared_ptr 這樣型別對應的物件無法使用結構化繫結, 即時通過第 2.1 節中類似的方法進行實作, 仍然會產生編碼錯誤 :

#include <memory>

struct X : private std::shared_ptr<int> {
    std::string fun_payload;
};

template <int N>
std::string &get(X &x) {
    if constexpr(N == 0) {
        return x.fun_payload;
    }
}

namespace std {
    template <>
    struct tuple_size<X> : std::integral_constant<int, 1> {};
    template <>
    struct tuple_element<0, X> {
        using type = std::string;
    };
}

int main(int argc, char *argv[]) {
    X x;
    auto &[y] = x;      // Error : 'get' is a private member of 'std::__1::shared_ptr<int>'
}

因為 std::shared_ptr 中的成員函式 get 本身就不是用於結構化繫結的. 為了解決這個問題, C++ 17 提案 P0961R1《Relaxing the structured bindings customization point finding rules》提出如果某個類別存在一個名稱為 get 的成員函式, 在結構化繫結的過程中, 若且唯若這個 get 是一個函式樣板並且第一個樣板參數為非型別參數, 才將這個成員函式樣板 get 考慮到結構化繫結的函式候選中. 這樣, Code 12 就可以通過編碼了.

3.3 使用友誼關係存取私用成員

在某個類別 T 中宣告友誼函式或者友誼類別, 都可以使得另一個實體可以存取到 T 中私用的成員. 但是對於結構化繫結來說就不能通過這種方式存取私用成員 :

class C {
    int i;
    friend void f();
    void func(const C &rhs) {
        auto [x] = rhs;     // Error
    }
};
class C2 : C {
    friend void f();
};

void f() {
    C c1;
    C2 c2;
    auto x {c1.i};      // OK
    auto &[y] {c1};      // Error
    auto &[z] {c2};     // Error
}

這不太符合友誼宣告本身的意義. 因此, C++ 提案 P0969R0《Allow structured bindings to accessible members》提出解除這種限制, 使得 Code 13 可以通過編碼.

4. 總結

我們可以發現, 結構化繫結可以減少程式碼的複雜性, 提高效能, 而且可以自訂. 不過這個特性也不是非要不可的, 因為在 C++ 17 之前, 我們還是可以通過其它方法避免效能損失. 這樣的語法不能說是不痛不癢, 但是自訂的結構化繫結確實增加了 C++ 的複雜性.