Rust 核心机制:所有权、借用与生命周期

前言

Rust 为了实现无垃圾回收(GC)的高性能和内存安全,引入了三个在其他语言中不常见或不存在的关键概念,这也是入门的最大障碍。

所有权 (Ownership)

  • 规则:每个值在同一时间只能有一个所有者(Owner)。当所有者离开作用域时,值会被自动清理(Drop)。
  • 痛点:当你将一个变量赋值给另一个,或将其传入函数时,所有权可能会发生“转移”(Move)。原变量会立即失效,再次使用它会导致编译错误。这与习惯了变量可以自由复制的开发者直觉相悖。

借用 (Borrowing)

  • 规则:为了在不转移所有权的情况下使用数据,你需要“借用”它,即创建引用(Reference)。

    但借用规则非常严格:

    • 在同一作用域内,你可以拥有任意数量的不可变引用 (&T)。
    • 但你只能拥有一个可变引用 (&mut T),并且在拥有可变引用时,不能存在任何不可变引用。
  • 痛点:这个规则是为了在编译期就杜绝数据竞争,但对初学者来说,编译器频繁报出的“借用检查器”(Borrow Checker)错误非常令人沮丧,感觉被“过度约束”。

生命周期 (Lifetimes)

  • 规则:生命周期是引用的有效作用域。Rust 要求所有引用都必须是有效的,不能成为“悬垂引用”(Dangling Reference)。

    在大多数情况下,编译器可以自动推断生命周期,但在某些复杂场景(如函数返回引用)下,需要开发者显式地标注。

  • 痛点:生命周期注解(如 'a)的语法和概念对新手来说非常抽象,是学习曲线中公认的“悬崖”部分。

依赖

本地需要已安装稳定的 rustccargo
下面示例使用任意相近的稳定版 Rust 均可。
若片段无法通过编译,请以本地 rustc --version 为准。

实现

建议按「所有权 → 借用 → 生命周期 → 悬垂」顺序读下去。
每一节都先给一句口语化总结,再给代码,避免一上来就啃语法细节。

所有权

谁负责释放这块内存

可以把所有权想成「这张桌子归谁收拾」。
Rust 规定同一时刻只有一个所有者,所有者离开作用域时自动调用 drop,不会出现两个人各释放一次、也不会漏释放。

堆上的 String 需要在运行时分配缓冲,所以「拷贝」成本高。
编译器默认选择移动:把所有权交给新位置,旧变量立刻作废,从根上杜绝二次释放。

下面用同一套「赋值给另一个变量」对比 i32String
i32 很小且实现了 Copy,赋值是复制一份比特模式,两个变量都还能用。
String 赋值会把所有权交给 ba 再被使用就会报错。

1
2
3
4
5
6
7
8
9
10
fn main() {
let x = 1;
let y = x;
println!("{x} {y}");

let a = String::from("hi");
let b = a;
// println!("{a}"); // 错误:a 已移动给 b
println!("{b}");
}

函数参数也会移动。
下面 take 的形参类型是 String,调用 take(a) 会把 a 的所有权搬进函数体,调用结束后在 take 里被 drop,所以调用方不能再打印 a

1
2
3
4
5
6
7
8
9
fn take(s: String) {
println!("{s}");
}

fn main() {
let a = String::from("hi");
take(a);
println!("{a}"); // 错误:a 已移动进 take
}

如果你既要把字符串交给函数处理,又希望调用方还能继续用同一个内容,有两条常见路。
一条路是 clone,花钱买一份独立的所有权。
另一条路是下一节的「借用」,只把只读或可写引用借出去,不把整张桌子搬走。

下面演示 clone:调用方保留自己的 String,函数拿到的是另一份拷贝。

1
2
3
4
5
6
7
8
9
fn take(s: String) {
println!("{s}");
}

fn main() {
let a = String::from("hi");
take(a.clone());
println!("{a}");
}

借用

不转移所有权地使用数据

借用就是「把钥匙给别人用一会儿」,房子还是你的。
不可变借用 &T 像只读通行证,很多人可以同时持有。
可变借用 &mut T 像独占施工许可,同一时段只能有一张,且不能与任何只读通行证并存,这样就不会出现「一个人读、另一个人写」的竞态。

把规则压成三条,遇事对照即可。

  1. 任意多个 &T 可以同时存在。
  2. 有一个 &mut T 时,不能再有别的 &T 或第二个 &mut T 与它重叠。
  3. 引用不能活得比被引用的数据更久(这就是生命周期要证明的事)。

下面函数只要 &String,调用方在调用结束后仍拥有 a,因此可以继续打印。

1
2
3
4
5
6
7
8
9
fn take(s: &String) {
println!("借用 {s}");
}

fn main() {
let a = String::from("hi");
take(&a);
println!("原来的字符串是 {a}");
}

需要改内容时,把变量声明成 mut,并传入 &mut
被调用方通过可变引用改缓冲区,所有权仍在调用方。

1
2
3
4
5
6
7
8
9
10
fn take(s: &mut String) {
s.push_str(" world");
println!("借用 {s}");
}

fn main() {
let mut a = String::from("hi");
take(&mut a);
println!("字符串是 {a}");
}

可变字段可以不可变借用,但是不可变字段不能可变借用

1
2
3
4
5
6
7
8
9
fn take(s: &String) {
println!("借用 {s}");
}

fn main() {
let mut a = String::from("hi");
take(&a);
println!("字符串是 {a}");
}

下面这段不能通过编译(整段保持注释,避免误贴进项目)。
r1 是不可变借用,在它还可能被使用的区域里再拿 r2 这个可变借用,就违反了上面的第 2 条。
println! 挪到 r2 之前、或缩小 r1 的作用域,通常就能化解。

1
2
3
4
5
6
// fn main() {
// let mut s = String::from("hello");
// let r1 = &s;
// let r2 = &mut s;
// println!("{r1} {r2}");
// }

生命周期

编译器如何检查引用的有效期

生命周期不是「运行时计时器」,而是编译器用来比较各段引用谁更长、谁更短的一套标注与推断。
函数签名里写 'a,意思是「这几个引用被放在同一档里比较:返回的那个借用的东西,不能比这一档里最短命的那份活得更久」。

很多简单函数不用写生命周期,编译器会帮你补全省略规则。
只有当你返回引用、或结构体里存引用时,才经常需要显式写出来

下面 shortest 返回 xy 之一,调用方拿到的切片最长只能跟「xy 中较短命的那一方」一样久。
两个参数都标成 'a,是在告诉编译器:返回值跟它们共享同一套寿命约束。

1
2
3
4
5
6
7
8
9
10
fn shortest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() <= y.len() { x } else { y }
}

