logo

Rust 所有权与借用机制深入解析

作者:rousong2026.11.06 22:07浏览量:0

简介:本文从所有权转移带来的使用痛点出发,系统介绍 Rust 中引用与借用的核心规则、非词法生命周期、悬垂引用防护,以及字符串切片与函数参数惯用法,帮助开发者写出更安全、更顺手的 Rust 代码。

一、从一个“尴尬”说起:所有权转移带来的不便

在 Rust 中,每一个值都有唯一的所有者。所有权机制保证了内存安全,但也带来了一些使用上的不便。考虑一个简单场景:我们想计算一个字符串的长度,但函数调用之后,调用方仍然希望继续使用这个字符串。

如果严格按所有权规则来写,函数必须接收 String 的所有权,计算完长度后,再把所有权连同结果一起返回给调用方:

  1. fn calculate_length(s: String) -> (String, usize) {
  2. let length = s.len();
  3. (s, length)
  4. }
  5. fn main() {
  6. let s1 = String::from("hello");
  7. let (s2, len) = calculate_length(s1);
  8. println!("'{}' 的长度是 {}.", s2, len);
  9. }

这里 (String, usize) 的返回方式显得有些笨重。函数只是为了“看一眼”长度,却不得不把整个字符串的所有权拿走,再通过元组归还。如果调用方忘记接收返回值中的字符串,那么后续就无法再使用原来的变量。

这种“为了读取数据而转移所有权”的模式,在真实项目中会迅速放大复杂度。尤其是当函数需要读取多个参数时,调用方不得不同时接收多个返回的所有权,代码可读性与维护性都会下降。

二、引用与借用:只“借来看一眼”,不拿走所有权

Rust 提供的解决思路是“引用”(reference)。引用允许我们在不获取所有权的情况下访问某个值,这种行为被称为“借用”(borrow)。创建引用使用 & 符号,创建可变引用使用 &mut。

上面的函数可以改写为:

  1. fn calculate_length(s: &String) -> usize {
  2. s.len()
  3. }
  4. fn main() {
  5. let s1 = String::from("hello");
  6. let len = calculate_length(&s1);
  7. println!("'{}' 的长度是 {}.", s1, len);
  8. }

函数参数类型从 String 变为 &String,调用时传入 &s1。函数内部可以读取字符串内容,但并不会在离开作用域时释放它,因为引用本身不拥有数据的所有权。

可以这样理解:所有权模式下,同事把书送给你,你看完还要把书寄回去;而借用模式下,你只是翻开放在桌上的书看一眼,书从头到尾都属于同事。借用结束后,你不再访问这本书,但书仍然在原处。

三、引用的两条基本类型:不可变引用与可变引用

Rust 提供两种引用类型:

1. 不可变引用 &T

不可变引用允许多个引用同时存在,因为它只提供读取能力,不会改变数据。这种共享读取是安全的,不存在数据竞争问题。

  1. let s = String::from("hello");
  2. let r1 = &s;
  3. let r2 = &s;
  4. println!("{} {}", r1, r2);

多个不可变引用可以共存,编译器不会阻止。

2. 可变引用 &mut T

可变引用允许修改数据,但在同一时间和同一作用域内,只能存在一个可变引用。这个限制避免了多个写者同时修改同一数据而引发的数据竞争。

  1. let mut s = String::from("hello");
  2. {
  3. let r1 = &mut s;
  4. r1.push_str(", world");
  5. }
  6. println!("{}", s);

如果把两个可变引用放在同一作用域内,编译器会直接报错。这个规则看起来严格,但它将数据竞争问题从运行时提前到了编译期。

四、借用规则的三条核心约束

Rust 对引用的使用有三条核心规则:

  1. 不可变引用可以同时存在多个:多个读取者共享数据不会产生问题。
  2. 可变引用在同一时刻只能有一个:同一时间只能有一个写者,避免数据竞争。
  3. 不可变引用与可变引用不能同时存在:读取与写入不能并发发生,确保读取到的数据是稳定一致的。

这三条规则构成了 Rust 内存安全模型的重要支柱。初学时会觉得限制较多,但随着实践深入,开发者会意识到这些限制在编译期就帮我们排除了大量潜在缺陷。

五、非词法生命周期:让借用在“最后一次使用”时结束

