跳到正文
原文
TonyBai· 白明的赞赏账户·· 3天前AI 评分22

Rust 引用的 4 种可变性组合:let mut 与 &mut,到底谁管谁?

原文标题:Rust 引用的 4 种可变性组合:let mut 与 &mut,到底谁管谁?(本篇免费)

AI 导读

Rust 引用的两层控制权可拆成 2×2 四种组合:let 后的 mut 管指针能否改指,& 后的 mut 管数据能否修改。

正文

【导读】

写了半年 Rust,你能不查资料写出 let r = &x、let mut r = &x、let r = &mut x、let mut r = &mut x 这四种写法,并说清各自的含义吗?本文用一个心法、一张图和一张对照表,把它们一次讲透。

【文章要点】

  1. 一个心法:let 后的 mut 管指针,& 后的 mut 管数据。

  2. 四种组合:每种都给出能做什么、不能做什么、典型场景。

  3. 横向对照:C++ 的 const 在 * 左边还是右边,Go 为什么没有这个问题。

  4. 三个陷阱:&T 不等于绝对不可变、&mut 不是 Copy、函数参数中的 mut。

  5. 回扣开头:用思考题检验成果。


先来一道题。下面这段代码能编译通过吗?如果不能,问题出在哪一行?

let mut a = 1;
let mut b = 2;
let mut p = &a;
*p = 10;
p = &b;

变量 a、b、p 前面都写了 mut,看起来该放开的全放开了。但这段代码编译不过。如果你的第一反应是“mut 都写了,为什么还报错”,说明你对 Rust 里两个 mut 的分工还没有形成稳定的认知。

这个问题值得专门花一篇文章讲清楚。原因很现实:只要你写引用相关的代码,就绕不开 let mut 和 &mut的组合。不理解它们,你只能靠“编译器报错,我就加个 mut”这种碰运气的方式写代码。更麻烦的是,后面学习生命周期、借用检查、智能指针时,这个认知漏洞会一次次放大。

这篇文章的讲法是:先给你一个“拆成两半看”的心法,再逐一拆解四种组合,并拿 C++ 和 Go 做对照,最后补充三个进阶陷阱。读完后,你应该能不借助编译器,一眼判断任意一行引用代码是否合法。

这篇文章的讲法是:先给你一个“拆成两半看”的心法,再逐一拆解四种组合,并拿 C++ 和 Go 做对照,最后补充三个进阶陷阱。读完后,你应该能不借助编译器,一眼判断任意一行引用代码是否合法。:该段配图

心法:一个变量,两层控制权

要理解 Rust 的引用,先要承认一个事实:一个引用类型的变量,本质上包含两层东西。

第一层是变量本身。它是一个盒子,里面存放着一个地址,也就是指针。

第二层是被指向的数据,即这个地址背后的那块内存。

Rust 用两个不同位置的 mut,分别控制这两层:

  • let 后面的 mut:控制变量这个盒子能不能被重新赋值,也就是指针能不能改指别处。

  • & 后面的 mut:控制指针指向的数据能不能被修改。

用一张图表示:


心法:一个变量,两层控制权:该节配图


这张图里有两个动作,请务必区分:

  • r = &y:把箭头从 x 指到 y。这改的是变量 r 自己,需要 let mut r。

  • *r = 100:顺着箭头,把目标里的值改掉。这改的是数据,需要引用本身是 &mut。

这两个动作互相独立,所以组合起来恰好是 2 × 2 = 4 种,这就是标题里的 4 种可变性组合。

四种组合逐一拆解

先交代前提:下面的例子里,x 和 y 都是 let mut 声明的,否则连 &mut x 都无法取得。

组合 1:let r = &x

let mut x = 42;
let mut y = 99;
let r = &x;
// r = &y; ❌ 变量 r 不是 mut,不能改指
// *r = 100; ❌ 引用是 &,不能改数据

这是最常见的借用形式:箭头不能动,目标也不能改。对应 C++ 的 const int* const r。大多数“只读取一下”的场景,用它就够了。

组合 2:let mut r = &x

let mut r = &x;
r = &y; // ✅ 变量是 mut,可以改指
// *r = 100; ❌ 引用是 &,仍然不能改数据

箭头可以动,目标只读。对应 C++ 的 const int* r。

典型场景是遍历:你拿着一个只读的游标,一步步往前走,但不改动沿途的数据。比如手写链表遍历时,let mut cur = &head; 然后循环里 cur = &node.next,就是这种形式。

组合 3:let r = &mut x

let r = &mut x;
*r = 100; // ✅ 引用是 &mut,可以改数据
// r = &mut y; ❌ 变量不是 mut,不能改指

箭头固定,目标可改。对应 C++ 的 int* const r。

这也是 Rust 里最经典的可变借用:我只盯着这一份数据,对它做修改,别的不管。函数参数 fn push(v: &mut Vec) 本质上就是这种形式。

组合 4:let mut r = &mut x

let mut r = &mut x;
*r = 100; // ✅ 改数据
r = &mut y; // ✅ 改指向
*r = 200; // ✅ 此时改的是 y

箭头和目标都能动。对应 C++ 的 int* r。它适合需要“一边移动、一边修改”的场景,比如在树或链表上原地定位到某个节点然后修改它,游标要不断下移,同时每到一个节点都可能写数据。

这里有一个很多人忽略的细节:同一时刻,对 x 的 &mut 引用只能有一个活跃。所以如果把四种写法放进同一个作用域,只要后面还在使用,就会触发借用冲突。写示例或做实验时,建议每种组合放在独立的 {}块里。

