Chapter III. 漸入佳境
實作知識
Structured binding
作者baluteshih

firstsecond 真的很長


讓我們來做做這個簡單的小例題:

例題
區間交集
Source:經典題

給定 $N$ 組區間 $[l_i, r_i]$,請找出交集最大的兩個區間。

條件限制
  • $1\leq N\leq 5000$

這題沒有什麼演算法,就是單純窮舉 $O(N^2)$ 組區間對計算交集就對了。不過要計算兩個區間的交集會怎麼寫呢?如果我們用 std::pair<int, int> 來存區間們的話,就會變成:

int intersection(pair<int, int> a, pair<int, int> b) {
    return max(0, min(a.second, b.second) - max(a.first, b.first));
}

在比競程時,如果是 pair 愛好者,這些 firstsecond 其實會很常被打出來。又因為這兩個單字的長度有點長,所以許多競賽選手都會在他們的 default code 加上:

#define f first
#define s second

等諸如此類的 macro。不過,這樣其實存在一些缺點:

因此,如果有一些替代方法來讓我們省去打一堆 firstsecond 的時間就太好了——這時候就要搬出我們的 Structured binding!請直接看看以下範例:

int intersection(pair<int, int> a, pair<int, int> b) {
    auto [al, ar] = a;
    auto [bl, br] = b;
    return max(0, min(ar, br) - max(al, bl));
}

沒錯,一段語法 auto [name1, name2] = pair 就可以把 pair 內的兩個變數分別提取出來放到 name1name2 兩個變數中!也因此我們能自由的取新的名字來撰寫相關程式。

雖然這段程式碼看起來比前面長了一點,但首先在可讀性上就大幅的增加了,而且可想而知,如果未來我們要設計的函式需要大量呼叫 firstsecond 時,使用 Structured binding 也會大幅減少我們打字的時間。

Structured binding 在 C++17 後才正式成為 C++ 的標準,因此在更低的版本有可能會不適用。不過如果不幸遇到很舊的 C++ 版本,其實可以找時間測看看競賽使用的編譯器認不認得這個功能,有時候即使版本不對,編譯器看得懂的話只會跳警告但還是會幫你編譯完成。

儘管實務上不太建議,但競程中如果因為舊版本綁手綁腳就有點太虧了,所以機器測試是很重要的!

與 reference 和 const 做搭配


也許有讀者會發現一個問題:如果我們要對 pair 內部的物件做修改的話,不就還是得呼叫那個變數本身的 firstsecond 嗎?

實際上還是可以使用 Structured binding 的,其方法就是使用我們之前學過用來取「別名」的 reference:

auto &[l, r] = itv;

像上面這樣一樣加個 & 在前面,l, r 就會分別對應到 itv 內兩個元素的「本體」,直接對 l, r 進行修改就會同時改到 itv 內部的元素!

也因此,如果你的 pair 是形如 pair<vector<int>, vector<int>> 這種龐大的型別,那麼像上面這樣寫也會讓你的時間複雜度是 $O(1)$,能夠放心使用。

同時,既然能加 reference,在前面加上 const 當然也是可以的:

const auto &[l, r] = itv;

這樣就可以在 $O(1)$ 時間拿出兩個元素,且鎖定在唯獨狀態,某些情況下這會是寫程式的好習慣。

搭配 Range-based for loop


有了前面的和 reference 的結合,我們在撰寫最一開始提到的「區間交集」這道題目時,其輸入就可以這樣寫:

int n;
cin >> n;
vector<pair<int, int>> itv(n);
for (auto &[l, r] : itv)
    cin >> l >> r;

與我們介紹 Range-based for loop 時一樣,我們可以直接在宣告變數型別的時候使用 Structured binding,就可以持續省下打出 firstsecond 的時間。

當然,因為這樣的用法不僅限於輸入,所以平常遍歷 pair 陣列時也可以這樣寫:

for (auto [l, r] : itv) {
    // do something
}

退一步來說,如果需要索引值,由於 Range-based for loop 在 C++17 以前不支援變數宣告就會有些困擾,但還是能這樣寫:

for (int i = 0; i < n; ++i) {
    auto [l, r] = itv[i];
    // do something
}

所以只要運氣好的話,整份 code 都不見得需要看見 firstsecond

遍歷 std::map

這一小段的內容需要稍微了解過後面文章才會學到的 std::map,若讀者有興趣可以先去看看 基礎資料結構 / Set 與 Map

如果寫出了以下的迴圈來遍歷 map

map<int, int> mp;
// insert something into mp
for (auto &[key, value] : mp) {
    // do something
}

這時候,想像中 keyvalue 會是 mp 內部每一個 pair 前後兩個元素的 reference,但在 map 中,作為鍵值的第一個元素其實是不可修改的。

而這時候,即使我們沒有加上 const 這個 specifier,Structured binding 還是會自動幫我們繼承他 const 的屬性,來讓我們得到的 key 是唯獨的。

同樣的道理其實也適用於要提取的變數自帶 const 或 reference 等屬性,這裡讀者可以想像是 Structured binding 會自動保留這些屬性就好。

