Skip to main content

Chapter 42: Ranges

C++20 Ranges 把传统 STL 中“两个 iterator 表示一个区间”的调用方式提升成“一个 range 对象表达可遍历序列”的接口层。读完本章后,读者应能判断一个对象是否能进入 ranges algorithm,能解释 iterator 与 sentinel 分离带来的终止模型,能区分 ranges algorithm 的立即执行和 view 管线的延迟执行,并能用 projection 把成员访问、键提取和比较逻辑拆开。

本章用一个订单列表贯穿说明:std::vector<Order> 是已经拥有元素的容器,std::ranges::sort 是立即修改容器的算法,std::views::filterstd::views::transform 形成延迟管线。这个例子覆盖 Ranges 的核心问题:接口表面变短之后,真正被标准库检查的是 begin/end、iterator 能力、sentinel 兼容性、callable 约束、投影结果和对象生命周期。

Ranges 进入标准库后,旧算法并未消失。传统算法仍然以 iterator pair 为主,Ranges algorithm 在 std::ranges 命名空间内提供受约束的重载和 range 重载。标准草案的 range requirements 把 range 定义为可通过 std::ranges::beginstd::ranges::end 取得边界的对象;algorithms requirements 说明 range 重载会通过 ranges::begin(r)ranges::end(r) 分派到 iterator/sentinel 形式。

本章的判断顺序是:先看对象能否提供 range 边界,再看 iterator 能力和 sentinel 关系,再看算法要求的 concept 是否满足,再看 projection 与 predicate 的调用形式,最后看管线是否延迟执行以及源对象生命周期是否覆盖迭代过程。

#include <algorithm>
#include <iostream>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

int main() {
std::vector<Order> orders{
{1, "Ada", 120, true},
{2, "Bjarne", 80, false},
{3, "Carol", 240, true},
{4, "Dennis", 60, false},
};

std::ranges::sort(orders, std::ranges::less{}, &Order::amount);

auto unpaid_amounts = orders
| std::views::filter([](const Order& order) { return !order.paid; })
| std::views::transform(&Order::amount);

for (int amount : unpaid_amounts) {
std::cout << amount << '\n';
}
}

这段代码里有三条主线。orders 提供可遍历元素,std::ranges::sort 通过 range 重载拿到边界并立即排序,&Order::amount 作为 projection 把排序键从元素对象中抽出。后面的 unpaid_amounts 只是一个 view 管线对象,循环开始迭代时才逐个访问 orders 中的元素。

42.1 range concept 与 iterator + sentinel

range 的工作定义是:给定对象 r,表达式 std::ranges::begin(r) 能返回起始位置,表达式 std::ranges::end(r) 能返回终止标记,并且这两个对象共同描述一个半开区间。这里的终止标记称为 sentinel,它的类型可以和 iterator 不同。传统 iterator pair 要求 firstlast 多数情况下同型;Ranges 把“当前位置”和“何时停止”拆成两个角色。

标准草案给出的 range concept 形状可以简化理解为下面这样。真实标准还包含语义约束、复杂度要求和转发细节,本段代码只展示最关键的接口条件。

#include <ranges>

template <class T>
concept simplified_range = requires(T& value) {
std::ranges::begin(value);
std::ranges::end(value);
};

begin 返回的对象负责访问当前元素和前进,end 返回的 sentinel 负责和 iterator 比较以判断迭代结束。容器型 range 常见形状是 iterator 与 sentinel 同型,例如 std::vector<int>begin()end() 都返回对应 iterator。生成型、流式或条件终止的 range 可以让 sentinel 只保存终止条件,例如“读到输入流结束”“遇到某个条件”“达到无界序列的外部截断点”。

这个分离改变了泛型算法的入口模型。传统算法常写成 std::sort(v.begin(), v.end()),调用者要手动拆出两个位置对象。Ranges algorithm 允许写成 std::ranges::sort(v),算法内部再调用 std::ranges::begin(v)std::ranges::end(v)。接口变短只是表面变化;更深的变化是约束位置从“模板实例化失败后报错”前移到 concept 检查。

#include <algorithm>
#include <ranges>
#include <vector>

void sort_numbers(std::vector<int>& numbers) {
std::ranges::sort(numbers);
}

这段代码成立,因为 std::vector<int> 满足 range,其 iterator 满足 random_access_iterator,元素满足可排序要求。std::ranges::sort 对 range 版本的要求高于普通“可遍历”:排序需要随机访问,需要可交换、可移动、可比较或能通过 comparator/projection 比较。range concept 只回答“能否形成区间”,具体算法还会继续检查 iterator category、元素操作和 callable 约束。

