C++11中基于范围的for循环

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 循环的这个特点,就可以方便地分析每次迭代的结果,提前避免算法的错误。