C++20 ranges悬垂问题全解析:从borrowed_range到owning_view
2026/9/10 12:40:43 网站建设 项目流程

接手过不少项目代码评审,最怕看到的一种 bug 就是:filter_view握着已销毁容器的迭代器,循环体里第一次解引用就崩溃,或者更糟——什么都不崩,每次运行结果还不一样,线上数据算错一整天,最后定位到头发现是std::ranges的视图悬垂。

C++20 的std::ranges是近年标准库最大的变革,它把“算法接收迭代器对”改成了“接收范围对象”,让管道写法变得极其顺手。但顺手不等于安全,尤其是**引用悬垂(dangling reference)**问题,ranges 在解决一部分老悬垂的同时,也借助“返回值类型体系”做了大量防护,只是这些防护机制很多 C++ 开发者并不清楚。如果你已经在写 ranges 代码,或者正准备从传统 STL 算法迁移到 ranges,这篇文章就是帮你把“哪些写法会悬垂、为什么悬垂、标准库怎么拦、你自己怎么拦”彻底想明白的实物地图。

我会先讲悬垂是怎么来的,再拆解borrowed_rangedangling这两个核心机制,然后逐个看filtertransform、临时范围、成员引用这些最常出事的场景,最后给出一套从编译期到运行期的排查手段。唠嗑方式偏工程实践,尽量不跟你拽标准措辞。

1. 悬垂是怎么找上门的:从传统 STL 讲到 ranges

1.1 老 STL 时代:迭代器悬垂是“用户自己的责任”

传统 STL 算法几乎都接收迭代器对,比如std::find(first, last, value)。迭代器本质上是指向容器元素的“指针带封装”,它不拥有数据。你传进去的firstlast如果来自一个临时容器,那这个容器在完整表达式结束时就被析构了,返回的迭代器自然指向垃圾内存。

// 典型的老式悬垂写法 std::vector<int> 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::vector<int>{1, 2, 3, 4, 5, 6} | std::views::filter([](int n) { return n % 2 == 0; });

这里std::views::filter构造了一个filter_view,它内部存了底层范围的“借用”,而底层vector<int>是个纯右值临时量。分号一结束,vector 销毁,filter_view就成了悬垂对象。可evens这个变量本身还活着,你拿去遍历就是未定义行为。

传统 STL 时代你不传临时容器就不会出事,而 ranges 时代你只是写了个管道表达式,就把临时容器“吸”进了视图里。这种问题肉眼极难发现,因为表达式写起来实在太流畅了。

1.3 两类悬垂的本质区别

我把悬垂分成两类,方便后面理解整个防护体系:

一类是用户生命周期管理失误。容器还活着或者已销毁但你在销毁后继续用迭代器,这种责任在用户,任何语言都救不了滥用生命周期的人。

另一类是库接口设计引发的可预期悬垂。你按正常语法调用,但库接口允许把不拥有资源的引用返回给用户存着,标准库明明知道这样做有风险,却什么都不拦。比如上面的filter_view接临时 vector——这就是接口设计上允许了“危险动作”发生。

std::ranges的设计思路很直接:在类型层面区分“拥有资源的范围”和“借用外部资源的范围”,对前者拒绝在右值语境下返回可用的迭代器或子范围。这就是borrowed_range概念和dangling哨兵类型要做的事。

2. ranges 的官方对策:borrowed_range 与 dangling

2.1 borrowed_range 概念是怎么工作的

std::ranges::borrowed_range<R>用来回答一个问题:“当 R 类型的右值作为参数传给算法时,返回的迭代器/子范围是否能安全脱离算法调用而继续使用?”

如果一个范围类型被视为borrowed_range,意味着它本身不拥有元素,元素活在别处,比如std::span<T>std::string_view、裸指针区间T* + std::size_tstd::ranges::subrange。这些类型被右值传递时,里面的指针或引用指向的数据仍然有效,因为数据的所有者另有其主。典型容器如std::vector<int>std::array<int, N>std::string则拥有元素,它们的右值在表达式结束就会析构元素,所以它们不是borrowed_range

