C++11中基於範圍的for迴圈

2020-07-16 10:04:49
在 C++03/98 中,不同的容器和陣列,遍歷的方法不盡相同,寫法不統一,也不夠簡潔,而 C++11 基於範圍的 for 迴圈以統一、簡潔的方式來遍歷容器和陣列,用起來更方便了。

C++11 for 迴圈的新用法

我們知道,在 C++ 中遍歷一個容器的方法一般是這樣的:
#include <iostream>
#include <vector>
int main(void)
{
    std::vector<int> arr;
    // ...
    for(auto it = arr.begin(); it != arr.end(); ++it)
    {
        std::cout << *it << std::endl;
    }
    return 0;
}
上面借助前面介紹過的 C++ auto 關鍵字,省略了疊代器的宣告。

當然,熟悉stl的讀者肯定還知道在 <algorithm> 中有一個 for_each 演算法可以用來完成和上述同樣的功能:
#include <algorithm>
#include <iostream>
#include <vector>
void do_cout(int n)
{
    std::cout << n << std::endl;
}
int main(void)
{
    std::vector<int> arr;
    // ...
    std::for_each(arr.begin(), arr.end(), do_cout);
    return 0;
}
std::for_each 比起前面的 for 迴圈,最大的好處是不再需要關注疊代器(Iterator)的概念,只需要關心容器中的元素型別即可。

但不管是上述哪一種遍歷方法,都必須顯式給出容器的開頭(Begin)和結尾(End)。這是因為上面的兩種方法都不是基於“範圍(Range)”來設計的。

我們先來看一段簡單的C#程式碼:
int[] fibarray = new int[] { 0, 1, 1, 2, 3, 5, 8, 13 };
foreach (int element in fibarray)
{
    System.Console.WriteLine(element);
}
上面這段程式碼通過“foreach”關鍵字使用了基於範圍的 for 迴圈。可以看到,在這種 for 迴圈中,不再需要傳遞容器的兩端,迴圈會自動以容器為範圍展開,並且迴圈中也遮蔽掉了疊代器的遍歷細節,直接抽取出容器中的元素進行運算。

與普通的for迴圈相比,基於範圍的迴圈方式是“自明”的。這種語法構成的迴圈不需要額外的注釋或語言基礎,很容易就可以看清楚它想表達的意義。在實際專案中經常會遇到需要針對容器做遍歷的情況,使用這種迴圈方式無疑會讓編碼和維護變得更加簡便。

現在,在 C++11 中終於有了基於範圍的 for 迴圈(The range-based for statement)。再來看一開始的 vector 遍歷使用基於範圍的 for 迴圈應該如何書寫:
#include <iostream>
#include <vector>
int main(void)
{
    std::vector<int> arr = { 1, 2, 3 };
    // ...
    for(auto n : arr)  //使用基於範圍的for迴圈
    {
        std::cout << n << std::endl;
    }
    return 0;
}
在上面的基於範圍的 for 迴圈中,n 表示 arr 中的一個元素,auto 則是讓編譯器自動推匯出 n 的型別。在這裡,n 的型別將被自動推導為 vector 中的元素型別 int。

在 n 的定義之後,緊跟一個冒號:,之後直接寫上需要遍歷的表示式,for 迴圈將自動以表示式返回的容器為範圍進行疊代。

在上面的例子中,我們使用 auto 自動推導了 n 的型別。當然在使用時也可以直接寫上我們需要的型別:

std::vector<int> arr;
for(int n : arr) ;

基於範圍的 for 迴圈,對於冒號前面的區域性變數宣告(for-range-declaration)只要求能夠支援容器型別的隱式轉換。因此,在使用時需要注意,像下面這樣寫也是可以通過編譯的:

std::vector<int> arr;
for(char n : arr) ; // int會被隱式轉換為char

在上面的例子中,我們都是在使用唯讀方式遍歷容器。如果需要在遍歷時修改容器中的值,則需要使用參照,程式碼如下:
for(auto& n : arr)
{
    std::cout << n++ << std::endl;
}
在完成上面的遍歷後,arr 中的每個元素都會被自加 1。

當然,若只是希望遍歷,而不希望修改,可以使用 const auto& 來定義 n 的型別。這樣對於複製負擔比較大的容器元素(比如一個 std::vector<std::string> 陣列)也可以無失真耗地進行遍歷。

基於範圍的 for 迴圈的使用細節

從前面的範例中可以看出,range-based for 的使用是比較簡單的。但是再簡單的使用方法也有一些需要注意的細節。

