Skip to main content

Chapter 43: Views

C++20 ranges 把区间操作从“立即执行一次算法”扩展成“构造一个可遍历的变换对象”。本章讨论的 view,就是这种变换对象的核心形态:它通常保存一个底层 range、若干 callable 对象和少量状态,在真正遍历时才读取元素、判断元素、转换元素或生成元素。

读完本章后,读者应能定位一个 view pipeline 中每一层保存了什么对象,判断求值发生在构造阶段还是遍历阶段,区分 filter_viewtransform_viewtake_viewdrop_viewiota_view 的状态模型,并排查 view 引入的生命周期、缓存和迭代器失效问题。

贯穿本章的材料是一条日志处理管线:从一组整数事件编号中筛出偶数,映射成权重,再截取前几项。这个例子看上去像链式调用,运行时却是多层 view 包装后的迭代器协作。标准接口以 C++20 <ranges> 为边界;std::ranges::viewstd::ranges::filter_viewstd::ranges::iota_view 等接口可在 cppreference 的 ranges view 页面 复查名称和版本信息。

下面的代码先给出贯穿材料。它没有把中间结果放进新的容器,pipeline 只是保存了一组可延迟遍历的范围变换。

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

int main() {
std::vector<int> events{1, 2, 3, 4, 5, 6, 7, 8};

auto is_even = [](int value) { return value % 2 == 0; };
auto weight = [](int value) { return value * 10; };

auto pipeline = events
| std::views::filter(is_even)
| std::views::transform(weight)
| std::views::take(3);

for (int value : pipeline) {
std::cout << value << ' ';
}
}

这段程序输出 20 40 60。构造 pipeline 时,events 仍然是原始容器;过滤、映射和截断在 for 循环拉取元素时发生。这个事实决定了本章后续的所有工程判断:view 的成本主要分布在迭代过程,生命周期责任通常仍由底层 range 和被保存的 callable 承担。

43.1 view 与 lazy pipeline

view 是满足 range 接口并适合进入管线组合的轻量范围对象。工作定义可以压缩成三个条件:它能提供 begin / end,对象自身可以移动,复制、移动、析构的复杂度适合在 adaptor 之间传递。C++20 的 std::ranges::view concept 在类型层面表达这组要求;语义层面还要求 view 的传递成本和销毁成本受控,使 adaptor 组合保持可预测复杂度。

“轻量”在这里指 view 对象本身轻量,底层数据可能很大。std::vector<int> 复制时会复制全部元素,所以普通 vector 通常以底层 range 的身份被引用或被包装;std::span<int>std::string_viewstd::ranges::subrange 这类对象自身只保存指针、长度或迭代器,天然更接近 view 的工程模型。C++20 之后也存在 owning view 形态,它可以持有一个范围对象,但要求 view 对象在管线中传递时仍符合 view 的复杂度语义。

lazy pipeline 的核心是“构造时保存规则,遍历时执行规则”。在贯穿材料中,std::views::filter(is_even) 保存谓词,std::views::transform(weight) 保存映射函数,std::views::take(3) 保存截断计数。真正读取元素时,迭代器从最外层向内请求下一个值,内层 view 再向它的底层 view 或底层容器请求元素。

下面的 Mermaid 图只描述对象关系和请求方向,省略具体模板类型名。它强调一件事:pipeline 的每层都保存自己的局部状态,遍历时请求沿管线向底层传递,结果再向外层返回。

这张图对应的工程结论是:看一条 view 管线时,应先确认最底层 range 的所有权和生命周期,再沿管线向外检查每个 adaptor 保存的 callable、计数、缓存和迭代器能力。任何一层保存的引用失效,整条管线都会在遍历阶段暴露问题。