common_range 用来描述 iterator 与 sentinel 同型的 range。标准容器通常满足这个条件。sentinel 分离的价值在于保留更宽的表达能力:一个 range 可以用 iterator 访问元素,用另一个轻量对象描述结束条件;算法只需要知道 sentinel_for<S, I> 成立,即 I 类型 iterator 能和 S 类型 sentinel 判断结束。

Ranges 阅读时要把三个层次分开。第一层是 range 边界:begin/end 是否可用。第二层是 iterator 能力:输入、前向、双向、随机访问、连续等能力决定算法可用范围。第三层是算法语义:排序、查找、拷贝、比较等算法会在 range 基础上增加元素和 callable 约束。

用订单例子看,orders 是一个 owning range,因为它拥有元素存储。unpaid_amounts 是一个 view 管线,它提供新的 begin/end,但元素来自 orders。二者都可被 range-for 遍历;它们的所有权、生命周期和迭代成本不同。判断 Ranges 代码时,先确认当前表达式代表“拥有数据的容器”还是“引用或包装另一个 range 的视图”,后续的生命周期判断依赖这个分界。

42.2 std::ranges::begin / end 与 ranges algorithm

std::ranges::beginstd::ranges::end 是 customization point object,简称 CPO。CPO 的作用是统一边界获取规则:数组走内置规则,成员 begin()/end() 优先,用户类型还可以通过参数相关查找接入自由函数 begin/end。标准草案的 range access 明确列出了这些查找顺序和 ill-formed 情况。

这套入口比直接调用 r.begin() 更适合泛型代码。数组没有成员函数,部分用户类型通过自由函数提供遍历入口,临时 range 还涉及 borrowed range 规则。泛型算法统一写 std::ranges::begin(r),把这些差异集中到 CPO 层处理。

#include <iostream>
#include <ranges>

void print_first(auto&& range) {
auto it = std::ranges::begin(range);
auto last = std::ranges::end(range);

if (it != last) {
std::cout << *it << '\n';
}
}

int main() {
int values[] = {10, 20, 30};
print_first(values);
}

print_first 对数组成立,因为 std::ranges::begin(values) 会得到首元素指针,std::ranges::end(values) 会得到尾后指针。这个例子说明 Ranges 接口的第一层收益:调用者无需把数组、容器和自定义 range 拆成不同重载。

ranges algorithm 是定义在 std::ranges 命名空间中的算法函数对象。它们常提供两类入口:一类接收 iterator/sentinel,另一类接收整个 range。以 std::ranges::sort 为例,标准草案的 sorting algorithms 展示了 iterator/sentinel 重载和 range 重载,并且 range 重载返回 borrowed_iterator_t<R> 这类和生命周期相关的结果类型。

#include <algorithm>
#include <ranges>
#include <vector>

void two_entries(std::vector<int>& values) {
std::ranges::sort(values.begin(), values.end());
std::ranges::sort(values);
}

这两个调用都会排序同一段元素。第一行显式传入 iterator pair;第二行让算法自己调用 std::ranges::begin(values)std::ranges::end(values)。第二种写法减少了“begin 来自一个容器、end 来自另一个容器”的调用错误,也让约束能围绕整个 range 表达。

受约束算法的错误定位通常更靠近真实原因。传统模板算法在深层实现里尝试使用 operator<++itit + n 时才暴露错误;ranges algorithm 在签名上就表达 random_access_rangesortableindirect_unary_predicate 等要求。编译器报错仍可能很长,但失败点往往能指向某个 concept。

Ranges algorithm 与 view 管线在执行时机上有明显差异。algorithm 调用通常立即遍历并产生效果,可能修改输入 range,也可能返回结果 iterator、subrange 或布尔值。view adaptor 调用通常返回轻量对象,把工作推迟到迭代发生时。std::ranges::sort(orders, ...) 这一行执行完成后,orders 的元素顺序已经改变;orders | std::views::filter(...) 这一行只构造了一个可遍历表达式。

源码阅读时可以按固定路径观察 ranges algorithm。先找算法对象的公开调用签名,再看 range 重载如何转换为 iterator/sentinel 重载,然后看 concept 约束,再看循环体如何使用 ranges::beginranges::endstd::invokeiter_moveiter_swap。常见实现可能把这些细节拆进内部命名空间;正文结论不依赖某个实现文件名。

42.3 projection