横向对照:C++、Go、Rust

把三门语言放在一起,你会发现 Rust 的设计取舍很有意思。

需求

C++

Go

Rust

不能改指,不能改数据

const T* const p

无法表达

let r = &x

能改指,不能改数据

const T* p

无法表达

let mut r = &x

不能改指,能改数据

T* const p

无法表达

let r = &mut x

能改指,能改数据

T* pp := &xlet mut r = &mut x

先说 C++。它用 const 相对于 * 的位置来区分:* 左边的 const 锁数据,* 右边的 const 锁指针。规则本身没问题,但这种“从右往左读”的语法很容易写错。Rust 则把两层控制拆到了两个不同的语法位置,一个在变量声明处,一个在类型处,读起来更直观。

再说 Go。Go 没有 const 指针这个概念,p := &x 之后,既能 *p = 10,又能 p = &y,也就是只有 Rust 的组合 4。你无法在类型层面声明“这个指针只读”,只能靠约定和代码评审。

Rust 的特别之处在于:它不仅提供了四种组合,还在编译期用借用规则保证了 &mut 的排他性。同样是“能改指、能改数据”,Go 允许你同时有多个指针指向同一个变量并任意修改,数据竞争的风险需要自己兜底。

Rust 的组合 4 则被借用检查器约束:在 &mut 活跃期间,不允许其他引用存在。

三个进阶陷阱

四种组合是基础,接下来三个细节,是真正拉开认知差距的地方。

陷阱一:&T 不等于“数据绝对不能改”

我们一直说 &x 不能改数据,但严格地说,&T 是共享引用,而不是“不可变引用”。当 T 具有内部可变性时,通过 &T 依然可以修改:

use std::cell::Cell;

let c = Cell::new(1);
let r = &c;
r.set(2); // ✅ 通过共享引用修改了数据

所以更准确的理解是:&T 保证的是“我不会通过这个引用,用普通的方式修改数据”,并且“可能有多个人同时在看”。&mut T 保证的则是“我是唯一的访问者”,独占才是它的核心语义,可变只是独占带来的结果。这一点我们会在以后讲 RefCell 的时候再深入。

陷阱二:&mut 不是 Copy

共享引用 &T 实现了 Copy,随便传递都没有问题。但 &mut T 没有,因为复制一个独占引用就破坏了“独占”。所以当你把一个 &mut 变量传给函数时,按理说它会被移动:

fn modify(v: &mut Vec<i32>) { v.push(1); }

let mut data = vec![];
let r = &mut data;
modify(r);
modify(r); // ✅ 实际能通过

为什么第二次调用不报错?因为编译器在函数参数这类位置,会自动做重借用(reborrow),相当于把 modify(r) 悄悄改写成 modify(&mut *r),临时借出去,用完再还回来。理解了这一点,你就明白为什么有些场景(比如把 &mut 放进泛型参数、闭包捕获)会突然报“use of moved value”:重借用没有触发,&mut真的被移动了。

陷阱三:函数参数里的 mut

同样的两层分工,在函数参数里也成立:

fn f1(v: &mut Vec<i32>) { v.push(1); }          // 能改数据,不能改 v 指向
fn f2(mut v: &mut Vec<i32>, other: &mut Vec<i32>) {
v.push(1);
v = other; // 能改指向
v.push(2);
}

mut v: &mut Vec 看起来有点绕,但按我们的心法拆开看,其实很自然:参数名前的 mut 管变量,类型里的 &mut 管数据。日常代码中 f1 的形式占绝大多数,f2 这种写法偶尔出现在需要“移动游标”的算法里。

回到开头的题

现在回头看那段代码:

let mut a = 1;
let mut b = 2;
let mut p = &a; // p 是 &i32:共享引用
*p = 10; // ❌ 报错
p = &b; // ✅ 这行本身没问题

出错的是第四行。let mut p 只让变量 p 可以改指向,但 p 的类型是 &i32,这是共享引用,不允许通过它修改数据。编译器会提示:不能给 & 引用背后的数据赋值。

修复很简单,把引用改成可变的:

let mut a = 1;
let mut b = 2;
let mut p = &mut a; // 组合 4:能改指,也能改数据
*p = 10;
p = &mut b;
*p = 20;

注意,这里也体现了前面提到的独占规则:p 从指向 a 改指到 b 之后,对 a 的可变借用就结束了。

小结

回顾一下,今天我们讲清楚了一件事:Rust 引用里有两层控制权,let 后的 mut 管指针,& 后的 mut管数据,两者独立,组合出四种形态。

划重点:

声明

改指(r = ...)

改数据(*r = ...)

一句话

let r = &x

❌

❌

双封印

let mut r = &x

✅

❌

游走的看客

let r = &mut x

❌

✅

专一的工匠

let mut r = &mut x

✅

✅

全面放开

升华一下,有三条实践建议:

  1. 默认选组合 1 和组合 3。能不改指就不改指,把 mut 控制在最小范围,代码更容易推理。

  2. 看到 mut r: &... 时,先问自己为什么要换绑。多半是在做遍历,用组合 2 就够了。

  3. 别把 &T 理解成“绝对不可变”,把 &mut T 理解成“独占”。这是你后续学习 RefCell、Mutex 时最重要的认知铺垫。

最后留一个思考题:下面这段代码,哪一行会编译失败?为什么?

let mut x = 1;
let r1 = &mut x;
let r2 = &x;
*r1 = 2;
println!("{}", r2);


小结:该节配图

来源:TonyBai · mp.weixin.qq.com