view pipeline 和传统 algorithm 的差异也来自这里。std::ranges::copy(pipeline, out) 或 range-for 会驱动 pipeline;构造 pipeline 本身不会扫描 events。这带来两个后果:一是构造成本低,多个 view 可以组合;二是副作用、异常、悬垂引用和迭代器失效通常在遍历时出现,排查时要把问题定位到“哪一次 ++it*it 触发了规则”。

一个可复用判断顺序是:先看底层 range 是否仍存活,再看 view 是否保存 callable 或引用,再看迭代器 category 是否被保留,再看 adaptor 是否引入缓存或计数状态,最后看整个 pipeline 被消费几次。这个顺序比按 API 名称逐个记忆更稳定,因为大多数 view 问题都能映射到这五个检查点。

43.2 filter_view 与 transform_view

filter_viewtransform_view 是最常见的两类 range adaptor。前者改变“哪些元素可见”,后者改变“元素被观察成什么值”。它们都包装底层 range,并把用户提供的 callable 保存到 view 对象中;差异在于迭代器推进和解引用时触发的动作不同。

filter_view 的谓词在寻找下一个可见元素时执行。迭代器递增时,它会从当前底层位置继续前进,直到遇到满足谓词的元素或到达终止位置。对于 forward range,常见实现会在 begin() 中缓存第一个满足谓词的位置,以满足 range 对 begin() 的摊还常数复杂度要求;这个缓存属于 view 对象状态,和底层 range 的修改边界直接相关。cppreference 的 filter_view 页面列出了 base_pred_ 和可选 begin_ 缓存这类 exposition-only 成员,可作为源码阅读前的结构提示。

transform_view 的 callable 在解引用时执行。迭代器推进通常只推进底层迭代器,operator* 再把当前元素交给映射函数。返回值可以是值,也可以是引用,取决于映射函数的返回类型。这个差异影响后续算法能否写入、能否多次读取同一个对象,以及临时对象是否会在表达式结束时销毁。

下面的代码把两种触发点显式打印出来。构造 pipeline 时没有输出;range-for 开始后,过滤谓词在查找候选元素时运行,映射函数在元素被解引用时运行。

#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 * value;
});

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

这个例子说明两个判断点。第一,filter_view 的成本受谓词命中率影响;为了取出一个可见元素,底层迭代器可能跳过多个元素。第二,transform_view 的结果没有自动缓存;同一个迭代器位置被多次解引用时,映射函数可能被多次调用。映射函数带有副作用时,程序行为会依赖消费方式。

filter_view 通常保留底层 range 的若干能力,但过滤动作会削弱随机访问语义。底层 vector 支持 it + n,过滤后的第 n 个可见元素需要扫描才能确定,所以 filter_view 只适合按输入、前向或双向方式理解。transform_view 在映射函数不破坏引用语义时更容易保留底层迭代能力;但返回纯值时,后续代码拿到的是变换结果,写回底层元素的语义会消失。

工程上判断这两类 view 时,应把 callable 当成 view 对象状态看待。lambda 捕获了引用,就意味着 view 保存了一个引用关系;lambda 持有大对象,就意味着 view 拷贝或移动时会携带该对象。标准库会通过 wrapper 保存 callable,但 wrapper 只处理类型和对象存放问题,外部对象生命周期仍由调用方保证。

43.3 take_view、drop_view 与 iota_view

take_viewdrop_viewiota_view 代表三种不同的状态模型:截断、跳过和生成。它们都能进入同一条管线,但保存的信息和迭代成本不同。掌握这三类模型后,读者可以从“这个 view 保存了什么状态”直接推导复杂度和生命周期边界。

take_view 保存一个最大元素数量。遍历时,它在底层 end 之前额外加入计数终止条件;底层元素少于计数时,以底层 end 为终点。它的关键状态是剩余数量或终止距离,目标是限制外层可见范围。对无限 range 来说,take_view 经常承担终止边界角色。