Projection 的工作定义是:在算法把元素交给 comparator、predicate 或 value 比较之前,先对元素执行一次键提取或成员访问。它解决的是“元素对象很大,算法只关心其中某个字段”这一类问题。Projection 通常通过 std::invoke(proj, element) 执行,因此成员指针、函数对象、lambda 都能作为投影入口。

订单排序例子中,元素类型是 Order,排序键是 amount。传统写法把字段访问和比较逻辑写在同一个 lambda 里。

#include <algorithm>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

void traditional_sort(std::vector<Order>& orders) {
std::sort(orders.begin(), orders.end(), [](const Order& left, const Order& right) {
return left.amount < right.amount;
});
}

Ranges 写法把比较关系和键提取拆开。std::ranges::less{} 负责比较两个投影结果,&Order::amount 负责从 Order 中取出金额。

#include <algorithm>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

void ranges_sort(std::vector<Order>& orders) {
std::ranges::sort(orders, std::ranges::less{}, &Order::amount);
}

这段代码对应的调用模型可以写成:算法取出两个元素 ab,先计算 std::invoke(&Order::amount, a)std::invoke(&Order::amount, b),再把这两个整数交给 std::ranges::less{}。Projection 让“按哪个字段看元素”成为算法参数,比较器仍然保持通用。

Projection 也能用于查找。假设要找金额为 240 的订单,投影把每个 Order 映射成 amount,算法比较投影结果与目标值。

#include <algorithm>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

bool has_amount_240(const std::vector<Order>& orders) {
auto it = std::ranges::find(orders, 240, &Order::amount);
return it != orders.end();
}

这类写法把业务字段从算法循环中抽出,读代码时更容易定位约束:Order::amount 的结果要能和 240 比较;排序时 projection 结果要能被 comparator 形成严格弱序。标准草案在排序章节中使用 compproj 描述“按投影后的值排序”,并把复杂度表达成 comparisons 与 projections 的数量。

Projection 的边界也要明确。Projection 只是读取或转换算法看到的值,它不改变容器元素的存储布局。若 projection 返回引用并且 comparator 或 predicate 修改该引用,算法的语义约束可能被破坏。工程上应把 projection 写成无副作用的键提取:成员指针、访问器、轻量 lambda 都适合;会修改元素、依赖外部可变状态或执行昂贵 I/O 的投影会让算法行为难以复盘。

Projection 和 transform view 的位置不同。Projection 是算法调用的一部分,每次算法需要比较或判断元素时执行。std::views::transform 生成一个新 view,迭代这个 view 时得到转换后的元素视角。前者服务一次算法,后者生成可组合的 range 表达式。用判断顺序说,先问“只是这个算法要按某个字段判断吗”,优先 projection;再问“后续多个步骤都要在转换后的序列上运行吗”,再考虑 transform view 管线。

42.4 lazy evaluation

Lazy evaluation 在 Ranges 语境中指:view adaptor 构造管线时保存底层 range 和操作对象,元素访问推迟到 iterator 解引用或递增时发生。std::views::filter 保存谓词,std::views::transform 保存转换函数;构造 auto pipeline = range | filter | transform 时,通常尚未逐个处理元素。

下面的例子让执行时机变得可观察。std::views::filterstd::views::transform 里的输出语句会在循环迭代时出现,而管线构造语句本身不会处理所有元素。

#include <iostream>
#include <ranges>
#include <vector>

int main() {
std::vector<int> values{1, 2, 3, 4};

auto pipeline = values
| std::views::filter([](int value) {
std::cout << "filter " << value << '\n';
return value % 2 == 0;
})
| std::views::transform([](int value) {
std::cout << "transform " << value << '\n';
return value * 10;
});

std::cout << "before loop\n";

for (int value : pipeline) {
std::cout << "result " << value << '\n';
}
}

这段代码的关键观察是:before loop 先输出,随后迭代驱动 filter 和 transform。filter 对每个候选元素判断是否保留,transform 只处理已经通过过滤的元素。管线的执行粒度是“按需推进到下一个可见元素”,这和先生成一个中间 std::vector<int> 再整体转换的模型不同。

可以用一个简化流程图理解管线。图只描述概念路径,具体标准库实现可能使用不同的内部 iterator、sentinel、缓存和 wrapper 名称。

管线对象本身保存的是“如何访问”的结构。循环请求下一个元素时,filter 的 iterator 会推进底层 iterator,直到谓词返回 true 或到达 sentinel;transform 的 iterator 在解引用时调用转换函数。这个路径说明 lazy evaluation 的性能收益和风险来自同一处:它减少中间容器分配,也把源 range 的生命周期、谓词状态和迭代顺序绑定到最终遍历时刻。

