Rust 的所有权系统入门
所有权系统:Rust 中最独特的特性
Rust 没有垃圾回收器,却依然能够保证内存安全,这完全归功于一套在编译期执行严格检查的规则——所有权系统。理解所有权,就是拿到了进入 Rust 世界的钥匙。它虽然会带来一定的学习曲线,但一旦掌握,你将能够写出高效、可靠且无需手动管理内存的代码。
所有权规则
Rust 的所有权系统围绕三条核心规则构建,编译器会在编译时强制检查:
- Rust 中的每一个值都有一个被称为 所有者 的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域,值将被丢弃。
这三条规则看起来简单,却从根本上杜绝了悬垂指针、双重释放和内存泄漏等内存安全问题。
变量作用域与 String 类型
在深入所有权之前,先回顾一下作用域。在 Rust 中,作用域通常就是 { } 包围的代码块。变量从声明的地方开始有效,直到当前作用域结束。
{ // s 在这里无效,尚未声明
let s = "hello"; // s 自此有效
// 使用 s 做一些事
} // 作用域结束,s 不再有效
上面的 s 是一个字符串字面值,它被硬编码在程序中,不可更改。为了理解所有权,我们通常使用 String 类型——一种分配在堆上的可变字符串。
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 向字符串追加字面值
println!("{}", s); // 输出 `hello, world!`
String 的数据结构实际上由三部分组成:一个指向堆内存的指针、长度和容量。而它的数据体则存放在堆上。当我们将 String 赋值给另一个变量时,就触发了所有权的核心机制。
移动语义
试着将 String 赋值给另一个变量:
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // 编译错误!
如果你有其他编程语言的经验,可能会觉得这段代码会复制一份堆上的数据,让 s1 和 s2 各自拥有一份独立的 "hello"。但在 Rust 中,为了避免复制堆内存带来的性能开销,也为了防止双重释放,let s2 = s1; 这一操作仅仅浅拷贝了栈上的指针、长度和容量,然后 使 s1 失效。这种操作被称为 移动(move)。
此时,内存布局变成了:s2 指向堆上的 "hello",而 s1 已经不再是有效的变量。因此后续使用 s1 会导致编译错误。Rust 通过这种方式保证了值的唯一所有者:在任意时刻,一个堆内存只有一个指针指向它。当 s2 离开作用域时,堆内存被释放。一切安全且高效。
克隆:深度复制
如果确实需要将 String 的数据完整复制一份到新的堆内存中,可以使用 clone 方法:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // 完全合法
这时,s1 和 s2 各自拥有独立的堆内存拷贝,互不影响。clone 的代价是完整的堆数据复制,因此只有当你确实需要副本时才使用。
对于仅存在于栈上的简单数据(如整数),Rust 会直接进行比特拷贝,因为这类类型实现了 Copy trait,没有所有权转移的问题。将 i32 赋值给新变量后,原变量仍然可用。
let x = 5;
let y = x; // x 被拷贝,不会失效
println!("x = {}", x); // 正常
所有权与函数
将值传给函数,或者从函数返回值,其所有权语义与变量赋值一致:会发生移动或拷贝。
fn main() {
let s = String::from("hello");
takes_ownership(s); // s 的值移动到函数里
// println!("{}", s); // 错误:s 已经失效
let x = 5;
makes_copy(x); // x 是 i32,被拷贝进函数
println!("{}", x); // 正常,x 仍然可用
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string 离开作用域,内存释放
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
}
函数的返回值同样可以转移所有权:
fn give_ownership() -> String {
let s = String::from("yours");
s // 返回 s,所有权移出函数
}
但每次都通过传入后再返回的方式来保留所有权十分繁琐。因此 Rust 提供了 引用 的概念。
引用与借用
引用允许我们在不转移所有权的情况下使用值。创建一个引用的行为称为 借用(borrowing)。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len); // s1 仍然有效
}
fn calculate_length(s: &String) -> usize { // s 是 String 的引用
s.len()
} // s 离开作用域,但因为它没有所有权,所以不会释放内存
&s1 创建了一个指向 s1 的引用,但并没有取得 s1 的所有权。在函数内,s 是一个引用,离开作用域时不会丢弃所指向的值。
可变引用
默认情况下,引用是不可变的,即不允许通过引用修改数据。若想要修改,必须使用 可变引用。
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
可变引用有一项重要限制:同一作用域,对同一个数据只能有一个可变引用。这个规则在编译期防止了数据竞争。
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 错误:同一作用域不能有多个可变引用
println!("{}, {}", r1, r2);
这个限制可以有效消除并发修改带来的未定义行为。此外,在同一个作用域内,不能同时拥有可变引用和不可变引用。因为不可变引用的使用者不希望值在背后突然变化。
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 错误:不能在拥有不可变引用时创建可变引用
println!("{}, {}, {}", r1, r2, r3);
这个看似严格的规则,保护了我们程序中的不变量,让数据竞争在编译时无所遁形。
悬垂引用与生命周期
指针概念中常见的错误是 悬垂引用:引用指向的内存已经被释放,但引用仍然存在。Rust 编译器会保证永远不会产生悬垂引用。
fn dangle() -> &String { // 该函数返回一个 String 的引用
let s = String::from("hello");
&s
} // s 被释放,&s 将成为悬垂引用,编译直接报错
编译器会通过 生命周期(lifetime) 分析来追踪引用的有效范围,强制要求引用必须始终有效。生命周期是所有权系统的进阶概念,我们后续会专门讨论。
切片类型
切片让你引用集合中的一段连续元素,而不需要取得整个集合的所有权。字符串切片 &str 就是最常见的例子。
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello 是一个切片,它是指向原来 String 一部分的引用。这也是一种借用,因此必须遵守借用的所有规则:不能同时有可变引用等。
小结
Rust 的所有权系统用一套严格的编译时检查,换来了无 GC 的内存安全和高性能。核心要点可以浓缩为:
- 每一个值只有一个所有者。
- 赋值或传参时,默认是移动,原变量失效。
- 需要深拷贝时,显式调用
clone。 - 通过引用可以借用数据而不获取所有权,引用分为不可变引用和可变引用。
- 同一时刻只能存在一个可变引用,或多个不可变引用。
- 编译器保证引用永远有效,杜绝悬垂指针。
这些规则刚开始可能让你频繁与编译器“搏斗”,但一旦内化,所有权系统就会成为你最可靠的伙伴,帮助你写出零成本抽象的健壮系统软件。在接下来的学习中,我们会看到这些规则如何在结构体、枚举和并发处理中继续发挥作用。