这个概念的内部实现有点绕,核心判断依据是“表达式ranges::begin(declval<R&>())的返回类型是否为引用类型”。借用的范围,其begin()返回的迭代器往往能由被借用空间推导出来,而拥有数据的容器,其迭代器类型不能脱离容器本身存在。标准库据此给出一版启发式定义,再配合用户特化enable_borrowed_range手工修偏。

写代码时你不必背内部原理,但一定要背下这张分类表:

类型是否 borrowed_range右值时返回迭代器是否安全
std::vector<T>/std::list<T>/std::deque<T>不安全,返回dangling
std::array<T, N>不安全,返回dangling
std::basic_string<T>不安全,返回dangling
std::span<T>安全,返回正常迭代器
std::basic_string_view<T>安全,返回正常迭代器
std::ranges::subrange<I, S>安全,返回正常子范围
裸指针区间T*安全,返回正常迭代器

这张表直接决定你调用std::ranges::find对临时 vector 会拿到什么。

2.2 dangling 哨兵类型与备用返回路径

如果算法接收了一个右值且该范围不是borrowed_range,标准库会让返回类型变成一个叫std::ranges::dangling的特殊类型。dangling的定义很简单:

namespace std::ranges { struct dangling { dangling() = default; template<class... Args> constexpr dangling(Args&&...) noexcept {} }; }

它几乎什么都没干,却能接受任意参数构造。这意味着代码里到处都有的if (it != last)*itit->foo对它全部失效,因为dangling既没有operator*,也没有operator->,更没有比较运算符。设计哲学很简单:与其让你拿到一个悬垂迭代器,不如让你拿到一个什么都做不了的占位符,让编译错误来提醒你“这里不对劲”。

看一个真实例子:

#include <algorithm> #include <vector> #include <ranges> int main() { auto it = std::ranges::find(std::vector<int>{1, 2, 3, 4}, 3); // 上面这行能编译,it 的类型是 std::ranges::dangling // 下面任意一行都会导致编译错误 // if (it != std::vector<int>{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_t

ranges 算法内部通过两个别名模板来映射返回值:

template<ranges::range R> using borrowed_iterator_t = conditional_t<borrowed_range<R>, iterator_t<R>, dangling>; template<ranges::range R> using borrowed_subrange_t = conditional_t<borrowed_range<R>, subrange<iterator_t<R>>, dangling>;

比如std::ranges::find的返回类型是ranges::borrowed_iterator_t<R>。当你传左值 vector 时R推导为std::vector<int>&borrowed_range<vector<int>&>为真(因为左值引用语义就是借用),返回正常迭代器;当你传右值 vector 时R推导为std::vector<int>,非借用,返回dangling

这个设计非常精妙,它把“借用关系”内建到了类型映射里。你自己写泛型算法接收范围参数时,也应该养成用borrowed_iterator_t声明返回值的习惯,而不是无脑返回ranges::iterator_t<R>。这是从“能用”到“不容易用错”的分水岭。

3. 最容易踩的坑:filter、transform、临时范围与成员引用

3.1 对临时范围调用算法,返回值变成 dangling

标准库已经帮我们挡下了大部分“算法 + 临时容器”的悬垂,但实际工程里最常见的误区是:开发者用std::ranges::beginstd::ranges::end或者视图工厂时,不知道同样的问题会以别的面目出现。

举个例子,views::counted工厂:

