资讯动态

Rust 两阶段借用(Two-Phase Borrows)深度解析:RFC 2025 如何让 `vec.push(vec.len())` 成为合法代码

发布时间:2026/10/16 2:14:20 来源:尧图企业网站定制
文档【免费下载链接】rfcsRFCs for changes to Rust项目地址https://gitcode.com/gh_mirrors/rf/rfcs点击查看免费下载导读vec.push(vec.len())这类外层是mut self方法、参数又读取同一变量的嵌套方法调用长期以来是 Rust 初学者最容易撞上的编译错误也是老手反复遇到的困扰。本篇文章以 RFC 2025 原文 为骨架系统讲解其核心方案——在 MIR 层面引入两阶段借用two-phase borrow让mut借用先经历一个仅相当于共享借用的保留reservation阶段待引用真正被使用时再激活activation为完整的可变借用。读完本文你将掌握该机制的完整设计细节a mut2MIR 新形式、borrowck 集成算法、激活点安全检查理解它为什么刻意只覆盖方法调用这一种语法形态并了解五种备选方案各自的取舍。背景为什么vec.push(vec.len())一开始就是错的从 MIR 视角看错误的根源要理解两阶段借用的价值首先得弄清一个看似自然的写法为何被拒绝。把vec.push(vec.len())其中vec: Vecusize稍微简化后转成 MIR其形态大致如下真实 MIR 为了处理 panic 会有多个基本块此处略去/* 0 */ tmp0 a mut vec; // -- mutable borrow starts here /* 1 */ tmp1 b vec; // -- shared borrow overlaps here /* 2 */ tmp2 Vec::len(tmp1); /* 3 */ EndRegion(b); // -- shared borrow ends here /* 3 */ Vec::push(tmp0, tmp2); /* 5 */ EndRegion(a); // -- mutable borrow ends here问题一目了然tmp0先对vec取了可变引用这把vec锁定到Vec::push()调用之后然而在调用Vec::len()时又要再次访问vec于是冲突报错。需要说明的是MIR 中的EndRegion注解是当时 borrowck 赖以工作的产物。在随后的 NLL非词法生命周期时代这些语句不复存在区域将完全依据引用被使用的位置来推断详见仓库中的 NLL RFC 2094但上述思想大体不变。危险的例子为什么不能简单关掉检查看到上面的反例后一个自然的想法是那直接把这条检查关掉不就行了RFC 明确指出这是行不通的因为确实存在导致崩溃的真实风险。考虑下面这个刻意构造的程序let mut v: VecString vec![format!(Hello, )]; let s: String format!(foo); v[0].push_str({ v.push(s); World! }); // ^^^^^^^^^ sneaky attempt to mutate v转成 MIR 后它大致是// First evaluate v[0] to get a mut String: tmp0 mut v; tmp1 IndexMut::index_mut(tmp0, 0); tmp2 tmp1; // Next, evaluate { v.push(s); World! } block: tmp3 mut v; tmp4 s; Vec::push(tmp3, tmp4); tmp5 World!; // Finally, invoke push_str: String::push_str(tmp2, tmp5);危险在于先把v[0]求值成一个引用之后Vec::push()却可能 resize 向量、改变元素地址从而让先前那个引用悬空。Rust 类型系统天然阻止了这一点——tmp0 mut v的借用一直延续到最后一次push_str()调用。即便是vec.push(vec.len())这样接收者只是局部变量的情形也必须警惕不能允许参数把接收者的所有权转手送走例如vec.push({ send_to_another_thread(vec); ... })必须依然是错误。当然这些块状复杂参数看起来非常人工但请记住实践中这类场景大多发生在参数是另一个方法/函数调用时而调用原则上可以有任意副作用。引入保留概念RFC 的提案是在 MIR 中引入两阶段借用作为可变借用的一种变体值起初处于保留reserved状态只有当结果引用第一次被使用时称为激活该借用才真正成为可变借用。在激活之前保留阶段的借用表现得和共享借用完全一致——因此被借用值仍然可以被读取。回过头看vec.push(vec.len())当该表达式被脱糖desugar后结果引用存入临时变量tmp0在tmp0被再次引用之前vec仅被视为保留/* 0 */ tmp0 mut2 vec; // reservation of vec starts here /* 1 */ tmp1 vec; /* 2 */ tmp2 Vec::len(tmp1); /* 3 */ Vec::push(tmp0, tmp2); // first use of tmp0, upgrade is heretmp0的第一次使用在第 3 行可变借用从那一点才开始并持续到借用区域结束。关键的是第 1、2 行对vec的共享借用发生在保留期内因此不报错——因为保留等价于共享借用而多个共享借用是被允许的。再来看偷偷改写接收者的危险例子。观察其 MIR会发现v的借用几乎立即就被用于IndexMut操作// First evaluate v[0] to get a mut String: tmp0 mut2 v; tmp1 IndexMut::index_mut(tmp0, 0); // tmp0 used here! tmp2 tmp1; // Next, evaluate { v.push(s); World! } block: tmp3 mut2 v; // -- Error! mutable borrow of v is active. ... // see above这意味着可变借用稍后已经处于激活状态当参数部分再次借用v时仍然会报错——安全性毫发无损。同样的处理也会拒绝一些看似无害的例子比如v[0].push_str(format!({}, v.len()));它看似与例 1 类似但这里v的可变借用已经被索引操作激活因此调用v.len()时v处于被可变借用状态而非保留状态于是报错。详细设计MIR 中两阶段借用的新形式当前 MIR 中借用的 rvalue 有三种形式纯内部语法MIR 并无公开的书面表示a lvalue a mut lvalue a unique lvalue无论哪种形式rvalue 都返回一个生命周期为a、指向lvalue地址的引用。其中unique引用是仅存在于 MIR 的内部概念在闭包脱糖时使用Rust 表层语法没有对应物。RFC 提出增加第四种形式a mut2 lvalue。和unique类似它由编译器在脱糖阶段生成短期内没有直接的用户表示形式。对绝大多数场景而言mut2借用与mut借用行为完全一致区别只在于借用检查器会按后文所述方式特殊对待它。何时生成两阶段借用两阶段借用只在一种特定情形下产生对mut self方法调用的脱糖。当前初始生成的 MIR 中调用此类方法总是插入一个 auto-mut-ref因为vec.push()中vec: mut Veci32被视为对vec的借用而非移动。该 auto-mut-ref 将从mut改为mut2。这是有意为之的保守一步。将来若有必要该机制可以扩展到其他语法形式或被非词法生命周期、或生命周期系统的某种其他泛化所吸收。将保留借用集成进借用检查器现有 MIR borrowck 算法RFC 的修复方案基于即将到来的 MIR borrowck其基本结构可转述如下MIR 中每个借用都形如lv1 r lv2或lv1 r mut lv2其中lv1、lv2是 MIR lvalue命名内存位置的路径r是借用的持续时间。每个借用以其位置P形如BB/nBB是所在基本块n是块内序号命名。位置P处的借用对所有从P出发、不经过区域r终点的可达点都是存活的。可用标准数据流分析计算任意点的全部存活借用集合。对点P处对 lvaluelv_w的每次写写 可变借用mut lv_w或赋值lv_w ...若存在某路径lv_b的存活借用可变或共享与lv_w可能重叠则报错。对点P处对 lvaluelv_r的每次读读 以lv_r作为操作数的任何使用若存在某路径lv_b的存活可变借用与lv_r可能重叠则报错。提出的变更当 borrowck 遇到mut2借用时处理略有不同。由于mut2借用的生成位置受限可以确定它只会出现在把值赋给 MIR 临时变量的语句中tmp r mut2 lv此时路径lv最初被视为保留。临时变量tmp只会被使用一次——作为实际调用的参数就在那个点路径lv被视为被可变借用。在安全检查层面保留与共享借用等价点P处对lv的写是非法的若该点存在lv的任何活跃借用或在作用域内的保留。点P处对lv的读是合法的若仅存在保留但存在可变借用则不行。此外需要一项新检查。在可变借用被激活的点Q必须确认作用域内不存在其他活跃借用或保留被升级的那个保留本身除外。否则像下面的测试就可能错误通过fn fooa(x: a Veci32) - a i32 { x[0] } let mut v vec![0, 1, 2]; let p; v.push({p foo(v); 3}); use(*p);脱糖为 MIR 后近似如下tmp0 a mut2 v; // reservation begins tmp1 b v; // shared borrow begins; allowed, because v is reserved p foo(tmp1); Vec::push(tmp0, 3); // mutable borrow activated EndRegion(a); // mutable borrow ends tmp2 *p; // shared borrow still valid! use(tmp2) EndRegion(b);这里在调用Vec::push()之前就创建了对v[0]的借用并且事后继续使用它——这本不应被接受。若没有激活点的新检查它却可能通过共享借用开始时v处于保留状态可变借用在其后激活但仍落在共享借用的作用域内。当今的 borrowck 中这种情况不可能发生因此只在借用开始时检查是否有其他借用处于作用域新机制必须补上激活点这一环。如何教学由于改动极为聚焦RFC 认为初级教材如 The Rust Programming Language、Rust By Example无需展开讲解内部机制——它只是让直觉上应该能编译的vec.push(vec.len())变得能编译而已。但有两点相关主题值得在未来合适的教材中覆盖当人们尝试本提案未覆盖的更复杂方法调用如v[0].push_str(format!({}, v.len()))而碰壁时可以用简单的脱糖示例解释编译器为何拒绝——与vec.push(vec.len())的对比尤其有教学价值。讨论中浮现过一个有趣误区许多人以为vec.push(vec.len())会被脱糖成let tmp vec.len(); vec.push(tmp)。实际上并非如此——vec和其他参数一样被求值为临时变量并施加 autoref 等处理。讲解方法脱糖机制时值得澄清这一点。这些规则的详细覆盖更适合放在 Rust Reference 中作为 MIR 脱糖与 borrowck 工作原理总体章节的一部分当时尚不存在该章节。缺点该提案明显的短板是只针对方法调用形态这意味着方法调用的手动脱糖版本不一定能通过编译——尤其当用户忠实复刻编译器的做法时。RFC 给出了几个实践上影响不大的理由实践中很少有人需要手动脱糖方法调用。需要脱糖时人们通常有大量余地调整语句顺序来达到同样效果——实际上每次被迫把vec.push(vec.len())改写为先把vec.len()存入临时变量你就是在做这种部分脱糖。真正忠实的脱糖本就罕见许多人会忽略 autoref 与精确求值顺序的角色更少有人能精确把握临时变量的生命周期。但不可否认该改动略微拉大了表层语言与 MIR 底层脱糖视图之间的鸿沟而一般而言这应尽量避免。备选方案围绕嵌套方法调用曾提出过多个重大替代设计。本提案有意与它们全部向前兼容但一个都不采纳。逐一说明如下。方案 A修改脱糖顺序——先求值参数、后求值接收者当前a.foo(b..z)的脱糖顺序是处理a并施加 autoref 等得到tmp0把b..z求值为临时变量得到tmp1..tmpN调用foo(tmp0..tmpN)备选方案是在某些条件下把a推迟求值把b..z求值为临时变量得到tmp1..tmpN处理a并施加 autoref 等得到tmp0调用foo(tmp0..tmpN)由于向后兼容约束重排序能适用的范围有限显然不能改变a().foo(b())这类带副作用复杂表达式的脱糖。事实上连a.foo(b)这样的简单表达式也可能是破坏性变更——如果方法是fn(self)声明的话trait Foo { fn foo(self, a: ()) - Self; } impl Foo for i32 { fn foo(self, a: ()) - Self { self } } let mut a 3; let b a.foo({ a 1; () }); // returns 3可行的规则集大致是仅当 (1) 被调方法foo()是mut self方法且 (2) 接收者仅是局部变量a的引用时才使用新顺序。这样vec.push(vec.len())会被接受而v[0].push(...)不会。该方案的吸引力在于更简单、易于解释不引入任何新概念borrowck 照旧工作只是把本就要做的脱糖换个顺序且只影响 autoref 适用的情形。但它也有自己的意外。把上面例子中的foo改成mut selftrait Foo { fn foo(mut self, a: ()) - Self; } impl Foo for i32 { fn foo(mut self, a: ()) - Self { *self } } let mut a mut 3; let b a.foo({ a mut 4; () }); // returns 4当前代码无法编译但在该方案下它会编译方法为mut self、接收者为简单变量引用于是参数求值的副作用突然变得可见。权衡之下RFC 作者认为让 borrowck 分析变复杂优于让脱糖与执行顺序变复杂。方案 B保留期内允许更多操作本提案中保留借用的行为与共享借用完全相同。原则上可以允许更多——任何不会使已创建引用失效的操作。例如可让共享引用也启用两阶段借用从而允许对被借用内容的写入。一个例子// pretend you could define an inherent method on integers // for a second, just to keep code snippet simple impl i32 { fn increment(mut self, v: i32) - i32 { *self v; *self // returns new value } } fn foo() { let mut x 0; let y x.increment(x.increment(1)); // what result do you expect from this? println!({}, y); }x.increment(x.increment(1))会脱糖为tmp0 mut2 x; tmp1 mut2 x; tmp2 1; tmp3 i32::increment(tmp1, tmp2); // activates tmp1 i32::increment(tmp0, tmp3); // activates tmp0在现有提案下这非法创建tmp1时x处于保留状态不允许对已保留的 lvalue 再取mut2借用放宽限制则会接受输出2。RFC 不采纳的理由有二这会让 borrowck 更复杂——不仅要引入两阶段借用还要细化一整套新限制而当前提案直接复用现有共享借用类别。主要收益是允许交错两个可变调用如上例或外层共享借用 内层可变借用。这隐含着精心安排的变更顺序外层方法调用会观察到内层调用造成的状态变化。这种场景反而有益于让用户把两个调用拆开使相对副作用清晰可见。当然未来可以随时放宽规则。方案 C更广泛地使用两阶段借用最初的提案见当时的博客文章更为激进在 MIR 层面把所有可变借用都转成两阶段借用。由于 MIR 的生成方式用户在某些情况下能观察到这两个阶段。例如下面的代码在当时可以通过类型检查今天和本 RFC 下都不行let tmp0 mut vec; // vec is reserved let tmp1 vec.len(); // shared borrow of vec; ok Vec::push(tmp0, tmp1); // mutable borrow of vec is activated本 RFC 放弃了这一激进姿态理由是把一切可变借用都视为可推迟会让普通用户每天都面对该概念而由此产生的心智模型令人不满意。由于 MIR 的生成方式推迟的借用绝大多数场景下几乎立即被激活只有在借用被let声明立即赋给变量时才有效。这导致两段代码被区别对待let x mut vec; // reserved // versus: let x; x mut vec; // immediately activated唯一的解释途径是考察其 MIR 脱糖——第二种情形引入了中间临时变量tmp0 mut vec; // reservation starts x tmp0; // borrow is activated问题根源在于该分析不是基于类型而是基于 MIR 变量和控制流图上的点。这意味着借用是否被激活会受到let x y这类空操作被视为对y的使用的影响。因此在方法调用脱糖之外引入两阶段借用并非正确方向。但若如本 RFC 所限仅用于方法调用脱糖它就是简单有效的机制不会产生更广影响。方案 D为未来借用Borrowing for the future另一种早期思路是为未来借用目前任何借用产生的引用都立即可用即引用生命周期必须包含借用发生点该方案放宽此规则允许产生的引用暂时不可用、只能在未来某个点使用其间被借用路径必须视为保留与本 RFC 的保留语义大致相同以保证引用不被失效。看vec.push(vec.len())的朴素脱糖版本为每一小段显式标注生命周期call: { let v: invoke mut Vecusize; let l: usize; eval_args: { eval_v: { v eval_l vec; } eval_l: { l Vec::len(v); } } invoke: { Vec::push(v, l); } }可见v invoke mut vec借用了vec一个尚未开始的未来生命周期invoke——即制造一个要交给这个函数的引用但在交付前不会使用。由于引用尚未活跃使用可用更宽松的限制但路径vec仍需视为保留以保证指针有效性不能让人把vec送进另一个线程之类。这些规则看似可以整合进非词法生命周期的概念中——把区域必须包含变量存活的每个点 P改为区域要么包含 P要么是这种未来区域。细节尚需推敲但那样会形成更自洽、可教的模型简言之创建引用后引用活跃使用的代码区间受限而通往该区间的代码把该值视为已被共享。方案 ERef2——两阶段生命周期在 internals 讨论中arielb1 提出了两阶段生命周期提案目标是把两阶段概念纳入生命周期推断而非像本 RFC 那样在 borrowck 中处理。其思路是定义类型RefMutr, w, T原型名为Ref2Φimmut, mutbl, T作为更丰富的mut类型带有两个生命周期r读生命周期包含引用将来可能被使用的每个点。wr的子集r: w表示写生命周期包含引用被积极写入的点。任何a mut T类型可保守地翻译为RefMuta, a, T——结构体声明或函数接口中出现的mut均如此处理。但对函数体内的mut T类型两个生命周期可分别推断r按 NLL 方式计算w只需包含发生写的点。borrowck 将保证每个mut借用的w区域与其他借用及共享借用的r区域不相交。该提案潜力很大但每个应用方向都伴随复杂度代价不连续借用Discontinuous borrows。该提案接受的程序比本 RFC 更多包括交错读写/* 0 */ let mut i 0; /* 1 */ let p: RefMut{2-5}, {3,5}, i32 mut i; // ^^^^^ ^^^^^ // r w /* 2 */ let j i; // just in r /* 3 */ *p 1; // must be in w /* 4 */ let k i; // just in r /* 5 */ *p 1; // must be in w写区域被推断为 3 和 5 两个点其间允许读。这种不连续借用代表系统复杂度的跃升若整合进生命周期推断区域将带有角色r捕获活性约束w捕获活动性。而示例本身是人工构造的现实中几乎见不到因此 RFC 倾向避免增长系统底层复杂度。此外Ralf Jung 的 RustBelt 形式化工作是一种能接受大量超出标准 Rust 程序的 Rust 泛化模型两阶段借用提案可较直接地嵌入其中但不连续区域会要求该系统更强表达力——这从侧面说明它使 Rust 系统在推理上发生质的复杂化。最直接的体现是教学不连续借用让构造演示借用的小例子更难底层规则更复杂、更难以直觉把握。把这些细节做对是一项重大设计挑战超出本 RFC 范围。从可变降级为共享Downgrading mutable to shared。该提案的另一个可能实现的目标是支持降级 mut 为 shared模式——函数接收可变引用却返回共享引用fn get_something(mut self) - T { self.data ...; self.data }这里确实需要self的可变借用开始更新self.data但函数返回后只需共享借用。可设想给mut self一个局限于调用本身的写生命周期和一个更大的读生命周期。但现实中还存在返回T后把self当共享并不安全的活跃用例例如把Mutex::get_mut包装成这种签名——get_mut()通过取mut引用在不加锁的情况下给出互斥锁内部数据的访问权这只有在该数据用完前self始终保持可变借用时才成立。因此该模式需要某种形式的用户标注把Ref2的两个生命周期暴露给最终用户等。与不连续借用一样设计这样的系统同样超出本 RFC 范围。未解决问题截至 RFC 成文时暂无None as yet。与仓库其他文档的关联两阶段借用方案在本 RFC 之后被 NLL RFC 2094 明确采纳其Layer 5: How the borrow check works一节指出为了支持vec.push(vec.len())这类嵌套方法调用计划继续采用本 RFC 提出的mut2借用方案且这些扩展与本 RFC 的变更大体正交——后者的效果相当于延迟一个借用的开始。本 RFC 的动机直接呼应 2017 路线图该路线图将生产力确立为核心价值把改善学习曲线列为重点并点名非词法生命周期是改善现有语言特性、降低借检查器摩擦的主要例子——消除vec.push(vec.len())这一新手必踩、老手常烦的坑正是其中的一环。结语两阶段借用是 Rust 编译器设计中最小化侵入的一次典范实践它没有引入新的表面语法没有扩大类型系统只在 MIR 层新增一种内部借用形式a mut2并在 borrowck 中为它赋予先保留、后激活的语义从而精准地让vec.push(vec.len())合法化同时原封不动地保住了对v[0].push_str({ v.push(s); World! })这类危险代码的拦截能力。理解这个设计不仅有助于读懂 MIR 与 borrowck 的协作方式也能帮助你判断什么样的嵌套方法调用是安全的、什么样的仍然必须拆开写。赞分享文档【免费下载链接】rfcsRFCs for changes to Rust项目地址https://gitcode.com/gh_mirrors/rf/rfcs点击查看免费下载相关推荐终极Miri借用跟踪指南Stacked Borrows与Tree Borrows深度对比终极Miri借用跟踪指南Stacked Borrows与Tree Borrows深度对比 Miri是Rust的中级中间表示解释器提供了强大的借用跟踪机制来帮开发工具解释器测试语言运行时深入 Rust 多重借用Multiple Borrows多个参数借用与返回值生命周期解析深入 Rust 多重借用Multiple Borrows多个参数借用与返回值生命周期解析 本文是 Google Android 团队开源 Rust 课程文档教程深入解读 Rust RFC 1660为 RefCellT 引入非 panic 的 try_borrow / try_borrow_mut 借用方法深入解读 Rust RFC 1660为 RefCellT 引入非 panic 的 try_borrow / try_borrow_mut 借用方法 导读 本文档上一篇从零搭建Snowy多租户系统数据隔离与权限控制实现方案下一篇Meshroom完全指南从照片到3D模型的AI重建技术创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