早期 Rust 的生命周期规则是词法作用域(Lexical Scope),即引用从创建开始,一直存活到所在代码块结束。这种方式在某些场景下会过于保守,导致代码无法通过编译。

Rust 后来引入了“非词法生命周期”(Non-Lexical Lifetimes,简称 NLL),让引用的有效期不再绑定到花括号末尾,而是在最后一次使用该引用的地方提前结束。

来看一个例子:

  1. let mut s = String::from("hello");
  2. let r1 = &s;
  3. let r2 = &s;
  4. println!("{} and {}", r1, r2);
  5. // 从此开始,r1 和 r2 不再被使用
  6. let r3 = &mut s;
  7. r3.push_str(", world");
  8. println!("{}", r3);

在词法生命周期下,r1 和 r2 会一直存活到 main 函数的末尾,导致 r3 无法创建可变引用。但在 NLL 下,编译器会分析出 r1 和 r2 在 println! 之后就不再被使用,因此它们的生命周期提前结束,r3 可以合法创建。

这个改进让代码表达更加自然,也减少了开发者为了绕过借用检查而必须引入额外花括号的情况。

六、悬垂引用:编译器在编译期就挡住的内存风险

“悬垂引用”(dangling reference)是指引用指向了已经被释放的内存。在 C 或 C++ 中,这种错误通常在运行时才会暴露,可能导致程序崩溃或产生不可预期的行为。而 Rust 编译器会在编译期就阻止这种代码出现。

  1. fn dangle() -> &String {
  2. let s = String::from("hello");
  3. &s
  4. }

上述代码中,s 在函数结束时会被释放,返回 &s 就会产生悬垂引用。Rust 编译器会直接报错,提示我们返回了一个对局部变量的引用。

如果确实需要返回数据,应当返回数据本身而不是引用:

  1. fn no_dangle() -> String {
  2. let s = String::from("hello");
  3. s
  4. }

通过所有权转移返回 String,调用方获得完整的所有权,内存生命周期得以安全延续。

七、切片类型:&str 与 &[T]

切片是另一种常见的引用形式。它允许引用一段连续数据中的部分元素,而不拥有这些数据的所有权。

1. 字符串切片 &str

字符串切片是对 String 中某一段数据的引用。字符串字面值本身就是一个 &str,它指向二进制文件中的只读数据区域。

  1. let s = String::from("hello world");
  2. let hello = &s[0..5];
  3. let world = &s[6..11];

hello 和 world 都是 &str 类型,它们借用 s 中的一部分数据。使用切片时需要注意边界必须落在 UTF-8 字符边界上,否则会引发运行时错误。

2. 数组与 Vec 切片 &[T]

切片同样适用于数组和 Vec:

  1. let arr = [1, 2, 3, 4, 5];
  2. let slice = &arr[1..4];
  3. let v = vec![10, 20, 30, 40, 50];
  4. let v_slice = &v[..2];

slice 与 v_slice 都是引用,它们不拥有数据,只是借用原集合中的一部分。

八、函数参数惯用法:优先使用 &str 而非 &String

在实际开发中,一个重要的惯用法是:当函数只需要读取字符串内容时,参数类型优先使用 &str,而不是 &String。

这是因为 &str 可以直接接受字符串字面值、String 的引用以及字符串切片,调用方无需进行额外的类型转换。

  1. fn print_message(msg: &str) {
  2. println!("{}", msg);
  3. }
  4. let s = String::from("hello");
  5. print_message(&s); // &String 自动转换为 &str
  6. print_message("world"); // 字符串字面值本身就是 &str

如果参数类型写成 &String,调用方传入 "world" 这样的字符串字面值时就需要先建立 String,产生不必要的分配。因此,在仅需要读取字符串内容时,&str 是更加通用的选择。

九、总结

Rust 的引用与借用机制,核心目标是让开发者在不需要转移所有权的情况下安全地访问数据。通过 &T 和 &mut T,我们可以区分共享读取与独占写入;通过三条借用规则,数据竞争在编译期就被排除;通过非词法生命周期,借用检查器在保证安全的同时也保持了足够的灵活性。

理解这些机制之后,编写 Rust 代码会变得更加顺畅。函数参数优先使用 &str 等惯用法,也能让 API 更加通用易用。最重要的是,这些规则并非为了为难开发者,而是为了在性能不损失的前提下,提供一种可靠的内存安全保证。

发表评论

活动