跳转到内容

1.6 所有权与借用:对齐 Rust 的核心术语

前五节我们没写一行真实的 Rust 语法,只顺着栈与堆的物理约束,一步步搭起了一套「既不用手写 free、也不用运行时 GC」的内存回收模型。

到了本章最后一节,我们把官方教程里的标准术语端出来,和你亲手推导出的这套机制一一对上号。

先看官方 The Book 的「所有权三条规则」

Section titled “先看官方 The Book 的「所有权三条规则」”

如果你打开 Rust 官方教程(The Book)的第 4 章,迎面看到的第一段话,就是著名的所有权三条规则(Ownership Rules):

  1. Rust 中的每一个值都有一个所有者(Owner)。
  2. 同一时刻,一个值只能有一个所有者。
  3. 当所有者离开作用域(Scope)时,这个值将被释放(Dropped)。

如果一上来直接背这三条条文,很容易觉得这是语言强加给你的死板教条。但现在回头对照我们在第 3 节的推导,你会发现这三句话每一条都对应着一个明确的物理原因:

  • 第 1 条与第 3 条(谁来释放、何时释放):堆上的资源自己不知道什么时候该回收,但持有它的那个栈变量有明确的作用域终点(})。把这个变量定为所有者,规定它离开 {} 时自动触发释放(Drop),就省去了手写 free。
  • 第 2 条(为什么同一时刻只能有一个所有者):在本章的基础模型里,如果一行赋值 t = s 让两个变量同时指向同一块堆,离开 {} 时就会对同一块堆释放两次(双重释放)。为了守住「只释放一次」的底线,赋值时必须让旧变量当场失效,把释放责任移交给新变量——这就是移动(Move);而像普通整数那样不需要释放资源的类型,按位复制后两边都有效——这就是复制(Copy)。

模型范围与真实判据:在本章的简化模型中,我们用「是否持有需要释放的堆资源」来引入复制与移动的分野。而在真实的 Rust 语言规则里,赋值走复制还是移动,唯一判据是该类型是否实现了 Copy:凡是需要释放资源的类型(如 String)都禁止实现 Copy;但没有堆资源的类型(例如后续第 4 章的可变引用 &mut T、第 5 章你自定义的结构体 struct),为了保证独占性或接口演进安全,默认也不实现 Copy,同样走移动语义。

再对上「借用」与「生命周期」

Section titled “再对上「借用」与「生命周期」”

光有上面那三条所有权规则,连调一个只读函数 print_it(s) 都会把调用方的变量移走。为了补上这个缺口,我们在第 4 节和第 5 节又推出了另外两套概念:

  • 引用(Reference,&T)与借用(Borrowing):设计一种只存地址、剥离了释放责任的新类型,叫引用;把地址传给别人使用而不交出所有权的动作,就叫借用(「地址给你用,责任还在我」)。
  • 生命周期(Lifetime)与借用检查器(Borrow Checker):引用自己不管释放,一旦原变量先退场把数据释放了,引用就会悬空。编译器里负责在编译期核对「Move 后的失效变量有没有被再用」以及「引用的有效区间(生命周期)有没有超出所有者存活范围」的静态检查模块,就叫借用检查器。

把这五节的机制与 Rust 术语放在一起对照:

本章模型里的机制 Rust 官方术语 核心作用与判据
负责在离开 {} 时清理资源的唯一变量 所有者(Owner) / 所有权(Ownership) 谁拥有值,谁就负责在退场时释放它
变量离开作用域 {} 时自动执行的清理动作 释放(Drop) 编译器在 {} 处自动插入的清理调用
赋值后两边各自有效 vs 旧变量失效、转移责任 复制(Copy) / 移动(Move) 真实判据为类型是否实现 Copy(有释放责任的类型必走 Move)
只存地址、不担释放责任的 &T,以及取地址的动作 引用(Reference) / 借用(Borrowing) 地址给你用,释放责任留在原变量身上
编译期比对「引用有效区间」与「所有者存活范围」 生命周期(Lifetime) / 借用检查器 确保引用不会活过它指向的数据

