展開目錄

Structured binding

好用的語法糖,讓你不用再打出 firstsecond

作者
baluteshih
重要度
常用

firstsecond 真的很長

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

例題

區間交集

Source:經典題

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

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

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

cpp
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 加上:

plaintext
#define f first
#define s second

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

  • 如果我們同時宣告兩個變數 ffirst,這樣會直接 CE。
  • pair 描述的物件是區間、平面上的二維點這種物件時,使用 fs 其實相對不直覺,也許在這時候我們會比較喜歡使用 l, rx, y

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

cpp
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:

cpp
auto &[l, r] = itv;

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

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

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

cpp
const auto &[l, r] = itv;

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

搭配 Range-based for loop

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

cpp
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 陣列時也可以這樣寫:

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

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

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

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

遍歷 std::map

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

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

cpp
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++ 的規範中,有三種類型可以被支援:

  1. array
  2. tuple-like 的型別
  3. structclass 描述的 data members

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

讓我們來一一介紹他們。

array

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

cpp
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:

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

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

structclass 描述的 data members

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

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

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

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

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

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

std::array vs std::vector

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

cpp
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 時,元素的個數應該得是一個才會編譯成功。就好像:

cpp
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 參考。

參考資料

NTUCPC Logo
國立臺灣大學程式解題社NTU Competitive Programming Club
This work is licensed under CC BY-SA 4.0