📚 Rust 课程系列

  1. 课程概览
  2. 基础语法(一):变量、数据类型与字符串
  3. 切片(Slice):序列的借用视图
  4. 基础语法(二):运算符、表达式与控制流
  5. 函数与输入输出
  6. 所有权、借用与生命周期(本文)
  7. 结构体
  8. 枚举
  9. 模式匹配
  10. 类型系统:泛型、trait 与多态
  11. 集合与容器
  12. 错误处理与 Panic 恢复
  13. 模块、属性与宏
  14. 智能指针、迭代器与闭包
  15. 并发与异步编程
  16. Unsafe Rust 与常用 trait 详解
  17. 工具链、Cargo 与外部 crate
  18. 最佳实践、性能与调试

所有权(ownership)、借用(borrowing)与生命周期(lifetime)是 Rust 最核心、也最与众不同的特性。它们让 Rust 无需垃圾回收与手动 free,在编译期保证内存安全与无数据竞争,也是理解后续智能指针、并发、异步等章节的基础。

所有权与借用

所有权的基本规则

规则说明示例
唯一所有者每个值有且仅有一个所有者,离开作用域时自动释放let s = String::from("hi");
移动语义赋值/传参时所有权转移,原变量失效let s2 = s1; —— s1 不再可用
复制语义Copy 类型赋值时按位复制,原变量仍有效let y = x;x: i32
1
2
3
4
5
6
let s1 = String::from("hello");
let s2 = s1; // s1 失效——所有权移动
// println!("{}", s1); // 错误:s1 已移动

let x = 5;
let y = x; // i32 实现了 Copy,x 仍有效

💡 提示:为什么 String 赋值后原变量失效?String 在栈上存放指针/长度/容量,堆上存放实际数据。let s2 = s1; 只复制了栈上部分——若两者同时有效,就会指向同一块堆内存,离开作用域时 double free。Rust 用移动语义在编译期杜绝此问题,零运行时开销。

🔄 对比:Rust 的移动语义 ≈ C++ 的 std::unique_ptr 语义,但 Rust 编译器强制执行——你无法意外复制。Go/Java 的赋值总是复制引用(或值),不存在"移动"概念。

💡 提示:判断能否 Copy 的简单规则——若类型销毁时需要特殊清理(如 String 要释放堆内存),就不能 Copy;标量、不可变引用、全是 Copy 字段的结构体可以 Copy

移动语义的内存模型

理解移动,要看清 String 这类类型的内存布局:栈上存「指针、长度、容量」三元组,堆上存实际字符数据。

flowchart LR
  subgraph STACK["栈"]
    s1["s1: ptr / len / cap"]
    s2["s2: ptr / len / cap"]
  end
  subgraph HEAP["堆"]
    data["h e l l o"]
  end
  s1 -. "移动后失效" .-> data
  s2 --> data

let s2 = s1; 只复制栈上的三元组并让 s1 作废,堆数据纹丝不动——移动是 O(1) 的。代价是编译器必须静态跟踪「谁还活着」,这正是所有权系统的工作。

Copy 与 Clone

复制行为由两个 trait 控制,语义与开销完全不同:

trait语义开销触发方式
Copy按位复制,原变量仍可用零开销赋值/传参时隐式发生
Clone通常是深拷贝可能分配堆内存显式调用 .clone()
1
2
3
4
5
6
let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝堆数据,s1 仍可用
println!("{} / {}", s1, s2);

let t = (1, 'a', true); // 元素全 Copy,元组也 Copy
let t2 = t; // 隐式复制,t 仍可用

实现 Copy 的常见类型:整数、浮点、boolchar、不可变引用 &T、函数指针、元素全 Copy 的元组与数组。自定义类型可用 derive 获得:

1
2
3
// 字段全是 Copy 才能 derive Copy
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }

⚠️ 注意CopyDrop 互斥——需要自定义清理逻辑的类型不能 Copy,否则按位复制会产生两个都要 drop 的副本,回到 double free 的老问题。

Drop:自动释放

所有者离开作用域时,Rust 自动调用其 Drop::drop——类似 C++ 析构函数,但全自动、不可遗忘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct FileGuard {
name: String,
}

impl Drop for FileGuard {
fn drop(&mut self) {
println!("关闭 {}", self.name);
}
}

{
let a = FileGuard { name: "a".into() };
let b = FileGuard { name: "b".into() };
} // 先释放 b,再释放 a:逆声明顺序

释放顺序规则:局部变量逆声明序释放;结构体字段按声明序释放。需要提前释放时用 std::mem::drop