Lazy evaluation 的第一个工程边界是生命周期。若 view 持有对外部容器的引用,容器必须活到 view 迭代完成。下面的写法把 view 返回给调用者,但底层 values 是局部对象,函数返回后 view 中的引用失效;这种代码在工程上应改成返回拥有数据的对象、在调用者侧传入 range,或使用明确的 owning view 方案。

#include <ranges>
#include <vector>

// 简化示例:展示生命周期风险,工程代码应重新设计所有权。
auto dangling_pipeline() {
std::vector<int> values{1, 2, 3, 4};
return values | std::views::filter([](int value) { return value % 2 == 0; });
}

Lazy evaluation 的第二个工程边界是重复遍历。某些 view 包装的是单次输入 range,例如输入流;重复调用 begin 或重复遍历可能消耗同一数据源。标准对不同 range refinement 有不同语义要求,例如 forward_range 支持多趟遍历,input_range 只承诺输入式推进。判断 view 管线能否复用,要先看底层 range 的 iterator category,再看每个 adaptor 是否保留了对应能力。

Lazy evaluation 的第三个工程边界是副作用。谓词和转换函数若打印日志、修改外部变量或依赖时间,每次迭代都会触发这些行为;重复遍历可能重复触发。稳定写法是让 filter 谓词表达纯判断,让 transform 表达纯转换,把副作用放在最终消费端。这样读代码时能沿 source → view pipeline → algorithm/loop 追踪数据流。

42.5 与传统 algorithm 对比

传统 STL algorithm 的核心接口是 iterator pair,Ranges algorithm 的核心接口是 range 或 iterator/sentinel。二者共享“算法独立于容器”的目标,但 Ranges 把区间边界、能力约束、projection、返回类型和 view 组合纳入标准接口层。对工程代码来说,差异主要体现在调用安全、错误定位、组合方式和生命周期判断上。

同一个排序需求可以写成两种风格。传统写法暴露边界对象,Ranges 写法暴露完整 range 和 projection。

#include <algorithm>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

void compare_styles(std::vector<Order>& orders) {
std::sort(orders.begin(), orders.end(), [](const Order& left, const Order& right) {
return left.amount < right.amount;
});

std::ranges::sort(orders, std::ranges::less{}, &Order::amount);
}

传统写法的优势是兼容面广,C++11 以来的工程都能使用,旧代码、旧编译器和大量第三方接口仍围绕 iterator pair 展开。Ranges 写法的优势是把“区间属于同一个对象”的意图放进调用形式,把 comparator 和 projection 分离,并用 concept 把算法要求前置。

对比应使用同一组维度。

  • 接口边界:传统 algorithm 接收 first, last,调用者负责保证二者形成有效半开区间;ranges algorithm 可以接收整个 range,由算法统一取边界。
  • 终止模型:传统 iterator pair 常见同型边界;Ranges 支持 iterator + sentinel,同步覆盖同型容器、条件终止序列和部分生成式序列。
  • 类型约束:传统算法依赖模板参数命名和实现内使用暴露错误;Ranges algorithm 用 concept 表达 iterator、range、predicate、projection 和 sortable 等要求。
  • 键提取:传统写法常把键提取塞进 comparator;Ranges algorithm 常把键提取写成 projection,让比较器保持通用。
  • 组合方式:传统算法通常一次调用完成一次遍历;Ranges 可以用 view adaptor 构造管线,再把管线交给算法或 range-for。
  • 执行时机:ranges algorithm 通常立即执行;view adaptor 通常延迟到迭代时执行。
  • 生命周期风险:传统 iterator 也可能悬垂;view 管线更容易把源对象引用保存到表达式中,所以要检查源 range 的所有权和存活时间。

这组维度能防止把 Ranges 简化成“写法更短”。例如 std::ranges::sort(orders, ..., &Order::amount) 的关键收益不是少写 begin/end,而是把排序键变成 projection,把随机访问和 sortable 要求放进签名,把返回类型和 borrowed range 规则连接起来。又如 orders | views::filter(...) 的关键收益不是语法像管道,而是产生了一个可组合、延迟求值的 range 表达式。

迁移旧代码时,判断顺序可以固定为五步。第一,确认当前函数是否能使用 C++20 标准库和目标编译器的 ranges 支持。第二,确认输入对象是否满足 range,临时对象是否涉及 borrowed range。第三,确认目标算法的 iterator 能力要求,例如 sort 需要随机访问,find 只需要输入范围。第四,把成员访问或键提取改成 projection,把真正的比较关系保留在 comparator。第五,若引入 view 管线,明确最终消费点和源对象生命周期。

