FORMA

第三章:所有权、借用与生命周期(核心)

所有权系统是 Rust 最独特、最核心的语言特性。它让 Rust 无需垃圾回收器就能保证内存安全,并在编译期消除数据竞争。本章你将深入理解所有权如何运转、引用和借用如何被检查、生命周期如何标注,以及常见智能指针如何扩展所有权模型。

3.1 所有权(Ownership)

所有权规则

Rust 的所有权系统遵循三条铁律:

  1. Rust 中每一个值都有且仅有一个所有者
  2. 当所有者离开作用域,值会被自动释放(调用 drop)。
  3. 所有权可以在变量之间转移(移动),转移后原变量失效。

移动(move)

在 Rust 中,赋值、传参、返回值默认都是移动语义:所有权从一个绑定转移到另一个绑定。原变量不能再被使用。

rust
let s1 = String::from("hello");
let s2 = s1;             // s1 的所有权移动到 s2
// println!("{s1}");     // 编译错误:s1 已失效

传参与返回也会发生移动:

rust
fn take(s: String) { }   // 接收所有权
let s = String::from("hi");
take(s);                 // s 被移动
// println!("{s}");      // 错误

函数返回一个值时,所有权也会移交给调用方。

Copy 与 Clone

有些类型在赋值时不会移动,而是执行按位复制。这个行为由 Copy trait 标记。实现了 Copy 的类型在重新赋值后,原变量仍然可用。标量类型(整数、浮点、布尔、字符)以及由 Copy 类型组成的元组和数组都实现了 Copy

rust
let x = 5;
let y = x;        // x 被复制,依然有效
println!("{x}");  // 正常

Copy 是一个隐式的、栈上的按位复制,不能重写,且要求类型中所有成员都是 Copy 的。

与之相对的,Clone 是一个显式调用的方法,用于深拷贝堆数据等。对于 String 这类拥有堆数据的类型,必须显式调用 .clone() 来创建副本,而不是隐式复制。

rust
let s1 = String::from("hello");
let s2 = s1.clone();   // 深拷贝,s1 仍有效

栈数据 vs 堆数据

为什么 String 会移动而整数会复制?根本原因在于它们在内存中的存储方式:

  • 整数等数据完全存储在栈上,按位复制成本极低。
  • String 内部由指针、长度和容量组成,但实际字符串内容在堆上。如果按位复制,会出现两个 String 指向同一块堆内存,离开作用域时会引发二次释放。因此 String 不实现 Copy,赋值就是移动。

Rust 通过所有权和移动语义从根本上杜绝了这种问题。

常见所有权转移场景

所有权转移不仅发生在显式赋值中,也会隐含在各种流程里:

rust
let s = String::from("text");
let v = vec![s];           // s 移动到 v 中

for item in v {            // 遍历会消耗 v 本身(非引用迭代)
    println!("{item}");
}
// 此处 v 已不可用

理解所有权的流动是阅读和编写 Rust 代码的基础。

3.2 引用与借用

如果每次使用一个值都要转移所有权,程序会变得极其繁琐。Rust 提供了引用(reference),允许在不获取所有权的情况下使用数据,这就是借用

共享引用 &T

共享引用允许多个变量同时借用同一个值,但只能读取,不能修改。用 & 创建。

rust
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}");  // 多个共享引用可以共存

可变引用 &mut T

可变引用允许修改被借用的数据,但只能存在一个可变引用,且在它的作用域内不能有任何其他引用(无论共享还是可变)。这是为了在编译期消除数据竞争。

rust
let mut s = String::from("hello");
let r = &mut s;
r.push_str(" world");
// println!("{s}");       // 错误,r 借用期间不可用 s

规则:在任意给定时间,你只能拥有以下两种引用之一,但不能同时拥有:

  • 任意数量的不可变引用
  • 恰好一个可变引用

借用检查器与 NLL

Rust 的借用检查器在编译期严格检查这些规则,确保引用永远不会悬垂(指向已被释放的内存)。同时,得益于非词法作用域生命周期(NLL),引用的有效期并不是简单地到所在代码块末尾,而是到它最后一次被使用的位置。这使得很多以往无法编译的代码变得合法:

rust
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}");  // 共享引用最后一次使用

let r3 = &mut s;        // r1,r2 已不再使用,此处可变引用合法
r3.push_str(" world");

NLL 让借用检查更聪明,减少了不必要的限制。

引用的作用域规则

引用的生命周期必须短于被引用值的生命周期。这自然导出下一节的生命周期概念。

3.3 生命周期(Lifetime)

生命周期是编译器用来确保所有引用都有效的泛型参数。大多数情况下,生命周期是隐式推导的,但编译器需要你显式标注引用之间的关系时,你就必须写出来。

生命周期标注语法

生命周期标注以单引号开头,通常使用 'a'b 等小写字母。它们标注在引用的 & 符号之后:

rust
&'a i32      // 拥有生命周期 'a 的 i32 引用
&'a mut i32  // 拥有生命周期 'a 的可变 i32 引用

标注本身并不改变引用的实际存活时间,而是告诉编译器多个引用之间的存活关系。

函数中的生命周期标注

当函数接收多个引用作为参数并返回一个引用时,编译器必须知道返回的引用与哪个参数的生命周期相关联。此时就需要显式标注:

rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这里 <'a> 声明了一个生命周期泛型参数,并约束了参数 xy 和返回值的生命周期都是 'a,即:返回值至少与两个参数中较短的那一个活得一样长。