1
2
3
let s = String::from("hello");
drop(s); // 立即释放,s 移动进 drop
// println!("{}", s); // 错误:s 已失效

💡 提示:RAII 模式(锁守卫、文件句柄、临时目录)都建立在 Drop 之上。MutexGuard 离开作用域自动解锁,正是《并发与异步编程》会反复见到的模式。

函数与所有权

传参和返回同样遵循移动/复制语义,让「谁负责释放」始终清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn takes_ownership(s: String) { /* s 在此释放 */ }
fn makes_copy(n: i32) { /* n 是 Copy,无事发生 */ }
fn gives_ownership() -> String { String::from("hello") }

let s = String::from("hello");
takes_ownership(s); // s 移动进函数,此后 s 不可用
// println!("{}", s); // 错误

let x = 5;
makes_copy(x); // x 是 Copy,仍可用
println!("{}", x); // 5

let s2 = gives_ownership(); // 返回值移动给 s2

💡 提示:如果函数只是需要「读一下」而不想拿走所有权,每次先拿走再返回很笨重——这就是借用的意义。

部分移动

移动可以精确到字段粒度——只移出结构体的部分字段,其余字段仍可使用:

1
2
3
4
5
6
7
8
9
10
11
12
struct Pair {
key: String,
value: String,
}

let p = Pair {
key: "name".into(),
value: "rust".into(),
};
let k = p.key; // 只移动 key 字段
println!("{}", p.value); // value 仍可用
// let q = p; // 错误:p 已被部分移动

⚠️ 注意:实现了 Drop 的类型不能部分移动——drop 需要拿到完整的值。若确实要取走字段,可用 Option::take / std::mem::take 留下占位值。

借用

使用 & 创建不可变引用,&mut 创建可变引用。引用不拥有数据,只是「指向」某个值:

1
2
3
4
5
6
fn calculate_length(s: &String) -> usize { s.len() }
// s 离开作用域,但不拥有所有权,什么都不发生

let s = String::from("hello");
let len = calculate_length(&s);
println!("'{}' 的长度是 {}", s, len); // s 依然可用

要修改借用的值,用 &mut T,但原变量本身也得是 mut

1
2
3
4
5
fn push_world(s: &mut String) { s.push_str(", world"); }

let mut s = String::from("hello");
push_world(&mut s);
println!("{}", s); // hello, world

🔄 对比&T ≈ C++ 的 const T&&mut TT&。但 Rust 编译器额外保证:同一时间要么有多个 &T,要么有且仅有一个 &mut T,从源头杜绝数据竞争。

解引用强制转换与再借用

函数参数常声明为 &str 而非 &String:前者通过**解引用强制转换(deref coercion)**同时接受 &String&str 和字符串字面量,API 更通用:

1
2
3
4
5
6
7
fn shout(s: &str) -> String {
s.to_uppercase()
}

let owned = String::from("hello");
let loud = shout(&owned); // &String 自动转为 &str
println!("{}", loud); // HELLO

同理 &Vec<T> 可转为 &[T]。经验法则:只读参数尽量写切片形式(&str&[T],详见《切片(Slice)》),调用方传什么都方便。

可变引用还支持再借用(reborrow):对 &mut T 再取 &mut,新引用生效期间原引用被「冻结」:

1
2
3
4
5
6
7
let mut s = String::from("hi");
let r1 = &mut s;
let r2 = &mut *r1; // 再借用:r1 暂时冻结
r2.push_str("!");
// r2 不再使用,r1 恢复活跃
r1.push_str("?");
println!("{}", s); // hi!?

🔬 进阶:方法调用 r1.push_str(...) 实际隐含一次 (&mut *r1).push_str(...) 再借用——这正是可以把 &mut &mut T 用在需要 &mut T 的位置的原因。

借用检查器规则

