Post

Rust 中 &mut 的 move 与 reborrow

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(相对地,&TCopy,共享引用可以随意复制)。

于是 let right_head = head; 被当作一次移动(move)head 中的可变引用被搬进 right_headhead 本身失效。后续 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,由 Deref trait 驱动。
  • UnsizingBox<T>Box<dyn Trait>&[T; N]&[T],把定长类型转成不定长类型。
  • 函数指针:不捕获环境的闭包 → fn 指针。

这些转换的共同点是:只在编译器已经知道「期望类型」的位置才会发生。 这类位置叫 coercion site。

五、什么是 coercion site

coercion site(强制转换点) 是那些编译器持有一个明确「预期类型(expected type)」的语法位置。有了预期类型,编译器才会拿实际表达式去匹配它、并在需要时插入 coercion。主要的 coercion site 包括:

  • 带类型注解的 letlet 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 *headhead 只是被借出。

因此下面三种写法等价,都得到 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 下通过——firstprintln! 之后不再使用,借用就结束了,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;&TCopy,不受此限。
  • reborrow 属于 coercion,只在 coercion site(带类型注解的 let、函数实参、return 等有预期类型的位置)自动发生。
  • NLL 让借用在最后一次使用后即结束,是 reborrow 之后原变量能重新使用的前提。
  • 实践建议:凡是「先用一个 &mut 走一趟、之后还要接着用原变量」的场景,第一次赋值直接写 let mut y = &mut *x;,比依赖类型注解更短、意图更明确。
This post is licensed under CC BY 4.0 by the author.