Chapter III. 漸入佳境
實作知識
Range-based for loop
作者baluteshih
先備知識Reference動態的陣列

更簡潔的遍歷寫法


Range-based for loop 是 C++11 推出的一種語法糖,主要是能夠簡潔的實現「遍歷一個容器」,讓整體的程式碼看上去更乾淨。

舉個例子,例如以下的這一小段程式碼,已知 v 是一個 std::vector<int>

for (int i = 0; i < int(v.size()); ++i)
    cout << v[i] << "\n";

在上面這個迴圈裡,變數 i 就只有索引的功用,顯得有點冗余。因此,Range-based for loop 就提供了這樣「直接讓該變數是元素內容」的寫法:

for (int i : v)
    cout << i << "\n";

沒錯,上面這段程式碼造成的效果和前面那段的功用是相同的!

更準確地說,Range-based for loop 的格式是這樣的:

for (item-declaration : range-initializer)
    statement		

這裡的「容器」其實可以擺上大多數能明確指出「範圍」的東西,像 vector 很明顯就是從頭遍歷到尾,當然陣列也可以:

int a[6] = {1, 2, 3, 4, 5, 6};
for (int i : a)
    cout << i << "\n";

這是因為陣列在宣告時會指定大小,所以在對他呼叫 Range-based for loop 時,就是按照他的大小去遍歷裡面的元素。

搭配 Reference 遍歷

還記得在 Reference 時我們有提過,可以幫變數取別名的方式來存取變數本身,這當然也能搭配著 Range-based for loop 使用,例如:

for (int &i : v)
    i += 1;

像這樣在宣告變數時加一個 & 在前面,就可以得到變數本身對其進行操作,而上面這段程式就有著將每個 v 內的元素加上 1 的功能。

常見應用


這裡介紹幾個有了 Range-based for loop 之後的好用寫法,在比賽時可以省下一些撰寫時間,也可以讓程式變得簡潔、好維護不少。

簡潔的輸入

時常有題目會需要輸入一串序列,例如:

int n;
cin >> n;
vector<int> arr(n);
for (int i = 0; i < n; ++i)
    cin >> arr[i];

首先,這裡會選擇使用 vector 來動態宣告一個長度為 n 陣列的理由有兩個:

因此,搭配上面這種寫法,又可以簡化成以下:

int n;
cin >> n;
vector<int> arr(n);
for (int &i : arr)
    cin >> i;

多少省下了幾個字元的時間。

枚舉物件

有時候我們會需要對不同的變數做同樣的事情,例如:

int x, y, z;
// 做了一些計算後,x, y, z 分別得到了不同的值
/*
執行一整段跟 x 有關的程式碼
*/
/*
執行一整段跟 y 有關的程式碼
*/
/*
執行一整段跟 z 有關的程式碼
*/

這時候,如果這三段程式碼根本就一模一樣,差別只有 x, y, z 之間的互相替換的話,就會浪費許多時間在複製程式碼,還會有出 bug 後要一口氣修三個地方的風險。

當然,如果養成寫函式的好習慣,其實可以很單純地寫出

int x, y, z;
// 做了一些計算後,x, y, z 分別得到了不同的值
f(x);
f(y);
f(z);

這種程式碼,直接在 f 這個函式實作想要的功能就好,光是這樣就能解決上面提到的問題了。但如果搭配 Range-based for loop,還可以進一步改成:

int x, y, z;
// 做了一些計算後,x, y, z 分別得到了不同的值
for (int i : {x, y, z})
    f(i);

沒錯,透過這個「大括號」,在 C++ 中其實被稱作「initializer-list」,就可以製造出一個「暫時的容器」來讓迴圈進行遍歷,這樣在要窮舉的變數非常多種時,就可以省下更多的時間了。

缺點和陷阱


Range-based for loop 雖然有以上若干種優勢,但還是有一些缺點要注意的。

無法獲得索引值

有時候,用作迴圈遍歷的索引值 i 有可能也需要被拿來做運算,一個最經典的例子就是在輸出行尾空白時:

for (int i = 0; i < int(v.size()); ++i) {
    cout << v[i];
    if (i + 1 == int(v.size())) cout << "\n";
    else cout << " ";
}

因為得特判是否是最後一個數字,而這時候使用 Range-based for loop 就會產生問題:沒有「索引值」本身!

當然也是有一些邪惡的解決方法,不過並不太正派。目前大概只有以下解決方法可以用:

int index = 0; 
for (int i : v) {
    cout << i;
    if (index + 1 == int(v.size())) cout << "\n";
    else cout << " ";
    ++index;
}

不過如果放眼新版本的 C++,其實可以獲得一定程度的改善,例如到了 C++20,上面的程式碼可以改成:

for (int index = 0; int i : v) {
    cout << i;
    if (index + 1 == int(v.size())) cout << "\n";
    else cout << " ";
    ++index;
}

也就是可以把一些變數宣告像我們原本使用 for 迴圈那樣寫在前面;甚至到了 C++23 還會有更方便的用法,不過語法上比較複雜,這裡只好先不作補充。有興趣的讀者可以自行搜尋 std::ranges::views::enumerate 這個東西。

註:現代程式比賽還是有可能只開放到 C++17 而已,所以要特別注意上述寫法有可能會導致 Compile Error。

字串陷阱

還記得前面講過「枚舉物件」這個應用嗎?如果要枚舉的是字元,例如 abc 這三種字元,很有可能會有人想直接這樣寫:

for (char c : "abc")
    cout << c << "\n";

這是因為想像中 "abc" 會是一個字串,而自然而然就會覺得這是一個可以遍歷的物件,但如果實際執行看看會發生什麼事呢?

為了方便辨識,我們把後面的 "\n" 改成 " *\n" 後,可以看見這樣的輸出:

a *
b *
c *
 *

沒錯,後面莫名奇妙多了一行不知道哪來的東西!

這是因為,在 C++ 裡的字串預設是用字元陣列來對待的,而 C++ 預設辨識字元陣列內字串結尾的方式,就是在字串結尾後面額外放一個 \0 來當成結尾。因此,陣列的大小在上面的例子其實是 $4$,而 Range-based for loop 是把這串東西當成陣列去遍歷的,所以就會不小心吃到那個 \0 導致行爲不如預期。

那要怎麼解決呢?其實有一個小訣竅是使用 <string> 偷偷幫大家定義的語法糖:

for (char c : "abc"s)
    cout << c << " *\n";

只要單純的加一個 s 在字串的後面,就可以把 "abc" 直接轉型成 std::string!這樣 Range-based for loop 在辨識容器時,就會當成 std::string 對待,也就不會吃到後面的那個零了。

關於這個 ""s 語法糖的原理,有興趣的讀者可以自行瀏覽這個連結