drop_view 保存一个跳过数量。第一次获取 begin 时,它需要把底层迭代器推进到跳过后的起点;对于 random access range,这个推进可以通过偏移完成;对于普通 input / forward range,它可能需要逐步递增。常见实现会在合适条件下缓存跳过后的 begin,以免多次 begin 重复推进。这个缓存同样会把底层 range 的修改风险传递到 view 对象。

iota_view 是生成型 view。它保存当前值和可选边界,通过递增值产生序列。它可以表达有限序列,也可以表达无界序列;无界序列必须由 take_view、谓词终止或外部消费逻辑限制,否则遍历不会自然结束。cppreference 的 iota_view 页面把它归入 range factory,这和 filter_viewtransform_view 这类包装已有 range 的 adaptor 形成了清晰差异。

下面的代码把三个 view 放到同一个例子里。iota 生成 0 到 19,drop 跳过前 5 个,take 限制后续 4 个元素,transform 再把可见值映射成业务权重。

#include <iostream>
#include <ranges>

int main() {
auto ids = std::views::iota(0, 20)
| std::views::drop(5)
| std::views::take(4)
| std::views::transform([](int value) { return value * 100; });

for (int id : ids) {
std::cout << id << ' ';
}
}

输出为 500 600 700 800。这个结果来自状态叠加:iota_view 给出原始序列,drop_view 改变起点,take_view 改变终点,transform_view 改变观察值。每一层都没有创建中间容器。

三者的复杂度判断应分开看。take_view 的单步迭代通常只附加计数或终止判断;drop_view 的初始 begin 成本取决于底层迭代器能力;iota_view 的存储成本和序列长度无关,但值类型的递增成本会影响单步迭代。把无界 iota_view 交给需要完整遍历的算法,会让算法持续请求新元素,程序表现为长时间运行或无法结束。

这三个 view 也能解释“size 信息如何传播”。std::views::iota(0, 20) 可以知道长度;drop(5) 之后长度最多减少 5;take(4) 之后长度上限变成 4。底层 range 满足 sized_range 时,部分 view 可以继续提供 size;底层只有 input 迭代能力时,size 往往需要遍历才能得到,因此接口可用性会下降。

43.4 ranges::views 与 view pipeline

std::views 命名空间提供的是 range adaptor object 和 range adaptor closure 的入口。std::views::filter(r, pred) 可以直接接收 range;std::views::filter(pred) 可以先生成一个 closure,再通过管道运算符和左侧 range 组合。管线写法本质上会形成嵌套的 view 对象,但源码层面由库实现隐藏了复杂模板类型。

下面两种写法表达同一类结构。第一种从内向外嵌套,第二种按数据流方向书写。

#include <ranges>
#include <vector>

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

auto nested = std::views::take(
std::views::transform(
std::views::filter(values, [](int v) { return v % 2 == 0; }),
[](int v) { return v * 10; }),
2);

auto piped = values
| std::views::filter([](int v) { return v % 2 == 0; })
| std::views::transform([](int v) { return v * 10; })
| std::views::take(2);

(void)nested;
(void)piped;
}

工程阅读更适合采用管线方向,源码阅读更容易看到嵌套模板。常见实现会把左侧 range 统一包装成一个 view 类型:lvalue range 常被包装成引用 view,已有 view 可直接移动或复制进下一层,合适的右值 range 可能被 owning view 接管。这个转换通常经由 views::all 一类机制完成,目的在于让 adaptor 内部只面对 view。

管线的类型会迅速变长。上例中 piped 的真实类型大致可以理解为 take_view<transform_view<filter_view<ref_view<vector>, Pred>, Func>> 这类嵌套结构。教学中写成这种形状只是为了说明对象关系;实际代码通常使用 auto、模板参数或 range concept 接收 pipeline。把完整类型暴露到接口签名里,会让接口和具体组合细节紧耦合。