规则总结

  1. 任何借用必须在其引用的值的生命周期内有效
  2. 可以同时存在多个不可变借用(&T
  3. 不能同时存在可变借用(&mut T)和任何其他借用
1
2
3
4
5
6
7
let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &s; // 另一个不可变借用 — 允许
// let r3 = &mut s; // 错误:不能同时有可变借用
println!("{} {}", r1, r2);
// r1 和 r2 在这里不再使用
let r3 = &mut s; // 现在允许 — 之前的借用已结束

💡 提示:上面 r1r2println! 之后就「不再使用」了,因此 r3 = &mut s 可以通过。这说明 Rust 的借用并非严格按词法作用域结束,而是采用 非词法生命周期(NLL):引用的「活跃区间」只到它最后一次被使用为止。

⚠️ 注意:在持有不可变借用时试图修改数据是典型报错场景:

1
2
3
4
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用 v
v.push(4); // 错误:与 first 的不可变借用冲突
println!("{}", first); // first 还要用,所以借用没结束

原因:push 可能触发扩容并把堆数据搬到新地址,此时 first 指向的旧地址就悬空了。修复方式是把不可变借用的使用提前到可变操作之前:

1
2
3
4
let mut v = vec![1, 2, 3];
let first = v[0]; // 直接拷贝出值,不再借用 v
v.push(4); // 没问题
println!("{}", first);

可变引用还有一个限制:同一时间只能有一个可变引用

1
2
3
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 错误:不能同时有两个可变借用

⚠️ 注意:遍历容器同时修改(如 for item in &vec { vec.push(...); })会被编译器拒绝——这是借用冲突最常见的场景之一。优先把修改操作收集到遍历之后,或用索引遍历。

💡 提示:函数参数设计原则——不需要修改用 &T,需要修改用 &mut T,需要获得所有权(如构建返回值)传 T。对大型结构频繁 clone() 有性能开销,优先考虑借用或 Rc/Arc

拆分借用

借用检查器能识别互不重叠的借用。同一结构体的不同字段可以同时被可变借用:

1
2
3
4
5
6
7
struct Size { w: i32, h: i32 }
let mut sz = Size { w: 3, h: 4 };
let w = &mut sz.w;
let h = &mut sz.h; // 不同字段,允许同时借用
*w *= 2;
*h *= 2;
// let all = &mut sz; // 错误:与字段借用冲突

但通过索引访问容器时,编译器无法证明两次索引不重叠,标准库为此提供显式拆分 API:

1
2
3
4
let mut v = vec![1, 2, 3, 4];
let (left, right) = v.split_at_mut(2);
left[0] = 10; // 左半可变
right[0] = 30; // 右半同时可变

🔬 进阶split_at_mut 内部用 unsafe 指针运算实现——「不重叠」由库作者用 unsafe 担保,对使用者仍是安全 API。详见《Unsafe Rust 与常用 trait 详解》

非词法生命周期(进阶)

NLL 自 Rust 2018 edition 起成为默认行为。在旧版词法生命周期(lexical lifetimes)下,引用的存活区间等于整个作用域——即使引用在作用域中途就不再使用,借用检查器也会认为它一直活跃到作用域末尾。NLL 将引用的存活区间缩短到最后一次使用点,使很多在词法模型下被拒的代码合法化:

1
2
3
4
5
// NLL 允许,词法生命周期会拒绝
let mut s = String::from("hello");
let r = &s;
println!("{}", r); // r 最后一次使用
s.push_str(", world"); // r 已不再活跃,可变借用 OK

🔬 进阶:NLL 的实现基于「生命周期即约束」模型——编译器先收集所有约束('a: 'b 表示 'a 至少和 'b 一样长),再求解最小可行生命周期,而非简单地将引用绑定到词法作用域。

生命周期

为什么需要生命周期

引用必须保证它指向的数据在引用使用期间一直有效,否则就是「悬垂引用」。大多数时候编译器能自动推断,但当函数返回引用时,编译器无法仅凭签名判断返回引用与哪个参数关联——这时就需要生命周期注解。

生命周期注解用 'a 表示,它不改变引用的实际存活时间,只是描述多个引用之间的约束关系:「返回的引用至少和入参 xy 中较短的那个一样长」。

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

⚠️ 注意:不要返回对局部变量的引用——编译器会直接拒绝:

1
2
3
4
fn dangle() -> &String { // 错误:缺少生命周期注解,且会悬垂
let s = String::from("hello");
&s
} // s 在这里被释放,&s 指向的内存已失效

正确做法是直接返回所有权:fn no_dangle() -> String { String::from("hello") }

💡 提示:当生命周期签名复杂时,考虑返回拥有所有权的类型(如 String)或使用 Arc/Cow 来简化 API。接口设计中尽量让调用方持有所有权或明确借用关系,减少不必要的生命周期注解暴露。

生命周期省略规则

Rust 编译器会自动应用三条规则来省略生命周期注解:

规则说明
规则1每个引用参数都有自己的生命周期参数
规则2如果只有一个输入生命周期参数,它被赋予所有输出生命周期参数
规则3如果有多个输入生命周期参数,但其中一个是 &self&mut self,则 self 的生命周期被赋予所有输出生命周期参数
1
2
3
4
5
6
7
8
9
10
// 编译器可以自动推断,无需显式生命周期注解
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[..]
}

