跳转到内容

4.1 引用与可变引用:&T 与 &mut T

在第一篇结尾,我们留下过一个尚未解决的问题:如果只靠共享引用 &T,函数和多处代码虽然能读取数据而不接手释放责任,但谁都不能通过引用去修改数据。

要通过引用修改数据,Rust 引入了可变引用 &mut T。

这节课我们就用第 3 章建立的「类型构造器 → 类型实体」视角,拆解 & 与 &mut 这两个内置构造器(这里照例省略编译器自动补全的生命周期参数 'a):为什么它们生成的类型实体在运行时都是 8 字节地址,在编译期却有着截然相反的约束?

构造器的拆法:(&mut) T,而非 &(mut T)

Section titled “构造器的拆法:(&mut) T,而非 &(mut T)”

很多初学者第一次见到 &mut i32 这个写法时,会按字面顺序把它拆读成 & (mut i32)——误以为 mut i32 是一种「可变的 i32 类型」,前面的 & 再对它取引用。

顺着这个误解,有人会尝试在代码里直接写 let x: mut i32 = 7;,结果当场报语法错误。

原因很直接:在 Rust 的类型系统里,根本不存在 mut T 这种类型修饰符。

正确的括号永远画在前面:(&) T 与 (&mut) T。

  • & 是一个类型构造器(共享引用构造器),把 i32 放进去,合成出 &i32;
  • &mut 是另一个独立的类型构造器(可变引用构造器),把 i32 放进去,合成出 &mut i32。

这里也要专门回应一个读过其他教程的读者常会提出的疑问:「我们在第一篇不是把 & 和 &mut 叫作『借用(Borrowing)』吗?怎么这里又说它们是类型构造器?」

其实一点都不冲突,只是同一个符号出现在了两个不同的语法位置上(比如在 let m: &mut i32 = &mut n; 这一行里,&mut 出现了两次):

  • 写在冒号 : 后面、作用于类型 T 时(如 : &i32、: &mut i32):它是类型构造器,在编译期合成出名为「引用(Reference)」的新类型实体;
  • 写在等号 = 右边、作用于变量 n 时(如 = &n、= &mut n):它是借用运算符(Borrow Operator),执行的动作叫借用——即取出变量 n 的栈地址,造出一个对应引用类型的值。

简单说:动作叫「借用(&n / &mut n)」,造出来的类型叫「引用(&T / &mut T)」。 把这层对应关系看清,两个概念就对齐了。

解引用 *:从位置表达式理解 *r 与 n

Section titled “解引用 *:从位置表达式理解 *r 与 n”

既然 &i32 和 &mut i32 在栈上存的都是指向目标变量的 8 字节内存地址,拿到引用之后,怎么顺着这个地址去读取或修改目标变量的值?

这里可以先建立一个关于位置表达式(Place Expression)的直觉:在代码里,有些表达式不是为了算出一个临时结果,而是用来指代内存里的一个具体位置(槽位)。

  • 最简单的位置表达式就是一个变量名(比如 n),或者大家熟悉的数组下标(比如 arr[0])——它们都直接对应内存里的一个槽位,放在 = 左边就是往这个槽位写入,放在右边就是从这个槽位读取。
  • 那么引用变量 m(类型为 &mut i32)呢?如果直接写 m,它指代的是引用变量自己在栈上的那个 8 字节位置(里面装着地址),并不是原始的整数数据;
  • 要想指代那个原始整数所在的位置,就要加上解引用运算符 *(Dereference Operator):*m 的意思就是「顺着 m 里的地址跳过去,找到它指向的那个目标内存位置」。

因此,在语义上,*m 和原变量名 n 完全一致——它们指代的是内存里的同一个位置(关于位置表达式的完整剖析,可选读附录:位置表达式与值表达式)。正因为 *m 就是一个位置表达式,它才能像普通变量 n 一样直接放在 = 左边修改原数据:

源文件:deref-rw.rs
fn main() {
let mut n = 7;
let r: &i32 = &n;
println!("read via *r: {}", *r);
let m: &mut i32 = &mut n;
*m = *m + 1;
println!("updated n: {n}");
}
在 Rust Playground 中运行

运行程序,输出 read via *r: 7 和 updated n: 8:

  • 当 r 的类型是 &i32 时,*r 这个位置只允许读取(如果写 *r = 8; 会直接报错);
  • 当 m 的类型是 &mut i32 时,*m 这个位置既可以读取,也可以放在等号左边执行 *m = *m + 1;,直接把栈上原变量 n 的值改成了 8(注意:要创建 &mut n,原变量 n 自身必须声明为 let mut n)。

仔细看上面代码里的 let m: &mut i32 = &mut n;,或者下面这段更鲜明的对比代码:

源文件:two-mut.rs
// Uncomment the marked line to trigger error[E0384].
fn main() {
let mut x = 1;
let mut y = 10;
let r: &mut i32 = &mut x;
*r += 1;
println!("{x}");
// r = &mut y;
println!("{y}");
}
在 Rust Playground 中运行

很多初学者看到 let r: &mut i32 = &mut x; 紧接着执行 *r += 1; 能顺利通过编译,会感到困惑:「变量 r 声明的时候写的是 let r,根本没写 let mut r,为什么却能执行 *r += 1; 修改值?」

用第 2 章的「绑定可变性」与本章的「类型构造器」一对照,两个不同位置的 mut 就彻底分清了:

mut 出现的位置 写法示例 它管的是什么?
1. 写在 let 后面(绑定属性) let mut r: &i32 = &x; 管变量 r 自己的栈槽位能不能换新值(即后面能不能再写 r = &y; 改指别的变量)
2. 写在 : 后面(类型构造器) let r: &mut i32 = &mut x; 管顺着 r 里的地址过去,能不能修改目标内存里的值(即能不能写 *r += 1;)

在 two-mut.rs 中:

  • r 的类型是 &mut i32,所以顺着地址修改目标值 *r += 1; 完全合法;
  • 但 r 的绑定是不可变的 let r,所以一旦取消 // r = &mut y; 的注释,试图让 r 改指另一个变量 y,编译器就会报我们在第 2 章熟悉的 error[E0384]: cannot assign twice to immutable variable r。
r 占 8 字节,里面是 x 的栈地址;*r 与 x 是同一位置。绑定上没有 mut,就不能把 r 改指别处;类型 &mut i32 允许通过 *r 写入。
两个 mut 各管一个槽位栈帧里并排两个槽位。左边是变量 r,声明为 let r: &mut i32,占 8 字节,槽里是 x 的栈地址。绑定没有 mut,所以不能写 r = &mut y 改指别处,否则报 E0384。箭头从 r 指向右边的变量 x,表示解引用 *r;类型 &mut i32 允许这次写入。x 声明为 let mut x: i32,占 4 字节,值从 1 变成 2。x 与位置 *r 是同一处,*r += 1 改的就是这里的值。栈帧rx(即位置 *r)let r: &mut i32let mut x: i328 字节4 字节x 的栈地址1 → 2解引用 *r(经由 &mut i32 允许写入)绑定无 mut:禁止 r = &mut y 重改指向(E0384)*r += 1 直接修改此位置的值

读写排他规则:为什么 &T 与 &mut T 不能并存?

Section titled “读写排他规则:为什么 &T 与 &mut T 不能并存?”

现在进入本节最关键的推导:既然 &T 负责只读、&mut T 负责修改,如果同一个变量在同一时间既有一个只读引用 r: &String,又有一个可变引用 m: &mut String,会发生什么?

先看编译器的态度:

源文件:borrow-conflict.rs
// Uncomment the marked line to trigger error[E0502].
fn main() {
let mut s = String::from("hello");
let r: &String = &s;
let m: &mut String = &mut s;
m.push_str(", world");
// println!("{r}");
println!("{m}");
}
在 Rust Playground 中运行

在默认源码中,只读引用 r 创建后从未在后面使用过(它的有效区间在创建行即结束),因此后面创建 m: &mut String 并调用 m.push_str(", world") 完全合法。

但只要你取消倒数第二行 // println!("{r}"); 的注释——让只读引用 r 的有效区间跨越 m 的修改操作——编译器立刻以 error[E0502] 拒绝编译:

编译器诊断摘要:取消 borrow-conflict.rs 中标记行的注释后
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

为什么要定下这条规则?如果编译器不拦这一行,在物理内存上会出什么事?

顺着第 1 章的栈与堆布局推演一步:

  1. 变量 s 最初是 "hello",假设堆上的字符缓冲区当前只申请了 5 个字节的容量(cap = 5);
  2. 只读引用 r 正借用着 s,准备顺着当时的 ptr 去读取堆上这 5 个字节(或者已经从中取出了指向这 5 个字节的内部指针);
  3. 此时,可变引用 m 执行 m.push_str(", world") 追加字符——原有的 5 字节容量装不下 12 个字节,触发扩容;
  4. 扩容时,如果原堆块后面刚好有足够的连续空闲内存,分配器可能原地扩大容量;但只要原位置后面空间不够,分配器就必须在堆上另找一块更大的新内存,把 "hello, world" 复制过去,并释放原来那块 5 字节的旧堆内存,最后把 s 栈上的 ptr 改指新地址。

想一想:只要扩容存在搬迁并释放旧堆块的可能,如果在 m 修改的同时,旁边还有只读引用指着原来的堆内存(或者在多线程下同时读写),一旦旧堆块被释放,那些只读引用手里的地址就会变成悬垂指针。

为什么读写必须排他:若允许 m 扩容修改的同时保留 r 的读取,旧堆缓冲区释放后会立刻造成悬垂读取(编译器以 E0502 提前拦截)。
若读写同时有效,扩容会让只读引用悬垂栈帧上方,只读引用 r(&String)仍保存旧地址,虚线箭头指向堆上已释放的 5 字节缓冲区 "hello",标注为悬垂读取(E0502 拦截)。下方可变引用 m(&mut String)已修改 s(String):ptr、len · 12、cap · 12 仍然有效,实线箭头从 ptr 指向新分配的 12 字节缓冲区 "hello, world"。栈帧堆r&String只读引用旧地址观测路径sStringptrm&mut String可变引用len · 12cap · 12原 5 字节缓冲区(扩容后当场释放)新分配的12 字节缓冲区"hello, world"len · 5cap · 5"hello"悬垂读取!(E0502 拦截)

为了在编译期消除这类因修改导致的内存失效与数据竞争,Rust 为 & 和 &mut 两个构造器定下了读写排他规则(Aliasing XOR Mutability):

在同一个有效时间区间内,针对同一份数据:

  • 要么允许存在任意多个只读引用(&T),此时谁都不能修改;
  • 要么只允许存在唯一一个可变引用(&mut T),此时连只读引用都不能同时存在。

理解了上面这条「同一时刻只能有唯一一个可变引用」,第 3 章留下的那个问题就解开了:

为什么同样是栈上的 8 字节地址,&i32 实现了 Copy,而 &mut i32 却不实现 Copy(赋值即移动)?

再看一眼我们在 3.1 写过的这段代码:

源文件:same-size-diff-rules.rs
// Uncomment the marked line to trigger error[E0382].
fn main() {
let x = 7;
let r1: &i32 = &x;
let r2 = r1;
println!("{r1} {r2}");
let mut y = 7;
let m1: &mut i32 = &mut y;
let m2 = m1;
// println!("{m1}");
println!("{m2}");
}
在 Rust Playground 中运行

设想一下:假如 &mut 构造器也像 & 构造器那样给生成的类型实体加上 Copy 能力,会发生什么?

  • 那么在执行 let m2 = m1; 之后,按位复制 8 字节地址,m1 和 m2 就会同时保持有效;
  • 仅仅一行普通的赋值,手里就同时握着两个指向同一个变量 y 的有效可变引用,「同一时刻只能有唯一一个 &mut T」的独占约束就无法维持了。

所以,为了守住可变引用的独占性:

  • & 构造器(共享只读):多个只读引用同时读取互不干扰,因此它为生成的所有 &T 类型实体赋予 Copy 能力;
  • &mut 构造器(独占可变):不允许出现第二个同时有效的副本,因此它为生成的所有 &mut T 类型实体去掉 Copy 能力——执行 let m2 = m1; 时必须走移动语义,让原引用 m1 当场失效(再用即报 E0382)。

把 &T 与 &mut T 放在一张表里核对,所有的设计细节都严丝合缝:

对比维度 共享引用 &T 可变引用 &mut T
栈上物理表示 8 字节地址(指向编译期定长类型时) 8 字节地址(指向编译期定长类型时)
是否承担释放责任 否(离开作用域不释放目标数据) 否(离开作用域不释放目标数据)
解引用 *r 的权限 仅允许只读访问 允许读取与写入修改(*m = new_val)
编译期并存规则 共享:同一区间可存在任意多个 &T 独占:同一区间只能存在唯一一个 &mut T(违规报 E0502)
是否实现 Copy 是(赋值即复制 8 字节,两边有效) 否(为保证独占,赋值即移动,原引用失效报 E0382)

打开 borrow-conflict.rs,当前代码中 // println!("{r}"); 位于 m.push_str(", world"); 之后,取消注释会报 E0502。

如果把这行 println!("{r}"); 挪到 let m: &mut String = &mut s; 的正上方(即先创建 r 并打印 r,然后再创建可变引用 m 修改字符串),程序能否通过编译?为什么?

核对答案:完全可以通过编译。因为借用检查器计算引用 r 的有效区间时,只算到 r 最后一次被使用的位置。当 println!("{r}"); 放在 let m = &mut s; 之前时,只读引用 r 用完即退场,它的有效区间与后面可变引用 m 的有效区间完全不重叠,没有违反读写排他规则。


注意上面汇总表第一行括号里的那几个字:「指向编译期定长类型时」。

到这里为止,我们见到的 &i32、&String、&User,在栈上全都只占 1 个机器字(8 字节地址),因为 i32、String、User 占多少字节在编译期一清二楚。

但如果我们要用引用去借用一段编译期根本不知道有多长的连续数据(比如从数组或字符串中间切出来的一段切片),8 字节的地址还够用吗?那个在编译期类型构造器里装不下的「长度」,又被挤到了哪里?

要回答这个问题,我们先从固定长度的数组([T; N])看起。

继续:数组 →