Rust 核心机制:所有权、借用与生命周期
前言
Rust 为了实现无垃圾回收(GC)的高性能和内存安全,引入了三个在其他语言中不常见或不存在的关键概念,这也是入门的最大障碍。
所有权 (Ownership)
- 规则:每个值在同一时间只能有一个所有者(Owner)。当所有者离开作用域时,值会被自动清理(Drop)。
- 痛点:当你将一个变量赋值给另一个,或将其传入函数时,所有权可能会发生“转移”(Move)。原变量会立即失效,再次使用它会导致编译错误。这与习惯了变量可以自由复制的开发者直觉相悖。
借用 (Borrowing)
规则:为了在不转移所有权的情况下使用数据,你需要“借用”它,即创建引用(Reference)。
但借用规则非常严格:
- 在同一作用域内,你可以拥有任意数量的不可变引用 (
&T)。 - 但你只能拥有一个可变引用 (
&mut T),并且在拥有可变引用时,不能存在任何不可变引用。
- 在同一作用域内,你可以拥有任意数量的不可变引用 (
痛点:这个规则是为了在编译期就杜绝数据竞争,但对初学者来说,编译器频繁报出的“借用检查器”(Borrow Checker)错误非常令人沮丧,感觉被“过度约束”。
生命周期 (Lifetimes)
规则:生命周期是引用的有效作用域。Rust 要求所有引用都必须是有效的,不能成为“悬垂引用”(Dangling Reference)。
在大多数情况下,编译器可以自动推断生命周期,但在某些复杂场景(如函数返回引用)下,需要开发者显式地标注。
痛点:生命周期注解(如
'a)的语法和概念对新手来说非常抽象,是学习曲线中公认的“悬崖”部分。
依赖
本地需要已安装稳定的 rustc 与 cargo。
下面示例使用任意相近的稳定版 Rust 均可。
若片段无法通过编译,请以本地 rustc --version 为准。
实现
建议按「所有权 → 借用 → 生命周期 → 悬垂」顺序读下去。
每一节都先给一句口语化总结,再给代码,避免一上来就啃语法细节。
所有权
谁负责释放这块内存
可以把所有权想成「这张桌子归谁收拾」。
Rust 规定同一时刻只有一个所有者,所有者离开作用域时自动调用 drop,不会出现两个人各释放一次、也不会漏释放。
堆上的 String 需要在运行时分配缓冲,所以「拷贝」成本高。
编译器默认选择移动:把所有权交给新位置,旧变量立刻作废,从根上杜绝二次释放。
下面用同一套「赋值给另一个变量」对比 i32 与 String。i32 很小且实现了 Copy,赋值是复制一份比特模式,两个变量都还能用。String 赋值会把所有权交给 b,a 再被使用就会报错。
1 | fn main() { |
函数参数也会移动。
下面 take 的形参类型是 String,调用 take(a) 会把 a 的所有权搬进函数体,调用结束后在 take 里被 drop,所以调用方不能再打印 a。
1 | fn take(s: String) { |
如果你既要把字符串交给函数处理,又希望调用方还能继续用同一个内容,有两条常见路。
一条路是 clone,花钱买一份独立的所有权。
另一条路是下一节的「借用」,只把只读或可写引用借出去,不把整张桌子搬走。
下面演示 clone:调用方保留自己的 String,函数拿到的是另一份拷贝。
1 | fn take(s: String) { |
借用
不转移所有权地使用数据
借用就是「把钥匙给别人用一会儿」,房子还是你的。
不可变借用 &T 像只读通行证,很多人可以同时持有。
可变借用 &mut T 像独占施工许可,同一时段只能有一张,且不能与任何只读通行证并存,这样就不会出现「一个人读、另一个人写」的竞态。
把规则压成三条,遇事对照即可。
- 任意多个
&T可以同时存在。 - 有一个
&mut T时,不能再有别的&T或第二个&mut T与它重叠。 - 引用不能活得比被引用的数据更久(这就是生命周期要证明的事)。
下面函数只要 &String,调用方在调用结束后仍拥有 a,因此可以继续打印。
1 | fn take(s: &String) { |
需要改内容时,把变量声明成 mut,并传入 &mut。
被调用方通过可变引用改缓冲区,所有权仍在调用方。
1 | fn take(s: &mut String) { |
可变字段可以不可变借用,但是不可变字段不能可变借用
1 | fn take(s: &String) { |
下面这段不能通过编译(整段保持注释,避免误贴进项目)。r1 是不可变借用,在它还可能被使用的区域里再拿 r2 这个可变借用,就违反了上面的第 2 条。
把 println! 挪到 r2 之前、或缩小 r1 的作用域,通常就能化解。
1 | // fn main() { |
生命周期
编译器如何检查引用的有效期
生命周期不是「运行时计时器」,而是编译器用来比较各段引用谁更长、谁更短的一套标注与推断。
函数签名里写 'a,意思是「这几个引用被放在同一档里比较:返回的那个借用的东西,不能比这一档里最短命的那份活得更久」。
很多简单函数不用写生命周期,编译器会帮你补全省略规则。
只有当你返回引用、或结构体里存引用时,才经常需要显式写出来。
下面 shortest 返回 x 或 y 之一,调用方拿到的切片最长只能跟「x 与 y 中较短命的那一方」一样久。
两个参数都标成 'a,是在告诉编译器:返回值跟它们共享同一套寿命约束。
1 | fn shortest<'a>(x: &'a str, y: &'a str) -> &'a str { |
其中let a = "long text"; a就是&str可借用的字符串类型
如果是数字就得这样写
1 | fn min_num<'a>(x: &'a i32, y: &'a i32) -> &'a i32 { |
结构体里如果存了引用,字段也必须带上生命周期,否则编译器不知道「这个引用指向的外面那块内存,至少要比结构体活多久」。
下面 Excerpt 持有指向 book 子串的引用,title 标注为 'a 表示它借来的字符串必须覆盖整个 Excerpt 的使用期。
1 | struct Excerpt<'a> { |
悬垂引用
悬垂引用指「指针还在,指向的内存已经没了」。
C 里这往往是运行时炸弹;Rust 把它挪到编译期,直接拒绝构建。
上一节的规则在这里落到一句话:函数不能返回指向自己栈上局部值的引用,因为函数一返回,局部 String 就被 drop,引用立刻悬空。
下面是被注释掉的错误写法,若去掉注释,s 在 dangle 返回时销毁,返回的 &str 没有合法目标。
1 | // fn dangle() -> &str { |
修法仍然回到「谁拥有内存」:要么返回 String 把所有权交给调用方,要么让调用方传入生命周期更长的缓冲区,你只返回指向其中的切片。
下面用返回 String 的方式,调用方拿到的是独立拥有的数据,不存在悬垂问题。
1 | fn owned() -> String { |
总结
步骤:
- 先判断值是否需堆管理;需要时牢记赋值/传参可能是移动而非隐式拷贝。
- 需要多人只读访问时用
&T;需要唯一写访问时用&mut T,避免与其它活跃引用重叠。 - 当函数或结构体把引用「缝」在一起时,用生命周期说明引用之间的约束关系。
- 遇到编译错误,从「所有权是否已转移」「是否存在同时可变与共享借用」「返回值是否可能悬垂」三条线排查。
注意:
&str与String的关系如同「借用视图」与「拥有缓冲」:API 设计里多用&str可提高复用度。- 标注生命周期不会改变运行时行为,它帮助编译器证明引用始终有效。
- 不理解规则时,优先缩小可变作用域、减少同时存在的引用数量,往往能让检查器与人类读者都更轻松。