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 循环的这个特点,就可以方便地分析每次迭代的结果,提前避免算法的错误。
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/22429.html