Rust 所有权
所有权(Ownership)是 Rust 最独特的特性,它让 Rust 在没有垃圾回收器的情况下保证内存安全。理解所有权是掌握 Rust 的关键。本章将深入讲解所有权的核心概念、工作原理以及实际应用。
为什么要了解所有权?
在编程语言发展历史中,内存管理一直是一个核心问题:
- C/C++:程序员手动管理内存(malloc/free 或 new/delete)。这要求程序员非常小心,一旦出错就会导致内存泄漏(分配后忘记释放)或悬垂指针(访问已释放的内存)等问题。
- Java/Python/Go:使用垃圾回收器(GC)自动管理内存。GC 会定期检查并释放不再使用的内存,但这会带来运行时开销,并且 GC 的行为有时候不可预测。
- Rust:通过所有权系统在编译时管理内存,不需要垃圾回收器。这意味着 Rust 程序可以拥有和 C/C++ 一样的高性能,同时又能保证内存安全。
根据 Rust 官方文档的定义,所有权是一套管理内存的规则,Rust 编译器会在编译时检查这些规则。如果有任何规则被违反,程序将无法编译。这种"零成本抽象"的理念是 Rust 的核心优势。
所有权规则
Rust 的所有权遵循三条核心规则,这些规则是 Rust 内存安全的基础:
- 每个值都有一个所有者:Rust 中的每个值都有且仅有一个变量作为其所有者(owner)。
- 同一时间只能有一个所有者:当一个值被赋值给另一个变量时,所有权会发生转移(move)。
- 当所有者离开作用域,值会被自动释放:当变量离开其作用域时,Rust 会自动调用
drop函数释放内存。
这三条规则看似简单,但它们构成了 Rust 独特的所有权系统的基础。理解这些规则对于编写正确的 Rust 代码至关重要。
变量作用域
在深入了解所有权之前,我们首先需要理解变量作用域(Scope)的概念。作用域是程序中变量有效的范围。
fn main() {
// s 在这里无效,尚未声明
let s = "hello"; // 从此处起,s 开始有效
// 使用 s
println!("{}", s);
} // 作用域结束,s 不再有效
在 Rust 中,当变量超出作用域时,Rust 会自动调用 drop 函数来释放该变量持有的资源。这是 Rust 独特的地方——内存管理不是程序员的责任,而是语言本身的一部分。
深入理解 String 类型
为了更好地理解所有权,我们需要区分栈上数据和堆上数据。这是理解所有权机制的关键。
栈 vs 堆
在计算机中,内存分为栈(Stack)和堆(Heap)两个区域:
- 栈:存储固定大小的数据,访问速度快。函数调用时的局部变量通常存储在栈上。
- 堆:存储可变大小的数据,访问速度相对较慢,但可以动态分配更多内存。
栈上数据 vs 堆上数据
fn main() {
// 栈上的数据:固定大小,复制成本低
let x = 5;
let y = x; // 复制,x 和 y 都有值
println!("x = {}, y = {}", x, y); // 都能正常使用
// 堆上的数据:String 类型
let s1 = String::from("hello");
let s2 = s1; // 移动,s1 不再有效
// println!("{}", s1); // 错误!s1 已失效
println!("{}", s2); // 正确
}
为什么会有这种区别?因为 i32 是固定大小的类型,它存储在栈上,复制成本极低。而 String 是可变大小的类型,它的一部分数据(指向堆内存的指针、长度、容量)存储在栈上,实际的字符串内容存储在堆上。
String 与 &str 的区别
在 Rust 中,字符串有两种主要表示方式:
fn main() {
// 字符串字面量(&str):不可变,编译时已知大小,存储在二进制文件的只读数据段中
let s: &str = "hello";
// s.push_str(", world"); // 错误!&str 是不可变的
// String:可变,运行时分配,存储在堆上
let mut s = String::from("hello");
s.push_str(", world!"); // 可以修改
println!("{}", s); // 输出: hello, world!
}
&str(字符串切片):对字符串的只读引用,存储在栈上的是指针和长度,实际数据可能是静态内存或堆上的 String。String:拥有所有权的可变字符串类型,实际数据存储在堆上。
移动(Move)
当一个值被赋值给另一个变量时,所有权会被转移。这就是 Rust 中的"移动"语义。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到 s2
// s1 在这里已经无效
// println!("{}", s1); // 编译错误!s1 已失效
println!("{}", s2); // 正确
}
为什么需要移动语义?
String 类型在内存中的布局通常包含三个部分:
- 指向堆上数据的指针(ptr)
- 长度(len)
- 容量(capacity)
如果只是复制这三个部分(浅复制),就会导致两个指针指向同一块堆内存。当它们离开作用域时,会尝试两次释放同一块内存,这就是著名的"双重释放"(double free)问题,是严重的内存安全漏洞。
Rust 通过移动语义解决了这个问题:转移所有权后,原变量自动失效,Rust 保证任何时候只有一个变量对数据负责,从而避免了双重释放。
克隆(Clone)
如果需要在赋值后保留原变量的有效性,可以使用 clone 方法进行深度复制。
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 深度复制,堆上的数据也会被复制一份
println!("s1 = {}, s2 = {}", s1, s2); // 两个都可以使用
}
clone 会复制堆上的数据,这涉及到内存分配和复制操作,因此在性能敏感的场景中需要谨慎使用。
复制(Copy)
对于存储在栈上的简单类型,复制成本很低,Rust 会自动复制这些值,而不是移动。
fn main() {
let x = 5;
let y = x; // 复制,x 和 y 都有值
println!("x = {}, y = {}", x, y); // 两个都能正常使用
}
实现了 Copy trait 的类型
根据 Rust 官方文档,以下类型默认实现了 Copy trait:
- 所有整数类型(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128)
- 浮点类型(f32, f64)
- 布尔类型(bool)
- 字符类型(char)
- 元组(当且仅当所有元素都是 Copy 类型时)
- 不可变引用(&T)
fn main() {
// Copy 类型的例子
let a = 5;
let b = a; // 复制
println!("a = {}, b = {}", a, b);
// 元组(当所有元素都是 Copy 类型时)
let c = (1, 2.0, true); // (i32, f64, bool) 都是 Copy
let d = c;
println!("c = {:?}, d = {:?}", c, d);
// 非 Copy 类型的例子
let e = (1, String::from("hello")); // String 不是 Copy
let f = e; // 移动
// println!("{:?}", e); // 错误!e 已失效
}
重要规则:如果你自定义的类型包含非 Copy 类型的字段,那么你的类型默认也不是 Copy。
函数与所有权
传递值给函数
将值传递给函数会发生所有权转移(除非传递引用)。
fn main() {
let s = String::from("hello");
take_ownership(s); // s 的所有权移动到函数
// println!("{}", s); // 错误!s 已失效
let x = 5;
make_copy(x); // x 是 Copy 类型,复制到函数
println!("{}", x); // 正确,x 仍然有效
}
fn take_ownership(some_string: String) {
println!("{}", some_string);
} // some_string 离开作用域,被丢弃
fn make_copy(some_integer: i32) {
println!("{}", some_integer);
} // some_integer 离开作用域,但因为是 Copy 类型,不会有特殊操作
返回值与所有权
函数返回值也会转移所有权。
fn main() {
let s1 = gives_ownership(); // 函数返回 String,所有权移动给 s1
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2); // s2 移动到函数,函数返回值移动给 s3
// println!("{}", s2); // 错误!s2 已失效
println!("s1 = {}, s3 = {}", s1, s3);
}
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string // 所有权移动给调用者
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // 所有权移动给调用者
}
元组返回多个值
如果函数需要返回多个值,但不想转移所有权,可以返回元组。
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("字符串 '{}' 的长度是 {}", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length) // 返回 String 和长度
}
引用和借用
所有权转移虽然安全,但在很多场景下并不方便。例如,我们可能只是想读取数据而不需要获得所有权。为此,Rust 提供了引用(Reference)机制。
引用
引用允许使用值但不获取所有权。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 传递引用
println!("字符串 '{}' 的长度是 {}", s1, len); // s1 仍然有效
}
fn calculate_length(s: &String) -> usize { // 参数是引用
s.len()
} // s 离开作用域,但因为没有所有权,不会丢弃数据
引用(使用 & 符号)创建了一个指向值的指针,但不获取所有权。引用也称为"借用"(Borrowing),因为它像是从所有者那里"借"来的数据,使用完毕后要"归还"。
可变引用
默认引用是不可变的,如果需要修改数据,需要使用可变引用。
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
引用规则
Rust 的引用规则在编译时进行检查,这些规则保证了内存安全:
- 同一作用域内,要么有一个可变引用,要么有任意数量的不可变引用,但不能同时存在
- 引用必须总是有效的(Rust 防止悬垂引用)
fn main() {
let mut s = String::from("hello");
// 规则1:可变引用和不可变引用不能同时存在
let r1 = &s; // 不可变引用
let r2 = &s; // 不可变引用
// let r3 = &mut s; // 错误!已有不可变引用
println!("{} {}", r1, r2); // r1 和 r2 最后一次使用之后
let r3 = &mut s; // 正确!r1 和 r2 已不再使用
r3.push_str(" world");
println!("{}", r3);
}
非词法作用域生命周期(NLL)
Rust 编译器足够智能,能够理解引用的实际使用范围:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // r1 和 r2 在这之后不再使用
let r3 = &mut s; // 正确!因为 r1 和 r2 已不再使用
println!("{}", r3);
}
这段代码在旧的 Rust 版本中可能无法编译,但现在的 Rust 已经支持 NLL,可以正确地推断出引用何时不再使用。
悬垂引用
Rust 在编译时防止悬垂引用(指向不存在数据的引用)。
fn main() {
let reference_to_nothing = dangle();
println!("{}", reference_to_nothing);
}
// 错误示例:返回悬垂引用
fn dangle() -> &String { // 返回 String 的引用
let s = String::from("hello");
&s // 返回 s 的引用
} // s 离开作用域被丢弃,返回的引用指向已释放的内存!
// 正确做法:返回所有权
fn no_dangle() -> String {
let s = String::from("hello");
s // 返回 String,所有权转移给调用者
}
Rust 编译器会阻止这段代码编译,并给出清晰的错误信息。
切片
切片(Slice)是对集合的部分引用,不拥有所有权。
字符串切片
fn main() {
let s = String::from("hello world");
// 字符串切片
let hello = &s[0..5]; // "hello"
let world = &s[6..11]; // "world"
// 简写形式
let hello = &s[..5]; // 从开头到索引 5
let world = &s[6..]; // 从索引 6 到结尾
let whole = &s[..]; // 整个字符串
println!("{} {}", hello, world);
}
字符串切片的类型是 &str。
实际应用:获取第一个单词
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let s = String::from("hello world");
let word = first_word(&s);
println!("第一个单词: {}", word);
// 字符串字面量本身就是切片类型 (&str)
let literal: &str = "hello world";
let word = first_word(&literal.to_string());
}
字符串切片作为参数
最佳实践是使用 &str 作为参数类型,这样既可以接受 String 的引用,也可以接受字符串字面量。
// 更好的函数签名:接受 &str
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
// 可以接受 String 的引用
let s = String::from("hello world");
let word = first_word(&s);
println!("{}", word);
// 也可以接受字符串字面量
let literal = "hello world";
let word = first_word(literal);
println!("{}", word);
}
数组切片
切片不仅适用于字符串,也适用于数组。
fn main() {
let a = [1, 2, 3, 4, 5];
// 数组切片
let slice = &a[1..3]; // [2, 3]
println!("{:?}", slice);
}
所有权模式总结
fn main() {
// 1. 移动
let s1 = String::from("hello");
let s2 = s1; // s1 移动到 s2
// s1 不再有效
// 2. 克隆
let s3 = String::from("hello");
let s4 = s3.clone(); // 深度复制
// s3 和 s4 都有效
// 3. 复制(Copy 类型)
let x = 5;
let y = x; // 栈上复制
// x 和 y 都有效
// 4. 不可变引用
let s5 = String::from("hello");
let r1 = &s5; // 借用
// s5 仍有效,r1 可以读取
// 5. 可变引用
let mut s6 = String::from("hello");
let r2 = &mut s6; // 可变借用
r2.push_str(" world");
// s6 被修改
// 6. 切片
let s7 = String::from("hello");
let slice = &s7[0..2]; // "he"
}
小结
本章我们学习了 Rust 所有权的核心概念:
- 所有权规则:每个值有唯一所有者,离开作用域时自动释放
- 移动语义:赋值和函数传参会转移所有权,原变量失效
- 克隆:使用
clone方法进行深度复制,保留原变量 - 复制语义:Copy 类型在赋值时自动复制,双方都有效
- 引用和借用:使用
&和&mut借用值,不获取所有权 - 引用规则:可变引用和不可变引用不能共存,引用必须有效
- 切片:对集合的部分引用,
&str和&[T]
这些概念是 Rust 内存安全的基础,在后续的章节中我们会继续深入探讨。
延伸阅读
练习
- 编写一个函数,接收
String并返回第一个单词(不含空格) - 解释为什么以下代码无法编译:
let s = String::from("hello");
let r1 = &s;
let r2 = &mut s;
println!("{} {}", r1, r2); - 编写一个函数,交换两个可变引用指向的值
- 使用切片重构字符串处理函数,使其同时支持
String和&str - 解释为什么
String不是Copy类型,而i32是