第三章:所有权、借用与生命周期(核心)
所有权系统是 Rust 最独特、最核心的语言特性。它让 Rust 无需垃圾回收器就能保证内存安全,并在编译期消除数据竞争。本章你将深入理解所有权如何运转、引用和借用如何被检查、生命周期如何标注,以及常见智能指针如何扩展所有权模型。
3.1 所有权(Ownership)
所有权规则
Rust 的所有权系统遵循三条铁律:
- Rust 中每一个值都有且仅有一个所有者。
- 当所有者离开作用域,值会被自动释放(调用
drop)。 - 所有权可以在变量之间转移(移动),转移后原变量失效。
移动(move)
在 Rust 中,赋值、传参、返回值默认都是移动语义:所有权从一个绑定转移到另一个绑定。原变量不能再被使用。
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到 s2
// println!("{s1}"); // 编译错误:s1 已失效
传参与返回也会发生移动:
fn take(s: String) { } // 接收所有权
let s = String::from("hi");
take(s); // s 被移动
// println!("{s}"); // 错误
函数返回一个值时,所有权也会移交给调用方。
Copy 与 Clone
有些类型在赋值时不会移动,而是执行按位复制。这个行为由 Copy trait 标记。实现了 Copy 的类型在重新赋值后,原变量仍然可用。标量类型(整数、浮点、布尔、字符)以及由 Copy 类型组成的元组和数组都实现了 Copy。
let x = 5;
let y = x; // x 被复制,依然有效
println!("{x}"); // 正常
Copy 是一个隐式的、栈上的按位复制,不能重写,且要求类型中所有成员都是 Copy 的。
与之相对的,Clone 是一个显式调用的方法,用于深拷贝堆数据等。对于 String 这类拥有堆数据的类型,必须显式调用 .clone() 来创建副本,而不是隐式复制。
let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝,s1 仍有效
栈数据 vs 堆数据
为什么 String 会移动而整数会复制?根本原因在于它们在内存中的存储方式:
- 整数等数据完全存储在栈上,按位复制成本极低。
String内部由指针、长度和容量组成,但实际字符串内容在堆上。如果按位复制,会出现两个String指向同一块堆内存,离开作用域时会引发二次释放。因此String不实现Copy,赋值就是移动。
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
共享引用允许多个变量同时借用同一个值,但只能读取,不能修改。用 & 创建。
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}"); // 多个共享引用可以共存
可变引用 &mut T
可变引用允许修改被借用的数据,但只能存在一个可变引用,且在它的作用域内不能有任何其他引用(无论共享还是可变)。这是为了在编译期消除数据竞争。
let mut s = String::from("hello");
let r = &mut s;
r.push_str(" world");
// println!("{s}"); // 错误,r 借用期间不可用 s
规则:在任意给定时间,你只能拥有以下两种引用之一,但不能同时拥有:
- 任意数量的不可变引用
- 恰好一个可变引用
借用检查器与 NLL
Rust 的借用检查器在编译期严格检查这些规则,确保引用永远不会悬垂(指向已被释放的内存)。同时,得益于非词法作用域生命周期(NLL),引用的有效期并不是简单地到所在代码块末尾,而是到它最后一次被使用的位置。这使得很多以往无法编译的代码变得合法:
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 等小写字母。它们标注在引用的 & 符号之后:
&'a i32 // 拥有生命周期 'a 的 i32 引用
&'a mut i32 // 拥有生命周期 'a 的可变 i32 引用
标注本身并不改变引用的实际存活时间,而是告诉编译器多个引用之间的存活关系。
函数中的生命周期标注
当函数接收多个引用作为参数并返回一个引用时,编译器必须知道返回的引用与哪个参数的生命周期相关联。此时就需要显式标注:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
这里 <'a> 声明了一个生命周期泛型参数,并约束了参数 x、y 和返回值的生命周期都是 'a,即:返回值至少与两个参数中较短的那一个活得一样长。
生命周期省略规则
Rust 编译器在函数签名中可以自动推导生命周期,遵循三条规则:
- 每个引用参数都有各自的生命周期(例如
fn foo(x: &i32)展开为fn foo<'a>(x: &'a i32))。 - 如果只有一个输入生命周期参数,那么输出生命周期被赋予该参数。
- 如果有多个输入生命周期参数,但其中一个是
&self或&mut self(方法),则输出的生命周期被赋予self的生命周期。
如果这三条规则无法推导出所有引用类型的生命周期,编译器就会报错,要求你手动标注。
结构体中的生命周期
当结构体持有引用时,必须标注生命周期参数,以保证该引用在结构体有效期间始终有效:
struct Excerpt<'a> {
part: &'a str,
}
这表示 Excerpt 实例不能比它持有的 part 引用活得更久。
静态生命周期 'static
'static 是一个特殊的生命周期,表示这个引用在整个程序运行期间都有效。所有字符串字面量都拥有 'static 生命周期:
let s: &'static str = "I am static";
但需要警惕:不要轻易对普通数据标注 'static,大多数情况下应该由编译器推断或限制为较短的生命周期。
生命周期界限 T: 'a
当泛型类型 T 包含引用时,我们可以用生命周期界限约束它。T: 'a 表示 T 类型中的所有引用都必须活得至少和 'a 一样长。这通常用于结构体同时持有引用和泛型数据:
struct RefHolder<'a, T: 'a> {
data: &'a T,
}
实际应用:多引用关系的标注
实际中,你可能需要标注更复杂的关系,例如:
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 的对象
经典例子是递归定义的链表节点:
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));
没有 Box,List 类型的大小将是无穷大,编译器无法确定。
Rc<T>:引用计数,单线程共享所有权
Rc<T>(Reference Counted)允许一个值拥有多个所有者,在单线程环境下通过不可变引用共享数据。每次调用 Rc::clone 都会增加引用计数,当最后一个 Rc 离开作用域时,数据被释放。
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> 功能相同,但使用原子操作进行计数,可安全用于多线程环境。在线程间共享所有权时使用。
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() 会动态地检查同一时间只有一个可变借用或多个不可变借用。违反规则会导致 panic。RefCell<T> 只能用于单线程。
use std::cell::RefCell;
let data = RefCell::new(42);
*data.borrow_mut() += 1;
println!("{}", data.borrow()); // 43
组合模式:Rc<RefCell<T>>
将 Rc 与 RefCell 结合在一起,就能在单线程环境下实现多个所有者同时拥有可变数据的能力。这是 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>:弱引用,避免循环引用
Rc 和 Arc 还可以产生弱引用 Weak<T>。弱引用不增加引用计数,不能直接访问数据(需要先升级为强引用),主要用于打破循环引用,防止内存泄漏。
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 | 生命周期 |
相关文章
第八章:测试、文档与质量
Rust 将测试和文档视为语言的一等公民。通过内置的测试框架,你可以在项目里编写单元测试、集成测试以及随文档一起运行的示例测试,三者共用 cargo test 命令。配合 cargo doc 生成文档,形成了一套确保代码质量与可维护性…
泛型与 Trait
泛型和 trait 是 Rust 实现代码复用与多态的两大支柱。前置:Rust 基础。泛型让代码可以工作在多种类型上而不牺牲性能,trait 定义了类型间的共享行为,二者结合形成了零成本抽象的强大表达能力。本章将深入泛型定义、trai…
第二章:基础语法与类型
Rust 的类型系统和语法设计处处体现着“安全”与“显式”的理念。这一章你将掌握变量绑定、基本类型、复合类型、函数定义以及所有基础控制流结构,它们是你写出任何 Rust 程序的基石。
第七章:模块系统与包管理
Rust 的模块系统为代码组织、封装和复用提供了一套严谨但灵活的机制。包、crate、模块以及 use 路径相互配合,让你能够把项目拆解成清晰的功能单元,同时精确控制哪些对外可见。本章将带你系统掌握这些构建大型 Rust 项目所必需的…
第十章:进阶特性与模式
Rust 的核心安全保证覆盖了绝大多数日常编程场景。但当你需要打破常规——无论是编写极致通用的抽象、与 C 库交互,还是内联优化——本章将带你进入 Rust 的深层能力:声明宏与过程宏、unsafe 的超能力与封装、高级类型系统技巧…
第五章:错误处理
Rust 将错误明确分为两类:不可恢复的错误与可恢复的错误。通过 panic! 处理前一种,Result 处理后一种,这让程序的错误路径不再是隐式的控制流,而是强类型、必须处理的代码分支。配合 Option 对缺失值的处理以及丰富的组…
Series
rust
3 / 10