ranges
下面分为常见面试题和常见坑两部分。代码都没有实测,涉及标准细节的地方以 C++20 最终版及其后续缺陷修正(DR)为准。
一、常见面试题
1. ranges 是什么,解决了什么问题
ranges 包含三部分:
- range:任何能调用
begin()/end()的东西。end可以和begin是不同的类型,这种 end 叫 sentinel(哨兵)。 std::ranges::算法:直接接收整个 range,并且用 concept 约束参数。- views(视图):惰性求值、可以组合的范围适配器,用
|串成管道。
std::vector<int> v;
auto r = v |
| ;
for std::cout << x << ' '; // 4 16 36;此前没有任何计算发生
C++20 的 ranges 来自 Eric Niebler 的 range-v3,标准只采纳了其中一个子集(P0896 "One Ranges Proposal")。
2. std::sort 和 std::ranges::sort 有什么区别
std::sort(first, last) |
std::ranges::sort(r) |
|
|---|---|---|
| 参数 | 一对迭代器 | 整个 range,或迭代器加哨兵 |
| 约束 | 没有,传错了在模板内部报一大串错误 | 用 concept 约束,比如 sort(list) 直接报"不满足 random_access_range" |
| 投影 | 不支持 | ranges::sort(people, {}, &Person::age) |
| 返回值 | void |
返回末尾迭代器 |
| 调用方式 | 普通函数模板,参与 ADL | niebloid(函数对象),不参与 ADL,不能显式写模板参数 |
| 悬空保护 | 没有 | 对临时对象调用时返回 std::ranges::dangling |
3. 投影(projection)是什么
投影是算法在比较或判断之前,先对每个元素调用的一个函数,省去了手写比较器:
; // 按 age 排序,{} 表示使用默认的 less
auto it = ; // 找 id == 42 的用户
auto m = ;
4. view 是什么,和容器有什么区别
- view 不拥有元素,只引用底层的数据。C++20 的缺陷修正 P2415 引入了
owning_view,允许 view 持有一个右值容器。 - view 的移动和析构都是 O(1)。所以 view 应该按值传递,不要按 const 引用传递,原因见坑 2。
- 惰性求值:构造 view 时什么都不计算,只有迭代时才逐个计算,因此可以表示无限序列,比如
std::views::iota(1)。 - 每次迭代都重新计算:结果不会被缓存,只有
begin()可能被缓存。
5. 什么是 sentinel,有什么用
哨兵让 end 可以是一个不同类型的"终止条件",不必事先算出结束位置:
;
// 遍历 C 字符串时不用先调用 strlen
;
std::unreachable_sentinel 表示"永远到不了结尾",调用者保证一定能找到目标时用它,可以省掉每一步的边界检查。
6. 什么是 borrowed range 和 dangling
对一个右值 range 调用返回迭代器的算法,这个迭代器在语句结束时就会悬空。标准的做法是返回 std::ranges::dangling,任何使用它的操作都会编译失败:
auto it = ; // it 的类型是 std::ranges::dangling
// *it; // 编译错误
borrowed range 是指迭代器不依赖 range 对象本身存活的类型,比如左值引用、std::string_view、std::span,以及特化了 enable_borrowed_range 的类型。它们的右值版本仍然返回真正的迭代器。
7. 各版本加了什么
| 版本 | 内容 |
|---|---|
| C++20 | 约束过的算法;filter、transform、take、drop、take_while、drop_while、reverse、join、split、iota、keys、values、elements、common、all |
| C++23 | ranges::to(把 view 收集成容器)、zip、enumerate、chunk、slide、stride、adjacent、join_with、cartesian_product、as_const、ranges::fold_left、std::generator |
| C++26 | views::concat、views::cache_latest 等(我的记忆,可能随最终版本变化) |
C++20 最明显的缺口是没有 ranges::to,把 view 变回容器很麻烦(见坑 5)。
二、常见坑
坑 1:view 引用了已经销毁的数据
auto
get_vector() | views::filter(...) 这种直接对临时对象建 view 的写法是安全的:P2415 之后,右值容器会被移动进一个 owning_view。危险的是 view 引用的左值先被销毁了,还有 lambda 按引用捕获了局部变量这两种情况。
坑 2:const 的 filter_view 不能迭代
filter_view::begin() 要找到第一个满足谓词的元素,这一步是 O(n)。标准要求 begin() 均摊 O(1),于是它第一次调用时把结果缓存下来,因此 begin() 不是 const 成员函数。
void
; // 编译错误:const filter_view 没有 begin()
修法:参数改成 auto&& r,或者直接按值传 view,因为 view 很便宜。drop_while、chunk_by、split 以及 reverse 作用于某些 view 时,也有同样的缓存问题。
坑 3:通过 filter 修改元素,让它不再满足谓词,是 UB
auto evens = v | ;
for x += 1; // 修改后元素变成奇数
for // 未定义行为:缓存的 begin 指向的元素已经不满足谓词
标准明确规定:可以通过 filter 的迭代器修改元素,但修改后的值如果不再满足谓词,行为未定义。同理,迭代过一次之后又修改底层容器(比如 push_back 导致重新分配),缓存的 begin 就失效了。
坑 4:先 transform 再 filter,转换函数被调用两次
auto r = v |
| ;
for ; // 每个被选中的元素,expensive 都调用了两次:filter 判断一次,你解引用时又一次
transform 不缓存结果,每次解引用都会重新计算。对策:
- 调换顺序,先 filter 后 transform,前提是谓词能写在原始元素上。
- 让
expensive足够便宜,并且没有副作用。 - 在 C++26 里用
views::cache_latest。
坑 5:不是所有 view 都是 common range,没法直接传给老接口
auto r = v | ; // begin 和 end 的类型不同
std::vector<int> ; // 编译错误:vector 的构造函数要求两个迭代器类型相同
auto c = r | std::views::common; // C++20 的写法
std::vector<int> ;
auto out3 = r | std::ranges::to<std::vector>; // C++23 的写法
坑 6:views::iota(0, v.size()) 类型不匹配
for // 编译错误:int 和 size_t 不能组成一个 iota
for // C++23 引入 uz 后缀
for
坑 7:很多 view 没有 size()
filter_view 在迭代之前不知道有多少个元素,它不是 sized_range,调用 r.size() 会编译失败。std::ranges::distance(r) 可以用,但它会把整个 range 遍历一遍,是 O(n)。
坑 8:算法的返回值和老版本不同
auto = ; // 返回 {in, out},不是只返回 out
auto tail = ; // 返回一个 subrange,表示要删除的尾部
v.;
; // C++20 更简单的写法
坑 9:niebloid 不能当普通函数模板用
- 不能写显式模板参数:
std::ranges::sort<...>(...)写不出来。 - ADL 找不到它们,要写全名
std::ranges::sort。 - 反过来说,它们本身是函数对象,可以直接作为参数传递:
apply(std::ranges::sort)。
坑 10:受约束的算法对迭代器类别有要求
std::ranges::sort(my_list)编译失败,因为std::list不支持随机访问。要用my_list.sort()。views::reverse要求底层至少是双向的,所以forward_list | views::reverse编译失败。
坑 11:调试构建的性能和编译时间
- 管道在
-O2下通常能优化得和手写循环一样快。 - 在
-O0下会有大量的函数调用层级,可能慢好几倍。 - 模板很重,编译时间会增加;报错信息比以前好,但嵌套一深仍然很长。
坑 12:C++20 里缺少的功能
C++20 没有 zip、enumerate、to,也没有 range-v3 那种原地修改容器的 actions。想"带下标遍历",在 C++20 里只能用 iota 配合下标,或者自己写。
三、面试速答
- ranges 和老算法的区别? 直接接收整个 range,用 concept 约束,支持投影,允许 end 是不同类型的哨兵,对临时 range 有悬空保护。
- view 是什么? 不拥有元素、惰性求值、O(1) 移动的轻量 range,用
|组合,应该按值传递。 - 为什么 const 的 filter_view 不能迭代?
begin()会缓存第一个满足条件的位置,所以它不是 const 成员函数。 - transform 接 filter 有什么问题? 转换函数会被调用两次,因为 transform 不缓存结果。
- view 怎么变回 vector? C++23 用
ranges::to<std::vector>();C++20 先经过views::common,再用迭代器对构造。 - 什么是 dangling? 对右值 range 调用算法时返回的占位类型,一旦使用就编译报错,防止迭代器悬空。
暂无评论,欢迎留下第一条评论。