范围(Range):STL 的进阶
一、简介
C++ 标准模板库 (STL) 是一个非常棒的工具,可以使代码更正确、更具表现力。它主要由两部分组成:
- 容器: 例如
std::vector
或std::map
。 - 算法: 一组相当庞大的通用函数,它们可以在容器上操作。这些算法主要位于
algorithm
头文件中。
许多使用 for
循环对容器进行的手动操作都可以用调用 STL 算法来代替。这样做可以使代码更清晰,因为无需费力解析复杂的 for
循环,就能立即理解代码的含义,因为那些令人头疼的 for
循环已经被明确的名称所取代,例如 std::copy
、std::partition
或 std::rotate
。
但是,STL 仍然存在一些可以改进的地方。这篇文章将重点关注其中的两个方面:
-
迭代器: 所有算法都操作指向它们所操作的集合的迭代器。虽然这在某些特定情况下很有用,比如在容器中的某个特定点停止,但大多数情况下,需要遍历整个容器,从它的
.begin()
到.end()
。std::copy(v1.begin(), v1.end(), std::back_inserter(v2)); std::set_difference(v2.begin(), v2.end(), v3.begin(), v3.end(), std::back_inserter(v4)); std::transform(v3.begin(), v3.end(), std::back_inserter(v4));
(注意:上面使用的
std::back_inserter
是一个输出迭代器,它每次被赋值时都会在它所传递的容器中执行push_back
操作。这免去了程序员对输出容器进行大小调整的麻烦。) -
算法组合性: 使用 STL 的 C++ 开发人员经常需要对满足某个谓词的集合元素应用某个函数。对集合
input
中的所有元素应用函数f
并将结果放入向量output
中,可以使用std::transform
实现:std::transform(input.begin(), input.end(), std::back_inserter(output), f);
而根据谓词
p
过滤元素可以使用std::copy_if
实现:std::copy_if(input.begin(), input.end(), std::back_inserter(output), p);
但是,没有简单的方法可以将这两个调用组合起来,而且没有像“
transform_if
”这样的算法。
范围 提供了一种不同的 STL 使用方法,可以以非常优雅的方式解决这两个问题。范围最初是在 Boost 中引入的,现在正在走向标准化。
二、范围的概念
这一切的核心是 范围 的概念。本质上,范围是可以遍历的东西。更准确地说,范围是具有 begin()
和 end()
方法的东西,这些方法返回对象(迭代器),这些对象允许你遍历范围(即,沿着范围的元素移动,并解引用以访问这些元素)。
用伪代码表示,范围应该符合以下接口:
Range
{
begin()
end()
}
这代表着所有 STL 容器本身都是范围。
在范围概念被定义之前,使用 STL 的代码已经以某种方式使用了范围,但很笨拙。正如这篇文章开头所见,它们是直接用两个迭代器(通常是 begin
和 end
)来操作的。然而,使用范围,通常不会看到迭代器。它们仍然存在,但被范围的概念抽象掉了。
这一点很重要。迭代器是技术性的构造,允许遍历集合,但它们通常对功能代码来说过于技术性。大多数情况下,真正想表示的是范围,它更符合代码的抽象级别。就像现金流范围、屏幕上的行范围或来自数据库的条目范围。
因此,以范围为单位进行编码是一个巨大的进步,因为从这个意义上说,迭代器违反了尊重抽象级别的原则,这是设计良好代码最重要的原则。
在范围库中,STL 算法被重新定义为直接接受范围作为参数,而不是两个迭代器,例如:
ranges::transform(input, std::back_inserter(output), f);
与之相对的是:
std::transform(input.begin(), input.end(), std::back_inserter(output), f);
这些算法在实现中重用了 STL 版本,通过将范围的 begin
和 end
传递给原生 STL 版本。
三、智能迭代器
尽管范围将它们抽象掉了,但范围遍历仍然是通过迭代器实现的。范围的全部威力来自于它与 智能迭代器 的结合。一般来说,集合的迭代器有两个职责:
- 沿着集合的元素移动(
++
、--
等) - 访问集合的元素(
*
、->
)
例如,向量迭代器就是这样做的。但是,起源于 boost 的“智能”迭代器会自定义其中一个或两个行为。例如:
transform_iterator
使用另一个迭代器it
和一个函数(或函数对象)f
构造,并自定义它访问元素的方式:当解引用时,transform_iterator
将f
应用于*it
并返回结果。filter_iterator
使用另一个迭代器it
和一个谓词p
构造。它自定义了它的移动方式:当filter_iterator
前进一个位置(++
)时,它会前进其底层迭代器it
,直到它到达满足谓词的元素或集合的末尾。
四、结合范围和智能迭代器:范围适配器
范围的全部威力来自于它们与智能迭代器的结合。这是通过 范围适配器 实现的。
范围适配器是一个可以与范围结合以生成新范围的对象。它们的一部分是 视图适配器:使用它们,初始的适配范围保持不变,而生成的范围不包含元素,因为它只是对初始范围的视图,但具有自定义的迭代行为。
为了说明这一点,以 view::transform
适配器为例。这个适配器用一个函数初始化,可以与范围结合以生成一个对它的视图,该视图具有在该范围内使用 transform_iterator
的迭代行为。范围适配器可以使用 |
运算符与范围结合,这使得它们具有优雅的语法。
对于以下数字集合:
std::vector numbers = { 1, 2, 3, 4, 5 };
范围
auto range = numbers | view::transform(multiplyBy2);
是对向量 numbers
的一个视图,它具有使用函数 multiplyBy2
的 transform_iterator
的迭代行为。因此,当遍历这个视图时,得到的结果都是这些数字,乘以 2。例如:
ranges::accumulate(numbers | view::transform(multiplyBy2), 0);
返回 1*2 + 2*2 + 3*2 + 4*2 + 5*2 = 30
(类似于 std::accumulate
,ranges::accumulate
对它所传递的范围的元素进行求和)。
还有很多其他的范围适配器。例如,view::filter
接受一个谓词,可以与范围结合以构建一个对它的视图,该视图具有 filter_iterator
的行为:
ranges::accumulate(numbers | view::filter(isEven), 0);
返回 2 + 4 = 6
。
需要注意的是,由与范围适配器关联而产生的范围,尽管它们只是对它们所适配的范围的视图,并且实际上不存储元素,但它们符合范围接口(begin
、end
),因此它们本身就是范围。因此,适配器可以适配适配范围,并且可以有效地以以下方式组合:
ranges::accumulate(numbers | view::filter(isEven) | view::transform(multiplyBy2), 0);
返回 2*2 + 4*2 = 12
。这为最初无法组合算法的问题提供了解决方案。
五、总结
范围提高了使用 STL 的代码的抽象级别,因此清除了使用 STL 的代码中多余的迭代器。范围适配器是一个非常强大且具有表现力的工具,可以以模块化的方式对集合的元素应用操作。
范围是 STL 的未来。要了解更多信息,可以查看 Boost 中的初始范围库。