fn main() {
let a = "long text";
let b = "short";
let s = shortest(a, b);
println!("{s}");
}

其中let a = "long text"; a就是&str可借用的字符串类型

如果是数字就得这样写

1
2
3
4
5
6
7
8
9
10
fn min_num<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x <= y { x } else { y }
}

fn main() {
let a = 3;
let b = 4;
let s = min_num(&a, &b);
println!("{s}");
}

结构体里如果存了引用,字段也必须带上生命周期,否则编译器不知道「这个引用指向的外面那块内存,至少要比结构体活多久」。

下面 Excerpt 持有指向 book 子串的引用,title 标注为 'a 表示它借来的字符串必须覆盖整个 Excerpt 的使用期。

1
2
3
4
5
6
7
8
9
10
11
struct Excerpt<'a> {
title: &'a str,
}

fn main() {
let book = String::from("The Book");
let ex = Excerpt {
title: &book[..3],
};
println!("{}", ex.title);
}

悬垂引用

悬垂引用指「指针还在,指向的内存已经没了」。
C 里这往往是运行时炸弹;Rust 把它挪到编译期,直接拒绝构建。

上一节的规则在这里落到一句话:函数不能返回指向自己栈上局部值的引用,因为函数一返回,局部 String 就被 drop,引用立刻悬空。

下面是被注释掉的错误写法,若去掉注释,sdangle 返回时销毁,返回的 &str 没有合法目标。

1
2
3
4
// fn dangle() -> &str {
// let s = String::from("oops");
// &s[..]
// }

修法仍然回到「谁拥有内存」:要么返回 String 把所有权交给调用方,要么让调用方传入生命周期更长的缓冲区,你只返回指向其中的切片。

下面用返回 String 的方式,调用方拿到的是独立拥有的数据,不存在悬垂问题。

1
2
3
4
5
6
7
8
fn owned() -> String {
String::from("ok")
}

fn main() {
let s = owned();
println!("{s}");
}

总结

步骤:

  1. 先判断值是否需堆管理;需要时牢记赋值/传参可能是移动而非隐式拷贝。
  2. 需要多人只读访问时用 &T;需要唯一写访问时用 &mut T,避免与其它活跃引用重叠。
  3. 当函数或结构体把引用「缝」在一起时,用生命周期说明引用之间的约束关系。
  4. 遇到编译错误,从「所有权是否已转移」「是否存在同时可变与共享借用」「返回值是否可能悬垂」三条线排查。

注意:

  • &strString 的关系如同「借用视图」与「拥有缓冲」:API 设计里多用 &str 可提高复用度。
  • 标注生命周期不会改变运行时行为,它帮助编译器证明引用始终有效。
  • 不理解规则时,优先缩小可变作用域、减少同时存在的引用数量,往往能让检查器与人类读者都更轻松。