Rust所有权(下):引用、借用与切片
引言
在上一章中,我们深入探讨了Rust的所有权系统,理解了其核心规则:单一所有者、所有权的移动(Move)以及Copy和Clone trait。我们看到了所有权如何在函数调用和返回时进行转移,例如takes_and_gives_back函数:
fn takes_and_gives_back(a_string: String) -> String {a_string
}
虽然这种模式保证了内存安全,但在实际编程中却显得相当笨拙。如果我们仅仅是想让一个函数读取或修改某个数据,而并不想交出其所有权,难道每次都必须像这样把所有权传来传去吗?这不仅使代码变得冗长,还可能带来不必要的性能开销。
幸运的是,Rust提供了一个优雅的解决方案来应对这一挑战:借用(Borrowing)。通过“借用”,我们可以在不转移所有权的前提下,允许代码的其他部分临时“使用”一个值。实现借用的机制就是引用(References)。
本章将带你深入理解引用和借用这两个紧密相连的概念。我们将学习如何创建不可变引用和可变引用,并探索Rust编译器为了保证安全而施加的“借用规则”。最后,我们将介绍一个基于引用机制的强大抽象——切片(Slices),它为我们提供了一种安全、高效的方式来引用集合的一部分。
掌握了借用和切片,你将能够编写出既符合所有权规则,又兼具灵活性和高效性的Rust代码,真正解锁Rust语言的强大潜力。
1. 引用(References)与借用(Borrowing)
引用允许你引用(refer to)某个值,而无需取得其所有权。它就像一个指针,存储的是另一个值的地址,但与C/C++中的指针不同,Rust的引用在编译时受到了借用检查器(borrow checker)的严格审查,以确保其始终指向有效的内存。
通过引用来访问值的过程,我们称之为借用(Borrowing)。
1.1 创建不可变引用
我们可以使用&操作符来创建一个值的引用。默认情况下,引用是不可变的,意味着你不能通过这个引用来修改它所指向的值。
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let s1 = String::from("hello");// calculate_length 函数“借用”了 s1,而不是取得其所有权let len = calculate_length(&s1);println!("The length of '{}' is {}.", s1, len); // s1 在这里仍然是有效的
}// a_string 的类型是 &String,表示它是一个指向 String 的引用
fn calculate_length(a_string: &String) -> usize {a_string.len()
} // a_string 在这里离开作用域。但因为它不拥有所指向的值,// 所以当它被销毁时,什么也不会发生。s1 的数据不会被 drop。
代码与内存分析:
let s1 = String::from("hello");:和之前一样,s1在栈上,拥有堆上的字符串数据。calculate_length(&s1);:&s1创建了一个指向s1所拥有数据的引用。这个引用本身(一个地址)被压入calculate_length函数的栈帧。s1的所有权没有被移动。它仍然是堆上数据的所有者。calculate_length函数的参数a_string的类型是&String。它“借用”了s1。
- 在
calculate_length函数内部,我们可以通过a_string来访问String的数据(例如调用.len()方法),但我们不能修改它。 - 当
calculate_length函数结束时,a_string这个引用被销毁。因为它不拥有数据,所以堆上的字符串数据安然无恙。 - 回到
main函数,s1依然是有效的所有者,我们可以继续使用它。
图示:借用时的内存布局
main's STACK HEAP
+----------------------+
| s1 |
| ptr ------------+------------------->+-----------+
| len = 5 | | h | e | l | l | o |
| capacity = 5 | +-----------+
+----------------------+|V 调用 calculate_length(&s1)calculate_length's STACK
+----------------------+
| a_string (&String) |
| (address of s1's data) --+
+----------------------+ ||main's STACK |
+----------------------+ |
| s1 | |
| ptr ------------+------+
| ... |
+----------------------+
通过借用,我们完美地解决了之前的问题。函数可以“观察”数据,而无需承担管理的责任。
1.2 创建可变引用
如果我们确实需要在一个函数中修改被借用的值,该怎么办?Rust允许我们创建可变引用(mutable references),使用&mut语法。
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello"); // s 必须是可变的change(&mut s);println!("{}", s);
}fn change(some_string: &mut String) {some_string.push_str(", world");
}
代码分析:
let mut s = ...:首先,被借用的值s本身必须被声明为mut。如果你想通过可变引用来修改它,那么它自身必须是可变的。change(&mut s):我们使用&mut s来创建一个指向s的可变引用。fn change(some_string: &mut String):函数的参数类型现在是&mut String,表示它接收一个可变的String引用。some_string.push_str(...):在函数内部,我们可以通过这个可变引用来调用会修改String的方法。
输出结果:
hello, world
可变引用为我们提供了强大的灵活性,但“能力越大,责任越大”。为了防止数据竞争(data races)等并发问题,Rust对可变借用施加了非常严格的限制。这正是我们要探讨的借用规则。
2. 借用的规则
借用机制的核心是编译器在编译时强制执行的一套规则。这些规则旨在从根本上防止数据竞争。数据竞争通常在以下三个条件同时满足时发生:
- 两个或更多的指针(或引用)同时访问同一块数据。
- 其中至少有一个指针(或引用)被用来写入数据。
- 没有使用任何同步机制来控制对数据的访问。
Rust的借用规则通过确保这种情况在编译时就不可能发生,从而保证了线程安全。
借用规则如下:
- 在任何给定的作用域内,对于一块数据,你只能拥有以下两者之一:
- 一个可变引用 (
&mut T)。 - 任意数量的不可变引用 (
&T)。
- 一个可变引用 (
- 引用必须始终有效(valid)。
让我们通过例子来深入理解第一条规则,这也是最核心的一条。
2.1 规则:一个可变引用 vs. 多个不可变引用
场景一:同时存在多个不可变引用(合法)
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let s = String::from("hello");let r1 = &s; // OKlet r2 = &s; // OKprintln!("r1 = {}, r2 = {}", r1, r2);// r1 和 r2 在这里之后不再被使用
}
这是完全合法的。我们可以创建任意多个对s的不可变引用。因为它们都只是读取数据,所以不会产生任何冲突。
场景二:一个可变引用(合法)
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello");let r1 = &mut s; // OKprintln!("r1 = {}", r1);
}
这也是合法的。我们创建了一个可变引用。
场景三:一个可变引用与一个不可变引用共存(非法)
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello");let r1 = &s; // 不可变借用let r2 = &mut s; // 可变借用// 下面这行代码将无法编译!// println!("r1 = {}, r2 = {}", r1, r2); // error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
}
为什么这是非法的?
想象一下,如果这段代码可以编译。r1期望它指向的数据s是不会改变的。但r2却可能随时修改s的内容。这就可能导致r1指向的数据突然变得无效或不一致,这是一种非常危险的状态,尤其是在多线程环境下。Rust编译器通过在编译时禁止这种情况来保护我们。
场景四:两个可变引用共存(非法)
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello");let r1 = &mut s;let r2 = &mut s; // 第二个可变借用// 下面这行代码将无法编译!// println!("r1 = {}, r2 = {}", r1, r2);// error[E0499]: cannot borrow `s` as mutable more than once at a time
}
这也是非法的,原因与数据竞争的定义直接相关。如果r1和r2都可以修改s,它们可能会在没有同步的情况下互相干扰,导致数据损坏。
2.2 引用的作用域与非词法作用域生命周期(NLL)
一个引用的作用域从它被创建开始,一直持续到它最后一次被使用的地方。这一点非常重要,它得益于Rust编译器的一项高级特性——非词法作用域生命周期(Non-Lexical Lifetimes, NLL)。
在旧版本的Rust中,引用的作用域会持续到其所在的花括号{}结束。但在现代Rust中(自2018版起),编译器足够智能,能够精确地分析出引用最后一次被使用的位置。
这使得一些在逻辑上安全的代码现在可以被接受了:
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello");let r1 = &s; // 不可变借用开始let r2 = &s; // 不可变借用开始println!("r1 = {} and r2 = {}", r1, r2);// r1 和 r2 的作用域在这里结束,因为它们之后再也没有被使用let r3 = &mut s; // OK!现在可以进行可变借用了println!("r3 = {}", r3);
}
代码分析:
r1和r2是不可变引用。它们最后一次被使用是在第一个println!宏中。- 编译器分析出,在
let r3 = &mut s;这一行,r1和r2已经不再活跃(no longer live)。 - 因此,此时对
s进行可变借用是安全的,因为不存在任何活跃的不可变引用与之冲突。
这个特性极大地提升了Rust编程的灵活性和人体工程学,使得借用规则在实践中更加合理。
3. 悬垂引用(Dangling References)
借用规则的第二条是“引用必须始终有效”。这意味着Rust编译器会保证你永远不会持有一个指向无效内存的引用。这种引用在其他语言中被称为悬垂指针(dangling pointer),是导致程序崩溃和安全漏洞的常见原因。
考虑以下在C/C++中很常见的错误模式:
// C 语言中的悬垂指针例子
char* dangle() {char s[] = "hello"; // s 是在 dangle 函数的栈上分配的return s; // 返回一个指向 s 的指针
} // 函数结束,s 的内存被释放,返回的指针现在指向无效内存
Rust编译器会如何处理类似的情况?
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let reference_to_nothing = dangle();
}fn dangle() -> &String { // dangle 返回一个 String 的引用let s = String::from("hello"); // s 是 dangle 函数内部的局部变量&s // 我们尝试返回对 s 的引用
} // s 在这里离开作用域,其拥有的内存被释放// 我们返回的引用将指向一块被释放的内存
这段代码无法通过编译。Rust编译器会给出一个非常明确的错误信息:
error[E0106]: missing lifetime specifier--> src/main.rs:7:16|
7 | fn dangle() -> &String {| ^ expected named lifetime parameter|= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime|
7 | fn dangle() -> &'static String {| ~~~~~~~error[E0515]: cannot return reference to local variable `s`--> src/main.rs:10:5|
10 | &s| ^^ returns a reference to data owned by the current function
编译器的错误信息非常清晰:
cannot return reference to local variable 's':你不能返回一个指向局部变量的引用。returns a reference to data owned by the current function:因为这个局部变量s在函数结束时就会被销毁,返回的引用会立即失效。
为了解决这个问题,我们应该直接返回String本身,将所有权转移出去:
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let s = no_dangle();println!("Got string: {}", s);
}fn no_dangle() -> String {let s = String::from("hello");s // 直接移动 s 的所有权
}
通过这种方式,Rust的借用检查器和所有权系统协同工作,在编译时就彻底根除了悬垂引用的可能性,这是Rust提供核心内存安全保证的关键所在。
4. 切片(Slices)
切片是另一种不持有所有权的数据类型。它允许你引用一个集合中连续的一部分元素,而不是整个集合。切片本身也是一个“胖指针”,它存储了指向起始元素的指针和切片的长度。
4.1 字符串切片(String Slices)
字符串切片是对String中一部分的引用。它的类型写作&str。
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let s = String::from("hello world");let hello = &s[0..5]; // [startIndex..endIndex]let world = &s[6..11];println!("'{}' -> '{}' + '{}'", s, hello, world);// Range 语法糖let s_len = s.len();let slice1 = &s[..5]; // 从开头到第5个字节(不含)let slice2 = &s[6..]; // 从第6个字节到结尾let slice_all = &s[..]; // 整个字符串的切片println!("slice1: {}, slice2: {}, slice_all: {}", slice1, slice2, slice_all);
}
输出结果:
'hello world' -> 'hello' + 'world'
slice1: hello, slice2: world, slice_all: hello world
重要提示:字符串切片的索引是基于字节(byte)的。如果你的字符串包含多字节的UTF-8字符,直接对字节索引进行切片可能会导致程序panic,因为它可能恰好切在一个字符的中间。处理UTF-8字符串时,应使用.chars().take()等更安全的方法。
字符串字面量就是切片
我们之前一直在使用的字符串字面量(e.g., "hello"),它的类型其实就是&'static str。它是一个指向存储在程序二进制文件中的、静态生命周期的字符串切片。这也是为什么我们可以把&str类型的变量传递给期望&String的函数(通过解引用强制多态,deref coercions,我们将在后续章节学习)。
4.2 切片与借用规则
字符串切片本质上是不可变引用,因此它们遵循借用规则。
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let mut s = String::from("hello world");let word = first_word(&s);// s.clear(); // 错误!println!("the first word is: {}", word);
}// 这个函数接收一个字符串切片
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[..]
}
在上面的代码中:
first_word(&s)创建了一个对s的切片,这是一个不可变借用。- 如果我们在
println!之前尝试调用s.clear()(这是一个需要可变借用的方法),代码将无法编译。 - 因为
word这个不可变引用在println!中还需要被使用,所以此时不能存在对s的可变借用。
这再次展示了Rust如何通过借用规则来防止迭代器失效等问题。word这个切片依赖于s的内部数据保持不变。如果s.clear()被允许执行,word就会变成一个指向无效内存的悬垂引用。
4.3 其他类型的切片
切片的思想不仅限于字符串。它也适用于其他集合类型,比如数组和向量。
// File: src/main.rs
// Rust Version: 1.73.0fn main() {let a = [1, 2, 3, 4, 5];let slice: &[i32] = &a[1..3]; // slice 的类型是 &[i32]assert_eq!(slice, &[2, 3]);println!("Array slice: {:?}", slice);
}
数组切片&[i32]和字符串切片&str在行为上非常相似。它们都提供了一种安全、高效的方式来共享对集合数据的只读访问,而无需复制数据本身。
总结
在本章中,我们解锁了Rust中除所有权转移之外的另一种核心数据交互方式——借用。
- 引用(References) (
&T和&mut T) 允许我们在不获取所有权的情况下访问数据。 - 借用(Borrowing) 是通过引用使用数据的行为。
- 借用规则 是编译器强制执行的一套规则,保证了数据安全,核心是在同一作用域内,一个值不能同时被可变地和不可变地借用,也不能同时被多次可变地借用。
- 悬垂引用 在Rust中是不可能存在的,因为借用检查器会确保所有引用都指向有效的数据。
- 切片(Slices) (
&str,&[T]) 是一种特殊的引用,它指向集合中的一部分连续数据。切片本身不持有所有权,并遵循借用规则,是传递集合只读视图的理想方式。
所有权、借用、引用和切片共同构成了Rust语言安全和性能的基石。初学者可能会觉得借用规则有些苛刻,但请相信,这些规则是你的朋友。它们在编译时为你捕获了整整一个类别的潜在bug,让你在运行时可以高枕无忧。
随着你编写更多的Rust代码,你会逐渐内化这些规则,形成一种“所有权思维”。届时,你将能够自如地在所有权转移和借用之间做出选择,编写出既安全又高效的程序。在接下来的章节中,我们将看到这些概念是如何在更复杂的数据结构(如struct)中应用的。
