1.4 引用:怎样使用数据而不接手释放责任
上一节结束时,我们的最简模型立下了两条规则:栈变量离开作用域(})时自动释放它指向的堆;带着堆资源的值在赋值 = 时走 Move 语义,旧变量当场失效。
上一节末尾留了一个问题:靠这两条规则搭起来的模型,日常写程序时最普通的协作——把一个值传给函数用一下——还能不能自然写出来?
函数传参的困境:只读一次,变量就失效
Section titled “函数传参的困境:只读一次,变量就失效”写程序离不开函数调用。假设我们写一个最普通的函数 print_it,什么都不改,只把字符串 s 传进去打印一行:
fn print_it(msg) { print(msg)} // ← msg 在这里离开作用域
{ s = alloc_heap("hello") print_it(s) print(s) // ← 还能用吗?}按最简模型的规则推演一下,调用 print_it(s) 会发生什么:
- 传参就是赋值:调用
print_it(s),在底层就是把实参s赋值给函数的形参msg(相当于执行了一行msg = s); - 触发 Move:因为字符串带着堆资源,走 Move 语义,
msg = s立刻让外面的变量s当场失效,释放责任转移给了函数里的形参msg; - 函数返回时堆直接被释放:
print_it打印完返回,msg离开函数作用域,触发「离开作用域就释放」——msg把堆上的"hello"直接释放掉了; - 调用方无法继续使用:回到外面的代码块,不仅
s已经失效,连堆上的数据都已经被print_it销毁了。
如果硬要在不改模型的前提下,让外面的代码在调完 print_it 之后还能继续用这个字符串,唯一的写法是:让函数在结尾把参数再 return 回来,外面的调用方重新用变量接住:
fn print_it(msg) { print(msg) return msg // ← 用完了还必须把值移回去}
{ s = alloc_heap("hello") s = print_it(s) // ← 每次调函数都得重新接住返回值 print(s)}想象一下:如果一个函数只是想读 3 个字符串、外加本身还要返回一个计算结果,你就得传进去 3 个参数,再让函数返回一个包含 4 个元素的元组 (result, s1, s2, s3),调用方再一个个拆出来接住。
连最基本的函数调用都繁琐到这种地步,只靠最简模型构造出来的语言,在真实工程里根本无法使用。
诊断:问题出在哪?
Section titled “诊断:问题出在哪?”在最简模型里,要让 print_it 读取字符串,就必须把整个 String 值传给它,包括堆指针 ptr、长度 len 和容量 cap。函数参数接手这个值,也接手释放堆的责任。
在开头的 print_it 中,函数返回时,参数离开作用域,堆上的数据随之释放。也就是说,哪怕函数只想读一次数据,调用方也必须让出释放责任。
要让函数只使用数据、不负责释放,我们需要为模型设计一种新类型:它能找到数据,但不承担释放堆内存的责任。
自己动手设计:一种剥离了释放责任的地址类型
Section titled “自己动手设计:一种剥离了释放责任的地址类型”如果我们是这门语言的设计者,怎么让函数既能读到 "hello",离开作用域时又不会去释放那块堆?
顺着内存布局想,办法其实很直接:不要把 s 那三个机器字传给函数,我们另外造一个小巧的值,里面只记下变量 s 在栈上的内存地址,把这个地址传给函数。
为了和原来的 s 区分开,我们把取 s 地址的动作记作 &s,把这种专门存放 String 栈地址的新类型记作 &String。先看它在内存里长什么样:
对照这张图,看清我们刚设计出来的这个新东西和原变量的物理区别:
- 原来的变量
s(String),在栈上占 3 个机器字(ptr、len、cap),其中ptr存着堆上字符缓冲区的起始地址。只有握着这套完整的控制结构,离开作用域时才负责去释放那块堆。 - 而我们新造出来的
&s(&String),在栈上只占 1 个机器字(8 字节),里面装的仅仅是变量s在栈上的地址。顺着它读数据要跳两次:先找到栈上的s,再顺着s里的ptr找到堆上的"hello"。
现在从角色分工想一个问题:为什么拿着这种新类型的变量离开作用域时,不会去释放堆?
因为从设计之初,它就是为了「只读数据、不管释放」而造出来的——这两类值在模型里的职责截然不同:
- 原类型
String:直接持有三格控制结构,离开作用域时负责释放堆; - 新类型
&String:只存着指向s的 8 字节栈地址,在类型约束上剥离了释放责任(也不允许顺着这个地址把s的值移走)——当它离开作用域时,只是丢掉栈上那 8 字节地址,堆上的资源纹丝不动。
释放责任牢牢留在原来的变量 s 身上——地址给你用,责任还在我。
给这个我们亲手设计出来的新概念起个正式名字:在 Rust 里,这种「只存地址、剥离了释放责任」的类型,就叫引用(Reference)。
顺理成章的推论:只读引用走的是 Copy 语义
Section titled “顺理成章的推论:只读引用走的是 Copy 语义”一旦把「引用」定义成「栈上 8 个字节的地址,且剥离了释放堆内存的责任」,上一节的规则就会自动推导出一个重要性质。
回想上一节我们是怎么区分 Copy 和 Move 的:因为同一块堆同一时刻只能由一个变量负责释放,所以凡是带着释放责任的值,赋值时必须让旧变量失效(Move);而像整数那样不带任何堆释放责任的栈上字节,赋值时直接按位抄一份,两边都有效(Copy)。
现在看我们刚设计的引用类型 &String:
- 它在栈上只有 8 字节的地址;
- 它不承担任何释放堆资源的责任。
哪怕把 &s 按位抄出一百份,等到这一百个引用变量离开作用域时,它们各自都只是丢掉栈上的 8 字节地址,谁也不会去碰那块堆,绝不会发生双重释放。
所以,在我们的模型里,只读引用天然就走 Copy 语义——跟普通整数一模一样,无论赋给多少个变量、传给多少个函数,都只是复制那 8 字节地址,原来的变量和引用全都继续有效。
现在用引用重写开场那段,之前函数传参的困境就彻底解开了:
fn print_it(msg: &String) { // ← 接收的是引用,不接手释放责任 print(msg)} // ← msg 离开作用域,只丢掉 8 字节栈地址,堆不动
{ s = alloc_heap("hello") print_it(&s) // ← 只把 s 的地址传过去 r1 = &s // ← 复制地址,Copy 语义 r2 = &s // ← 再复制一份地址,多个只读引用同时有效 print(s) // ← s 从未失效,照常使用} // ← 只有 s 在这里离开作用域时,释放一次堆资源这套模型已经完美了吗?
Section titled “这套模型已经完美了吗?”停下来核对一下,加上「引用类型(&T)」之后,我们的自动回收模型已经做成了什么:
- 释放时机确定且唯一:每块堆资源只由一个原变量(如
s)负责,离开作用域时自动释放,既不用 GC,也不会双重释放; - 数据可以自由读取和共享:只想使用数据时,传引用
&s——因为它剥离了释放责任,函数用完退场不会误删堆,多个只读引用还能按 Copy 语义自由复制、同时并存。
看起来,自动释放、禁止双重释放、函数传参与多处共享,全都严丝合缝地跑通了。
那么请先问自己一个问题:现在的这套模型,是不是已经完美解决了所有问题?
顺着「引用自己不管释放,堆什么时候释放完全听原变量 s 的」这条规则,再往极端处想一步:如果负责释放的原变量和拿着地址的引用不在同一个作用域里——有没有可能原变量先一步离开作用域、把堆释放掉了,而某个引用还留在外面的变量手里?
带着这个疑问,我们进入下一节。