编程 Rust E0505:引用明明没用过,为什么还是报「cannot move out」

2026-09-30 00:03:20

Rust E0505:引用明明没用过,为什么还是报「cannot move out」

E0505 的信息是 cannot move out of ... because it is borrowed。初学者最容易卡住的地方不是 move 本身,而是那段看起来毫无意义的场景:

fn main() {
let s = String::from("hello");
let r = &s;     // 借用了 s
let s2 = s;     // error[E0505]: cannot move out of `s` because it is borrowed
// r 在这里之后再没有被使用过
}

r 之后一行都没出现,为什么编译器还要拦着?要回答这个问题,得先看 move 和引用之间到底是什么关系。

引用是合约,Move 是合约失效

编译器的报错信息其实已经把关系写清楚了:

error[E0505]: cannot move out of `s` because it is borrowed
--> src/main.rs:4:14
|
3 |     let r = &s;
|             -- borrow of `s` occurs here
4 |     let s2 = s;
|              ^ move out of `s` occurs here
5 |     println!("{}", r);
|                    - borrow later used here

let r = &s 建立了一份合约:r 有效期内,s 的存储必须保持原样,不能改、不能搬。let s2 = s 是一次 move,把 s 拥有的堆内存所有权转给 s2,原来的 s 作废——合约的另一方突然消失了。所以 E0505 不是「你不该 move」,而是「你在这份合约生效期间 move 了」。

这样理解之后,真正的问题就变成:这份合约什么时候到期?

NLL:从词法作用域到 MIR 活性分析

答案是十年来变过的。

Rust 2015:借用活到块尾

词法作用域(lexical scope)时代,引用的生命周期直接绑定在花括号上。let r = &s; 一旦出现在块内,借用就持续到 } 为止,中间用不用它无关紧要。上面那段代码在这个模型下必然报 E0505——因为 move 发生在块尾之前,而合约还没到期。

按块尾判断的好处是实现简单,代价是一大批实际安全的代码被拒。写起来就是各种人为的 {} 嵌套。

Rust 2018:按 MIR 做活性分析

NLL(Non-Lexical Lifetimes)换了判断依据。编译器把函数体降级成 MIR,得到一张控制流图,然后对每个引用做 活性分析(liveness analysis):从某个程序点往后,如果这个引用在任何一条可能执行的路径上都不会再被读取,它的 region 就在这个点结束。

于是上面那段代码在 NLL 下可以正常编译——r 的最后一次使用是 let r = &s; 本身,region 立刻结束,后面的 move 不冲突。合约不是按花括号算的,是按「还有没有人需要它」算的。

NLL 随 Rust 1.31 在 2018 edition 落地,1.36 起 2015 edition 也默认启用。所以同一段代码在不同 edition / 不同工具链下结论会不一样,遇到 E0505 先确认这一点。

未使用的绑定:找不到「最后使用点」时

活性分析依赖「最后一次使用」。如果一个引用绑定从头到尾没被读取过呢?

fn main() {
let s = String::from("hello");
let r = &s;      // r 从未被读取
let s2 = s;      // 是否报错取决于 r 的 region 被推断到哪里
}

这里没有「最后使用点」可以锚定,编译器只能按类型本身的规则给一个保守答案。两条经验规则:

  • 值本身实现了 Drop:析构函数会在作用域结尾执行,而析构需要独占访问,region 因此被拉到块尾。这时即使你没读它,它也在占用借用。
  • 没有 Drop 且从未被读取:borrow 可以在借用点之后立即失效,move 不受影响。

换句话说,未使用的绑定不是「优化掉就没事」,而是先由类型决定它是否必须活到块尾,再谈借用的长短。判断不清的时候,先看这个值有没有析构逻辑。

控制流里的多路径

真实代码很少有直线的控制流,region 需要在 if/else、match、loop 上求一个一致解:

fn pick(flag: bool, v: &mut Vec) -> i32 {
let r = &v[0];          // 借 v
if flag {
v.push(1);          // 这条路径上 r 之后还会被用到 → 冲突
}
*r
}

