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
item-declaration
可以理解成變數的型態,在前面的例子就是 int
。range-initializer
就是你要遍歷的容器,在前面的例子是 v
。statement
就是迴圈的內容。這裡的「容器」其實可以擺上大多數能明確指出「範圍」的東西,像 vector
很明顯就是從頭遍歷到尾,當然陣列也可以:
int a[6] = {1, 2, 3, 4, 5, 6};
for (int i : a)
cout << i << "\n";
這是因為陣列在宣告時會指定大小,所以在對他呼叫 Range-based for loop 時,就是按照他的大小去遍歷裡面的元素。
還記得在 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。
還記得前面講過「枚舉物件」這個應用嗎?如果要枚舉的是字元,例如 a
、b
、c
這三種字元,很有可能會有人想直接這樣寫:
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
語法糖的原理,有興趣的讀者可以自行瀏覽這個連結。