2.1 声明与绑定:复制、移动与借用
第一章推演出了一套不靠 GC 的内存回收模型:每块堆资源在同一时刻只由唯一的所有者负责,在离开作用域(})时自动释放;赋值时根据是否转移释放责任,分成复制(Copy)与移动(Move)。
从这一节开始,我们打开编译器,用真实的 Rust 代码核对这套模型。
许多人第一次写 Rust,挫败感往往从最基础的变量声明和赋值开始:明明只是写下几行 let 和 =,为什么有的变量能继续用,有的变量却当场报错?我们先从第一行 let 的语法看起,再把第一章的模型对上编译器的报错。
第一个可运行程序:let 与类型推断
Section titled “第一个可运行程序:let 与类型推断”在 Rust 中,fn main() {} 是可执行程序的入口,代码从它的 {} 内部开始执行。先看一段最基础的程序:
binding.rs
fn main() { let n = 7; let count: i32 = 7; println!("{n} {count}");}函数体内的三行语句分别对应三个基础语法:
let与绑定(Binding):let n = 7;声明变量,把名字n与栈上的值7关联起来——这个动作叫建立绑定,语句以分号;结束。- 类型标注(Type Annotation)与类型推断(Type Inference):
let count: i32 = 7;用: i32显式指明变量是 32 位有符号整数(在栈上固定占 4 字节);let n = 7;省略了: i32,由编译器根据右侧的7自动推算出类型是i32。 - 打印输出:
println!("{n} {count}");把两个变量的当前值打印到终端(println!末尾带感叹号是因为它是宏,这里先当作现成的打印工具使用)。更多写法可查格式化打印。
你可以直接点击代码块的 Playground 按钮运行,预期输出 7 7。
这里要先立住一个判断:省略类型标注,绝不等于没有类型,更不等于动态类型。(如果你对 usize、bool、char 或单元类型 () 等内置基础类型还不熟悉,可随时翻阅基础速查:标量、元组与单元类型。)
因为编译器必须清楚每个变量在栈上占几个字节、离开作用域时要不要释放堆,才能生成机器码。哪怕省略了 : i32,编译器依然在编译期为 n 确定了具体类型 i32——它的内存大小与使用约束在编译期就已经固定。
同一个 =,为什么有复制和移动?
Section titled “同一个 =,为什么有复制和移动?”上面的例子里,= 右侧写的是字面量 7。如果把 = 右侧换成一个已经存在的变量,用它来初始化新变量,会发生什么?
在其他大多数语言里,b = a 和 t = s 没有任何区别,赋值之后两个变量都能照常读取。但在 Rust 里,把同样的写法分别用在整数 i32 和动态字符串 String 上,结果截然不同。
1. 整数初始化:栈上按位复制
Section titled “1. 整数初始化:栈上按位复制”先看 i32。下面这段代码用变量 a 初始化变量 b,随后同时打印 a 和 b:
init-copy.rs
fn main() { let a = 7; let b = a; println!("{a} {b}");}运行程序,顺利输出 7 7。
对照第一章的内存布局:整数 7 的 4 个字节全部直接放在栈帧里,没有向堆申请任何需要归还的资源。let b = a; 直接把 a 在栈上的 4 个字节按位复制一份给 b——这就是复制。复制完成后,a 和 b 各自持有独立的 7,两个变量都能继续使用。
2. 字符串初始化:原变量当场失效
Section titled “2. 字符串初始化:原变量当场失效”现在保持代码结构完全不变,只把整数换成带着堆上字符缓冲区的 String:
init-move.rs
// Uncomment the marked line to trigger error[E0382].fn main() { let s = String::from("hello"); let t = s; // println!("{s}"); println!("{t}");}这里 String::from("hello") 在堆上申请了一块存放 "hello" 的字符缓冲区,并把三个机器字的控制头(堆指针 ptr、长度 len、容量 cap)放进栈变量 s。
默认源码注释掉了打印 s 的那一行。现在把 // println!("{s}"); 的注释去掉,尝试像刚才打印整数 a 那样打印 s。
编译器会当场拒绝,并给出我们在 Rust 里最常遇到的第一个所有权报错:
编译器诊断摘要:取消 init-move.rs 中标记行的注释后error[E0382]: borrow of moved value: `s`明明和 let b = a; 语法相同,换成 String 后为什么连打印原变量 s 都不允许?用第一章的模型逐行核对:
- 执行
let t = s;之前:s是堆上"hello"的唯一所有者(Owner),负责在离开main作用域(})时释放这块堆缓冲区; - 执行
let t = s;时:若让s和t同时有效,离开}时就会触发双重释放(Double Free)。为守住「同一块堆同一时刻只有一个所有者」,这一行触发移动:- 运行时(物理内存):把
s栈槽位里的三格控制头(24 字节)按位复制给t,堆上的"hello"不复制;s在栈帧上的 24 字节槽位并没有被释放,内存里也没有打上任何物理标记; - 编译期(编译器状态表):编译器在内部的状态表里,把原变量
s的状态改记为已失效(已移动),并将释放责任转记到t名下;
- 运行时(物理内存):把
- 执行
println!("{s}");时:借用检查器(Borrow Checker)在编译期查状态表,发现代码试图读取状态为「已失效」的s,直接以error[E0382]拒绝编译。
(注:图中 s 的虚线框与「已失效」表示编译器状态表里的静态状态,它的栈槽位仍留在当前栈帧中,下一节我们会看到如何往这个空置的栈槽位里重新填入新值。)
3. 不想让原变量失效:用 & 创建引用
Section titled “3. 不想让原变量失效:用 & 创建引用”看到 error[E0382],你自然会问:如果我写下 let 时,只是想多一个变量来读取 s 里的 "hello",并不想把释放责任移走导致 s 失效,在 Rust 代码里该怎么写?
直接用第一章第四节设计出来的引用(Reference)——在变量名前加上 &:
init-reference.rs
fn main() { let s = String::from("hello"); let r: &String = &s; println!("{s} {r}");}运行这段程序,顺利打印出 hello hello,s 和 r 都能正常使用。
这里的 &s 就是借用(Borrowing)操作:它不移走 s 的三格控制头,而是取出 s 在栈上的地址,造出一个只占 8 字节、且在类型上剥离了释放责任的引用(类型为 &String)交给 r。因为释放责任依然留在 s 身上,s 不会失效,最终仍由 s 在离开 main 的 } 时释放堆内存。
总结:三种初始化对比与动手核对
Section titled “总结:三种初始化对比与动手核对”把这一节的三种初始化写法放在一起对照:
| 初始化语句 | 等号执行的动作 | 原变量还能用吗 | 堆上字符缓冲区由谁负责释放 |
|---|---|---|---|
let b = a;(a: i32) |
复制:复制栈上 4 字节整数(i32 实现了 Copy) |
能 | 无堆缓冲区 |
let t = s;(s: String) |
移动:复制控制头,转移释放责任(String 未实现 Copy) |
不能(报 E0382) |
新变量 t |
let r: &String = &s; |
借用:取 s 的栈地址,剥离释放责任 |
能 | 仍是原变量 s |
打开 init-move.rs(或点击它的 Playground),把 let t = s; 改成 let t = &s;,同时取消 println!("{s}"); 这一行的注释。
在运行前,先顺着上面的表格推演两步:
- 加上
&之后,编译器为t推断出的类型变成了什么? - 此时
s为什么不再触发error[E0382],堆上的"hello"最终由谁释放?
核对答案:加上 & 后,右侧变成了借用操作,t 的类型推断为 &String。因为借用不转移释放责任,原变量 s 依然有效,两行 println! 都能正常打印 hello,堆上的字符缓冲区最终仍由 s 在离开 } 时释放。
这一节我们看到的每一个 =,左边都带着 let——它们都是在创建一个全新变量的同时接手初始值。
如果一个变量已经创建好了,后面不写 let,直接写一行 s = ... 给这个已有变量换入新值,会发生什么?它原本持有的那块旧堆内存又去了哪里?
带着这个问题,我们进入下一节。