跳转到内容

理解迭代器与范围库

第 16 章“C++ 标准库概览”介绍了标准库,解释了它的基本设计理念,并概览了其中提供的功能。本章将开始更深入地探索标准库,先讨论贯穿标准库大量组件的迭代器思想,再介绍可用的流迭代器与迭代器适配器。本章后半部分则聚焦 Ranges 库——这是一个非常强大的库,它让你能够以更偏 函数式风格(functional-style programming)的方式编写代码:你描述的是“想完成什么”,而不是“具体怎么做”。

标准库使用迭代器模式,为访问容器元素提供一种通用抽象。每个容器都会提供该容器专属的 iterator。你可以把它看成一种“增强版指针”:它知道如何遍历该特定容器中的元素,也就是说,iterator 支持在容器元素之间前进。不同容器提供的 iterator 虽然各不相同,但都遵循 C++ 标准定义的统一接口。因此,尽管各类容器的内部机制不同,iterator 却向“想处理容器元素的代码”呈现出一致的使用方式。这会带来多方面好处:代码更容易读写、更不容易出错(例如,与指针算术相比,iterator 更容易被正确使用)、通常也更高效(尤其是对那些不支持随机访问的容器,例如 std::listforward_list;见第 16 章),同时也更容易调试(例如,在调试构建中,iterator 可以执行边界检查)。此外,如果你通过 iterator 遍历容器内容,那么即使容器的底层实现将来完全改变,基于 iterator 的代码通常也不需要修改。

你可以把 iterator 理解成“指向容器中某个特定元素的位置”。和数组元素指针一样,iterator 也可以通过 operator++ 移动到下一个元素。类似地,你通常可以对 iterator 使用 operator*operator->,以访问其对应的实际元素,或者访问元素的成员。有些 iterator 还支持使用 operator==operator!= 进行比较,并支持 operator-- 回到前一个元素。

所有 iterator 都必须是可拷贝构造、可拷贝赋值、可析构的;iterator 的 lvalue 还必须可交换。不同容器提供的 iterator,会在这些基础能力之上附加稍有差异的特性。标准一共定义了六种 iterator 类别,如下表所示:

迭代器类别必需操作说明
输入(也叫读)operator++*->===!=、拷贝构造函数提供只读访问,只能向前移动(没有 operator-- 回退)。可以赋值、拷贝,并可比较是否相等。
输出(也叫写)operator++*=、拷贝构造函数提供只写访问,只能向前移动。可以赋值,但不能比较是否相等。输出 iterator 的一个特点是支持 *iter = value。注意这里没有 operator->。同时提供前置与后置 operator++
前向输入 iterator 的全部能力,再加默认构造函数提供只读访问,只能向前移动。可以赋值、拷贝,并可比较是否相等。
双向前向 iterator 的全部能力,再加 operator--具备前向 iterator 的全部能力,并且还可以向后移动到前一个元素。提供前置与后置 operator--
随机访问双向 iterator 的能力,再加:operator+-+=-=<><=>=[]等价于原生指针:支持指针算术、数组下标语法,以及全部比较操作。
连续具备随机访问能力,并且容器中逻辑上相邻的元素在内存中也必须物理相邻例如 std::arrayvector(不含 vector<bool>)、stringstring_view 的 iterator。

根据这张表,标准中存在六种 iterator:input、output、forward、bidirectional、random access 和 contiguous。它们之间并不存在正式的类继承层次;不过,根据所要求支持的功能,我们可以自然推导出一种“能力层级”。具体来说,每个 contiguous iterator 也一定是 random access iterator;每个 random-access iterator 也一定是 bidirectional iterator;每个 bidirectional iterator 也一定是 forward iterator;而每个 forward iterator 也一定是 input iterator。额外满足 output iterator 要求的 iterator,被称为 mutable iterator;否则就称为 constant iterator图 17.1 展示了这一层级关系。图中使用虚线,是因为它并不是真实的类继承关系。

展示五种迭代器能力层级的示意图,从上到下依次为 input、forward、bidirectional、random access、contiguous。

[^图 17.1]

标准算法通常通过类似下面这样的模板类型参数命名,来表达它们所要求的 iterator 类型:InputIteratorOutputIteratorForwardIteratorBidirectionalIteratorRandomAccessIteratorContiguousIterator。这些名字本身只是名字,并不会自动带来真正的类型约束检查。因此,理论上你完全可以尝试把一个只要求 RandomAccessIterator 的算法,用 bidirectional iterator 去实例化。模板本身无法阻止这种实例化;但一旦函数体中的实现真的用到了 random-access iterator 才具备的能力,代码就会在编译阶段失败。也就是说,要求确实被强制执行了,只不过不是在你最直观以为的地方,因此报错信息往往会有点费解。例如,把要求随机访问 iterator 的泛型 sort() 算法,用在只提供 bidirectional iterator 的 list 上,就可能得到一条相当难懂的编译错误。下面是 Visual C++ 2022 生成的一条错误信息:

…\MSVC\14.37.32705\include\algorithm(8061,45): error C2676: binary '-': 'const std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<_Ty>>>' does not define this operator or a conversion to a type acceptable to the predefined operator
with
[
_Ty=int
]

本章后半部分会介绍 Ranges 库。该库为大多数标准库算法提供了基于 range 的受约束版本。这些受约束算法会为其模板类型参数附上真正的类型约束(见第 12 章“使用模板编写泛型代码”)。因此,如果你把某个算法用在了“提供错误 iterator 类型”的容器上,编译器通常就能给出更清晰的错误信息。

iterator 的实现方式和 smart pointer 很相似:它们通过重载特定运算符来呈现所需行为。关于运算符重载的细节,请参见第 15 章“重载 C++ 运算符”。

基本的 iterator 操作与原生指针支持的操作很相像,因此原生指针本身就可以充当某些容器的合法 iterator。事实上,vector 的 iterator 从技术上讲完全可以直接实现为普通指针。不过,作为容器的使用者,你不需要关心这些内部实现细节,只要使用 iterator 这一抽象即可。

标准库中所有支持 iterator 的数据结构,都会公开其 iterator 类型别名,通常命名为 iteratorconst_iterator。例如,一个 vector<int>const iterator 类型就是 std::vector<int>::const_iterator。支持反向遍历的容器还会额外提供 reverse_iteratorconst_reverse_iterator 这两个公开类型别名。这样一来,使用者就能在不关心实际底层类型的情况下,直接使用容器提供的 iterator。

容器还会提供 begin() 成员函数,它返回一个指向容器第一个元素的 iterator;end() 成员函数则返回指向该序列“越过末尾位置”的 iterator。也就是说,end() 返回的 iterator,等价于对“指向最后一个元素的 iterator”执行一次 operator++ 后得到的结果。begin()end() 共同定义了一个 左闭右开区间:它包含第一个元素,但不包含最后一个位置。之所以采用这种看起来稍显绕的设计,是为了支持空区间(也就是不含任何元素的容器);在这种情况下,begin()end() 会相等。由 begin()end() 这对 iterator 界定的左闭右开区间,通常记作 [begin, end)

此外,还提供下列成员函数:

  • cbegin()cend():返回 const iterator
  • rbegin()rend():返回 reverse iterator
  • crbegin()crend():返回 const reverse iterator