活性分析会看所有可能路径。只要有一条路径在借用之后还要读 r,region 就得覆盖到那里,其他分支上的借用同样受约束。这也是为什么有时候注释掉一行看似无关的 println!("{}", r) 就能编译——那一行正是某条路径上的「最后使用点」。

绑定作用域 ≠ 生命周期作用域

把这两件事分开,很多困惑会消失:

  • 绑定作用域:名字从 let 到所在块末尾可见,这是词法规则,NLL 之后也没变。
  • 生命周期作用域(region):借用实际有效的区间,由活性分析在数据流上算出来。

NLL 只改了后者。所以变量「还活着」和借用「还生效」是两回事,绝大多数情况下 region 比名字的作用域短得多。

四类应对策略

1) 用块界定作用域

最直接的做法是手动提醒编译器:让不需要的借用提前结束。

fn main() {
let mut data = vec![1, 2, 3];
{
let r = &data[0];
println!("{}", r);
} // r 的 region 到此为止
data.push(4); // 不再冲突
}

2) 用函数封装

把借用限制在函数内部,对外只返回拥有所有权的值,调用方就不需要关心 region 的边界:

fn first_copy(data: &[i32]) -> i32 {
data[0] // 借用随函数返回结束
}

fn main() {
let mut data = vec![1, 2, 3];
let x = first_copy(&data);
data.push(4);
println!("{}", x);
}

3) Clone 的权衡

当 region 冲突无法通过重排代码解决时,克隆是逃生舱:

let snap = data.clone(); // 断开借用关系,代价是复制

它换来的是借用检查器的确定性,付出的是内存与时间。只在真的需要独立副本时用;如果只是为了绕开报错而 clone,通常说明变量所有权划分有问题。

4) 下划线诊断

调试借用问题时,_ 和 _x 的区别很有用:

let _ = make_ref();   // 立即丢弃,借用随之结束
let _x = make_ref();  // 绑定留存到作用域结尾,region 被拉长

把可疑绑定换成 _,如果不报错了,就说明是这个绑定的存活期在延长借用。

三个常见陷阱

闭包隐式借用

let mut v = vec![1, 2, 3];
let f = || println!("{:?}", v); // 隐式借用 v
v.push(4);                      // E0505:闭包还持有借用
f();

闭包捕获默认是借用。捕获方式由闭包体内的用法决定,move 会把捕获变成所有权转移。想避免这类问题,要么在闭包创建前完成 v 的修改,要么显式用 move,要么交出克隆。

迭代器持有借用

let mut v = vec![1, 2, 3];
let mut it = v.iter(); // it 持有 &v
v.push(4);             // E0505:迭代器还活着
for x in it { /* ... */ }

iter() 借的是 &self,iter_mut() 借 &mut self。迭代器存活期间集合不能再改。需要边遍历边修改,通常得先 collect 出一份结果,或者换成索引循环。

方法链返回引用

let s = String::from("hi");
let r = s.trim();       // 返回 &str,借用 s
let owned = s;          // E0505
println!("{}", r);

链式调用里,只要某一环返回的是引用,借用就沿着链继续延伸。把中间结果 .to_string() / .to_owned() 变成拥有所有权的值,借用到此结束。

一张对照表

概念判定依据边界
绑定作用域词法规则let 到所在块 }
生命周期作用域(region,NLL 后)MIR 活性分析从借用到最后一次使用
需要 Drop 的值类型实现强制拉到作用域尾
控制流多路径各路径求一致解所有路径上最后一次使用的并集
未被读取的绑定类型 + 活性无 Drop 时立即失效

E0505 的排查顺序可以固定下来:先确认 edition 和工具链(词法作用域还是 NLL),再看是哪个绑定把 region 拉长了,最后才考虑用块、用函数、用 clone 去缩短它。

扩展阅读

推荐文章

程序员茄子在线接单