auto sub = std::views::counted(std::vector<int>{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::vector<int> numbers{1, 2, 3, 4, 5, 6}; // 注意:下面这条在 C++20 标准下是合法的,但非常危险 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::vector<int>{1, 2, 3, 4, 5, 6}; } auto evens = make_numbers() | std::views::filter([](int x) { return x % 2 == 0; });

make_numbers()返回纯右值 vector,filter_view 保存的是对这个临时 vector 的引用。完整表达式结束,临时 vector 析构,evens变成了一个金玉其外的悬垂对象。

为什么会这样?因为std::views::filter的底层是filter_view,它的构造函数参数是转发引用:

template<ranges::input_range R, class Pred> filter_view(R&& r, Pred pred) -> filter_view<views::all_t<R>, Pred>;

传入右值时,推导出的views::all_t<R>在 C++20 中是ref_view<R>或者R。而R如果是std::vector<int>右值,ref_view不能绑定到右值,此时views::all_t就退化成std::vector<int>本身——于是 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都是想把这个问题讲透。c++23的owning_view是我们安全的缝合点,放到下一节好好讲。 ## 4. C++23 的新武器:owning_view 与 views::all 的变化 ### 4.1 views::all 的语义变化:从拷贝到持有 C++23 之前,`views::all` 的作用是“把范围适配成一个 view”。对左值范围返回 `ref_view`,对右值借用范围返回 `R` 本身,对右值非借用范围返回……返回 `R` 本身。没错,就是原样拷贝含资源的容器,造成上面 filter_view 那样的悬垂隐患。 C++23 对它动了一次大手术:对右值非借用范围,`views::all` 现在返回 `owning_view<R>`。`owning_view` 如其名,它会**移动构造**容器,把临时量的所有权转移到自身内部。容器被视图“接管”,视图存活多久,容器就存活多久。 ```cpp // C++23 auto evens = std::views::all(std::vector<int>{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::vector<int> 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的移动包装。

为什么 C++23 才补上这个特性?因为标准委员会花了很长时间才意识到,“视图工厂接临时量”这种写法在真实代码里太常见。C++20 发布时他们更在意概念纯净性,把 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。别嫌麻烦,你写下这个类型映射的时候,就把“右值非借用”挡在了编译期。

第五,C++23 里多用ranges::to(不过它还在标准演进中)或std::views::all(std::move(...))完成“物化+视图化”的过渡,避免在引用上滑来滑去。

5. 排查与实践经验:编译期、调试期与代码规范

5.1 如何用类型系统在编译期发现隐患

遇到疑似悬垂的代码,第一步就是让编译器告诉你类型到底是什么。用一个小技巧,强制实例化一个错误来打印类型:

template<typename T> struct TypePrinter; // 故意不定义 int main() { auto evens = std::views::all(std::vector<int>{1, 2, 3}) | std::views::filter([](int x) { return x % 2 == 0; }); TypePrinter<decltype(evens)> tp; // 编译错误,错误信息里会显示完整类型 }

这个技巧在写模板、推导复杂视图链时特别好用。如果类型是ranges::owning_view<std::vector<int>>ranges::filter_view<ranges::ref_view<vector<int>>, ...>说明底层范围被持有了;如果类型是ranges::filter_view<std::vector<int>, ...>,多半是在接收临时 vector,典型悬垂雷区。

还有一个静态断言大法,在编写公共接口时强制约束参数:

template<typename Range> void process(Range&& r) { static_assert(std::ranges::borrowed_range<Range>, "process() requires an lvalue or borrowed range"); // ... }

这样如果调用者把make_vector()的结果直接传进来,编译直接失败,比运行时崩溃友好一百倍。

5.2 调试时的技巧与工具

即使到了运行期,也有很多方式抓住悬垂访问。我常用“三件套”:

第一,ASan + UBSan。AddressSanitizer 对堆内存的 use-after-free 极其敏感。临时 vector 在堆上分配缓冲区,视图悬垂后第一次解引用迭代器,ASan 立刻报错,栈回溯能直接指向过滤器里的表达式。带你写:

# 以 gcc/clang 为例 g++ -std=c++23 -g -fsanitize=address,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)编译失败非借用迭代器传给借用约束先具名保存容器再取 begin
static_assert(borrowed_range<decltype(view)>)失败某层视图内部藏了非借用范围检查视图链每个环节的来源范围是否 borrowable

这些表里的场景,每一个我都写进过测试用例。每次踩完坑,补一个最小复现用例放进测试集,以后任何人再回归到这些API,都能第一时间暴露问题。

最后说一点个人体会。我在实际使用中养成了两个习惯:一是尽量让视图链的“最底层”来自可以借用且轻量的范围,像string_viewspansubrange,它们天然的 borrowed 属性让整个管道都安全;二是不要迷信“编译器帮我挡了”,编译期拦截挡得住库接口层,挡不住你的谓词捕获和成员引用,真正的安全最后还是靠对自己写的每一条视图管道的生命周期心中有数。写 ranges 代码,说到底是在用借用的思维翻译一段数据的变换过程,谁借的、借多久、什么时候还,想清楚这些,悬垂基本就追不上你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询