<iterator> 还提供了若干全局的非成员函数,用于为容器获取对应 iterator:

函数名函数概述
begin() / end()返回序列中第一个元素的非 const iterator,以及越过末尾位置的非 const iterator
cbegin() / cend()返回序列中第一个元素的 const iterator,以及越过末尾位置的 const iterator
rbegin() / rend()返回序列中最后一个元素的非 const reverse iterator,以及位于第一个元素之前位置的非 const reverse iterator
crbegin() / crend()返回序列中最后一个元素的 const reverse iterator,以及位于第一个元素之前位置的 const reverse iterator

这些非成员函数都定义在 std 命名空间中;不过,尤其是在编写类模板或函数模板这类泛型代码时,推荐按下面这种方式使用它们:

using std::begin;
begin();

注意,这里的 begin() 没有写任何命名空间限定。这样做是为了启用 argument-dependent lookup(ADL,依赖参数的查找)。

如果你为自己的类型特化了本节这些非成员函数,那么你既可以把这些特化放在 std 命名空间里,也可以把它们放在对应类型所在的同一个命名空间里。后者是推荐方式,因为这样就能利用 ADL。借助 ADL,你在调用这些特化时就不需要显式写命名空间限定了:编译器会根据传入参数的类型,到你的命名空间里自动找到正确的特化版本。

把 ADL(也就是不带命名空间限定地调用 begin(…))与 using std::begin 声明结合起来时,编译器会先通过 ADL,到参数类型所属命名空间里查找合适重载;如果没找到,再由于 using 声明而继续去 std 命名空间里找。若你只是直接写 begin() 而没有 using 声明,那么只有通过 ADL 才可能找到用户自定义重载;如果你直接写 std::begin(),那它则只会在 std 命名空间里查找。

当然,ADL 并不只适用于本节讨论的这些函数,它适用于任意函数调用。

有些算法实现需要拿到 iterator 的额外信息。例如,它们也许需要知道该 iterator 所指向元素的类型,以便声明临时变量;又或者它们想知道该 iterator 是 bidirectional 还是 random access。

C++ 为此提供了一个名为 iterator_traits 的类模板,定义在 <iterator> 中。通过它,你可以提取某个 iterator 类型的这些信息。你只需用目标 iterator 类型实例化 iterator_traits,然后访问其中五个类型别名之一:

  • value_type:iterator 所引用元素的类型
  • difference_type:能够表示两个 iterator 之间距离(也就是元素个数差)的类型
  • iterator_category:iterator 的类别,即 input_iterator_tagoutput_iterator_tagforward_iterator_tagbidirectional_iterator_tagrandom_access_iterator_tagcontiguous_iterator_tag
  • pointer:指向元素的指针类型
  • reference:元素引用类型

例如,下面这个函数模板声明了一个临时变量,其类型就是 IteratorType 所引用元素的类型。注意 iterator_traits 前面的 typename 关键字:只要你访问的是“基于一个或多个模板参数推导出来的类型”,就必须显式写出 typename。这里正是因为模板类型参数 IteratorType 被用于访问 iterator_traitsvalue_type

template <typename IteratorType>
void iteratorTraitsTest(IteratorType it)
{
typename iterator_traits<IteratorType>::value_type temp;
temp = *it;
println("{}", temp);
}

你可以用下面这段代码测试这个函数:

vector v { 5 };
iteratorTraitsTest(cbegin(v));

在这段测试代码中,iteratorTraitsTest() 里的变量 temp 会是 int 类型,输出结果为 5

当然,这个例子完全可以使用 auto 来简化代码;不过那样就无法展示 iterator_traits 的用法了。

下面这个例子只是简单地用 for 循环加 iterator,遍历 vector 中的全部元素,并把它们打印到标准输出:

vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for (auto iter { cbegin(values) }; iter != cend(values); ++iter) {
print("{} ", *iter);
}

你也许会想用 operator< 来判断是否到达公共区间末尾,例如写成 iter < cend(values)。不过不建议这样做。判断是否到达区间末尾的标准写法,是使用 !=,也就是 iter != cend(values)。原因在于:!= 适用于全部 iterator 类型,而 < 则并不支持 bidirectional iterator 和 forward iterator。

你还可以实现一个辅助函数:它接收一个由 begin/end iterator 描述的公共区间,并把其中所有元素输出到标准输出。这里使用 input_iterator concept 来约束模板类型参数,只允许输入 iterator:

template <input_iterator Iter>
void myPrint(Iter begin, Iter end)
{
for (auto iter { begin }; iter != end; ++iter) { print("{} ", *iter); }
}

这个辅助函数可以像下面这样使用:

myPrint(cbegin(values), cend(values));

再看第二个例子:一个 myFind() 函数模板,用于在给定公共区间中查找某个值。如果找不到,就返回该区间的 end iterator。注意 value 参数的类型写法:它使用 iterator_traits 来取得传入 iterator 所指向值的类型。

template <input_iterator Iter>
auto myFind(Iter begin, Iter end,
const typename iterator_traits<Iter>::value_type& value)
{
for (auto iter { begin }; iter != end; ++iter) {
if (*iter == value) { return iter; }
}
return end;
}

这个函数模板可以像下面这样使用。这里借助 std::distance() 来计算容器中两个 iterator 之间的距离。

vector values { 11, 22, 33, 44 };
auto result { myFind(cbegin(values), cend(values), 22) };
if (result != cend(values)) {
println("Found value at position {}", distance(cbegin(values), result));
}

本章以及后续章节里还会继续给出更多 iterator 使用示例。

标准库提供了 std::advance(iter, n) 函数,用来把给定 iterator iter 前进 n 个位置。这个函数适用于所有 iterator 类型。对 random-access iterator 来说,它本质上只是执行 iter += n;而对其他类型的 iterator 来说,则需要在循环里执行 ++iter--itern 次,具体取决于 n 是正还是负。你可能会好奇,这样的行为是怎么实现的。一个常见办法是使用 函数分发(function dispatching):根据 iterator 类别,把请求分派给不同的辅助函数。下面给出一个简化版的 myAdvance(iter, n) 实现,用它来展示这种分发技巧:

template <typename Iter, typename Distance>
void advanceHelper(Iter& iter, Distance n, input_iterator_tag)
{
while (n > 0) { ++iter; --n; }
}
template <typename Iter, typename Distance>
void advanceHelper(Iter& iter, Distance n, bidirectional_iterator_tag)
{
while (n > 0) { ++iter; --n; }
while (n < 0) { --iter; ++n; }
}
template <typename Iter, typename Distance>
void advanceHelper(Iter& iter, Distance n, random_access_iterator_tag)
{
iter += n;
}
template <typename Iter, typename Distance>
void myAdvance(Iter& iter, Distance n)
{
using category = typename iterator_traits<Iter>::iterator_category;
advanceHelper(iter, n, category {});
}

这份 myAdvance() 实现既可以用于 vector 的 random-access iterator,也可以用于 list 的 bidirectional iterator,等等:

template <typename Iter>
void testAdvance(Iter iter)
{
print("*iter = {} | ", *iter);
myAdvance(iter, 3); print("3 ahead = {} | ", *iter);
myAdvance(iter, -2); println("2 back = {}", *iter);
}
int main()
{
vector vec { 1, 2, 3, 4, 5, 6 }; testAdvance(begin(vec));
list lst { 1, 2, 3, 4, 5, 6 }; testAdvance(begin(lst));
}