首先,看一下使用 range-based for 對 map 的遍歷方法:
#include <iostream>
#include <map>
int main(void)
{
    std::map<std::string, int> mm =
    {
        { "1", 1 }, { "2", 2 }, { "3", 3 }
    };
    for(auto& val : mm)
    {
        std::cout << val.first << " -> " << val.second << std::endl;
    }
    return 0;
}
這裡需要注意兩點:
  • for 迴圈中 val 的型別是 std::pair。因此,對於 map 這種關聯性容器而言,需要使用 val.first 或 val.second 來提取鍵值。
  • auto 自動推匯出的型別是容器中的 value_type,而不是疊代器。

關於上述第二點,我們再來看一個對比的例子:
std::map<std::string, int> mm =
{
    { "1", 1 }, { "2", 2 }, { "3", 3 }
};
for(auto ite = mm.begin(); ite != mm.end(); ++ite)
{
    std::cout << ite->first << " -> " << ite->second << std::endl;
}
for(auto& val : mm) // 使用基於範圍的for迴圈
{
    std::cout << val.first << " -> " << val.second << std::endl;
}
從這裡就可以很清晰地看出,在基於範圍的 for 迴圈中每次疊代時使用的型別和普通 for 迴圈有何不同。

在使用基於範圍的 for 迴圈時,還需要注意容器本身的一些約束。比如下面這個例子:
#include <iostream>
#include <set>
int main(void)
{
    std::set<int> ss = { 1, 2, 3 };
    for(auto& val : ss)
    {
        // error: increment of read-only reference 'val'
        std::cout << val++ << std::endl;
    }
    return 0;
}
例子中使用 auto& 定義了 std::set<int> 中元素的參照,希望能夠在迴圈中對 set 的值進行修改,但 std::set 的內部元素是唯讀的——這是由 std::set 的特徵決定的,因此,for 迴圈中的 auto& 會被推導為 const int&。

同樣的細節也會出現在 std::map 的遍歷中。基於範圍的 for 迴圈中的 std::pair 參照,是不能夠修改 first 的。

接下來,看看基於範圍的 for 迴圈對容器的存取頻率。看下面這段程式碼:
#include <iostream>
#include <vector>
std::vector<int> arr = { 1, 2, 3, 4, 5 };
std::vector<int>& get_range(void)
{
    std::cout << "get_range ->: " << std::endl;
    return arr;
}
int main(void)
{
    for(auto val : get_range())
    {
      std::cout << val << std::endl;
    }
    return 0;
}
輸出結果:
get_range ->:
1
2
3
4
5

從上面的結果中可以看到,不論基於範圍的 for 迴圈疊代了多少次,get_range() 只在第一次疊代之前被呼叫。

因此,對於基於範圍的 for 迴圈而言,冒號後面的表示式只會被執行一次。

最後,讓我們看看在基於範圍的 for 迴圈疊代時修改容器會出現什麼情況。比如,下面這段程式碼:
#include <iostream>
#include <vector>
int main(void)
{
    std::vector<int>arr = { 1, 2, 3, 4, 5 };
    for(auto val : arr)
    {
        std::cout << val << std::endl;
        arr.push_back(0); // 擴大容器
    }
    return 0;
}
執行結果(32位元mingw4.8):
1
5189584
-17891602
-17891602
-17891602

若把上面的 vector 換成 list,結果又將發生變化。

這是因為基於範圍的 for 迴圈其實是普通 for 迴圈的語法糖,因此,同普通的 for 迴圈一樣,在疊代時修改容器很可能會引起疊代器失效,導致一些意料之外的結果。由於在這裡我們是看不到疊代器的,因此,直接分析對基於範圍的 for 迴圈中的容器修改會造成什麼樣的影響是比較困難的。

其實對於上面的基於範圍的 for 迴圈而言,等價的普通 for 迴圈如下:
#include <iostream>
#include <vector>
int main(void)
{
    std::vector<int> arr = { 1, 2, 3, 4, 5 };
    auto && __range = (arr);
    for (auto __begin = __range.begin(), __end = __range.end(); __begin != __end; ++__begin)
    {
        auto val = *__begin;
        std::cout << val << std::endl;
        arr.push_back(0); // 擴大容器
    }
    return 0;
}
從這裡可以很清晰地看到,和我們平時寫的容器遍歷不同,基於範圍的 for 迴圈傾向於在迴圈開始之前確定好疊代的範圍,而不是在每次疊代之前都去呼叫一次 arr.end()。

當然,良好的程式設計習慣是盡量不要在疊代過程中修改疊代的容器。但是實際情況要求我們不得不這樣做的時候,通過理解基於範圍的 for 迴圈的這個特點,就可以方便地分析每次疊代的結果,提前避免演算法的錯誤。