Rust 中 &mut 的 move 与 reborrow
记录
&mut的 move 与 reborrow 行为差异,以及背后的 coercion 与 NLL 机制。
在写链表、树这类需要「拿着一个 &mut 指针沿结构往下走」的代码时,会遇到一个现象:把一个可变引用赋给另一个变量,编译器报 borrow of moved value;而给这次赋值补上类型注解之后,同样的逻辑就能通过。这篇文章记录这个差异的成因,涉及 Rust 的两套机制:coercion(类型强制转换) 和 NLL(非词法生命周期)。
一、现象
下面这段代码在链表中间做截断,head 的类型是 &mut Option<Box<ListNode>>:
1
2
3
4
5
6
7
let mut right_head = head; // borrow of moved value: `head`
for _ in 0..cnt / 2 + 1 {
right_head = &mut right_head.as_mut().unwrap().next;
}
let mut right = Self::reverse(right_head.take());
let left = head.take(); // head 已被 move,这里报错
给第一行补上类型注解后,整段代码通过编译:
1
let mut right_head: &mut Option<Box<ListNode>> = head; // 通过
差别只有一个类型注解。下面逐层解释它为什么起作用。
二、&mut 不是 Copy,值上下文默认是 move
第一个事实:可变引用 &mut T 没有实现 Copy。
&mut 的语义是独占访问。如果它可以被 Copy,就能同时得到两个指向同一块内存的可变引用,这违反借用规则。因此 &mut 不是 Copy(相对地,&T 是 Copy,共享引用可以随意复制)。
于是 let right_head = head; 被当作一次移动(move):head 中的可变引用被搬进 right_head,head 本身失效。后续 head.take() 使用了已被移动的值,报错。
三、reborrow:从一个 &mut 借出一个新的 &mut
这里需要的不是「把 head 搬走」,而是「从 head 临时借一个新的 &mut 出去用,用完归还」。这个操作是 reborrow(重借用),显式写法:
1
let mut right_head = &mut *head; // 从 head 重新借出一个 &mut
&mut *head 的过程是:先解引用 *head 得到底层的 Option<Box<ListNode>>,再对它取一个新的可变引用。这个新引用是 head 的下级借用——它存活期间 head 被冻结,它结束后 head 恢复可用。链表里 right_head = &mut right_head.as_mut().unwrap().next 能逐步推进,依赖的就是这个机制。
四、什么是 coercion
coercion(类型强制转换) 指编译器在某些位置,自动把一个类型的值转换成另一个兼容类型,不需要手写转换代码。它不同于 as 显式转换,也不同于 From/Into,是编译器在类型检查时隐式插入的。
Rust 中常见的 coercion 有几类:
- reborrow:
&mut T→&mut T(借出新的下级借用),以及&mut T→&T。本文的主角。 - Deref coercion:
&String→&str、&Vec<T>→&[T]、&Box<T>→&T,由Dereftrait 驱动。 - Unsizing:
Box<T>→Box<dyn Trait>、&[T; N]→&[T],把定长类型转成不定长类型。 - 函数指针:不捕获环境的闭包 →
fn指针。
这些转换的共同点是:只在编译器已经知道「期望类型」的位置才会发生。 这类位置叫 coercion site。
五、什么是 coercion site
coercion site(强制转换点) 是那些编译器持有一个明确「预期类型(expected type)」的语法位置。有了预期类型,编译器才会拿实际表达式去匹配它、并在需要时插入 coercion。主要的 coercion site 包括:
- 带类型注解的
let:let x: T = expr;,注解T就是预期类型。 - 函数/方法实参:形参声明的类型就是预期类型。
return与函数结尾表达式:函数返回类型是预期类型。- 结构体/枚举字段初始化:字段声明类型是预期类型。
- 显式类型的常量/静态项。
反过来,没有类型注解的 let x = expr;、纯粹按表达式自身类型求值的位置,都不是 coercion site,编译器不会主动做转换。
把这套规则套回第一节的两行:
1
2
let mut right_head = head; // 无预期类型 → 按原样 move
let mut right_head: &mut Option<Box<ListNode>> = head; // 有预期类型 → 触发 reborrow
- 第一行没有注解,不是 coercion site,编译器把
head按它本身的&mut类型求值,得到 move。 - 第二行的类型注解构成 coercion site,编译器带着「期望
&mut Option<Box<ListNode>>」这个信息去匹配head,插入 reborrow coercion,实际执行&mut *head,head只是被借出。
因此下面三种写法等价,都得到 reborrow:
1
2
3
let right_head: &mut _ = head; // 类型注解提供预期类型
let right_head = &mut *head; // 显式 reborrow,意图最清晰,推荐
foo(head); // 实参:形参类型即预期类型,自动 reborrow
最后一行也解释了:fn foo(x: &mut T) 能用同一个 &mut 变量连续调用多次而不报 move,是因为每次调用时形参类型都充当预期类型,编译器每次都插入一个 reborrow。
六、什么是 NLL
NLL(Non-Lexical Lifetimes,非词法生命周期) 是 Rust 2018 引入的借用检查改进。它改变的是「一个借用活到什么时候结束」的判定方式。
在 NLL 之前,借用的生命周期是词法的(lexical):一个引用从创建那一刻起,一直存活到它所在的作用域(大括号)结束,即使后面再也没用到它。这会导致很多明明安全的代码被拒绝:
1
2
3
4
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用开始
println!("{}", first); // first 最后一次使用
v.push(4); // 旧规则:报错,因为 first 的借用"词法上"还没结束
NLL 把生命周期改成基于实际使用范围(非词法):借用只活到它最后一次被使用的地方,之后即告结束。上面这段在 NLL 下通过——first 在 println! 之后不再使用,借用就结束了,v.push(4) 拿到的是干净的可变借用。
回到本文的例子:
1
2
3
4
5
6
7
let mut right_head = &mut *head; // reborrow 开始
for _ in 0..cnt / 2 + 1 {
right_head = &mut right_head.as_mut().unwrap().next;
}
let mut right = Self::reverse(right_head.take()); // right_head 最后一次使用
let left = head.take(); // 此处 head 已可用
right_head 这个从 head 借出的 reborrow,最后一次使用在 right_head.take()。按 NLL,借用到这一行就结束,head 随即恢复可用,所以下一行 head.take() 合法。如果沿用旧的词法规则,right_head 的借用要撑到函数末尾,head.take() 就会被判为冲突。
七、两套机制的分工
move / reborrow 的选择,和之后 head 能否再用,是两套独立机制协作的结果,不要混为一谈:
- coercion(在 coercion site 上) 决定这次赋值是 move 还是 reborrow。
- NLL 决定:确定是 reborrow 之后,这个借用在哪里结束,从而是否放行后续对
head的使用。
顺序上,先由 coercion 把 move 改成 reborrow,NLL 才有对象去计算它的结束点。缺了第一步,后面无从谈起。
八、总结
| 写法 | 是否 coercion site | 结果 |
|---|---|---|
let y = x; |
否 | move,x 失效 |
let y: &mut _ = x; |
是(类型注解) | reborrow |
let y = &mut *x; |
显式 reborrow | reborrow |
foo(x) |
是(形参类型) | reborrow |
要点:
&mut不是Copy,裸赋值let y = x一定是 move;&T是Copy,不受此限。- reborrow 属于 coercion,只在 coercion site(带类型注解的
let、函数实参、return等有预期类型的位置)自动发生。 - NLL 让借用在最后一次使用后即结束,是 reborrow 之后原变量能重新使用的前提。
- 实践建议:凡是「先用一个
&mut走一趟、之后还要接着用原变量」的场景,第一次赋值直接写let mut y = &mut *x;,比依赖类型注解更短、意图更明确。