生命周期省略规则

Rust 编译器在函数签名中可以自动推导生命周期,遵循三条规则:

  1. 每个引用参数都有各自的生命周期(例如 fn foo(x: &i32) 展开为 fn foo<'a>(x: &'a i32))。
  2. 如果只有一个输入生命周期参数,那么输出生命周期被赋予该参数。
  3. 如果有多个输入生命周期参数,但其中一个是 &self&mut self(方法),则输出的生命周期被赋予 self 的生命周期。

如果这三条规则无法推导出所有引用类型的生命周期,编译器就会报错,要求你手动标注。

结构体中的生命周期

当结构体持有引用时,必须标注生命周期参数,以保证该引用在结构体有效期间始终有效:

rust
struct Excerpt<'a> {
    part: &'a str,
}

这表示 Excerpt 实例不能比它持有的 part 引用活得更久。

静态生命周期 'static

'static 是一个特殊的生命周期,表示这个引用在整个程序运行期间都有效。所有字符串字面量都拥有 'static 生命周期:

rust
let s: &'static str = "I am static";

但需要警惕:不要轻易对普通数据标注 'static,大多数情况下应该由编译器推断或限制为较短的生命周期。

生命周期界限 T: 'a

当泛型类型 T 包含引用时,我们可以用生命周期界限约束它。T: 'a 表示 T 类型中的所有引用都必须活得至少和 'a 一样长。这通常用于结构体同时持有引用和泛型数据:

rust
struct RefHolder<'a, T: 'a> {
    data: &'a T,
}

实际应用:多引用关系的标注

实际中,你可能需要标注更复杂的关系,例如:

rust
fn select<'a, 'b>(first: &'a str, second: &'b str, flag: bool) -> &'a str {
    if flag { first } else { first } // 只返回 first
}

这里返回值只依赖于 'a,所以不需要将两个参数绑定到同一生命周期。理解这种关系能让你写出更精确的 API。

3.4 智能指针初步

智能指针是行为类似指针,但拥有额外元数据和功能的结构体。Rust 标准库提供了多个重要的智能指针,它们扩展了所有权模型,让你能在安全的边界内完成更复杂的内存管理。

Box<T>:堆分配,单一所有权

Box<T> 让你将值分配在堆上,自身保留了唯一的所有权。它常用于以下场景:

  • 类型大小在编译期无法确定,如递归类型
  • 传输大量数据时希望避免拷贝
  • 拥有一个实现了 trait 的对象

经典例子是递归定义的链表节点:

rust
enum List {
    Cons(i32, Box<List>),
    Nil,
}
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));

没有 BoxList 类型的大小将是无穷大,编译器无法确定。

Rc<T>:引用计数,单线程共享所有权

Rc<T>(Reference Counted)允许一个值拥有多个所有者,在单线程环境下通过不可变引用共享数据。每次调用 Rc::clone 都会增加引用计数,当最后一个 Rc 离开作用域时,数据被释放。

rust
use std::rc::Rc;
let a = Rc::new(String::from("shared"));
let b = Rc::clone(&a);
let c = Rc::clone(&a);
println!("count: {}", Rc::strong_count(&a)); // 3

Rc<T> 提供的共享引用是不可变的;如果需要在多个所有者间修改数据,就需要内部可变性。

Arc<T>:原子引用计数,多线程安全

Arc<T>Rc<T> 功能相同,但使用原子操作进行计数,可安全用于多线程环境。在线程间共享所有权时使用。

rust
use std::sync::Arc;
use std::thread;
let a = Arc::new(10);
let a_clone = Arc::clone(&a);
thread::spawn(move || {
    println!("{a_clone}");
}).join().unwrap();

RefCell<T>:内部可变性,运行时借用检查

RefCell<T> 允许你在拥有不可变引用的同时修改内部值,它通过运行时的借用规则来保证安全:borrow()borrow_mut() 会动态地检查同一时间只有一个可变借用或多个不可变借用。违反规则会导致 panicRefCell<T> 只能用于单线程。

rust
use std::cell::RefCell;
let data = RefCell::new(42);
*data.borrow_mut() += 1;
println!("{}", data.borrow()); // 43

组合模式:Rc<RefCell<T>>

RcRefCell 结合在一起,就能在单线程环境下实现多个所有者同时拥有可变数据的能力。这是 Rust 实现共享可变状态的一种常见模式:

rust
use std::rc::Rc;
use std::cell::RefCell;
let value = Rc::new(RefCell::new(5));
let a = Rc::clone(&value);
let b = Rc::clone(&value);
*a.borrow_mut() += 1;
*b.borrow_mut() += 2;
println!("{}", value.borrow()); // 8

Weak<T>:弱引用,避免循环引用

RcArc 还可以产生弱引用 Weak<T>。弱引用不增加引用计数,不能直接访问数据(需要先升级为强引用),主要用于打破循环引用,防止内存泄漏。

rust
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,  // 弱引用指向父节点
    children: RefCell<Vec<Rc<Node>>>,
}

父节点持有子节点的 Rc,子节点通过 Weak 指向父节点,形成父强子弱的关系,避免了双向强引用导致的内存泄漏。

所有权、借用和生命周期构成了 Rust 内存安全的基石。理解这套规则后,你不仅能编写安全且高效的代码,更会发现大部分内存问题在编译时就被拦下。接下来请阅读 结构体与集合

参考文献

资料说明
Ownership官方书第 4 章
References借用
Lifetimes生命周期

相关文章