view pipeline 的参数传递需要关注三类对象。第一类是底层 range,它决定元素所有权、迭代器 category 和失效规则。第二类是 callable,它决定过滤、映射、比较或投影逻辑,并携带捕获状态。第三类是 adaptor 的局部状态,例如 take 的计数、drop 的偏移和 filter 可能存在的缓存。排查管线问题时,把这三类对象分别列出,比观察最终输出更可靠。

管线消费也会反向影响设计。一次性消费的 input range 经过 view 组合后,第二次遍历可能得不到相同结果;forward range 支持多次遍历,但 view 内部缓存和底层修改仍要一起检查。把 view 作为函数返回值时,接口设计者应明确返回的是惰性规则,调用者拿到对象后再遍历才会真正访问底层数据。

如果要把 pipeline 物化成容器,C++20 通常需要显式循环或 std::ranges::copy;C++23 引入了 std::ranges::to,可以把范围转换为容器。物化会改变所有权:结果容器拥有元素拷贝或移动后的状态,后续不再依赖原始 range;代价是立即遍历和分配目标存储。

43.5 view 生命周期问题

view 的生命周期问题来自一个事实:view 常常保存对底层 range 或外部对象的引用,并把访问推迟到遍历阶段。构造时看上去安全的表达式,可能在返回函数、跨作用域保存、底层容器修改或 callable 捕获失效后变成悬垂访问。排查 view 生命周期时,要同时检查底层 range、callable 捕获、内部缓存和迭代器失效。

下面的函数展示了底层 range 悬垂。local 是函数局部 vector,返回的 view 保存了指向 local 的引用关系。函数返回后,调用者遍历返回值时会访问已经销毁的 vector。

#include <ranges>
#include <vector>

auto bad_view_from_local() {
std::vector<int> local{1, 2, 3, 4};
return local | std::views::filter([](int value) { return value % 2 == 0; });
}

这个问题的稳定修复方向是让返回对象拥有数据,或让数据生命周期覆盖 view 的消费过程。可以返回容器本身,也可以在调用者侧传入长期存活的 range,再由函数返回基于该 range 的 view。选择哪种方式取决于接口语义:返回容器表示结果已物化,返回 view 表示结果仍是延迟规则。

callable 捕获也会悬垂。下面的 lambda 把局部变量 threshold 按引用捕获,返回的 view 中保存了这条引用。函数结束后,谓词对象仍存在,但它内部引用的对象已经结束生命周期。

#include <ranges>
#include <vector>

auto bad_predicate_capture(std::vector<int>& values) {
int threshold = 10;
return values | std::views::filter([&threshold](int value) {
return value > threshold;
});
}

修复方式是按值捕获 threshold,或把阈值作为长期存活的配置对象传入。这里的关键判断是:view 保存 callable,callable 保存捕获状态;遍历时才会调用谓词。只检查底层 vector 的生命周期还不够,必须继续检查 callable 内部保存的引用。

缓存状态是第三类风险。filter_view 为 forward range 缓存第一个满足谓词的位置,drop_view 在某些条件下可能缓存跳过后的起点。底层容器发生插入、删除、扩容或元素值改变后,缓存的迭代器或缓存对应的谓词结果可能失去语义基础。对 vector 这类连续容器,扩容会使既有迭代器、引用和指针失效;保存这些位置的 view 也随之失去安全遍历前提。

下面的代码展示了 view 和底层容器修改之间的边界。pipeline 基于 values 构造后,后续 push_back 可能触发 vector 扩容;扩容后,之前从 view 内部保存或派生的迭代器位置就没有可用保证。

#include <ranges>
#include <vector>

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

auto pipeline = values | std::views::filter([](int value) {
return value % 2 == 0;
});

auto first = pipeline.begin();

values.push_back(6);

// first 的可用性取决于底层 vector 是否让相关迭代器保持有效。
(void)first;
}

