ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++20 ranges悬垂问题全解析:从borrowed_range到owning_view

2026/9/10 12:40:48 拓冰建站 浏览量
C++20 ranges悬垂问题全解析:从borrowed_range到owning_view 接手过不少项目代码评审最怕看到的一种 bug 就是filter_view握着已销毁容器的迭代器循环体里第一次解引用就崩溃或者更糟——什么都不崩每次运行结果还不一样线上数据算错一整天最后定位到头发现是std::ranges的视图悬垂。C20 的std::ranges是近年标准库最大的变革它把“算法接收迭代器对”改成了“接收范围对象”让管道写法变得极其顺手。但顺手不等于安全尤其是**引用悬垂dangling reference**问题ranges 在解决一部分老悬垂的同时也借助“返回值类型体系”做了大量防护只是这些防护机制很多 C 开发者并不清楚。如果你已经在写 ranges 代码或者正准备从传统 STL 算法迁移到 ranges这篇文章就是帮你把“哪些写法会悬垂、为什么悬垂、标准库怎么拦、你自己怎么拦”彻底想明白的实物地图。我会先讲悬垂是怎么来的再拆解borrowed_range和dangling这两个核心机制然后逐个看filter、transform、临时范围、成员引用这些最常出事的场景最后给出一套从编译期到运行期的排查手段。唠嗑方式偏工程实践尽量不跟你拽标准措辞。1. 悬垂是怎么找上门的从传统 STL 讲到 ranges1.1 老 STL 时代迭代器悬垂是“用户自己的责任”传统 STL 算法几乎都接收迭代器对比如std::find(first, last, value)。迭代器本质上是指向容器元素的“指针带封装”它不拥有数据。你传进去的first、last如果来自一个临时容器那这个容器在完整表达式结束时就被析构了返回的迭代器自然指向垃圾内存。// 典型的老式悬垂写法 std::vectorint make_vector() { return {1, 2, 3, 4, 5}; } auto it std::find(make_vector().begin(), make_vector().end(), 3); // 两个临时 vector 在分号处全部销毁it 立即悬垂这代码能编译能跑甚至有时候还“跑对了”像极了一颗定时炸弹。老标准库的立场很明确我只保证你在合法生命周期内使用迭代器你传了临时量是你自己的问题。std::find签名就是Iterator find(Iterator first, Iterator last, const T value)它压根不在乎你两个迭代器是从哪来的。这种设计有一个好处就是算法可以接受“借用来的迭代器”。比如文件流是流式迭代器std::istream_iterator并不属于某个容器但它一样能喂给算法。代价就是迭代器的生命周期管理完全甩锅给开发者的责任心。代码审查时肉眼找这种悬垂效率极低因为问题往往藏在不显眼的临时对象构造和较长调用链里。1.2 ranges 带来的新问题返回值类型变得“看起来安全”std::ranges把算法签名从“给迭代器对”改成“给一个范围对象”比如std::ranges::find(range, value)。这一改范围对象在内部负责拆成迭代器对但范围对象的生命周期和算法执行所在的完整表达式紧密绑定在一个语法位置上用户几乎不太会注意临时范围是什么时候销毁的。于是 ranges 引入了一类新悬垂视图对象保存临时范围的引用。最经典的例子auto evens std::vectorint{1, 2, 3, 4, 5, 6} | std::views::filter([](int n) { return n % 2 0; });这里std::views::filter构造了一个filter_view它内部存了底层范围的“借用”而底层vectorint是个纯右值临时量。分号一结束vector 销毁filter_view就成了悬垂对象。可evens这个变量本身还活着你拿去遍历就是未定义行为。传统 STL 时代你不传临时容器就不会出事而 ranges 时代你只是写了个管道表达式就把临时容器“吸”进了视图里。这种问题肉眼极难发现因为表达式写起来实在太流畅了。1.3 两类悬垂的本质区别我把悬垂分成两类方便后面理解整个防护体系一类是用户生命周期管理失误。容器还活着或者已销毁但你在销毁后继续用迭代器这种责任在用户任何语言都救不了滥用生命周期的人。另一类是库接口设计引发的可预期悬垂。你按正常语法调用但库接口允许把不拥有资源的引用返回给用户存着标准库明明知道这样做有风险却什么都不拦。比如上面的filter_view接临时 vector——这就是接口设计上允许了“危险动作”发生。std::ranges的设计思路很直接在类型层面区分“拥有资源的范围”和“借用外部资源的范围”对前者拒绝在右值语境下返回可用的迭代器或子范围。这就是borrowed_range概念和dangling哨兵类型要做的事。2. ranges 的官方对策borrowed_range 与 dangling2.1 borrowed_range 概念是怎么工作的std::ranges::borrowed_rangeR用来回答一个问题“当 R 类型的右值作为参数传给算法时返回的迭代器/子范围是否能安全脱离算法调用而继续使用”如果一个范围类型被视为borrowed_range意味着它本身不拥有元素元素活在别处比如std::spanT、std::string_view、裸指针区间T* std::size_t、std::ranges::subrange。这些类型被右值传递时里面的指针或引用指向的数据仍然有效因为数据的所有者另有其主。典型容器如std::vectorint、std::arrayint, N、std::string则拥有元素它们的右值在表达式结束就会析构元素所以它们不是borrowed_range。这个概念的内部实现有点绕核心判断依据是“表达式ranges::begin(declvalR())的返回类型是否为引用类型”。借用的范围其begin()返回的迭代器往往能由被借用空间推导出来而拥有数据的容器其迭代器类型不能脱离容器本身存在。标准库据此给出一版启发式定义再配合用户特化enable_borrowed_range手工修偏。写代码时你不必背内部原理但一定要背下这张分类表类型是否 borrowed_range右值时返回迭代器是否安全std::vectorT/std::listT/std::dequeT否不安全返回danglingstd::arrayT, N否不安全返回danglingstd::basic_stringT否不安全返回danglingstd::spanT是安全返回正常迭代器std::basic_string_viewT是安全返回正常迭代器std::ranges::subrangeI, S是安全返回正常子范围裸指针区间T*是安全返回正常迭代器这张表直接决定你调用std::ranges::find对临时 vector 会拿到什么。2.2 dangling 哨兵类型与备用返回路径如果算法接收了一个右值且该范围不是borrowed_range标准库会让返回类型变成一个叫std::ranges::dangling的特殊类型。dangling的定义很简单namespace std::ranges { struct dangling { dangling() default; templateclass... Args constexpr dangling(Args...) noexcept {} }; }它几乎什么都没干却能接受任意参数构造。这意味着代码里到处都有的if (it ! last)、*it、it-foo对它全部失效因为dangling既没有operator*也没有operator-更没有比较运算符。设计哲学很简单与其让你拿到一个悬垂迭代器不如让你拿到一个什么都做不了的占位符让编译错误来提醒你“这里不对劲”。看一个真实例子#include algorithm #include vector #include ranges int main() { auto it std::ranges::find(std::vectorint{1, 2, 3, 4}, 3); // 上面这行能编译it 的类型是 std::ranges::dangling // 下面任意一行都会导致编译错误 // if (it ! std::vectorint{1, 2, 3, 4}.end()) {} // std::cout *it \n; }在传统 STL 里std::find(临时容器.begin(), 临时容器.end(), 3)返回的是普通迭代器完全编译通过运行时才出问题。在 ranges 里同一场景直接编译期给你拦下来。这就是“类型系统治悬垂”的核心价值——把问题从运行时挪到编译期。需要注意的是并不是所有算法都会返回dangling只有那些返回迭代器或子范围的算法才需要处理。像std::ranges::count返回数值、std::ranges::all_of返回布尔值它们在线性遍历过程中得到结果不会把引用带出调用因此不受影响。2.3 借用迭代器类型怎么映射borrowed_iterator_t 与 borrowed_subrange_tranges 算法内部通过两个别名模板来映射返回值templateranges::range R using borrowed_iterator_t conditional_tborrowed_rangeR, iterator_tR, dangling; templateranges::range R using borrowed_subrange_t conditional_tborrowed_rangeR, subrangeiterator_tR, dangling;比如std::ranges::find的返回类型是ranges::borrowed_iterator_tR。当你传左值 vector 时R推导为std::vectorintborrowed_rangevectorint为真因为左值引用语义就是借用返回正常迭代器当你传右值 vector 时R推导为std::vectorint非借用返回dangling。这个设计非常精妙它把“借用关系”内建到了类型映射里。你自己写泛型算法接收范围参数时也应该养成用borrowed_iterator_t声明返回值的习惯而不是无脑返回ranges::iterator_tR。这是从“能用”到“不容易用错”的分水岭。3. 最容易踩的坑filter、transform、临时范围与成员引用3.1 对临时范围调用算法返回值变成 dangling标准库已经帮我们挡下了大部分“算法 临时容器”的悬垂但实际工程里最常见的误区是开发者用std::ranges::begin、std::ranges::end或者视图工厂时不知道同样的问题会以别的面目出现。举个例子views::counted工厂auto sub std::views::counted(std::vectorint{1, 2, 3, 4, 5}.begin(), 3);这条表达式能编译吗答案是不能。counted要求接收的迭代器类型能安全借用而订正规则是看传入的是否 borrowed iterator本质是enable_borrowed_iterator_t的约束。但很多人会试图这么写// 错误示例 auto it std::views::counted(make_vector().begin(), 3);乍一看begin()是从临时容器上取的容器消失后迭代器悬垂已经是老问题ranges 在counted的约束里也对“借用迭代器”做了类型检查所以这种写法在编译期就会失败。整体思路一致能报的错不拖到运行时。真正难办的反倒是那些不经过算法的纯视图链auto values std::views::iota(1, 100) | std::views::filter([](int x) { return x % 3 0; }) | std::views::transform([](int x) { return x * x; });iota_view本身是borrowed_range它不拥有元素元素是即时生成的所以整个视图链怎么传递都不会悬垂。这里能看出来悬垂风险其实集中在“持有真实范围如容器”和“按引用保存底层”的视图上。3.2 filter 视图的经典悬垂场景临时容器 管道符来看这段代码它是我在评审里见过不止一次的写法std::vectorint numbers{1, 2, 3, 4, 5, 6}; // 注意下面这条在 C20 标准下是合法的但非常危险 auto evens numbers | std::views::filter([](int x) { return x % 2 0; });严格说这里numbers是左值filter_view内部通过ref_view借用numbers只要numbers生命周期比evens长完全没问题。但问题在于太容易写成这样了auto make_numbers() { return std::vectorint{1, 2, 3, 4, 5, 6}; } auto evens make_numbers() | std::views::filter([](int x) { return x % 2 0; });make_numbers()返回纯右值 vectorfilter_view 保存的是对这个临时 vector 的引用。完整表达式结束临时 vector 析构evens变成了一个金玉其外的悬垂对象。为什么会这样因为std::views::filter的底层是filter_view它的构造函数参数是转发引用templateranges::input_range R, class Pred filter_view(R r, Pred pred) - filter_viewviews::all_tR, Pred;传入右值时推导出的views::all_tR在 C20 中是ref_viewR或者R。而R如果是std::vectorint右值ref_view不能绑定到右值此时views::all_t就退化成std::vectorint本身——于是 filter_view 内部cpp std::vector vec{1, 2, 3, 4, 5, 6}; auto it std::ranges::find(vec, 4); if (it ! vec.end()) { // ^^^ 这里不能用 it ! std::ranges::end上的临时范围 // 正确姿势在调用算法之前把范围存成具名变量 } }这里的关键是 **视图不能超出底层范围的寿命**这是所有容器、视图生命周期管理的铁律借用的东西永远不能活得比被借用的对象久。 这段讲到临时范围那一节还是会有读者犯迷糊。再强调一遍**任何把纯右值范围直接喂给视图生成器的行为都等于让视图去借用临时对象**。 上面3.1、3.2都是想把这个问题讲透。c23的owning_view是我们安全的缝合点放到下一节好好讲。 ## 4. C23 的新武器owning_view 与 views::all 的变化 ### 4.1 views::all 的语义变化从拷贝到持有 C23 之前views::all 的作用是“把范围适配成一个 view”。对左值范围返回 ref_view对右值借用范围返回 R 本身对右值非借用范围返回……返回 R 本身。没错就是原样拷贝含资源的容器造成上面 filter_view 那样的悬垂隐患。 C23 对它动了一次大手术对右值非借用范围views::all 现在返回 owning_viewR。owning_view 如其名它会**移动构造**容器把临时量的所有权转移到自身内部。容器被视图“接管”视图存活多久容器就存活多久。 cpp // C23 auto evens std::views::all(std::vectorint{1, 2, 3, 4, 5, 6}) | std::views::filter([](int x) { return x % 2 0; }); // evens 是 owning_view 包装的过滤器视图安全你可能会问owning_view不膨胀开销吗理论上有一次移动构造但 vector 的移动构造基本就是拷贝三个指针O(1) 时间可以忽略不计。4.2 用 owning_view 安全地保存临时范围owning_view不是只能给编译器内部用你也可以直接显式构造用它来“把临时范围的寿命绑到当前变量上”。这特别适合查询函数的实现你希望返回一个视图但视图底层的范围是函数内部构造的非永久对象。比如这个经典需求按条件过滤并返回视图但业务层希望返回类型是独立的#include ranges #include vector #include string // 返回一个能安全持有的过滤视图 auto even_squares(int limit) { // 生成 1..limit 的 vector存到 owning_view 里 std::vectorint tmp; for (int i 1; i limit; i) tmp.push_back(i * i); return std::views::all(std::move(tmp)) | std::views::filter([](int x) { return x % 2 0; }); };此处std::move(tmp)让owning_view接管 vector返回的视图链能在函数外部继续安全遍历。不过需要提醒一下owning_view返回的视图不可复制只能移动因为它内部持有的是唯一所有权。写泛型容器时要注意拷贝语义要求如果你代码里要用std::function或者std::vector存多个视图尽量把视图存成std::move_only_function或直接存owning_view的移动包装。为什么 C23 才补上这个特性因为标准委员会花了很长时间才意识到“视图工厂接临时量”这种写法在真实代码里太常见。C20 发布时他们更在意概念纯净性把 view 定义为“不拥有数据”的对象结果逼出了一堆悬垂用法。owning_view其实是对现实妥协承认有时视图不得不做数据所有者。4.3 依赖 ranges 的现代代码怎么设计才不容易出问题使用owning_view不等于万事大吉。工程里我更推荐用一套组合拳从代码结构上避开悬垂雷区。第一视图链的源头尽量使用具名变量。今天搜代码大量悬垂来自临时函数返回值直接喂给视图生成器。给范围起名、加引用、保持生命周期清晰成本极低收益极高。第二优先返回借用范围而不是视图链。如果你的仅需要一个可遍历的子序列用std::ranges::subrange返回“迭代器对容器”更安全。subrange是 borrowed 的它不拥有任何东西调用方在使用时需要保证底层对象存活——这个契约一眼就能看懂不容易产生“auto 存起来就能活”的错觉。第三谨慎使用带有状态引用的谓词或投影。filter的谓词如果捕获了外部变量一旦外部变量生命周期结束视图仍然在调用这个谓词同样是隐蔽悬垂auto make_predicate() { int limit 5; return [limit](int x) { return x limit; }; } auto filtered vec | std::views::filter(make_predicate()); // 返回的视图链在调用 filter 谓词时limit 已经销毁第四用模板约束在编译期收紧接口。写接收范围参数的函数时如果函数内部取迭代器然后返回返回值最好用borrowed_iterator_t如果函数返回子范围用borrowed_subrange_t。别嫌麻烦你写下这个类型映射的时候就把“右值非借用”挡在了编译期。第五C23 里多用ranges::to不过它还在标准演进中或std::views::all(std::move(...))完成“物化视图化”的过渡避免在引用上滑来滑去。5. 排查与实践经验编译期、调试期与代码规范5.1 如何用类型系统在编译期发现隐患遇到疑似悬垂的代码第一步就是让编译器告诉你类型到底是什么。用一个小技巧强制实例化一个错误来打印类型templatetypename T struct TypePrinter; // 故意不定义 int main() { auto evens std::views::all(std::vectorint{1, 2, 3}) | std::views::filter([](int x) { return x % 2 0; }); TypePrinterdecltype(evens) tp; // 编译错误错误信息里会显示完整类型 }这个技巧在写模板、推导复杂视图链时特别好用。如果类型是ranges::owning_viewstd::vectorint或ranges::filter_viewranges::ref_viewvectorint, ...说明底层范围被持有了如果类型是ranges::filter_viewstd::vectorint, ...多半是在接收临时 vector典型悬垂雷区。还有一个静态断言大法在编写公共接口时强制约束参数templatetypename Range void process(Range r) { static_assert(std::ranges::borrowed_rangeRange, process() requires an lvalue or borrowed range); // ... }这样如果调用者把make_vector()的结果直接传进来编译直接失败比运行时崩溃友好一百倍。5.2 调试时的技巧与工具即使到了运行期也有很多方式抓住悬垂访问。我常用“三件套”第一ASan UBSan。AddressSanitizer 对堆内存的 use-after-free 极其敏感。临时 vector 在堆上分配缓冲区视图悬垂后第一次解引用迭代器ASan 立刻报错栈回溯能直接指向过滤器里的表达式。带你写# 以 gcc/clang 为例 g -stdc23 -g -fsanitizeaddress,undefined -fno-omit-frame-pointer main.cpp -o demo ./demo如果代码跑了半天才随机崩溃先怀疑悬垂再用 ASan 跑一遍大概率直接给出“heap-use-after-free”的报告。第二比较迭代器是否越界。有些场景下你拿到的迭代器没有真正悬垂但它指向的元素已经被移动走了比如std::pmr::vector重新分配内存。这种我一般用std::ranges::distance(view.begin(), view.end())打个日志看距离和预期是否一致快速定位哪一环的数据被换掉了。第三刻意缩短临时量生命周期看是否崩溃。构造一个最小浮现用例用一个函数返回临时 vector然后直接遍历视图链。如果代码一进入循环就崩那基本可以确诊悬垂如果还能跑继续缩小范围逐步删减视图环节确定哪个适配器出了问题。5.3 团队实践经验与代码审查清单我把这些年踩过的坑总结成一个审查清单每次 code review 里遇到 ranges 都能对着号视图链的底层范围是否为纯右值如果是有没有用owning_view/std::views::all(std::move(x))接管返回视图的函数视图的底层范围来源是哪来源是函数局部变量吗局部变量析构视图还活着吗filter的谓词、transform的投影函数有没有捕获外部局部对象捕获的是引用还是值算法调用前参数范围是否可能用右值容器直接传入返回值用了borrowed_iterator_t吗在 range 管道的中间有没有对auto推导的临时值取begin()取完以后临时值还活着吗有没有直接把类成员 vector 的迭代器存到类外面成员 vector 被重新分配比如push_back扩容后迭代器失效这类问题跟 ranges 无关但经常混在一起出现。把这些写进团队规范比靠个人自觉强太多。我甚至见过自动化 CI 脚本扫描所有包含views::filter(、views::transform(的代码行人工确认右侧是否出现make_xxx()、[](...){ return ...; }这类函数调用减少临时右值混入视图链的风险。这些规则虽然简陋但确实拦住过线上故障。6. 常见问题速查表症状大概率原因解决方案调用ranges::find(临时vector, val)返回类型是dangling临时非借用范围传入算法改用具名变量保存容器或使用owning_view/ranges::to视图链由临时容器构造编译成功但运行崩溃filter_view/transform_view 保存了已析构容器的引用给临时量一个owning_view包装auto evens make_numbers() | views::filter(...)后遍历无输出或随机值filter_view 内部持有了临时 vector显式views::all(std::move(tmp))接管所有权返回视图链的函数在函数外不能正常使用函数内定义的容器被析构范围借用失效返回owning_view或务必让容器生命周期覆盖到调用方transform 投影返回了成员引用但容器被resize导致引用失效代理引用和指针迭代器的典型问题尽量避免 transform 返回引用必须返回引用时确认底层容器不再扩容谓词捕获局部变量视图链活着但谓词里的局部变量没了谓词生命周期和视图不匹配谓词按值捕获或确保捕获对象生命周期足够长counted(临时容器.begin(), n)编译失败非借用迭代器传给借用约束先具名保存容器再取 beginstatic_assert(borrowed_rangedecltype(view))失败某层视图内部藏了非借用范围检查视图链每个环节的来源范围是否 borrowable这些表里的场景每一个我都写进过测试用例。每次踩完坑补一个最小复现用例放进测试集以后任何人再回归到这些API都能第一时间暴露问题。最后说一点个人体会。我在实际使用中养成了两个习惯一是尽量让视图链的“最底层”来自可以借用且轻量的范围像string_view、span、subrange它们天然的 borrowed 属性让整个管道都安全二是不要迷信“编译器帮我挡了”编译期拦截挡得住库接口层挡不住你的谓词捕获和成员引用真正的安全最后还是靠对自己写的每一条视图管道的生命周期心中有数。写 ranges 代码说到底是在用借用的思维翻译一段数据的变换过程谁借的、借多久、什么时候还想清楚这些悬垂基本就追不上你。