下面的查找例子展示迁移后的判断结果。std::ranges::find 只需要可输入 range,projection 提取 id,返回 iterator 指向原容器元素。

#include <algorithm>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

Order* find_order(std::vector<Order>& orders, int id) {
auto it = std::ranges::find(orders, id, &Order::id);
if (it == orders.end()) {
return nullptr;
}
return &*it;
}

这个函数没有创建中间容器,没有改变 orders 的所有权。返回裸指针的有效性仍然受 orders 后续修改影响:若调用者之后让 vector 扩容、删除元素或销毁容器,指针会失效。Ranges 改善了算法调用表面和约束表达,容器的 iterator 失效规则仍按容器本身成立。

本章最终建立的判断是:Ranges 不是一个单独 API 集合,而是一层把 range 边界、sentinel、concept 约束、projection 和 view 管线组合起来的 STL 接口模型。写 Ranges 代码时,语法表面要让位于对象关系判断:谁拥有元素,谁只是视图,何时执行,算法需要什么 iterator 能力,callable 作用在哪个值上。

最小自检任务

阅读下面代码,判断三件事:sorted 构造后是否已经完成排序,std::ranges::sort 的 projection 作用在哪个对象上,函数返回后 result 是否可以安全迭代。要求按 range 边界、执行时机、生命周期三个维度回答。

#include <algorithm>
#include <ranges>
#include <string>
#include <vector>

struct Order {
int id;
std::string customer;
int amount;
bool paid;
};

auto make_result() {
std::vector<Order> orders{
{1, "Ada", 120, true},
{2, "Bjarne", 80, false},
{3, "Carol", 240, true},
};

std::ranges::sort(orders, std::ranges::less{}, &Order::amount);

auto result = orders
| std::views::filter([](const Order& order) { return order.paid; })
| std::views::transform(&Order::customer);

return result;
}

答案要点

std::ranges::sort 调用结束后,orders 已经按 amount 完成原地排序。它是 ranges algorithm,接收完整 range 后内部取得 begin/end,并立即执行排序;&Order::amount 是 projection,算法比较的是每个 Order 投影后的金额值。

result 是 view 管线对象。构造 result 时保存过滤谓词、转换入口和对底层 range 的访问关系,筛选与转换在迭代时发生。std::views::filter 迭代时检查 paidstd::views::transform 解引用时取得 customer

函数返回后,orders 作为局部变量已经销毁。若返回的 view 持有对 orders 的引用,迭代 result 会访问失效对象。稳定设计应让调用者传入存活期足够的 range,在函数内直接消费管线,或返回一个拥有结果数据的容器。

本章知识点总结

  • range 边界:range 的最低入口是 std::ranges::begin(r)std::ranges::end(r) 能共同描述一个可遍历半开区间。
  • sentinel 分离:sentinel 负责表达终止条件,它可以和 iterator 不同型,只要二者满足可比较的终止关系。
  • 能力分层:range concept 只说明区间可形成,具体算法还会要求输入、前向、双向、随机访问或连续等 iterator 能力。
  • CPO 入口std::ranges::begin/end 统一数组、成员函数和自由函数边界获取规则,适合泛型代码使用。
  • 算法重载:ranges algorithm 常同时提供 iterator/sentinel 重载和 range 重载,range 重载会把完整对象转换成边界对象。
  • 约束前置:Ranges 用 concept 表达算法要求,让 iterator 能力、predicate、projection 和元素操作更早进入类型检查。
  • projection 位置:projection 在算法比较或判断前作用于元素,用来提取成员、键或视图值。
  • 比较拆分:projection 负责取键,comparator 负责比较键,二者拆开后算法调用更接近业务意图。
  • 立即算法std::ranges::sort 这类 algorithm 调用会立即遍历并可能修改输入 range。
  • 延迟视图:view adaptor 构造管线时保存访问结构,元素处理推迟到迭代或解引用时发生。
  • 生命周期边界:view 管线可能引用底层 range,源对象必须覆盖管线的迭代周期。
  • 重复遍历:管线能否多次遍历取决于底层 range 和各层 adaptor 保留的 iterator 能力。
  • 副作用位置:filter 谓词和 transform 函数会在迭代时触发,副作用应放在最终消费端便于复盘。
  • 迁移顺序:迁移旧算法时先检查标准版本,再看 range 与 iterator 能力,再处理 projection,最后确认管线生命周期。