ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++20 ranges哨兵与subrange:迭代器+哨兵如何解放遍历逻辑

C++20 ranges哨兵与subrange:迭代器+哨兵如何解放遍历逻辑 标准库算法刚入门的时候几乎每个人都会背一条规则std::find接收的first和last必须是同一个迭代器类型last本身也是一个迭代器。这个规则看似自然实际上给很多真实场景带来了麻烦。尤其当你处理的是一段带结束标记的内存、一个流式输入、或者一个理论上的无限序列时结束位置往往不是一个迭代器而是一个条件。C20 的std::ranges里subrange、迭代器与哨兵sentinel的配合把这个问题彻底解开了。这篇文章我想从这些年踩过的坑出发聊聊为什么迭代器对和哨兵这套新机制能带来真正的灵活性以及如何把它用到自己的代码里。如果你还没接触过 ranges也不用慌。我会从最朴素的痛点讲起代码都基于 C20 实测可编译。本文既适合想系统理解std::ranges的读者也适合正在写自定义 range、做协议解析、处理 C 接口缓冲区、或者单纯想优化循环终止逻辑的人。看完你会明白所谓子范围迭代器对与哨兵本质上是在说一件事遍历的当前位置和停止条件被拆成了两个独立的东西而算法不再被强制要求 end 必须是一个真正的迭代器。1. 告别同类型迭代器对哨兵带来的设计变化1.1 传统迭代器对的同类型限制到底卡在哪老式 STL 算法要求的[first, last)中first和last类型必须一致。这个约束在标准容器里没什么问题因为std::vector的begin()和end()返回的都是vectorT::iterator它们天然同类型。但一旦跳出容器问题就来了。一个最典型的场景是处理 C 风格字符串。你有一个const char*想对它做算法操作比如统计某个字符出现的次数。传统写法要么先strlen拿到长度把字符串扫描一遍然后再让算法从str走到str len等于至少多遍历一次要么你写一个自定义迭代器让它默认构造出来就是结束迭代器还得维护内部指针状态样板代码一大堆。第二个场景是流式输入。std::istream_iteratorint的 end 迭代器本质上是默认构造出来的它根本不是一个指向某个元素的迭代器只是一个表示流已经结束的标记。老式算法要求你把这个标记包装成迭代器类型这还能忍。但如果你想要一个读整数直到读到 0 为止的停止条件呢你没法直接表达只能while循环 break或者把条件塞进谓词里循环结构依然漏在外面。第三个场景更极端无限序列。老式算法压根没法直接处理无限流因为 end 迭代器对应一个真实位置而无限流根本没有最后一个元素的位置。你只能借助类似boost::iterator_range配合计数器或者干脆手动 for 循环。这三个场景的共同根源在于迭代器同时承担了我在哪和什么时候结束两件事。但很多情况下终点不是一个位置而是一个条件。老式接口把条件硬编码成再走几步到达某个位置自然就不灵活了。1.2 哨兵把终止条件从位置中解放出来C20 的 ranges 引入了一个更朴素的抽象算法接收的不再是两个同类型迭代器而是一个迭代器加一个哨兵。哨兵几乎不要求完整迭代器语义它只需要能回答一个问题——迭代器是否已经到达终点在标准库里这个要求被形式化成了std::sentinel_for概念。它不要求哨兵支持、*、-也不要求哨兵和迭代器同类型。它只要求哨兵是半正则类型semiregular并且能够和迭代器做比较。也就是说哨兵可以是一个空对象、一个带阈值的状态对象、甚至是一个谓词对象。这就像门禁系统。传统模式里你需要安排一个保安从起点一路护送访客到终点保安和访客必须都是能走路的人而哨兵模式里终点只需要放一个闸机访客走过去闸机检测到到达就放行结束。闸机不需要会走路它只需要认识到达信号。这种解耦带来的灵活性是质变的。字符串的终点可以是一个专门检查\0的哨兵流式输入的终点可以是流结束标记无限序列的终点可以是一个记数达到 N的计数哨兵。算法循环内部依然是while (first ! sentinel) first但sentinel的类型不再受限于first的类型。我把这看作是 C 迭代器设计上最重要的一次简化它把怎么走和停在哪彻底分开了。2. subrange 与哨兵一对多类型端点怎么配合工作2.1 subrange一个 range 有了两种类型的端点std::ranges::subrange是展示迭代器 哨兵模型的最直接类型。它本质上就是一个轻量结构体内部保存一个迭代器和一个哨兵而这两个成员的类型可以不同。只要这两个类型满足sentinel_for关系就能组成一个合法的 range。看一个最简单的例子——把 C 字符串包成 subrange#include ranges #include iostream struct CStringSentinel { friend constexpr bool operator(const char* p, CStringSentinel) noexcept { return *p \0; } friend constexpr bool operator(CStringSentinel, const char* p) noexcept { return *p \0; } }; int main() { const char* text hello; std::ranges::subrange word{text, CStringSentinel{}}; for (char c : word) { std::cout c; // 输出 hello } }这里subrange的迭代器类型是const char*哨兵类型是CStringSentinel。循环中char c : word会先调用ranges::begin(word)得到指针再调用ranges::end(word)得到哨兵然后按照迭代器 ! 哨兵判断是否继续。哨兵的operator会检查当前指针指向的字符是不是\0是就停止。整个过程完全没有提前计算字符串长度也没有为结束专门构造一个指向\0的迭代器。subrange的另一层价值是可以随时把一个迭代器区间包装成 range传给那些接受 range 的算法或视图。比如std::ranges::for_each、std::ranges::find_if等都能直接接收它。相比传统make_pair(first, last)subrange除了存储终点还天然携带 begin/end 语义并且作为 borrowed_range 使用起来更安全。2.2 标准库自带的哨兵default_sentinel_t 与 unused 哨兵标准库里最常用的哨兵是std::default_sentinel_t配合全局常量std::default_sentinel使用。它的设计哲学非常简单这是一个不携带任何信息的空标记专门用来表示我已经没有更多元素了。三个典型的搭配对象是std::istream_iterator、std::istreambuf_iterator和std::counted_iterator。例如#include iterator #include sstream #include ranges int main() { std::istringstream iss(10 20 30); std::ranges::subrange numbers{ std::istream_iteratorint{iss}, std::default_sentinel }; int sum 0; for (int v : numbers) sum v; // sum 60 }istream_iterator是一个真正的迭代器它内部持有输入流的指针而default_sentinel只是一个空对象。两者之间通过标准库提供的operator判断流是否结束。这种设计让 流终止 不再依赖一个具有迭代器全部能力的对象编译器也不需要为一个什么都不存的对象生成复杂的迭代器操作。另一个值得提的是std::unreachable_sentinel_t。这个哨兵与任何迭代器比较都不会相等等于告诉算法这里没有终点。配合std::views::iota这类无限视图时它可以表达继续走到程序逻辑自己停的意图。我通常把它用在一个受限场景明确知道某个循环条件一定会在范围内终止但不想让算法进行额外检查时。它同时也是一个编译器优化提示不过新手慎用因为一旦循环条件漏写就会真正死循环。2.3 sentinel_for 概念它到底要求哨兵会什么如果你要写一个自定义哨兵真正需要满足的是std::sentinel_for。这个概念的约束很宽松主要由三部分组成semiregularS哨兵必须可以默认构造、拷贝构造和拷贝赋值。weakly_equality_comparable_withS, I哨兵可以和迭代器比较相等且比较结果可转换为bool。迭代器类型I必须满足input_or_output_iterator。也就是说你的哨兵类只需要能默认构造、可复制再提供与迭代器比较的operator就可以。它完全不需要operator*、operator或operator--。这比写一个完整的 iterator 要轻松得多。如果想在 subrange 上调用某些想知道元素数量的算法比如std::ranges::size哨兵还必须满足std::sized_sentinel_for。这个概念的额外要求是迭代器与哨兵之间可以用-运算符计算距离返回类型和迭代器的 difference_type 一致。原生的const char*和同类型指针天然满足这个要求因为ptr1 - ptr2可计算但前面的CStringSentinel不满足因为没有定义operator-所以含有这种哨兵的 subrange 无法直接用std::ranges::size得到长度。很多刚接触 ranges 的人在size()上报错都是栽在这里。有一个细节需要留意operator的参数顺序。C20 的重写候选中只要定义了iterator sentinel编译器也能推导出sentinel iterator所以理论上写一个方向就行。但为了避免某些模板推导场景下的意外我习惯两个方向都写或者像前面那样用 friend 函数同时提供两个重载。这不是必须的但在模板中更稳妥。3. 手写三种自定义哨兵从零到可编译运行3.1 模式一零终止哨兵把字符串遍历交给算法这一节我给三个可以直接抄走的实战模式。第一个就是处理 C 风格字符串的零终止哨兵。这类哨兵的核心就是检查*p \0因此它本身不存状态是一个空类。#include ranges #include algorithm #include cctype #include iostream struct NullTerminated { friend constexpr bool operator(const char* p, NullTerminated) noexcept { return *p \0; } friend constexpr bool operator(NullTerminated, const char* p) noexcept { return *p \0; } }; int main() { const char* msg ranges are fun; std::ranges::subrange text{msg, NullTerminated{}}; std::ranges::transform(text, std::ostream_iteratorchar(std::cout, ), [](unsigned char c) - char { return static_castchar(std::toupper(c)); }); }这个哨兵的优势在于transform可以逐个字符处理不需要先把 C 字符串转成std::string。你既没有额外分配内存也没有提前调用strlen扫描一次。对于超长字符串或者流式处理的场景这个优势会被放大。我自己在做嵌入式日志解析时就经常这么干内存受限的环境下少一次全量扫描很关键。需要注意哨兵在循环中解引用指针但只解引用当前指针指向的字符。当指针已经指向\0时解引用是安全的因为\0仍然是合法可读的字符。只要你保证字符串确实以\0结尾这个哨兵不会越界。如果字符串不是\0结尾那不管什么写法都会问题属于调用方责任。3.2 模式二按值或谓词终止让停止条件携带状态第二种模式更贴近真实业务停止条件不是读到某个位置而是读到某个值。比如解析一串整数遇到0就停或者解析日志遇到END就停。这个时候哨兵可以携带状态在比较时读取迭代器指向的内容。#include ranges #include vector #include iostream struct UntilLimit { int limit; templatestd::input_or_output_iterator I friend constexpr bool operator(const I it, UntilLimit s) noexcept { return *it s.limit; } templatestd::input_or_output_iterator I friend constexpr bool operator(UntilLimit s, const I it) noexcept { return it s; } }; int main() { std::vectorint data{3, 1, 4, 1, 5, 9, 2}; std::ranges::subrange until_five{data.begin(), UntilLimit{5}}; for (int v : until_five) { std::cout v ; // 输出 3 1 4 1 } }这里UntilLimit保存了一个阈值。当迭代器指向的值 5时哨兵判定到达终点。循环输出3 1 4 1遇到第一个5就停止后面的元素根本不会解引用。传统写法需要自己写for (auto it data.begin(); it ! data.end() *it 5; it)条件逻辑写在循环外部一旦要在多个地方复用就得复制粘贴。现在终止条件被封装成一个类型可以直接搭配subrange到处传算法内部自动处理何时停。这种模式特别适合解析 CSV 里的空行、流式事件里的结束标记、或者读取连续数据块时遇到某个特殊值就切换逻辑的场景。成本也不高模板operator一次写好任何输入迭代器都能匹配。3.3 模式三与 counted_iterator 配合桥接 C 接口第三个模式针对一个很常见的痛点C 接口经常返回指针 长度而不是完整的迭代器对。比如uint8_t* data, size_t size。传统做法是data size构造一个 end 指针但如果size非常大、或者指针不是连续数组的一部分data size可能是不安全甚至非法的。std::counted_iterator让你用指针 剩余长度表示迭代器然后用default_sentinel表示计数值归零。#include iterator #include ranges #include iostream int main() { int raw[] {10, 20, 30, 40, 50}; auto begin std::counted_iterator{raw 1, 3}; std::ranges::subrange slice{begin, std::default_sentinel}; for (int v : slice) { std::cout v ; // 输出 20 30 40 } }这段代码从raw[1]开始取 3 个元素不需要也没有形成一个指向raw[4]的显式指针。counted_iterator内部记录当前指针和剩余计数当计数为零时它就和default_sentinel相等。这个模式在对接网络协议缓冲区、图像像素数组、序列化数据时极其有用因为你可以直接把起始指针 长度作为一个 range 传进 ranges 算法而不用自己小心翼翼地做指针加法构造 end。同样的counted_iterator也支持把标准容器中间片段变成一个可遍历的 range。比如你要跳过前两个元素处理后面三个用counted_iterator(vec.begin() 2, 3)即可语义清晰还不会误触到容器边界。4. 算法实操与踩坑把哨兵真正用起来4.1 哪些算法能直接用迭代器-哨兵对C20 ranges 里的算法几乎都重载成了接收iterator sentinel的版本比如ranges::for_each、ranges::find_if、ranges::distance、ranges::advance、ranges::next等。也就是说你传一个subrange进去或者直接传迭代器和哨兵算法都能正常工作。一个很实用的例子是ranges::distance在非 sized range 上的行为。老式std::distance要求两个迭代器同类型而std::ranges::distance(range)面对NullTerminated这样的哨兵时会通过不断自增迭代器直到哨兵相等数出距离。如果你对自定义哨兵的范围调用距离它会自动变为 O(n) 线性遍历这通常也是唯一的办法。const char* greeting hello; std::ranges::subrange text{greeting, NullTerminated{}}; auto dist std::ranges::distance(text); // 输出 5算法内部通常这样工作while (first ! sentinel) { first; }。这个循环只依赖哨兵的相等比较不要求算出总长度。很多只需要单趟遍历的算法find、count、for_each都可以在未知长度的情况下工作。但要注意像ranges::sort这类需要随机访问的算法情况略有不同。它的签名也允许iterator sentinel但底层为了做分治排序往往需要先求出区间长度或者高效地跳转到中间位置。对于自定哨兵如果它不满足sized_sentinel_for排序算法可能用一次 O(n) 遍历来计算长度这在复杂度上一般可以接受。但如果你给了一个随机访问迭代器 自定义哨兵的组合却没有实现-运算某些标准库的排序实现可能会退化成性能不佳的路径。我实测下来的建议是如果数据量可控直接用subrange 哨兵问题不大如果追求极致性能还是改用普通迭代器对。4.2 编写自定义 range 时的生命周期问题哨兵解耦迭代器和终止条件之后一个容易踩的坑是生命周期。subrange本身是 borrowed_range也就是说它只保存迭代器不拥有底层数据。如果你从一个临时容器创建 subrange然后返回给外部使用迭代器会悬垂。auto bad() { std::vectorint tmp{1, 2, 3, 4}; return std::ranges::subrange{tmp.begin(), tmp.end()}; // 容器已析构悬垂 }这个问题的根因是subrange的 borrowed_range 特性只代表subrange 不拥有数据不代表数据一定活着。同理如果你用std::views::all(临时容器)得到owning_view它虽然生命周期跟随 view但把它作为函数返回值时同样要小心。规则很简单只要底层容器是局部变量就不要返回基于它的迭代器或视图。我在实际工作中遇到的另一个坑是把自定义哨兵存进了成员变量但对应迭代器指向的缓冲区已经被外部释放。哨兵本身不管理缓冲区生命周期它只是一个判断条件。设计哨兵时最好只保存原始的停止值或标记值不要保存容器引用或资源句柄否则容易在堆上额外管理、泄漏或悬垂。4.3 与旧代码互操作common_view 和显式转换如果你有一段老代码API 是templatetypename Iter void legacy(Iter first, Iter last)它要求两个迭代器同类型。那么你的 subrange 即使有自定义哨兵也没法直接传进去因为end()返回的是哨兵不是迭代器。此时可以使用std::ranges::common_view或者视图适配器std::views::common把哨兵包装成一个和迭代器同类型的迭代器。#include ranges #include vector #include iostream templatetypename Iter void legacy_sum(Iter first, Iter last) { long long total 0; for (; first ! last; first) { total *first; } std::cout total \n; } int main() { std::vectorint data{1, 2, 3, 4, 5}; auto sub std::ranges::subrange{data.begin(), data.begin() 3}; // sub.end() 的类型是 vectorint::iterator直接兼容 legacy_sum(sub.begin(), sub.end()); // 也兼容自定义哨兵 int raw[] {1, 2, 3, 4, 5}; std::ranges::subrange tricky{std::counted_iterator{raw, 4}, std::default_sentinel}; auto common std::views::common(tricky); legacy_sum(common.begin(), common.end()); }common_view的本质是把哨兵适配成迭代器它内部会保存迭代器/哨兵的状态对外暴露统一的迭代器类型。使用它的代价是本来很轻量的哨兵可能会变成一个完整的迭代器对象携带更多状态也可能失去某些优化机会。但面对遗留代码这是最平滑的桥接方式。还有一类互操作是反过来你只有一个subrange但某些老算法不接收 range 概念只接收两个迭代器。这时候sub.begin()和sub.end()只要类型相同就能直接用。如果类型不同common_view就是最快捷的适配方案。需要特别提醒的是common_view不能解决哨兵相等判断里解引用越界的问题因为它在包装过程中不会改变哨兵语义只是把哨兵伪装成迭代器。5. 常见编译/运行问题排查速查5.1 编译报错no match for operator这是写自定义哨兵时最典型的编译错误。原因是哨兵类型没有提供与迭代器类型匹配的operator或者参数顺序不对。如果你定义了operator(const I, S)但I被推导成了volatile或引用类型也可能匹配失败。排查思路是先确认迭代器I和哨兵S的组合确实满足weakly_equality_comparable_with。最简单的检查是写一行static_assert(std::sentinel_forMySentinel, MyIter);用编译器的报错去反推缺哪个约束。大多数情况下就是缺一个operator或者比较函数不是friend导致无法访问私有成员。经验之谈我建议哨兵的所有比较函数都用非成员friend函数而不要用成员函数。因为成员函数天然只支持哨兵 迭代器这一侧另一个方向虽然 C20 会尝试重写但在模板推导时容易出奇怪问题。两个方向都写成 friend 重载是最不挑场景的写法。5.2 死循环哨兵永远不相等如果你的自定义哨兵在应该停止时没有停止程序就会陷入死循环。最常见的两个原因迭代器在自增后越过了终止条件标记哨兵比较永远检测不到。哨兵的operator里用了解引用但解引用的位置根本没有对应的值可读或者读取的是已经被清除的内存。举个例子如果哨兵检查*it 0而迭代器一直自增到越过缓冲区末尾再解引用就已经是未定义行为。正确做法是确保迭代器每次自增后至少在哨兵判断时仍然能安全解引用。对于 C 字符串\0本身就是合法字符所以安全对于自定义数据流你得保证缓冲区始终有一个哨兵值或者迭代器的在末尾不会越界。这与循环结构设计有关。如果你控制迭代器的自增时机可以在每次自增后立即检查哨兵而不是等到算法内部循环去检查。但既然是标准库算法在驱动循环你就没法控制自增时机只能保证哨兵判断时解引用当前迭代器是合法的。这是一个值得写进代码评审清单的细节。5.3 size() 不可用 / 距离无法计算前面提到过std::ranges::size(sub)需要 subrange 满足sized_range也就是哨兵必须满足sized_sentinel_for。如果你的自定义哨兵没有实现operator-编译会报一个很隐晦的错误说std::ranges::size找不到匹配的重载。解决方案有两种。一种是给哨兵补上operator-让迭代器与哨兵可以相减struct NullTerminated { // operator 略 friend constexpr std::ptrdiff_t operator-(NullTerminated, const char* p) noexcept { const char* q p; while (*q ! \0) q; return q - p; } friend constexpr std::ptrdiff_t operator-(const char* p, NullTerminated s) noexcept { return -(s - p); } };这样一来NullTerminated也满足sized_sentinel_forsubrange就变成了sized_rangeranges::size可直接使用。代价是求 size 需要 O(n) 扫描因为空终止哨兵天然不知道长度。另一种方案是不要依赖size()改用ranges::distance(sub)它对非 sized range 也会用迭代来自动计算长度。实际项目里如果性能敏感且长度已知我会优先用counted_iterator或者显式传长度而不是给哨兵写一个 O(n) 的operator-。只有确实没有其他长度来源时才用哨兵去算。5.4 快速排查速查表症状可能原因解决方案no match for operator哨兵缺少与迭代器的operator用 friend 函数补两个方向的比较重载死循环哨兵判断条件不触发或解引用越界后 UB确保哨兵判断时迭代器解引用安全检查自增逻辑std::ranges::size编译失败哨兵不满足sized_sentinel_for实现operator-或改用ranges::distance迭代器与哨兵!编译失败使用了旧版编译器或operator!未被推导C20 中!由重写确认编译器标准为 C20返回 subrange 后数据失效底层临时容器已析构不要返回基于局部容器的迭代器/视图传 subrange 给旧函数失败旧函数要求同类型迭代器对用std::views::common(sub)包装这套速查表帮我解决了很多次同事的编译困惑。尤其是第一行十个报错里至少五个是operator没写对方向。写完哨兵之后先用static_assert验证一下概念满足关系比对着长篇报错猜要快得多。回到开头那个问题为什么我要费这么大劲介绍迭代器 哨兵因为在真实项目里终止条件从来不只是走到容器末尾这么单一。协议解析要读到结束字节文件处理要读到 EOF数值处理要读到阈值链表要走到 nullptr。这些条件在老式迭代器模型里都要靠额外包装和循环逻辑实现而在 ranges 里只需要一个几十行的哨兵类型。我现在写解析器时第一个反应不再是写循环 break而是抽象出终止条件把它做成哨兵让算法或 view 去驱动遍历。这种思维转变就是 C20 给我最值回票价的一部分。
返回列表