输出如下:

*iter = 1 | 3 ahead = 4 | 2 back = 2
*iter = 1 | 3 ahead = 4 | 2 back = 2

有了 concepts(见第 12 章)之后,myAdvance() 还可以写得更简洁:不再需要辅助函数,直接给 myAdvance() 提供带约束的重载即可:

template <input_iterator Iter, typename Distance>
void myAdvance(Iter& iter, Distance n)
{
while (n > 0) { ++iter; --n; }
}
template <bidirectional_iterator Iter, typename Distance>
void myAdvance(Iter& iter, Distance n)
{
while (n > 0) { ++iter; --n; }
while (n < 0) { --iter; ++n; }
}
template <random_access_iterator Iter, typename Distance>
void myAdvance(Iter& iter, Distance n)
{
iter += n;
}

标准库提供了四种 stream iterator(流迭代器)。它们是一些“表现得像 iterator 的类模板”,让你可以把输入流和输出流分别当作 input iterator 与 output iterator 来使用。借助这些流迭代器,你就能把输入输出流适配成各种标准库算法的数据源或目标端。可用的流迭代器如下:

  • ostream_iterator:向 basic_ostream 写入数据的 output iterator
  • istream_iterator:从 basic_istream 读取数据的 input iterator
  • ostreambuf_iterator:向 basic_streambuf 写入数据的 output iterator
  • istreambuf_iterator:从 basic_streambuf 读取数据的 input iterator

ostream_iterator 是一种 输出流迭代器。它是一个类模板,以元素类型作为模板参数。它的构造函数接收一个输出流,以及一个分隔符字符串;每写出一个元素之后,都会把该分隔符也输出到流中。ostream_iterator 使用 operator<< 来写入元素。

先看一个例子。假设你有下面这样一个 myCopy() 函数模板:它把一个由 begin/end iterator 给出的公共区间复制到另一个由 begin iterator 指定的目标区间。第二个模板类型参数被约束为 output iterator,并且它接收的值类型是 std::iter_reference_t<InputIter>,也就是给定 InputIter 所引用元素的类型。

template <input_iterator InputIter,
output_iterator<iter_reference_t<InputIter>> OutputIter>
void myCopy(InputIter begin, InputIter end, OutputIter target)
{
for (auto iter { begin }; iter != end; ++iter, ++target) { *target = *iter; }
}

myCopy() 的前两个参数是待复制区间的 begin 与 end iterator,第三个参数则是目标区间的 iterator。你必须确保目标区间足够大,能够容纳源区间的全部元素。把 myCopy() 用来把一个 vector 复制到另一个 vector,写法相当直接:

vector myVector { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Use myCopy() to copy myVector to vectorCopy.
vector<int> vectorCopy(myVector.size());
myCopy(cbegin(myVector), cend(myVector), begin(vectorCopy));

现在,借助 ostream_iteratormyCopy() 还可以被用来“只写一行代码就打印容器中的元素”。下面这段代码会输出 myVectorvectorCopy 的内容:

// Use the same myCopy() to print the contents of both vectors.
myCopy(cbegin(myVector), cend(myVector), ostream_iterator<int> { cout, " " });
println("");
myCopy(cbegin(vectorCopy), cend(vectorCopy), ostream_iterator<int> { cout, " " });
println("");

输出如下:

1 2 3 4 5 6 7 8 9 10
1 2 3 4 5 6 7 8 9 10

你可以使用 输入流迭代器 istream_iterator,以 iterator 抽象的方式从输入流中读取值。它同样是一个类模板,以元素类型作为模板参数;构造函数接收一个输入流。元素通过 operator>> 读取。你可以把 istream_iterator 作为算法或容器成员函数的数据源。

假设你有下面这个 sum() 函数模板,用来对给定公共区间中的所有元素求和:

template <input_iterator InputIter>
auto sum(InputIter begin, InputIter end)
{
auto sum { *begin };
for (auto iter { ++begin }; iter != end; ++iter) { sum += *iter; }
return sum;
}

现在,你就可以使用 istream_iterator 从控制台读取整数,直到流结束为止。在 Windows 上,这通常是在按下 Ctrl+Z 后再回车;在 Linux 上,则是先回车再按 Ctrl+D。然后调用 sum() 计算这些整数的总和。一个默认构造的 istream_iterator 表示 end iterator。

println("请输入以空白分隔的数字。");
println("按 Ctrl+Z 再回车以结束输入。");
istream_iterator<int> numbersIter { cin };
istream_iterator<int> endIter;
int result { sum(numbersIter, endIter) };
println("总和:{}", result);

istreambuf_iterator 的一个典型用途,是只用一条语句就读取整个文件内容。默认构造的 istreambuf_iterator 表示 end iterator。示例如下:

ifstream inputFile { "some_data.txt" };
if (inputFile.fail()) {
println(cerr, "Unable to open file for reading.");
return 1;
}
string fileContents {
istreambuf_iterator<char> { inputFile },
istreambuf_iterator<char> { }
};
println("{}", fileContents);

标准库提供了若干 迭代器适配器,也就是特殊形式的 iterator,它们都定义在 <iterator> 中。它们大体分成两类。第一类适配器由容器构造而来,通常作为 output iterator 使用:

  • back_insert_iterator:通过 push_back() 向容器插入元素
  • front_insert_iterator:通过 push_front() 向容器插入元素
  • insert_iterator:通过 insert() 向容器插入元素

另一类适配器则是基于另一个 iterator 构造出来的,通常作为 input iterator 使用。两种常见的适配器是:

  • reverse_iterator:把另一个 iterator 的遍历方向反转
  • move_iterator:它的解引用运算符会自动把值转换成 rvalue reference,因此值可以被移动到新目标位置,而不必复制

当然,也可以自己编写迭代器适配器,不过本书不再展开。相关细节请参考附录 B“注释书目”里列出的标准库参考资料。

本章前面实现的 myCopy() 函数模板,并不会真正往容器里插入元素;它只是用新元素覆盖目标区间中原有的旧元素。为了让这类算法更实用,标准库提供了三种真正会往容器里插入元素的 插入迭代器适配器insert_iteratorback_insert_iteratorfront_insert_iterator。它们都以容器类型作为模板参数,并在构造函数里接收一个实际容器引用。由于它们满足 iterator 接口要求,因此可以作为诸如 myCopy() 这类算法的目标 iterator 使用。只不过,它们不会覆盖已有元素,而是调用容器自身的插入操作,把新元素真正插入进去。

基础版的 insert_iterator 会对容器调用 insert(position, element)back_insert_iterator 会调用 push_back(element)front_insert_iterator 则调用 push_front(element)

下面这个例子使用 back_insert_iterator 配合 myCopy(),把 vectorOne 中的所有元素复制插入到 vectorTwo。注意,这里并没有提前把 vectorTwo 调整到足够大小,插入迭代器会负责正确插入新元素:

vector vectorOne { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
vector<int> vectorTwo;
back_insert_iterator<vector<int>> inserter { vectorTwo };
myCopy(cbegin(vectorOne), cend(vectorOne), inserter);
println("{:n}", vectorTwo);

可以看到,使用插入迭代器时,你不再需要预先调整目标容器大小。

你也可以使用 std::back_inserter() 这个工具函数来创建 back_insert_iterator。对于前面的例子,可以删掉定义 inserter 变量的那一行,并把 myCopy() 改写成:

myCopy(cbegin(vectorOne), cend(vectorOne), back_inserter(vectorTwo));

借助类模板实参推导(CTAD),它还可以写成:

myCopy(cbegin(vectorOne), cend(vectorOne), back_insert_iterator { vectorTwo });

front_insert_iteratorinsert_iterator 的工作方式类似,只不过 insert_iterator 的构造函数还需要额外接收一个初始插入位置 iterator,并把它作为第一次调用 insert(position, element) 时的位置。后续插入时用到的位置 hint,则根据每次 insert() 的返回值自动更新。

insert_iterator 的一个好处,是它允许你把关联容器作为“修改型算法”的目标区间。第 20 章“精通标准库算法”会解释:关联容器的问题在于,你不能修改它们正在迭代的 key。而当你使用 insert_iterator 时,你做的是“插入新元素”,而不是“修改现有元素”。关联容器都提供接收 iterator position 的 insert() 成员函数,该 position 只是一个 hint,容器本身可以忽略它。当你在关联容器上使用 insert_iterator 时,可以把容器的 begin()end() 传进去作为这个 hint。insert_iterator 会在每次 insert() 之后,基于其返回值自动调整下一个插入位置,使之位于刚插入元素之后。

下面把前面的例子改成以 set 而不是 vector 作为目标容器:

vector vectorOne { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
set<int> setOne;
insert_iterator<set<int>> inserter { setOne, begin(setOne) };
myCopy(cbegin(vectorOne), cend(vectorOne), inserter);
println("{:n}", setOne);

类似于前面的 back_insert_iterator 示例,这里也可以使用 std::inserter() 来创建 insert_iterator

myCopy(cbegin(vectorOne), cend(vectorOne), inserter(setOne, begin(setOne)));

或者借助类模板实参推导,写成:

myCopy(cbegin(vectorOne), cend(vectorOne),
insert_iterator { setOne, begin(setOne) });

标准库提供了 std::reverse_iterator 这个类模板,用来以相反方向遍历某个 bidirectional 或 random-access iterator。标准库中所有可逆容器——也就是除 forward_list 与无序关联容器之外的全部标准容器——都提供了 reverse_iterator 类型别名,以及 rbegin()rend() 成员函数。这些 reverse_iterator 类型别名,本质上都是 std::reverse_iterator<T>,其中 T 就是对应容器的 iterator 类型别名。rbegin() 返回一个指向容器最后一个元素的 reverse_iteratorrend() 则返回一个指向“位于第一个元素之前位置”的 reverse_iterator。对 reverse_iterator 应用 operator++,实际上会对其底层容器 iterator 执行 operator--;反过来也一样。例如,从头到尾遍历一个集合,可以这样写:

for (auto iter { begin(collection) }; iter != end(collection); ++iter) {}

而如果想从尾到头遍历它,就可以通过 rbegin()rend() 使用 reverse_iterator。注意,这里依然写的是 ++iter

for (auto iter { rbegin(collection) }; iter != rend(collection); ++iter) {}

std::reverse_iterator 主要适合与那些“本身没有反向版本”的标准库算法或你自己的函数配合使用。本章前面介绍的 myFind() 用于搜索序列中的第一个匹配元素。如果你想找的是最后一个匹配元素,就可以改用 reverse_iterator。注意,当你把 reverse_iterator 传给 myFind() 这类算法时,算法返回的也会是 reverse_iterator。你可以随时通过 base() 成员函数,从 reverse_iterator 取回它内部包装的底层普通 iterator。不过,由于 reverse_iterator 的实现方式,base() 返回的 iterator 总是指向“当前 reverse_iterator 所指元素的后一个位置”。因此,如果你想回到同一个元素,就必须再减一。

下面是使用 reverse_iterator 调用 myFind() 的示例:

vector myVector { 11, 22, 33, 22, 11 };
auto it1 { myFind(begin(myVector), end(myVector), 22) };
auto it2 { myFind(rbegin(myVector), rend(myVector), 22) };
if (it1 != end(myVector) && it2 != rend(myVector)) {
println("Found at position {} going forward.",
distance(begin(myVector), it1));
println("Found at position {} going backward.",
distance(begin(myVector), --it2.base()));
} else {
println("Failed to find.");
}

程序输出如下:

Found at position 1 going forward.
Found at position 3 going backward.

第 9 章“精通类与对象”讨论了 移动语义(move semantics)。在你确信源对象在赋值或拷贝构造之后会被销毁的场景中,或者你显式使用 std::move() 的时候,移动语义可以避免不必要的复制。标准库为此提供了一个迭代器适配器:std::move_iterator。它的解引用运算符会自动把值转换成 rvalue reference,也就是说,这个值可以被移动到新目标位置,而不需要承担复制的开销。使用移动语义之前,前提是你自己的对象类型已经正确支持它。下面这个 MoveableClass 就支持移动语义。更多细节请见第 9 章

class MoveableClass
{
public:
MoveableClass() {
println("Default constructor");
}
MoveableClass(const MoveableClass& src) {
println("Copy constructor");
}
MoveableClass(MoveableClass&& src) noexcept {
println("Move constructor");
}
MoveableClass& operator=(const MoveableClass& rhs) {
println("Copy assignment operator");
return *this;
}
MoveableClass& operator=(MoveableClass&& rhs) noexcept {
println("Move assignment operator");
return *this;
}
};

这些构造函数和赋值运算符本身并没有真正做什么有意义的事情;它们只是打印一条消息,方便你观察当前到底调用的是哪一个。现在,有了这个类之后,你可以这样定义一个 vector,并往里面放入几个 MoveableClass 实例:

vector<MoveableClass> vecSource;
MoveableClass mc;
vecSource.push_back(mc);
vecSource.push_back(mc);

输出可能如下所示。每行后面的编号并不是程序输出的一部分,而是为了便于后文解释:

Default constructor // [1]
Copy constructor // [2]
Copy constructor // [3]
Move constructor // [4]

代码第二行通过默认构造函数创建了一个 MoveableClass 实例,也就是 [1]。第一次调用 push_back() 时,会触发拷贝构造函数,把 mc 复制进 vector 中,对应 [2]。此时 vector 中有空间存放一个元素,也就是 mc 的第一份副本。需要说明的是:这里的讨论基于 Microsoft Visual C++ 2022 中 vector 的增长策略和初始容量行为。C++ 标准本身并没有规定 vector 的初始容量和扩容策略,所以不同编译器的输出可能不同。

第二次调用 push_back() 时,会导致 vector 扩容,为第二个元素分配更多空间。扩容会触发移动构造函数,把旧 vector 中已有的元素移动到新的、更大的存储区里,对应 [4]。与此同时,拷贝构造函数还会再次被调用,把 mc 的第二份副本复制到 vector 中,对应 [3]。移动和复制发生的先后顺序未定义,因此 [3] 与 [4] 的顺序也有可能相反。

你可以像下面这样创建一个新的 vector vecOne,让它包含 vecSource 中元素的副本:

vector<MoveableClass> vecOne { cbegin(vecSource), cend(vecSource) };

如果不使用 move_iterator,这段代码会触发两次拷贝构造函数——vecSource 中每个元素各拷贝一次:

Copy constructor
Copy constructor

但如果你使用 std::make_move_iterator() 创建 move_iterator,那么被调用的就不再是拷贝构造,而是 MoveableClass 的移动构造:

vector<MoveableClass> vecTwo { make_move_iterator(begin(vecSource)),
make_move_iterator(end(vecSource)) };

这会产生如下输出:

Move constructor
Move constructor

你也可以结合类模板实参推导来写 move_iterator

vector<MoveableClass> vecTwo { move_iterator { begin(vecSource) },
move_iterator { end(vecSource) } };

请记住,一旦某个对象已经被移动到别处,就不应再继续使用它。

标准库中的 iterator 机制,使算法能够脱离具体容器类型而独立工作,因为 iterator 屏蔽了“如何在容器元素之间移动”的底层细节。正如你在本章前面所有 iterator 示例中看到的那样,大多数算法都需要一对 iterator:一个 begin iterator,指向序列的第一个元素;一个 end iterator,指向越过末尾的位置。这样的设计确实让算法能够适配各种容器,但每次都要手动传入两个 iterator,既显得有点繁琐,也容易不小心传错,形成不匹配的 iterator 组合。Ranges 是建立在 iterator 之上的一层抽象。它消除了 begin/end iterator 不匹配这类错误,并带来了更多功能,例如借助 range adapter 对底层元素序列做惰性过滤与变换。定义在 <ranges> 中的 Ranges 库,主要由以下几大部分组成:

  • Ranges: range 是一个 concept(见第 12 章),它规定了一个类型如果想支持对自身元素的迭代,需要满足哪些要求。任何支持 begin()end() 的数据结构,都是合法的 range。例如 std::arrayvectorstring_viewspan、定长 C 风格数组等,全部都属于合法 range。
  • 受约束算法: 第 16 章第 20 章介绍了那些通过 iterator 对来工作的标准库算法。对于其中大多数算法,标准库都提供了等价的、基于 range 的受约束版本,它们既可以接收 iterator 对,也可以直接接收 range。
  • Projection(投影): 很多受约束算法都支持 projection 回调。算法会对 range 中的每个元素先调用该回调,把元素投影为另一个值,再将这个值交给算法后续处理。
  • Views(视图): 视图可以用来对某个底层 range 的元素执行变换或过滤。多个视图还可以组合起来,形成一个对 range 逐步施加操作的管线。
  • Factories(工厂): 范围工厂用来构造“按需生成值”的视图。

对 range 元素的遍历,可以通过 ranges::begin()end()rbegin() 等访问器拿到相应 iterator 来完成。range 还支持 ranges::empty()data()cdata()size()。其中 size() 只有在可以常数时间取得元素个数时才可用;否则,就该使用 std::distance() 来计算 range 的 begin iterator 与 end iterator 之间的距离。所有这些访问器都不是成员函数,而是独立的自由函数,并且都需要以 range 作为参数。

此外,std::format()print()println() 都原生支持对 range 进行格式化和打印,本节中的不少例子都会展示这一点。

std::sort() 就是一个典型例子:它要求你传入一个由 begin 与 end iterator 构成的元素序列。标准库算法在第 16 章中做过介绍,并会在第 20 章中详细展开。sort() 的用法很直接。例如,下面这段代码会对一个 vector 中的全部元素排序:

vector data { 33, 11, 22 };
sort(begin(data), end(data));

这段代码确实能对 data 容器的全部元素排序,但你必须显式把该序列写成 begin/end iterator 对。有没有一种方式,能让代码更直接地表达你的真实意图?这正是 基于 range 的受约束算法(在本书中简称 受约束算法)要解决的问题。这些算法位于 std::ranges 命名空间中,并定义在与其不受约束版本相同的头文件中。借助它们,你就可以直接写成:

ranges::sort(data);

这段代码更准确地表达了你的意图:对 data 容器中的全部元素排序。既然你不再显式传入 iterator,那么这类受约束算法也就顺便消除了“误传不匹配 begin/end iterator”的可能性。此外,受约束算法会为其模板类型参数施加真正的类型约束(见第 12 章),因此如果你把某个“不能提供算法所要求 iterator 类型”的容器传进去,编译器通常能给出更清晰的错误信息。例如,把 ranges::sort() 用在 std::list 上时,编译器就会更明确地指出:sort() 需要的是 random-access range,而 list 并不满足这一点。和 iterator 一样,range 也有 input、output、forward、bidirectional、random-access 与 contiguous 等分类,对应的 concept 例如 ranges::contiguous_rangeranges::random_access_range 等。

很多受约束算法都带有一个 projection(投影)参数。它是一个回调,用来在元素真正交给算法前,先把元素转换成另一个值。来看个例子。假设你有一个简单的 Person 类:

class Person
{
public:
explicit Person(string first, string last)
: m_firstName { move(first) }, m_lastName { move(last) } { }
const string& getFirstName() const { return m_firstName; }
const string& getLastName() const { return m_lastName; }
private:
string m_firstName;
string m_lastName;
};

下面这段代码把几个 Person 对象存入一个 vector

vector persons { Person {"John", "White"}, Person {"Chris", "Blue"} };

由于 Person 类没有实现 operator<,你无法直接用普通的 std::sort() 来对这个 vector 排序,因为默认排序比较的是元素的 operator<。因此,下面这行代码无法编译:

sort(begin(persons), end(persons)); // Error: does not compile.

表面上看,切换到受约束版本的 ranges::sort() 似乎也没有立刻解决问题,因为算法依然不知道该如何比较 range 中的 Person 元素:

ranges::sort(persons); // Error: does not compile.

不过,你可以通过 projection,让 sort 按 first name 对 persons 排序:也就是在比较每个 Person 时,先把它投影为自己的名字。projection 是 sort 的第三个参数,因此第二个参数——比较器——也必须一并写出来;默认比较器是 std::ranges::less。下面这个调用里,{} 表示使用默认比较器,而 projection(投影)则由一个 lambda expression 给出;相关细节见后面的说明。

ranges::sort(persons, {},
[](const Person& person) { return person.getFirstName(); });

还可以写得更简洁:

ranges::sort(persons, {}, &Person::getFirstName);

视图 允许你对某个底层 range 的元素执行操作,例如过滤或转换。多个视图还可以串接/组合起来,形成一个对元素依次施加多步处理的 管线。组合视图非常简单:把不同操作通过按位或运算符 operator| 串起来即可。比如,你可以先过滤某个 range 的元素,再对过滤后的结果做转换。相对而言,如果你想用不受约束算法去完成类似“先过滤、再转换”的工作,代码往往会更难读,也可能更低效,因为你常常不得不引入临时容器保存中间结果。

视图具有以下几个重要特性:

  • 惰性求值: 仅仅构造一个视图时,并不会立刻执行任何操作。只有当你真正开始遍历这个视图的元素,并解引用它的 iterator 时,这些操作才会实际发生。
  • 非拥有1 视图不拥有任何元素。正如名字所示,它只是“对某个 range 元素的一种视图”;这些元素通常真正存放在某个容器里,而那个容器才是数据的拥有者。视图只是让你以不同方式观察那份数据。因此,视图中元素数量的多少,并不会影响视图本身的拷贝、移动或析构成本。这与第 2 章“使用字符串与字符串视图”中介绍的 std::string_view,以及第 18 章“标准库容器”中介绍的 std::span 十分相似。
  • 不修改底层数据: 视图本身永远不会去修改底层 range 中的数据。

视图自己也是一种 range;不过并不是每个 range 都是视图。容器就是 range,但它不是视图,因为它拥有自己的元素。

视图可以通过 range adapter(范围适配器)来构造。范围适配器接收一个底层元素序列,并可选接收一些额外参数,然后返回一个新的视图。下表列出了标准库提供的范围适配器。如果这些标准适配器都不能满足你的需求,你也可以自己编写范围适配器,并让它与现有适配器正常互操作。不过,要写出一个真正完善、可用于生产环境的范围适配器并不简单,这超出了本书的范围。更详细的内容请参考你熟悉的标准库资料。

范围适配器说明
views::all创建一个包含某个 range 全部元素的视图。
filter_view / views::filter基于给定谓词过滤底层序列中的元素。若谓词返回 true,元素被保留;否则跳过。
transform_view / views::transform对底层序列中的每个元素调用回调,将其转换为另一个值,甚至可变成不同类型。
take_view / views::take取另一个视图的前 n 个元素,组成新的视图。
take_while_view / views::take_while从底层序列开头开始取元素,直到遇到某个让谓词返回 false 的元素为止。
drop_view / views::drop丢弃另一个视图的前 n 个元素,得到新的视图。
drop_while_view / views::drop_while丢弃底层序列起始处所有让谓词持续返回 true 的元素,直到遇到返回 false 的元素为止。
join_view / views::join把“由多个 range 组成的视图”拍平成单一视图。例如把 vector<vector<int>> 拍平成 vector<int> 的视图。
lazy_split_view / views::lazy_split / split_view / views::split给定分隔符,把某个视图按该分隔符拆成多个子区间。分隔符可以是单个元素,也可以是一个元素视图。
reverse_view / views::reverse创建一个按相反顺序遍历另一个视图元素的视图。要求底层视图至少是 bidirectional view。
elements_view / views::elements要求底层视图的元素是 tuple-like,对其提取每个 tuple-like 元素的第 n 个成员,形成视图。
keys_view / views::keys要求底层视图的元素是 pair-like,提取每个 pair 的第一个成员,形成视图。
values_view / views::values要求底层视图的元素是 pair-like,提取每个 pair 的第二个成员,形成视图。
common_view / views::common在某些 range 中,begin()end() 返回的类型可能不同,例如一个返回 begin iterator、一个返回 end sentinel。这意味着这对对象不能直接传给那些要求“二者类型相同”的函数。common_view 可把这种 range 转换成 common range,也就是 begin()end() 返回同类型的 range。本章习题中你会用到它。

C++23 新增了下列范围适配器:

范围适配器说明
as_const_view / views::as_const创建一个视图,在这个视图中不能修改底层序列的元素。
as_rvalue_view / views::as_rvalue创建一个由底层序列所有元素的 rvalue 组成的视图。
enumerate_view / views::enumerate创建一个视图,其中每个元素都同时表示底层序列中某个元素的位置和值。
zip_view / views::zip给定多个视图,创建一个由 tuple 组成的视图,其中每个 tuple 都包含各个视图对应位置元素的引用。
zip_transform_view / views::zip_transform给定多个视图与一个 callable,创建一个视图,其第 i 个元素是该 callable 作用于所有视图第 i 个元素后的结果。
adjacent_view / views::adjacent给定 n,创建一个视图,其第 i 个元素是一个 tuple,包含原始视图第 i 到第 (i + n − 1) 个元素的引用。
adjacent_transform_view / views::adjacent_transform给定 n 与一个 callable,创建一个视图,其第 i 个元素是该 callable 作用于原始视图第 i 到第 (i + n − 1) 个元素后的结果。
views::pairwise / views::pairwise_transform分别是 views::adjacent<2>views::adjacent_transform<2> 的辅助别名。
join_with_view / views::join_with给定一个分隔符,对某个视图进行扁平化,并在每个元素之间插入分隔符中的元素。分隔符可以是单个元素,也可以是一个元素视图。
stride_view / views::stride给定 n,创建一个视图:它每次前进 n 个元素,而不是逐个前进。
slide_view / views::slide给定 n,创建一个视图,其第 i 个元素本身又是一个视图,覆盖原始视图中第 i 到第 (i + n − 1) 个元素。它和 views::adjacent 类似,但 slide 的窗口大小 n 是运行时参数,而 adjacentn 是模板参数。
chunk_view / views::chunk给定 n,把原始视图的元素按顺序分成多个大小为 n、互不重叠的连续块,返回由这些子视图组成的 range。
chunk_by_view / views::chunk_by根据给定谓词,把一个视图拆成若干子区间;每当相邻两个元素让谓词返回 false 时,就在它们之间切开。
cartesian_product_view / views::cartesian_product给定若干个 range,创建一个由 tuple 组成的视图,这些 tuple 来自所有给定 range 的笛卡尔积。

上面两张表第一列中列出的名字,同时包含了位于 std::ranges 命名空间中的类名,以及位于 std::ranges::views 命名空间中的对应 range adapter object(范围适配器对象)。标准库还提供了一个命名空间别名 std::views,它等价于 std::ranges::views

每个范围适配器都可以通过调用其构造函数来构造,并传入所需参数。第一个参数永远是待处理的 range,后面还可以跟零个或多个附加参数:

std::ranges::operation_view { range, arguments… }

不过,在实际使用中,你通常不会直接调用这些适配器的构造函数,而更常见的是使用 std::ranges::views 命名空间中的 range adapter object(范围适配器对象),再配合按位或运算符 | 来形成管线:

range | std::ranges::views::operation(arguments…)

下面通过一个完整示例来看看这些范围适配器是如何工作的。示例首先定义了一个缩写函数模板 printRange(),用来输出一条消息以及给定 range 中的全部元素。接着,在 main() 函数里先创建一个整数 vector,内容为 1…10,然后依次对它应用多个范围适配器;每做一步,都调用 printRange() 输出当前结果,便于你观察每一步发生了什么。之后,这个示例还演示了若干 C++23 新增的范围适配器。示例里还会用到本章前面介绍过的 myCopy()

void printRange(string_view msg, auto&& range) { println("{}{:n}", msg, range); }
int main()
{
vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printRange("原始序列:", values);
// 过滤掉所有奇数值,只保留偶数值。
auto result1 { values
| views::filter([](const auto& value) { return value % 2 == 0; }) };
printRange("仅保留偶数:", result1);
// 把所有值都变成原来的两倍。
auto result2 { result1
| views::transform([](const auto& value) { return value * 2.0; }) };
printRange("数值翻倍后:", result2);
// Drop the first 2 elements.
auto result3 { result2 | views::drop(2) };
printRange("First two dropped: ", result3);
// Reverse the view.
auto result4 { result3 | views::reverse };
printRange("Sequence reversed: ", result4);
// C++23: views::zip
vector v1 { 1, 2 };
vector v2 { 'a', 'b', 'c' };
auto result5 { views::zip(v1, v2) };
printRange("views::zip: ", result5);
// C++23: views::adjacent
vector v3 { 1, 2, 3, 4, 5 };
auto result6 { v3 | views::adjacent<2> };
printRange("views::adjacent: ", result6);
// C++23: views::chunk
auto result7 { v3 | views::chunk(2) };
printRange("views::chunk: ", result7);
// C++23: views::stride
auto result8 { v3 | views::stride(2) };
printRange("views::stride: ", result8);
// C++23: views::enumerate + views::split
string lorem { "Lorem ipsum dolor sit amet" };
for (auto [index, word] : lorem | views::split(' ') | views::enumerate) {
print("{}:'{}' ", index, string_view { word });
}
println("");
// C++23: views::as_rvalue
vector<string> words { "Lorem", "ipsum", "dolor", "sit", "amet" };
vector<string> movedWords;
auto rvalueView { words | views::as_rvalue };
myCopy(begin(rvalueView), end(rvalueView), back_inserter(movedWords));
printRange("movedWords: ", movedWords);
// C++23: Cartesian product of vector v with itself.
vector v { 0, 1, 2 };
for (auto&&[a, b] : views::cartesian_product(v, v)) {print("({},{}) ", a, b);}
}

程序输出如下:

Original sequence: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Only even values: 2, 4, 6, 8, 10
Values doubled: 4, 8, 12, 16, 20
First two dropped: 12, 16, 20
Sequence reversed: 20, 16, 12
views::zip: (1, 'a'), (2, 'b')
views::adjacent: (1, 2), (2, 3), (3, 4), (4, 5)
views::chunk: (1, 2), (3, 4), (5)
views::stride: 1, 3, 5
0:'Lorem' 1:'ipsum' 2:'dolor' 3:'sit' 4:'amet'
movedWords: Lorem, ipsum, dolor, sit, amet
(0,0) (0,1) (0,2) (1,0) (1,1) (1,2) (2,0) (2,1) (2,2)

值得再次强调的是,视图是惰性求值的。在这个例子中,仅仅构造 result1 这个视图时,并不会真的执行过滤;真正的过滤发生在 printRange() 开始遍历 result1 元素的那一刻。

上面的代码使用的是 std::ranges::views 中的 range adapter object(范围适配器对象)。你当然也可以直接通过构造函数来创建这些范围适配器。例如,result1 也可以这样构造:

auto result1 { ranges::filter_view { values,
[](const auto& value) { return value % 2 == 0; } } };

这个例子为了方便你观察每一步发生了什么,特意创建了若干中间视图:result1result2result3result4。如果你并不需要这些中间视图,那么完全可以把所有操作串成单一管线:

vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printRange("Original sequence: ", values);
auto result { values
| views::filter([](const auto& value) { return value % 2 == 0; })
| views::transform([](const auto& value) { return value * 2.0; })
| views::drop(2)
| views::reverse };
printRange("Final sequence: ", result);

输出如下。最后一行可以看到,这个最终序列与前面得到的 result4 视图完全一致:

Original sequence: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Final sequence: 20, 16, 12

有些 range 是只读的。例如,views::transform 的结果就是只读视图,因为它生成的是“变换后的元素视图”,而不是直接改写底层 range 中的真实值。如果某个 range 不是只读的,那么你就可以通过视图来修改其中元素。下面看一个例子。

下例先构造了一个包含十个元素的 vector,然后依次构造出一个“只看偶数”的视图、一个“丢掉前两个偶数”的视图,以及一个“逆序”的视图。随后,range-based for 循环会把最终视图中的元素全部乘以 10。最后一行再输出原始 values vector 的内容,用来证明:这些元素确实是通过视图被修改了。

vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printRange("Original sequence: ", values);
// Filter out all odd values, leaving only the even values.
auto result1 { values
| views::filter([](const auto& value) { return value % 2 == 0; }) };
printRange("Only even values: ", result1);
// Drop the first 2 elements.
auto result2 { result1 | views::drop(2) };
printRange("First two dropped: ", result2);
// Reverse the view.
auto result3 { result2 | views::reverse };
printRange("Sequence reversed: ", result3);
// Modify the elements using a range-based for loop.
for (auto& value : result3) { value *= 10; }
printRange("After modifying elements through a view, vector contains:\n", values);

输出如下:

Original sequence: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Only even values: 2, 4, 6, 8, 10
First two dropped: 6, 8, 10
Sequence reversed: 10, 8, 6
After modifying elements through a view, vector contains: 1, 2, 3, 4, 5, 60, 7, 80, 9, 100

对一个 range 做 transform,并不要求结果中的元素类型必须和原来相同。你完全可以把元素 映射(map)成另一种类型。下面这个例子从一组整数开始,先过滤掉所有奇数,再保留前三个偶数,最后用 std::format() 把它们变成字符串:

vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printRange("Original sequence: ", values);
auto result { values
| views::filter([](const auto& value) { return value % 2 == 0; })
| views::take(3)
| views::transform([](const auto& v) { return format("{}", v); }) };
printRange("Result: ", result);

输出如下:

Original sequence: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Result: "2", "4", "6"

Ranges 库还提供了若干 范围工厂,用于构造“按需、惰性地产生元素”的视图:

范围工厂说明
empty_view创建一个空视图。
single_view创建一个只包含单个给定元素的视图。
iota_view创建一个无限或有界的视图:从初始值开始,后续每个元素都等于前一个元素加一。
repeat_view创建一个重复给定值的视图。它既可以是无界(无限)的,也可以被限制为固定数量的元素。
basic_istream_view / istream_view创建一个视图,其元素通过对底层输入流调用提取运算符 operator>> 得到。

和前面介绍的范围适配器一样,表中这些名字都是位于 std::ranges 命名空间中的类名,因此你可以直接通过构造函数来创建它们。另一种方式,是使用位于 std::ranges::views 命名空间中的工厂函数。例如,下面这两条语句是等价的,都会创建一个从 10 开始、无限延伸为 10、11、12…… 的视图:

std::ranges::iota_view { 10 }
std::ranges::views::iota(10)

下面看一个实际示例。这个例子与本章前面的某个示例有些相似,只不过它不再预先构造一个包含 10 个元素的 vector,而是通过 iota 范围工厂创建一个从 10 开始的惰性无限整数序列。接着,它过滤掉所有奇数,把剩余元素翻倍,最后只保留前十个元素,再通过 printRange() 输出到控制台:

// Create an infinite sequence of the numbers 10, 11, 12, …
auto values { views::iota(10) };
// Filter out all odd values, leaving only the even values.
auto result1 { values
| views::filter([](const auto& value) { return value % 2 == 0; }) };
// Transform all values to their double value.
auto result2 { result1
| views::transform([](const auto& value) { return value * 2.0; }) };
// Take only the first ten elements.
auto result3 { result2 | views::take(10) };
printRange("Result: ", result3);

输出如下:

Result: 20, 24, 28, 32, 36, 40, 44, 48, 52, 56

这里的 values 代表的是一个无限 range,随后它又被过滤和变换。之所以还能安全处理无限 range,正是因为这些操作都是惰性求值的:只有在 printRange() 真正遍历视图时才会发生。这也意味着,在这个例子里,你不能直接对 valuesresult1result2 调用 printRange(),因为那会导致 printRange() 进入无限循环——它们都是无限 range。

当然,你完全可以去掉这些中间视图,直接构造一个单一的大管线。下面的代码会得到和刚才完全一样的输出:

auto result { views::iota(10)
| views::filter([](const auto& value) { return value % 2 == 0; })
| views::transform([](const auto& value) { return value * 2.0; })
| views::take(10) };
printRange("Result: ", result);

另一个范围工厂的示例展示了如何使用 repeat_view

printRange("Repeating view: ", views::repeat(42, 5));

它会输出:

Repeating view: 42, 42, 42, 42, 42

basic_istream_view / istream_view 这个范围工厂,可以用来在输入流(例如标准输入)之上构造一个视图。元素通过 operator>> 读取。

例如,下面这段代码会持续从标准输入读取整数。对于每一个小于 5 的输入值,它会把该值乘以 2 并输出到标准输出;一旦你输入 5 或更大的整数,循环就会结束。

println("Type integers, an integer>= 5 stops the program.");
for (auto value : ranges::istream_view<int> { cin }
| views::take_while([](const auto& v) { return v < 5; })
| views::transform([](const auto& v) { return v * 2; })) {
println("> {}", value);
}
println("Terminating…");

可能的输出如下:

Type integers, an integer>= 5 stops the program.
1 2
> 2
> 4
3
> 6
4
> 8
5
Terminating…

在 C++23 之前,把一个 range 转换成容器并不容易。C++23 引入了 std::ranges::to(),让这种转换变得非常直接。由于视图本身也是 range,因此它同样可以把视图中的元素转成容器。更进一步,因为容器本身也是 range,所以你甚至可以用 ranges::to() 把一个容器转换成另一个容器,哪怕两者的元素类型也不相同。

下面这段代码展示了 ranges::to() 的几种用法。例子里还展示了 setstring 的特殊构造函数:它们可以把 std::from_range 标签作为第一个参数,并直接从给定 range 构造出 setstring。现在所有标准库容器都已经支持这样的构造函数。

// Convert a vector to a set with the same element type.
vector vec { 33, 11, 22 };
auto s1 { ranges::to<set>(vec) };
println("{:n}", s1);
// Convert a vector of integers to a set of doubles, using the pipe operator.
auto s2 { vec | ranges::to<set<double>>() };
println("{:n}", s2);
// Convert a vector of integers to a set of doubles, using from_range constructor.
set<double> s3 { from_range, vec };
println("{:n}", s3);
// Lazily generate the integers from 10 to 14, divide these by 2,
// and store the result in a vector of doubles.
auto vec2 { views::iota(10, 15)
| views::transform([](const auto& v) { return v / 2.0; })
| ranges::to<vector<double>>() };
println("{:n}", vec2);
// Use views::split() and views::transform() to create a view
// containing individual words of a string, and then convert
// the resulting view to a vector of strings containing all the words.
string lorem { "Lorem ipsum dolor sit amet" };
auto words { lorem | views::split(' ')
| views::transform([](const auto& v) { return string { from_range, v }; })
| ranges::to<vector>() };
println("{:n:?}", words);

输出如下:

11, 22, 33
11, 22, 33
11, 22, 33
5, 5.5, 6, 6.5, 7
"Lorem", "ipsum", "dolor", "sit", "amet"

C++23 还为标准库容器引入了一批新的成员函数,以加强容器与 range 之间的互操作性。这些成员函数命名模式为 xyz_range(…),其中 xyz 可能是 insertappendprependassignreplacepushpush_frontpush_back第 18 章会详细讨论各类标准库容器,但如果你想知道到底哪些容器支持哪些成员函数,还是应当查阅标准库参考资料。下面这个例子演示了 vectorappend_range()insert_range()

vector<int> vec3;
vec3.append_range(views::iota(10, 15));
println("{:n}", vec3);
vec3.insert_range(begin(vec3), views::iota(10, 15) | views::reverse);
println("{:n}", vec3);

输出如下:

10, 11, 12, 13, 14
14, 13, 12, 11, 10, 10, 11, 12, 13, 14

本章解释了 iterator 背后的思想:它是一种抽象,使你无需了解容器的具体内部结构,也能在容器元素之间导航。你已经看到,输出流迭代器可以把标准输出当作基于 iterator 的算法的目标,而输入流迭代器则能把标准输入作为算法的数据来源。本章还介绍了插入迭代器、反向迭代器和移动迭代器这些适配器,它们能够对其他 iterator 进行适配。

本章最后讨论了 Ranges 库——它是 C++ 标准库的一部分。借助它,你可以以更偏函数式的方式编写代码:你描述“想做什么”,而不是“每一步怎么做”。你可以构造由多个操作组成的管线,按顺序作用于某个 range 的元素。而这些管线都是惰性执行的;也就是说,只有当你真正开始遍历结果视图时,它们才会做实际工作。

通过完成下面这些练习,你可以巩固本章讨论过的内容。所有习题解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某个练习上卡住了,建议先回头重读本章相关部分,尽量先自己找到答案,再去查看网站上的解答。

  1. 练习 17-1: 编写一个程序,惰性构造元素序列 10-100,对每个数字求平方,移除所有能被 5 整除的数,再把剩余值通过 std::to_string() 转换成字符串。
  2. 练习 17-2: 编写一个程序,创建一个 vector,其中每个元素都是一个 pairpair 里包含本章前面介绍的 Person 类实例,以及该人的年龄。然后使用 Ranges 库构造一个单一管线,从这个 vector 的全部 pair 中提取年龄,移除所有小于 12 和大于 65 的年龄。最后,使用本章前面介绍过的 sum() 算法,计算剩余年龄的平均值。由于你要把一个 range 传给 sum(),因此你需要处理公共区间(common range)。
  3. 练习 17-3: 在练习 17-2 解法的基础上,为 Person 类补充 operator<< 的实现。

接着,构造一个管线:从 vector<pair<…>> 中提取每个 pair 里的 Person,并只保留前四个 Person。使用本章介绍过的 myCopy() 算法,把这四个人的名字输出到标准输出,每行一个。

最后,再构造一个类似管线,但这次还要把筛选后的 Person 投影为其姓氏。然后只用一条 println() 语句,把这些姓氏输出到标准输出。 4. 练习 17-4: 编写一个程序,结合 range-based for 循环和 ranges::istream_view(),从标准输入读取整数,直到输入 -1 为止。把读取到的整数存入 vector,随后再把 vector 内容输出到控制台,以确认它保存了正确的值。 5. 附加练习: 你能否把练习 17-4 的解法改成完全不显式使用循环?提示:一种办法是使用 std::ranges::copy() 算法,把某个 range 从源复制到目标。它的第一个参数可以是一个 range,第二个参数则是一个 output iterator。

  1. C++23 对 view 的定义做了轻微调整:它允许某个视图拥有自己的元素,但前提是它必须保证自己要么不可复制,要么可以在常数时间 O(1) 内完成复制。绝大多数视图仍然是非拥有的,因此本书不再专门讨论拥有型视图。