不過反過來說,如果要提取的變數們本身都沒有 const,我們卻只想讓其中一個提取的變數有 const 屬性的話,這就是 Structured binding 辦不到的了——這是因為這些 specifier 必須加在中括號前面,也就只能一口氣影響所有要提取的變數。

Structured binding 實際上適用於什麼型別?


前面我們都只有舉過 pair 當例子,甚至 pair 內的兩個型別都是一樣的,Structured binding 當然沒有這麼遜只能支援這樣的型別。

首先,對於型別 pair<A, B>AB 的型別不一樣是完全可以的,例如將 pair<int, double> 提取出來後,就會確實的分別得到 intdouble 兩個型別的變數。

再來,不是只有 pair 適用於這個語法,實際上在 C++ 的規範中,有三種類型可以被支援:

上面的描述其實省略了一些特殊狀況,不過在競程中通常不會遇到,因此省略之。

讓我們來一一介紹他們。

array

對於宣告好的一條陣列,只要使用相同個數的元素,就可以對他使用 Structured binding 來提取出裡面的元素,例如:

int arr[5] = {1, 2, 3, 4, 5};
auto [a, b, c, d, e] = arr;

上面這段語法,a, b, c, d, e 就會分別對應到 arr 的五個元素。

不過用在陣列上,就得打出相應數量的元素量,所以更多時候只適用於短的陣列。

C++ 預計從 C++26 後開始支援將部分元素「打包」成一個 pack 的 Structured binding,來放寬這種每次都得打出對應元素量的限制。

tuple-like 的型別

就像 std::pair 一樣,這種把多種型別打包在一起的組合型別就是我們這裡稱呼的 tuple-like 型別,他們的共同點就是有 std::get 這個功能可以提取單一變數中特定位置型別的功能。不過其實只要大於兩種型別,在 C++ 中就會必須得使用 std::tuple

因此,與 pair 一樣,tuple 也可以使用 Structured binding:

tuple<int, double, bool> tup = {1, 0.5, true};
auto [a, b, c] = tup;

這樣 a, b, c 三個變數就會對應到 tup 所包含的三個不同變數型別。概念與 pair 基本上是一樣的,就不再多解釋。

structclass 描述的 data members

相信一定也有一派的選手不喜歡使用 pairtuple,而是會自己果斷的實作一個 structclass 來打包一坨變數,例如:

struct my_struct {
    int x = 1, y = 2;
    vector<int> v = {3, 4, 5};
};

即使你設計的 struct 包含若干種不同型別的變數,使用 Structured binding

my_struct S;
auto [x, y, z] = S;

還是能正確的按照順序提取內部的變數!

所以,千萬別以為 Structured binding 是什麼只適用於 pair 的語法,他的用途可多了。

std::array vs std::vector

前面我們有提過陣列也能使用 Structured binding,但如果對 std::arraystd::vector 使用,會發生什麼事呢?直接看看下面兩種範例:

array<int, 3> arr1 = {1, 2, 3};
vector<int> arr2 = {4, 5, 6};
auto [a, b, c] = arr1;
auto [d, e, f] = arr2; // compile error

實際測試的結果會發現,第三行的編譯是會過的,第四行則不會。

其實不會過反而比較合邏輯,這是因為我們通常會把這兩個東西當成一個單一的 class,而想像中他們的內容物應該已經被打包成一個變數了,所以用 Structured binding 時,元素的個數應該得是一個才會編譯成功。就好像:

struct array_struct {
    int a[2] = {0, 1};  
};

上面這個 array_struct 其實要寫成 auto [a] = array_struct{}; 編譯才會成功,因為 Structured binding 按照這個邏輯只能提取 int a[] 這個型別的變數而已。

不過為什麼 std::array 卻是用元素個數來提取呢?其理由是因為,std::array 支援 std::get 等 tuple-like 的操作,所以在這裡,Structured binding 才把他當成了 tuple-like 的類型來提取。這算是一個比較不直觀的個案。

std::pair of std::pair

如果出現 pair<pair<int, int>, pair<int, int>> 這種型別的變數,有辦法用 Structured binding 一口氣提取四個變數嗎?

答案是不行,雖然有點可惜,但目前就是這樣,我們可以期待 C++ 未來做出有可能可以處理這種狀況的改版 :)

小結


在本文章中我們學到了 Structured binding 的用法和用途,Structured binding 在競程上時常會是省下 coding 時間、增加可讀性的好工具,讀者可以多加利用。

而且 Structured binding 還不只適用於 pair,不管是陣列還是自定義的 structclass,只要元素數量吻合都是可以做使用的。若覺得記憶起來麻煩的話,讀者可以在使用 Structured binding 時試圖以「如果我是編譯器的話,我看得懂嗎?」這樣的方式來做思考。

當然,由於本文只介紹在競程中常見的用途,實際上 Structured binding 還有更多細節是在這種小篇幅不好解釋的,如果有興趣的讀者,可以自行去 cppreference 參考。

參考資料