view 生命周期的工程检查顺序可以固定为五步。先确认底层 range 是否拥有数据,以及数据是否覆盖消费期;再确认 adaptor 通过 views::all 形成的是引用包装还是 owning 包装;随后检查 callable 捕获是否引用了短生命周期对象;接着检查 view 是否可能缓存底层迭代器;最后检查底层容器在 view 构造后是否发生会导致迭代器失效的修改。

这个顺序也能解释 borrowed range 的位置。borrowed_range 解决的是从临时 range 返回迭代器时如何表达悬垂风险;view pipeline 的生命周期问题更广,包含底层 range、callable 捕获和缓存状态。看到 std::ranges::dangling 或 borrowed 相关类型时,应把它作为迭代器返回值安全性的信号,再继续检查 view 对象自身保存的关系。

本章最终建立的判断是:view 是把范围变换保存为对象的机制,lazy pipeline 是这些对象的组合与消费方式。写工程代码时,先把 pipeline 拆成“底层 range、callable、局部状态、缓存、消费时机”五类对象,再判断复杂度、生命周期和迭代器有效性。

最小自检任务

阅读下面的代码,判断 make_pipeline 返回的对象在调用者遍历时有哪些风险,并给出稳定修改方向。要求按底层 range、callable 捕获、延迟求值、缓存和容器修改五个维度回答。

#include <ranges>
#include <vector>

auto make_pipeline() {
std::vector<int> values{1, 2, 3, 4, 5, 6};
int limit = 3;

auto pipeline = values
| std::views::filter([&limit](int value) { return value > limit; })
| std::views::transform([](int value) { return value * 10; })
| std::views::take(2);

values.push_back(7);

return pipeline;
}

答案要点

返回对象存在两个核心悬垂风险。第一,values 是局部 vector,pipeline 基于这个 lvalue range 构造,返回后 view 保存的底层引用关系指向已经销毁的对象。第二,谓词 lambda 按引用捕获 limit,返回后 callable 内部保存的引用也失效。由于 view 是延迟求值对象,这些问题通常在调用者遍历返回值时触发。

values.push_back(7) 还引入了迭代器失效检查点。即使 values 的生命周期足够长,vector 扩容也可能使此前由 view 派生或缓存的迭代器失效。filter_view 可能缓存第一个满足谓词的位置,take_view 保存计数状态,transform_view 在解引用时执行映射函数;这些状态本身不会修复底层容器失效问题。

稳定修改方向有两类。第一类是物化结果:在函数内遍历 pipeline,把结果写入 std::vector<int> 后返回容器。第二类是调整接口所有权:让调用者传入生命周期足够长的 range,并让 limit 按值捕获或作为长期配置对象保存。选择返回 view 时,函数签名应表达返回对象依赖输入 range 的生命周期。

本章知识点总结

  • view 条件:view 是满足 range 接口、可移动并适合低成本传递的范围对象。
  • 延迟求值:view 构造阶段保存规则,遍历阶段才读取、过滤、映射或生成元素。
  • 底层 range:管线安全性首先取决于底层 range 的所有权、生命周期和迭代器规则。
  • callable 状态:谓词和映射函数会被 view 保存,捕获引用会把外部生命周期引入管线。
  • filter 推进filter_view 在迭代器推进时查找满足谓词的下一个元素。
  • transform 解引用transform_view 通常在解引用时调用映射函数并产生观察结果。
  • take 状态take_view 通过计数或终止边界限制外层可见元素数量。
  • drop 起点drop_view 通过推进或偏移改变初始位置,成本取决于底层迭代器能力。
  • iota 生成iota_view 保存当前值和可选边界,通过递增生成序列。
  • 管线类型:view pipeline 通常形成嵌套 view 类型,工程接口应优先用 auto 或 range 约束承接。
  • 缓存边界:部分 view 会缓存底层迭代器,底层容器修改后必须重新检查缓存有效性。
  • 判断顺序:分析 view 时按底层 range、callable、局部状态、缓存、消费时机依次检查。