按规则 1,s: &str 被看作 s: &'a str;按规则 2,唯一的输入生命周期 'a 被赋给输出,于是签名等价于 fn first_word<'a>(s: &'a str) -> &'a str,无需手写。当三条规则都无法确定输出生命周期时,编译器才要求显式标注(如 longest 有两个输入引用却没有 self,规则 3 不适用,故必须手写)。

多个生命周期参数与约束

省略规则不够用时,需显式写出生命周期参数及其关系。多个参数各自独立命名:

1
2
3
4
// 返回值只与 x 相关,两个输入互不约束
fn pick_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
x
}

'a: 'b 读作「'a 至少和 'b 活得一样久」,用于把长生命周期缩短为短的:

1
2
3
4
5
6
fn shrink<'a, 'b>(x: &'a str, _y: &'b str) -> &'b str
where
'a: 'b, // x 的数据比 'b 活得久,返回才安全
{
x
}

生命周期也可作泛型约束:T: 'a 表示 T 内部所有引用都至少活到 'aT: 'static 表示 T 不含任何非 'static 引用(thread::spawn 的闭包约束即来源于此)。

Rust 2018 起还可用匿名生命周期 '_,不想起名时交给编译器推断:

1
2
// '_ 等价于完全省略,但强调了「这里有借用」
fn suffix(s: &str) -> &'_ str { &s[1..] }

结构体中的生命周期

结构体若持有引用,必须标注生命周期,表明该引用的数据必须比结构体活得久:

1
2
3
4
5
6
7
8
9
struct ImportantExcerpt<'a> {
part: &'a str,
}

let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt { part: first_sentence };
// i 不能比 novel 活得久,否则 part 会悬垂
println!("{}", i.part);

🔄 对比:C++ 中类持有引用不需要显式标注生命周期,但可能产生悬垂引用(运行时 UB);Rust 在编译期就强制你声明约束关系。

方法中的生命周期

impl 块中,结构体的生命周期参数要在 impl 后声明、类型名后使用;方法内部仍享受省略规则(规则 3 以 &self 为准):

1
2
3
4
5
6
7
8
9
10
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3 // 不涉及引用返回,无需标注
}

fn announce_and_return(&self, note: &str) -> &str {
println!("注意:{}", note);
self.part // 规则 3:返回值绑定 self 的生命周期
}
}

announce_and_return 的完整签名等价于 fn announce_and_return<'b, 'c>(&'b self, note: &'c str) -> &'b str——调用方传入的 note 不需要和 self 活得一样久。

静态生命周期

1
let s: &'static str = "I have a static lifetime.";

'static 表示引用在程序整个运行期间都有效。所有字符串字面量都是 &'static str,因为它们直接被编译进二进制的只读数据段。'static 是所有生命周期的「上界」:'static 引用可以满足任意 'a 的约束,因此常出现在需要「任意生命周期」的地方。

⚠️ 注意:不要为了消除编译错误而随手给参数标 'static,这往往掩盖了真正的设计问题(本该返回拥有所有权的 String 却返回了引用)。真正需要 'static 的场景包括:全局常量、std::thread::spawn 闭包的默认约束等。

生命周期与型变(variance)、'static 约束的更深入讨论,见《类型系统》一章。

常见错误速查

编译器报错根因修复方向
use of moved value移动后继续使用原变量改用借用、.clone(),或接收返回值
cannot borrow as mutable与已有借用冲突调整顺序,让旧借用先结束(NLL)
does not live long enough数据先于引用释放扩大数据作用域,或改返回所有权类型
missing lifetime specifier省略规则无法确定输出来源显式标注,或重签名为所有权类型
cannot return reference to local返回局部变量的引用返回 String/Vec 等所有权类型
cannot move out of borrowed试图从 &T 后移出值.clone()、改返回引用、或 mem::take

小结

  • 所有权:一个值一个所有者;移动是 O(1) 的栈上复制加原变量作废;Copy 隐式复制、Clone 显式深拷贝、Drop 自动清理。
  • 借用:任意多个 &T 或唯一一个 &mut T;NLL 让借用在最后使用后即结束;字段级拆分借用与 split_at_mut 解决重叠问题。
  • 生命周期:注解描述引用间的约束关系而非改变实际存活;三条省略规则覆盖多数场景,复杂签名用显式参数、'a: 'b 约束或 '_