去掉术语包装:编译期静态算清释放位置

Section titled “去掉术语包装:编译期静态算清释放位置”

第一次接触「所有权、移动、借用、生命周期」这一串专有名词时,很多人会以为 Rust 在运行时有一套复杂的管理引擎。

其实把术语包装去掉,这套机制在内存管理上的目标很直接:让编译器在编译期间算清楚,该在哪一个 } 处插入 drop(释放)指令,并确保插入后不会出错。

  1. 所有权与 Drop,让编译器知道在哪一处 {} 插入释放指令,避免遗漏(防内存泄漏);
  2. Move 语义(单一所有者),保证同一份资源只会被释放一次(防双重释放);
  3. 借用与生命周期,保证在释放指令执行之后,不会还有任何引用去读那块内存(防悬垂引用)。

一旦借用检查器在编译期把这三件事核对完毕、在对应的 {} 处生成好释放指令,所有权、借用和生命周期这些概念在机器码里就会擦除。到了运行时,程序里既没有垃圾回收器扫描,也没有引用计数加减。


本章综合练习:为日志打印设计传参方式

Section titled “本章综合练习:为日志打印设计传参方式”

先不写完整的 Rust 语法,直接用本章建立的模型做一次设计裁决。

假设你要写一个程序:先在主代码块里读取了一段配置文本 config(存放在堆上),随后依次做三件事:

  1. 调用 validate(config) 检查配置格式是否合法(只需要读取 config);
  2. 调用 print_banner(config) 打印启动横幅(只需要读取 config);
  3. 把 config 交给后台服务 start_server(config) 长期保存,主代码块后面不再使用 config。

请回答下面三个问题:

  1. 选择传参方式:在 validate、print_banner 和 start_server 这三次调用中,哪几次应该传引用(&config),哪一次应该直接传值(config,触发 Move)?为什么?
  2. 诊断错误写法:如果有人在写 validate 时,不仅读取了 config,还试图返回一个指向 validate 内部局部临时字符串 temp 的引用 &temp 给外面使用,编译器会用本章的哪条规则拦住它?那行代码违反了什么物理约束?
  3. 追踪释放位置:按你在第 1 题选定的方案,config 指向的那块堆内存最终会在哪个函数或代码块结束的 } 处被释放?
点击核对参考答案
  1. 传参方式选型:
    • validate(&config) 和 print_banner(&config) 应该传引用(借用)。因为这两个函数只需要读取内容,并且调用结束后主代码块还要继续使用 config;如果直接按值传入,第一次调用 validate(config) 就会触发 Move 让 config 失效,并在 validate 返回时把堆内存释放掉。
    • start_server(config) 应该直接传值(Move)。因为主代码块后续不再使用 config,而服务需要接手这块堆内存的唯一所有权与释放责任。
  2. 错误诊断:
    • 编译器会用生命周期(借用检查)规则拦住它。
    • 物理原因:局部变量 temp 属于 validate 的栈帧,当 validate 返回(遇到 })时,temp 离开作用域并当场释放它管理的堆内存。如果把 &temp 返回给调用方,引用的有效区间就超出了所有者 temp 的存活范围,变成悬垂引用。
  3. 释放位置:
    • 因为前两次调用只是借用(不转移释放责任),第三次调用 start_server(config) 把所有权移动给了 start_server 的形参,所以这块堆内存最终会在 start_server 内部持有它的所有者离开作用域(})时被释放。

下一步:打开编译器,看真实代码

Section titled “下一步:打开编译器,看真实代码”

到这里,基础模型已经立稳了。当然,真实工程还会遇到更复杂的场景——比如怎么通过引用去修改数据而不引发冲突(第二篇将引出可变引用 &mut T)、怎么让多个组件在运行时共同拥有一份数据(第五篇将引出引用计数 Rc)。

在进入那些进阶机制之前,下一章我们先打开编译器,把这套基础模型落到最日常的 Rust 代码里:写下 let 和 =,亲眼看看编译器是如何按照这套规则裁决每一行变量声明、重新赋值与遮蔽的。

下一章:变量与赋值 →