跳到主要内容

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 内存安全的基础:

  1. 每个值都有一个所有者:Rust 中的每个值都有且仅有一个变量作为其所有者(owner)。
  2. 同一时间只能有一个所有者:当一个值被赋值给另一个变量时,所有权会发生转移(move)。
  3. 当所有者离开作用域,值会被自动释放:当变量离开其作用域时,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 类型在内存中的布局通常包含三个部分:

  1. 指向堆上数据的指针(ptr)
  2. 长度(len)
  3. 容量(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 的引用规则在编译时进行检查,这些规则保证了内存安全:

  1. 同一作用域内,要么有一个可变引用,要么有任意数量的不可变引用,但不能同时存在
  2. 引用必须总是有效的(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 所有权的核心概念:

  1. 所有权规则:每个值有唯一所有者,离开作用域时自动释放
  2. 移动语义:赋值和函数传参会转移所有权,原变量失效
  3. 克隆:使用 clone 方法进行深度复制,保留原变量
  4. 复制语义:Copy 类型在赋值时自动复制,双方都有效
  5. 引用和借用:使用 &&mut 借用值,不获取所有权
  6. 引用规则:可变引用和不可变引用不能共存,引用必须有效
  7. 切片:对集合的部分引用,&str&[T]

这些概念是 Rust 内存安全的基础,在后续的章节中我们会继续深入探讨。

延伸阅读

练习

  1. 编写一个函数,接收 String 并返回第一个单词(不含空格)
  2. 解释为什么以下代码无法编译:
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &mut s;
    println!("{} {}", r1, r2);
  3. 编写一个函数,交换两个可变引用指向的值
  4. 使用切片重构字符串处理函数,使其同时支持 String&str
  5. 解释为什么 String 不是 Copy 类型,而 i32