C++20 的 Ranges 库把我想对数据做什么和具体怎么走迭代器这两层逻辑,拆开了、理清了,还顺手把管道操作符塞了进来,让我们的代码更简洁,终于不用再和代码干架了。
认识 Ranges 库
1. 为什么需要 Ranges?
还记得刚接触 std::sort 的感觉吗?那叫一个通用,直到我们写出这玩意:
std::vector<int> v = {1, 2, 3, 4, 5, 6};
// 想得到所有偶数,平方,取前三个
std::vector<int> tmp1;
std::copy_if(v.begin(), v.end(), std::back_inserter(tmp1), [](int i){ return i%2==0; });
std::vector<int> tmp2;
std::transform(tmp1.begin(), tmp1.end(), std::back_inserter(tmp2), [](int i){ return i*i; });
if (tmp2.size() > 3) tmp2.resize(3);
这代码恶心在哪儿?我们脑子里很清楚要干什么:过滤、变换、截断。但写出来全是战术性内存分配、迭代器对、中间容器,像在帮编译器捡垃圾。
更操蛋的是,我们把先过滤再变换写成“先 copy_if 再 transform”这种顺序,实际运行时是先干完所有过滤,再干完所有变换,我们想表达的是流水线,但它给我们的是两段死数据。万一数据量巨大,中间 vector 就是个没有卵用的内存炸弹。
所以 Ranges 要解决的核心问题就一个:把我想对数据做什么和具体怎么走迭代器拆开。我们用 C++20 Ranges 重写上面那段:
for (int x : v | std::views::filter([](int i){ return i % 2 == 0; })
| std::views::transform([](int i){ return i * i; })
| std::views::take(3)) {
std::cout << x << ' ';
}
读代码就是读意图:过滤偶数 → 平方 → 取前三。没有中间 vector,没有 begin/end 对,完全惰性求值:每个元素在流水线上走到底才拿下一个。
这才是人该写的 C++ 嘛。
2. 初见 Ranges
核心操作就三个东西:
- 管道操作符 | :这个符号本来是个烂大街的 OR 运算,在这儿成了数据流动的箭头,我们把左边的 range 泵进右边的适配器,流出一个新视图。
- std::views::* :它产生一个轻量级的适配器对象,这个对象能记住我们给的条件,却根本不持有数据。
- 惰性求值 :v | filter(...) 这个表达式本身不做任何过滤,它只返回一个 filter_view 对象。真正跑起来是在我们的 for 循环里,一个元素一个元素地拉取、判断、转换。
3. 详细介绍
Range——终于有范围这个概念了
以前 STL 界没有范围这个数据结构,只有一对迭代器,但它们两个是散装的,我们可以传 begin() 给一个容器,end() 给另一个,然后喜提未定义行为。
Ranges 库用 std::ranges::range concept 把它钉死了:一个类型只要我们能对其调用 ranges::begin 和 ranges::end,它就是 range。
vector、array、string、还有后面那些 view,全是 range。这下算法接口直接接受 range 对象,而不是一对破迭代器。
别小看这个改动,它意味着我们终于可以写出 std::ranges::sort(v) 而不是 std::sort(v.begin(), v.end())。
View——轻量级的实时窗口
View 也是 range,但它有极其苛刻的语义要求:轻量拷贝,不拥有元素的所有权,往往是惰性的。
我们拷贝一个 view,成本必须是 O(1),而且两个拷贝独立访问底层序列不互相干扰(除非修改了共享状态,那要注意线程安全)。标准里所有 std::views::xxx 返回的都是 view。
为啥这么设计?
因为 view 天生就是用来在管道里扔来扔去,作为纯函数式组合的积木。我们写 auto v2 = v | filter(p) | transform(f); 时,内部的 filter_view 被拷贝给 transform_view,如果拷贝开销是 O(N),那整个管道表达式就成了性能灾难。所以 view 必须得像一个轻量级的数据描述符,只存一个底层 range 的引用或迭代器对,外加我们自己的条件。
这个约束直接导致一个后果:我们不能随便把 view 赋值给 auto 然后存起来,指望底层容器还活着。
一个经典的栗子:
auto evil = std::vector{1,2,3} | std::views::filter(...);
// evil 持有临时 vector 的迭代器,vector 死掉了,evil 就悬空咯
对于 View 这一类东西应该记住这一大特点:不拥有,只观察。
我们让它观察一个鬼魂,这不胡闹嘛,它没闹鬼就不错了。这也是为什么标准把 vector 这种拥有数据的叫做“owning range”,不能当作 view 随意抛弃生命期。
范围适配器(Range Adaptor)——管道背后的对象
我们写 std::views::filter(p),得到的不是一个普通函数,而是一个 range adaptor 对象。这个东西重载了 operator() 接受 range,也重载了 operator| 来实现管道组合,本质是一个函数对象 + 管道适配包装。
更精妙的是它支持部分应用,我们可以先造一个条件,等数据来了再往上套:
auto even = std::views::filter([](int i){ return i % 2 == 0; }); // 适配器,还没看到数据
auto square_then_even = std::views::transform(square) | even; // 组合
for (int x : v | square_then_even) { ... }
这就是函数式编程里的组合子,只不过用管道重载能让我们看得顺眼些。
它的背后是标准库塞了一堆繁杂的 view_closure 机制,但我们不用管,我们只要知道“造适配器、接管道、数据流过去”这三步转悠就行了。
一些组件详解
1. 基础视图适配器
Ranges 第一个让我惊艳的地方,是它能无中生友。不用先构造 vector,直接生成我们想要的序列。
std::views::iota
我们想生成连续整数?iota(1) 就是从 1 开始的无限流,不过它会跟 take 锁死 CP:
auto ten = std::views::iota(1) | std::views::take(10); // 1,2,3...10
但它没法直接指定浮点步长,iota(0.0) 的步长固定是 1.0。想生成 0.0, 0.1, 0.2... 这种序列,我们可以这样玩:
auto floats = std::views::iota(0)
| std::views::transform([](int i) { return i * 0.1; });
爽不爽?我们造了一个无限的 0.0, 0.1, 0.2 ... 流,内存占用可以忽略不计。
std::views::all
这货是一个身份适配器:我们给它一个 range,它还我们一个 view。
几乎所有管道表达式里,左值容器进管道前会隐式调它。我们自己显式用,主要是为了把容器强制转成 view 传给某个只接受 view 的函数,或者明确拷贝一下意图。
std::views::empty<T> 和 std::views::single
两个小工具:empty<T> 造一个空的 range,single(x) 造一个只有一个元素 x 的 range。
泛型代码里有时要返回空结果,返回 std::views::empty<int> 比临时造个空 vector 环保得多,无分配且是常量对象。
std::views::counted
从迭代器加计数造一个有限 view,这货是为了兼容老接口,我平常用的不多,但当我们手里只有迭代器+长度时,可以把它往管道里送,避免手写 while 循环。
2. 常用操作适配器
这才是我们日常真正会使用的,咱们一起来瞅瞅。
过滤与变换
filter(pred) 和 transform(fn) 前面看过了,但有一句忠告得刻在脑门儿上:filter 和 transform 的顺序置换代价巨大。先 filter 再 transform 是先淘汰再做,先 transform 再 filter 是先做再淘汰,后者多算了无效数据。以为换个顺序只是风格问题?在大的数据上能把我们的 CPU 干到起飞。
截断:take 与 take_while,drop 与 drop_while
take(n) 取前 n 个,take_while(pred) 满足条件就继续取,一旦不满足立刻掐断,后面永远不看。
不过小心点 take_while 这个一刀切的特性:如果我们的数据是 [1,2,3,1,2],条件 x<3,take_while 只会输出 1,2,后面那个 1 会被扔掉,因为 3 已经把门关死了。这跟 filter 完全不同。
drop(n) 和 drop_while 就是反向操作:跳过前 n 个,跳过开头满足条件的。drop_while 第一次不满足后就不再跳过,后续全部吐出,适合作去掉开头空行之类的场景。
组合的坑:filter 之后随机访问就没了
vector 能够随机访问范围,但 v | filter(pred) 之后,编译器眼里它最多是个双向范围。
为什么?因为 filter_view 内部必须维护一个迭代器,每次 ++ 都要找到下一个符合条件的元素,没法 O(1) 跳 N 步。
所以如果我们做了 v | filter(...) | std::ranges::sort 就直接编译报错,sort 要随机访问。这时候我们得先 v | filter(...) | ranges::tostd::vector() 物化下来再排序。
现在我们应该要知道:视图的迭代器类别会随适配器退化。
展开与缝合:join 与 split
join 把 range 的 range 拍平。比如 vector<vector<int>> 用 join 之后变成一个一维 view,所有子元素首尾相连。
但要小心:它要求内层 range 是 view 或者能临时构造,不然外层的 view 会持有内层容器引用导致悬空。安全写法是 v | views::transform(...) | views::join,让 transform 返回 view。
split 按分隔符切割字符串或序列,返回子 range 的 range。举个栗子:
auto s = std::string_view{"hello world foo"};
for (auto word : s | std::views::split(' '))
{
// word 是一个 view,还要再遍历一次才能拿到字符
}
因为 split 返回的是 split_view,内部元素其实是 subrange,得用 string_view(word.begin(), word.end()) 转一下,这是个小不便。
处理关联容器:keys 和 values
老朋友 map 迭代器解引用是 pair<const K, V>,原先想遍历所有键要写 for (const auto& kv : m) { kv.first...}。
现在嘛:
for (const auto& k : m | std::views::keys) { ... }
for (auto& v : m | std::views::values) { ... }
这也算是一种进步,而且它们返回的是 view,可以继续套管道。
3. 算法的 Range 版本
这是我想安利的部分,因为std::ranges 命名空间下的算法基本覆盖了 <algorithm> 中的大多数算法。
直接传容器
所有 std::ranges::xxx 算法第一参数都是 range,而不是两个迭代器。所以我们终于能写:
std::ranges::sort(v);
我们传的就是整个 vector,它自己在内部取 begin、end。这不仅是少打几个字的事,是语义的正确提升:我就是要对这个范围排序,我为什么要关心迭代器这种细枝末节?
投影参数
这玩意是 Ranges 算法真正封神的地方。每个算法都多了一个重载,接受一个投影函数 Projection,让我们在比对、寻找、排序时,只关注对象的某个成员。
假设有一堆 Point 结构体:
struct Point { int x, y; };
std::vector<Point> pts = {...};
我们要按照 x 排序,老办法:写个 lambda [](const Point& a, const Point& b){ return a.x < b.x; }。
现在是:
std::ranges::sort(pts, {}, &Point::x);
第二个参数 {} 是比较器,默认是 std::ranges::less,第三个就是投影。算法内部会先对每个元素应用 &Point::x 得到 x 值,再比较 x 值,我们不用包一层 lambda 了。
查找更是方便:
auto it = std::ranges::find(pts, 5, &Point::y); // 找到 y==5 的第一个点
如果一个算法不支持多级投影,我们可以自己在适配器层做 transform,但这里单级投影已经干掉了大多数琐碎的 lambda。
详谈惰性求值
惰性求值这玩意,一面是性能与抽象的爽,一面是悬空与重复求值的坑。
1. 理解视图的惰性
惰性视图不是缓存,是配方。我们写下一长串 v | filter | transform | take,它给我们建立了一个计算描述的链条,而不是在内存里摆出一排新数据。
每次我们从视图里拉取一个元素,整个链条就动起来:底层 range 喂进 filter,满足条件的给 transform,transform 算完给 take 数个数,够了就停。这过程是按需驱动的,是一个拉取式流水线。
这个特性带来极致的组合力,也带来三个一眼看不出的坑:
坑1:多次遍历 = 多次重算
auto v = std::vector{ 1, 2, 3, 4 };
auto view = v | std::views::filter(is_even) | std::views::transform(square);
// 第一次遍历
for (int x : view) std::cout << x << ' '; // 4 16
// 第二次遍历
for (int x : view) std::cout << x << ' '; // 再来一遍 4 16
如果你以为 view 里面存好了 4, 16,那就大错特错咯。
它只是配方,我们遍历它两次,它会把底层 v 重新过滤、重新变换。底下数据要是没变,浪费掉的是 CPU;底下数据要是变了,第二次遍历结果直接不同。这不叫缓存失效,这叫你根本没缓存。
所以当我们在代码里看到一个视图被遍历了好几次,要么是我们该用 ranges::to 把它物化,要么是我们刻意想要重新观察底层的效果。后者场景极少,我建议一律默认:视图别反复遍历,要嘛一次性消费,要嘛赶紧存下来。
坑2:同一个元素可能被多次求值
假设我们写了 filter(p) | transform(f),一个元素先经过 p 判定,通过了就进 f,这没问题,很干净。但如果后续还有别的东西,比如 filter(p) | take(n),某些元素可能只被 p 检查一次(如果是前 n 个),但如果我们后面又接了个 transform(f) | another_filter(q),那个 f 的结果可能被 q 再考察一次。
本质上每次拉都会触发上游重新计算,除非上游的某个视图缓存了结果,但标准库视图都不缓存,只有少数如 cache_latest 之类的会。
最经典的撕扯现场是生成式 range + filter:
auto gen = std::views::iota(0); // 无限
auto v = gen | std::views::filter(prime) | std::views::take(5);
for (auto x : v) ... // 算了无数质数判定,只拿了前5个
// 再遍历一次?filter 会从头开始找质数,因为配方记得 iota 从0开始。
它不会记住“我已经给了你前5个”,因为 filter 的状态只记录当前迭代器位置,而 iota 是个生成函数,重新构造 view 时会归零。这个例子如果我们不留意,算法复杂度直接爆炸。
惰性视图有状态(迭代器位置)但不能缓存元素,所以重复求值是常态。
解决方案:依旧用 ranges::tostd::vector() 切断惰性,物化成普通容器。
2. 视图的所有权与生命周期
auto get_view()
{
std::vector<int> v = { 1, 2, 3 };
return v | std::views::filter(is_even); // 灾难
}
函数返回后 v 销毁,返回的视图还拿着 v 里面的迭代器,接下来任何访问都是未定义行为。
这不是偶然翻车,这是视图设计的一个小代价:视图不拥有数据,它只观察。
标准把那类“我虽然不拥有,但你可以安全借走我迭代器”的 range 叫做 borrowed_range。普通左值容器通常是 borrowed,因为它们没被移动走。但临时容器(std::vector{...})不是 borrowed,因为马上要析构。
为了在编译期抓这个 bug,std::ranges 算法在接收临时右值时,如果返回迭代器,会返回一个特殊类型 dangling,我们没法解引用它。
auto it = std::ranges::find(std::vector{ 1,2,3 }, 2); // it 是 dangling
*it; // 编译错误
这保护了对吧?
但是啊但是,管道符 | 创建的视图对这层保护是盲区。v | filter 中的 v 如果是临时对象,filter_view 照样构造出来,并且不会变成什么 dangling_view,它就是傻傻地拿着一条指向坟地的指针。
编译器不报错,我们跑起来可能莫名其妙正确(未定义行为里最恶心的玩意),直到某天我们换了个编译器,那它就有可能突然爆炸。
所以我们要做的就是让数据活过视图。
3. C++23 中 Ranges 的增强
ranges::to
味大,无需多盐,它是断掉惰性依赖最干净的办法。
auto v = std::vector{1,2,3} | std::views::filter(is_even) | std::ranges::to<std::vector>();
它把惰性链条一口吃掉,吐出一个实打实的 vector。以后 v 的自由和生命期全由这个 vector 负责,再也不用看原临时容器的脸色。
zip
用于多序列并行遍历:
auto a = {1,2,3}; auto b = {'x','y','z'};
for (auto [x, y] : std::views::zip(a, b)) ... // (1,'x'), (2,'y'), (3,'z')
zip 会以最短的 range 为准,其他多余元素直接抛弃。这是符合大部分语义的,但如果我们期望报错或对齐,就得自己处理。
更要命的是 zip 返回 tuple 的引用,如果任何一个底层视图是惰性的,里面某个元素可能是临时的,那我们就悬了(可以和房梁来一场酣畅淋漓的拔河)。这种嵌套悬空比单一视图更隐蔽,因为编译一样没管。
chunk
chunk(n) 把 range 按固定大小分块,每一块本身是个 view。
auto v = std::vector{ 1, 2, 3, 4, 5, 6, 7 };
auto cv = v | std::views::chunk(3);
for (const auto& c : cv)
{
for (int i : c)
{
std::cout << i << " ";
}
std::cout << "\n";
}
/*
1 2 3
4 5 6
7
*/
因为它是惰性的,窗口内元素仍然指向原数据,所以如果我们对 chunk 里的元素做 ranges::to,要先保证原 range 还活着。否则就是视图套视图,死得不明不白。
as_const_view 和 as_rvalue_view
帮我们在视图里添加 const 或右值转换,实际上就是变了个迭代器包装,依然惰性。
好用,但存在的坑是:如果底层容器不是 const 的,我们拿着 as_const_view 的迭代器,底层在过程中被改了,照样引起未定义行为,因为视图没锁住任何东西,它只是假装给我们 const 访问。
(就先介绍这些吧,不然文章太长了,我也不想写了其实)
自定义视图适配器
又到了手搓环节。
1. 实现思路
标准库里有这么多的视图,但总有不够的时候。
比如我们想要一个条件替换视图:满足条件就替换成另一值。标准里的 replace_if 视图要是满足不了,怎么办?自己造一个 my_views::replace_if,然后像 v | my_views::replace_if(pred, new_val) 这么用。
它得满足四个核心:
- 是个 view:不拥有数据,轻量拷贝,惰性求值。
- 能用管道:v | replace_if(cond, new_value)。
- 解引用时自动替换:遍历的时候,碰到满足条件的元素,吐出新值而不是原值。
- 维持迭代器类别:底层范围是什么迭代能力,我们就尽量保留。
所以我们的设计很直接:包装底层视图,定义一个自己的迭代器,在 operator* 里调用谓词,返回替换值或原值。剩下的全是怎么把自己打扮的像标准库一样。
2. 代码实现步骤
第一步:先搭一个 view 架子
template <std::ranges::input_range V, typename Pred, typename T>
requires std::ranges::view<V> // 确保 V 是视图
class replace_if_view
: public std::ranges::view_interface<replace_if_view<V, Pred, T>>
{
V base_ = V(); // 存储视图
Pred pred_; // 一个谓词
T new_val_; // 替换值
public:
replace_if_view() = default;
replace_if_view(V base, Pred pred, T val)
: base_(std::move(base)), pred_(std::move(pred)), new_val_(std::move(val)) {}
}
为什么要继承 view_interface?因为标准库里这玩意是一个用于定义视图接口的辅助类模板,它提供了一些常用的成员函数,我们只要实现 begin() 和 end(),它就还我们一个完整的 view 外壳,这能帮我们简化流程。
我们的视图只存 base_、pred_、new_val_ 三个成员,不存数据,只存视角。拷贝构造和赋值都是默认的,成本就是拷贝视图(O(1))。
第二步:包装迭代器
// 解引用时判断
struct iterator
{
using base_iter = std::ranges::iterator_t<V>;
base_iter current_; // 当前位置
const replace_if_view* parent_; // 指向父视图
iterator& operator++() { ++current_; return *this; }
auto operator*() const
{
// 先取 v 再判断,为真就返回要替换的值
auto v = *current_;
if (parent_->pred_(v)) return parent_->new_val_;
else return v;
}
bool operator==(const iterator&) const = default;
};
这里的迭代器持有父视图指针。因为视图本身是轻量的,迭代器依赖于父视图的生命期,所以使用裸指针。我们不负责管理寿命,只是借用。
解引用时,我们先取原值 *current_,再执行谓词,为真就返回 new_val_,为假返回原值。
我们的 iterator 只做到了基础的前向迭代,没有实现 --、+= 等,因为写全了代码会膨胀,而且最少概念已经满足 input_range 使用。需要更高级别时可以继续加,原理一样。
第三步:给视图安上 begin() 和 end()
// 获取底层范围的起始和结束迭代器
iterator begin() { return { std::ranges::begin(base_), this }; }
iterator end() { return { std::ranges::end(base_), this }; }
begin 把底层 base_ 的开始迭代器和自己的 this 塞进自定义迭代器,end 同理。现在 replace_if_view 已经是个合法的 view 了,我们可以直接用范围 for 遍历它。
第四步:实现管道支持
// 定义一种可被管道识别的闭包类型
template <typename F>
struct adaptor_closure : F
{
using F::operator();
};
// 重载 |:左侧是 range,右侧是 adaptor_closure
template <typename R, typename F>
auto operator|(R&& r, adaptor_closure<F> c)
{
return std::forward<F>(c)(std::forward<R>(r));
}
逻辑很简单:如果我们把一个 adaptor_closure 类型放在管道右边,就调用它的 operator() 并将左值 range 传进去。这个 operator() 应该返回一个 view,这样我们就能写出 r | replace_if(pred, val) 了。
接下来我们要创造一个工厂,它接受条件和新值,返回一个 adaptor_closure 对象:
struct replace_if_fn
{
template <typename Pred, typename T>
auto operator()(Pred pred, T val) const {
return adaptor_closure {
// lambda 捕获 p 和替换值 v,接受一个范围 R
[p = std::move(pred), v = std::move(val)] <typename R>(R && r) {
return replace_if_view<std::views::all_t<R>, Pred, T>(
std::forward<R>(r), p, v);
}
};
}
};
constexpr replace_if_fn replace_if;
这里使用 std::views::all_t<R>,它确保无论我们传进左值容器、右值容器还是另一视图,都能转换成一个合适的视图类型。例如我们传 vector<int>&,它会推导为 std::ranges::ref_view<vector<int>>,这样我们的 replace_if_view 总是拿视图,安全。
我们定义了一个全局的 constexpr replace_if_fn replace_if,现在 replace_if(pred, val) 就是一个调用表达式,返回管道闭包。
3. 进行组装
replace_if_view 来组成头部,struct iterator 组成身体,begin() 和 end() 组成双手,adaptor_closure 组成双脚,那么 replace_if_fn 该组成什么呢( ˘・з・)?
template <std::ranges::input_range V, typename Pred, typename T>
requires std::ranges::view<V> // 确保 V 是视图
class replace_if_view
: public std::ranges::view_interface<replace_if_view<V, Pred, T>>
{
/* ... */
// 解引用时判断
struct iterator
{
/* ... */
};
// 获取底层范围的起始和结束迭代器
/* ... */
};
// 定义一种可被管道识别的闭包类型
template <typename F>
struct adaptor_closure : F
{
/* ... */
};
// 重载 |:左侧是 range,右侧是 adaptor_closure
template <typename R, typename F>
auto operator|(R&& r, adaptor_closure<F> c)
{
/* ... */
}
struct replace_if_fn
{
/* ... */
};
constexpr replace_if_fn replace_if;
使用:
int main()
{
auto view = std::vector{ 1, 2, 3, 4, 5 }
| replace_if([](int x) { return x % 2 == 0; }, 0);
for (const auto& v : view)
std::cout << v << " ";
// 输出:1 0 3 0 5
return 0;
}
// 可以手动调试感受一下它是怎么工作的
只有我们亲手实现一个视图适配器,才真正理解为什么 C++20 的 Ranges 设计那么重:只写一个最简单的 replace_if,就用上了一堆技术。标准库里几十个适配器,每个还要处理迭代器类别退化、sized_range 传递……这工作量想想就令人头秃。
但好处是,一旦我们理解了这个模式,就可以造任何我们想要的视图:log_view(遍历时打印日志)、step_view(自己实现步长切片)、async_view(配合协程)……
所有的实现都是:继承 view_interface,包装迭代器,写个工厂函数返回管道闭包。这是现代 C++ 为我们准备的元编程盒子,开箱即用。
不过我今天必须再啰嗦一句生命期:我们的 replace_if_view 里存 base_ 如果是临时容器转换来的,返回后直接悬空。自己写适配器,这个意识必须刻进骨髓。
比如这样是自杀:
auto evil = std::vector{ 1,-2,3 } | replace_if(is_neg, 0);
// vector 临时对象死亡,evil 内部 base_ 变成悬空引用
编译器不会救我们,我们只能靠自己。把物化做好,把数据放在比 view 更外层的栈上,这些都是前面说过的。