第17章 基于范围的for循环(C++11 C++17 C++20)

通常遍历一个容器里的所有元素会用到for循环和迭代器,在大多数情况下我们并不关心迭代器本身,而且在循环中使用迭代器的模式往往十分固定——获取开始的迭代器、不断更新当前迭代器、将当前迭代器与结束的迭代器作比较以及解引用当前迭代器获取我们真正关心的元素:

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };

std::map<int, std::string>::iterator it = index_map.begin();
for (; it != index_map.end(); ++it) {
  std::cout << "key=" << (*it).first << ", value=" << (*it).second << std::endl;
}

从上面的代码可以看到,为了输出index_map中的内容不得不编写很多关于迭代器的代码,但迭代器本身并不是业务逻辑所关心的部分。对于这个问题的一个可行的解决方案是使用标准库提供的std::for_each函数,使用该函数只需要提供容器开始和结束的迭代器以及执行函数或者仿函数即可,例如:

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };

void print(std::map<int, std::string>::const_reference e)
{
  std::cout << "key=" << e.first << ", value=" << e.second << std::endl;
}

std::for_each(index_map.begin(), index_map.end(), print);

相对于上一段代码,这段代码使用std::for_each遍历容器比直接使用迭代器的方法要简洁许多。实际上单纯的迭代器遍历操作完全可以交给编译器来完成,这样能让程序员专注于业务代码而非迭代器的循环。

C++11标准引入了基于范围的for循环特性,该特性隐藏了迭代器的初始化和更新过程,让程序员只需要关心遍历对象本身,其语法也比传统for循环简洁很多:

for ( range_declaration : range_expression ) loop_statement

基于范围的for循环不需要初始化语句、条件表达式以及更新表达式,取而代之的是一个范围声明和一个范围表达式。其中范围声明是一个变量的声明,其类型是范围表达式中元素的类型或者元素类型的引用。而范围表达式可以是数组或对象,对象必须满足以下2个条件中的任意一个。

1.对象类型定义了beginend成员函数。

2.定义了以对象类型为参数的beginend普通函数。

#include <iostream>
#include <string>
#include <map>

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };
int int_array[] = { 0, 1, 2, 3, 4, 5 };

int main()
{
  for (const auto &e : index_map) {
       std::cout << "key=" << e.first << ", value=" << e.second << std::endl;
  }

  for (auto e : int_array) {
       std::cout << e << std::endl;
  }
}

以上代码通过基于范围的for循环遍历数组和标准库的map对象。其中const auto &eauto e是范围声明,而index_mapint_array是范围表达式。为了让范围声明更加简洁,推荐使用auto占位符。当然,这里使用std::map<int, std::string>:: value_typeint来替换auto也是可以的。值得注意的是,代码使用了两种形式的范围声明,前者是容器或者数组中元素的引用,而后者是容器或者数组中元素的值。一般来说,我们希望对于复杂的对象使用引用,而对于基础类型使用值,因为这样能够减少内存的复制。如果不会在循环过程中修改引用对象,那么推荐在范围声明中加上const限定符以帮助编译器生成更加高效的代码:

#include <vector>
struct X
{
  X() { std::cout << "default ctor" << std::endl; }
  X(const X& other) {
       std::cout << "copy ctor" << std::endl;
  }
};

int main()
{
  std::vector<X> x(10);
  std::cout << "for (auto n : x)" << std::endl;
  for (auto n : x) {
  }
  std::cout << "for (const auto &n : x)" << std::endl;
  for (const auto &n : x) {
  }
}

编译运行上面这段代码会发现for(auto n : x)的循环调用10次复制构造函数,如果类X的数据量比较大且容器里的元素很多,那么这种复制的代价是无法接受的。而for(const auto &n : x)则解决了这个问题,整个循环过程没有任何的数据复制。

在C++11标准中基于范围的for循环相当于以下伪代码:

{
  auto && __range = range_expression;
  for (auto __begin = begin_expr, __end = end_expr; __begin != __end; ++__begin) {
       range_declaration = *__begin;
       loop_statement
  }
}

其中begin_exprend_expr可能是__range.begin()__range.end(),或者是begin(__range)end(__range)。当然,如果__range是一个数组指针,那么还可能是__range__range+__count(其中__count是数组元素个数)。这段伪代码有一个特点,它要求begin_exprend_expr返回的必须是同类型的对象。但实际上这种约束完全没有必要,只要__begin != __end能返回一个有效的布尔值即可,所以C++17标准对基于范围的for循环的实现进行了改进,伪代码如下:

{
  auto && __range = range_expression;
  auto __begin = begin_expr;
  auto __end = end_expr;
  for (; __begin != __end; ++__begin) {
       range_declaration = *__begin;
       loop_statement
  }
}

可以看到,以上伪代码将__begin__end分离到两条不同的语句,不再要求它们是相同类型。

读者是否注意到了,无论是C++11还是C++17标准,基于范围的for循环伪代码都是由以下这句代码开始的:

auto && __range = range_expression;

理解了右值引用的读者应该敏锐地发现了这里存在的陷阱auto &&。对于这个赋值表达式来说,如果range_expression是一个纯右值,那么右值引用会扩展其生命周期,保证其整个for循环过程中访问的安全性。但如果range_ expression是一个泛左值,那结果可就不确定了,参考以下代码:

class T {
  std::vector<int> data_;
public:
  std::vector<int>& items() { return data_; }
  // …
};

T foo() 
{
    T t;
    return t;
}
for (auto& x : foo().items()) {} // 未定义行为

请注意,这里的for循环会引发一个未定义的行为,因为foo().items()返回的是一个泛左值类型std::vector<int>&,于是右值引用无法扩展其生命周期,导致for循环访问无效对象并造成未定义行为。对于这种情况请读者务必小心谨慎,将数据复制出来是一种解决方法:

T thing = foo(); 
for (auto & x :thing.items()) {}

在C++20标准中,基于范围的for循环增加了对初始化语句的支持,所以在C++20的环境下我们可以将上面的代码简化为:

for (T thing = foo(); auto & x :thing.items()) {}

前面用大量篇幅介绍了使用基于范围的for循环遍历数组和标准容器的方法,实际上我们还可以让自定义类型支持基于范围的for循环。要完成这样的类型必须先实现一个类似标准库中的迭代器。

1.该类型必须有一组和其类型相关的beginend函数,它们可以是类型的成员函数,也可以是独立函数。

2.beginend函数需要返回一组类似迭代器的对象,并且这组对象必须支持operator *operator !=operator ++运算符函数。

请注意,这里的operator ++应该是一个前缀版本,它需要通过声明一个不带形参的operator ++运算符函数来完成。下面是一个完整的例子:

#include <iostream>

class IntIter {
public:
  IntIter(int *p) : p_(p) {}
  bool operator!=(const IntIter& other)
  {
       return (p_ != other.p_);
  }

  const IntIter& operator++()
  {
       p_++;
       return *this;
  }

  int operator*() const
  {
       return *p_;
  }
private:
  int *p_;
};

template<unsigned int fix_size>
class FixIntVector {
public:
  FixIntVector(std::initializer_list<int> init_list)
  {
       int *cur = data_;
       for (auto e : init_list) {
            *cur = e;
            cur++;
       }
  }

  IntIter begin()
  {
       return IntIter(data_);
  }

  IntIter end()
  {
       return IntIter(data_ + fix_size);
  }
private:
  int data_[fix_size]{0};
};

int main()
{
  FixIntVector<10> fix_int_vector {1, 3, 5, 7, 9};
  for (auto e : fix_int_vector)
  {
       std::cout << e << std::endl;
  }
}

在上面的代码中,FixIntVector是存储int类型数组的类模板,类IntIterFixIntVector的迭代器。在FixIntVector中实现了成员函数beginend,它们返回了一组迭代器,分别表示数组的开始和结束位置。类IntIter本身实现了operator *operator !=operator ++运算符函数,其中operator *用于编译器生成解引用代码,operator !=用于生成循环条件代码,而前缀版本的operator ++用于更新迭代器。

请注意,这里使用成员函数的方式实现了beginend,但有时候需要遍历的容器可能是第三方提供的代码。这种情况下我们可以实现一组独立版本的beginend函数,这样做的优点是能在不修改第三方代码的情况下支持基于范围的for循环。

基于范围的for循环很好地解决了遍历容器过于烦琐的问题,它自动生成迭代器的遍历代码并将其隐藏于后台。强烈建议读者使用基于范围的for循环来处理单纯遍历容器的操作。当然,使用时需注意临时范围表达式结果的生命周期问题。另外,对于在遍历容器过程中需要修改容器的需求,还是需要使用迭代器来处理。