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}");}运行程序,输出 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)。
区分两个位置的 mut
Section titled “区分两个位置的 mut”仔细看上面代码里的 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}");}很多初学者看到 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。
读写排他规则:为什么 &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}");}在默认源码中,只读引用 r 创建后从未在后面使用过(它的有效区间在创建行即结束),因此后面创建 m: &mut String 并调用 m.push_str(", world") 完全合法。
但只要你取消倒数第二行 // println!("{r}"); 的注释——让只读引用 r 的有效区间跨越 m 的修改操作——编译器立刻以 error[E0502] 拒绝编译:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable为什么要定下这条规则?如果编译器不拦这一行,在物理内存上会出什么事?
顺着第 1 章的栈与堆布局推演一步:
- 变量
s最初是"hello",假设堆上的字符缓冲区当前只申请了 5 个字节的容量(cap = 5); - 只读引用
r正借用着s,准备顺着当时的ptr去读取堆上这 5 个字节(或者已经从中取出了指向这 5 个字节的内部指针); - 此时,可变引用
m执行m.push_str(", world")追加字符——原有的 5 字节容量装不下 12 个字节,触发扩容; - 扩容时,如果原堆块后面刚好有足够的连续空闲内存,分配器可能原地扩大容量;但只要原位置后面空间不够,分配器就必须在堆上另找一块更大的新内存,把
"hello, world"复制过去,并释放原来那块 5 字节的旧堆内存,最后把s栈上的ptr改指新地址。
想一想:只要扩容存在搬迁并释放旧堆块的可能,如果在 m 修改的同时,旁边还有只读引用指着原来的堆内存(或者在多线程下同时读写),一旦旧堆块被释放,那些只读引用手里的地址就会变成悬垂指针。
为了在编译期消除这类因修改导致的内存失效与数据竞争,Rust 为 & 和 &mut 两个构造器定下了读写排他规则(Aliasing XOR Mutability):
在同一个有效时间区间内,针对同一份数据:
- 要么允许存在任意多个只读引用(
&T),此时谁都不能修改; - 要么只允许存在唯一一个可变引用(
&mut T),此时连只读引用都不能同时存在。
为什么 &mut T 必须剥夺 Copy?
Section titled “为什么 &mut T 必须剥夺 Copy?”理解了上面这条「同一时刻只能有唯一一个可变引用」,第 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}");}设想一下:假如 &mut 构造器也像 & 构造器那样给生成的类型实体加上 Copy 能力,会发生什么?
- 那么在执行
let m2 = m1;之后,按位复制 8 字节地址,m1和m2就会同时保持有效; - 仅仅一行普通的赋值,手里就同时握着两个指向同一个变量
y的有效可变引用,「同一时刻只能有唯一一个&mut T」的独占约束就无法维持了。
所以,为了守住可变引用的独占性:
&构造器(共享只读):多个只读引用同时读取互不干扰,因此它为生成的所有&T类型实体赋予Copy能力;&mut构造器(独占可变):不允许出现第二个同时有效的副本,因此它为生成的所有&mut T类型实体去掉Copy能力——执行let m2 = m1;时必须走移动语义,让原引用m1当场失效(再用即报E0382)。
总结:两种引用全面对照
Section titled “总结:两种引用全面对照”把 &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])看起。