Rust 学习教程
这是一本从示例代码出发的 Rust 学习笔记,也是一份配套 src/study 示例模块的教程索引。每个章节都对应可运行的代码演示,你可以在阅读文档的同时,直接运行、修改、调试对应的代码,在实践中理解 Rust。
Rust 学习模式
学习 Rust 时,一个常见的误区是一上来就死磕语法细节。Rust 的语法体系庞大且相互关联,这些语法对初学者来说都很难一次性吃透。
推荐的节奏是两遍法:
-
第一遍:快速通读,留下印象。 不需要理解每一个细节,重点是知道“有这个语法、它大概用来干什么“。比如看到
match,知道它是 Rust 中的模式匹配,能处理Option和Result;看到&和&mut,知道这是在标记借用关系即可。 -
第二遍:从复刻示例代码出发,边写边学。 把
src/study中的示例代码亲手敲一遍(不要复制粘贴),在复刻过程中你会自然地遇到编译错误——这正是 Rust 学习的精华时刻。Rust 编译器以友好著称,它的报错信息会告诉你:- 哪里出了问题
- 为什么会出问题
- 甚至建议你怎么修改
认真阅读每一条编译错误,理解编译器在说什么,这个过程本身就是最好的学习。遇到看不懂的概念,再回到文档对应章节精读,带着问题学远比被动阅读有效。
简单来说:先建立语法→功能的心智地图,再用编译器的反馈来校准理解。
Rust 重点学习内容
1. 内存管理(第四章的四个部分)
这是 Rust 区别于其他主流语言的核心设计,也是学习曲线最陡的部分:
- 所有权机制:Rust 如何在不使用 GC 的情况下保证内存安全?移动语义、借用规则、引用的生命周期约束——这套规则是 Rust 的“宪法“,理解了所有权,就理解了 Rust 的一半。
- 智能指针:
Box<T>、Rc<T>、Arc<T>——当所有权规则无法满足灵活的数据共享需求时,智能指针提供了受控的“弹性空间“。 - 内部可变性:
Cell<T>和RefCell<T>让你在持有不可变引用时也能修改数据,把借用检查从编译期推迟到运行时。 - 生命周期:显式生命周期标注是 Rust 的特色——它把对象之间的引用关系显式化,让你(和编译器)能清晰地看到“谁依赖谁、谁比谁活得长“。
2. 错误处理:Option、Result 与 match
Rust 没有异常机制,而是通过 Option<T> 和 Result<T, E> 将错误作为类型的一部分进行编码。配合 match 模式匹配,Rust 强制你在编译期就处理所有可能的错误路径——不会有“忘记 try-catch 导致运行时崩溃“的问题。这是 Rust 写出健壮代码的根基,详见第五章:返回值和错误处理。
3. Trait 体系
Trait 是 Rust 中定义和约束行为的核心机制。它既是接口,也是泛型约束,还能实现动态分发。理解 Trait 才能写出泛型、灵活、可组合的 Rust 代码。详见第七章:trait 特征。
从 GC 语言转向 Rust 需要转变的两个思维
如果你之前主要使用 Java、Go、Python、JavaScript 等带垃圾回收(GC)的语言,Rust 要求你在以下两个方面主动转变思维:
思维一:关注变量的内存位置——堆还是栈?
GC 语言中,你几乎不需要关心一个变量分配在堆上还是栈上——运行时帮你管理一切。但在 Rust 中,内存位置直接影响你的代码写法:
- 栈上数据:大小固定、生命周期确定,直接使用即可,编译器帮你搞定。
- 堆上数据:大小动态、需要共享或延长生命周期,你得显式使用
Box、Rc、Arc等智能指针来管理。
每当你声明一个变量时,试着问自己:这个数据的大小在编译期能确定吗?它的生命周期需要超出当前作用域吗?它需要被多处共享吗?——这些问题的答案决定了你应该把它放在栈上还是堆上,以及该用什么类型来承载它。
思维二:从对象生命周期的角度设计程序
GC 语言中,对象的生命周期是模糊的——只要还有引用,它就活着;引用没了,GC 在某个不确定的时刻回收它。你不需要、也很难精确掌控一个对象“什么时候死“。
Rust 则完全不同。所有权的转移、引用的有效期、drop 的调用时机——一切都是确定的。你需要养成一个习惯:在设计程序时,先想清楚每个关键数据的“生老病死“。谁负责创建它?谁持有它?它在什么时候、因为什么被释放?这种思维方式的转变一开始会很不适应,但一旦内化,你写出的代码会比 GC 时代的代码更加可靠和可预测。
从 C++ 转向 Rust 需要转变的一个关键思维
C++ 程序员对 Rust 的内存管理思想(RAII、确定性析构)并不陌生,但有一个核心差异需要主动适应:
C++ 中普通值的默认语义更偏向拷贝,而 Rust 对非 Copy 类型的默认语义是移动。
在 C++ 中,很多普通值赋值或传参时默认会发生拷贝;想表达“我愿意把资源转走”时,通常需要显式调用 std::move。在 Rust 中恰恰相反:没有实现 Copy 的类型赋值和传参默认就是移动——所有权直接转移,原变量立刻失效。只有实现了 Copy trait 的类型(基本是简单的标量类型)才会自动拷贝。
这个差异意味着:C++ 程序员在 Rust 中需要反向管理默认行为——在 C++ 中你主动决定“什么时候 move“,在 Rust 中你主动决定“什么时候 clone“。如果你发现自己在到处写 .clone(),那往往是一个信号:你的所有权设计可能需要重新审视。
本书结构
教程共十六章,从基础语法到高级异步编程,覆盖了 Rust 的主要知识领域:
| 章节 | 内容 | 对应代码 |
|---|---|---|
| 第一章 | 基础语法 | src/study/base_syntax.rs |
| 第二章 | Cargo 构建系统 | - |
| 第三章 | 泛型 | src/study/generic.rs |
| 第四章 | Rust 内存管理 | 4 个子章节均配有示例代码 |
| 第五章 | 返回值和错误处理 | src/study/return_handle.rs |
| 第六章 | 结构体 | src/study/struct_demo.rs |
| 第七章 | trait 特征 | src/study/trait_demo.rs 等 |
| 第八章 | 模块系统 | - |
| 第九章 | 闭包和函数 | src/study/closure_demo.rs |
| 第十章 | 容器 | src/study/collections.rs |
| 第十一章 | 面向对象 | src/study/oop_demo.rs |
| 第十二章 | 宏 | src/study/macro_rules_demo.rs 等 |
| 第十三章 | 并发编程 | 4 个子章节均配有示例代码 |
| 第十四章 | 文件 IO | src/study/file_io_demo.rs |
| 第十五章 | 网络 IO | src/study/network_io_demo.rs |
| 第十六章 | async 编程 | 3 个子章节均配有示例代码 |
阅读建议:前三章夯实基础后,第四章(内存管理)是全书最重要的部分,建议多花时间精读并动手调试代码。之后五到七章补齐类型系统,再根据兴趣和需求选择后续章节深入。
关于 Rust 的一些思考
1. Rust 最大的四个优势
- Cargo如果你让一个 C++ 程序员第一次接触 Cargo,他一定会爱上它。这种开箱即用的包管理和构建工具,在 C++ 生态中完全找不到。
- 友好的错误提示Rust 的编译器错误信息非常清晰、实用,比起 C++ 那堆抽象的模板报错好上太多。
- 函数是第一公民Rust 中函数可以作为参数、返回值,支持闭包,函数式编程体验流畅。
- 轻量级 OOP
Rust 保留了面向对象的一些影子,但做了很好的解耦。
- 封装 :
struct依旧保持了抽象的能力,聚焦于数据结构的抽象。同时mod依旧有访问控制 - 多态 : 基于trait对象也能实现动态多态。
- 继承 : 砍掉继承,解耦实体之间的关系,组合替代继承,强调组合优于继承。
- 封装 :
- 所有权机制 这个是把双刃剑
- 优势:
- 把C++的核心内存管理思想和遇到的内存问题内化成语法规则,最大程度在编译阶段解决内存问题
- 显示生命周期标注,解决了 C++ 中的悬空引用问题,并且把生命周期分析显式标注,更能直观反应出对象的生命周期
- 劣势: 过于严格的检查,导致代码灵活性下降;并且面向编译器编程
- 优势:
2. Rust 目前最大的问题
代码的灵活性与安全性难以兼得 C++ 很容易写出编译正确但实际运行错误的代码;而 Rust 则很容易写出实际逻辑正确但编译不通过的代码。 借用检查器仍然需要继续迭代和优化。
3. Rust 的三大难点
3.1 内存管理(所有权机制)
很多人觉得所有权机制本身难理解,但我不这么认为。所有权机制的本质,其实是对 C++ 内存管理思想的内化(RAII、手动内存释放等)。 那为什么很多人觉得难? 因为 Rust 在用户层面隐藏了手动内存分配和 RAII 的具体实现细节。初学者无法像在 C++ 中那样,直观地通过代码感受 RAII 的魅力——你可以亲手写一个类的构造/析构函数,看到内存何时申请、何时释放。 Rust 做不到这一点,它把底层思想“藏”了起来,这才导致了所有权机制的理解门槛。
3.2 宏(Macro)
Rust 的宏和 C 的宏完全是两个东西。
- 初学 Rust 泛型时,你会觉得它比 C++ 的泛型少了某些能力。
- 后来才发现,Rust 把很多元编程能力从泛型“移”到了宏,尤其是声明宏。
- 而最难理解的无疑是过程宏。它直接介入编译过程,你需要了解编译原理中的
token、CST、AST等概念,才能写出正确的过程宏。
3.3 Async(异步)
我认为 Rust 最大的失败之一,就是把这套机制叫做“异步编程”。它其实不应该叫这个名字,更应该叫 async 状态机。
- 它本身不具备异步能力,你无法仅依靠标准库轻松写出一个异步程序。它只是可以用来实现异步的底层机制。
- Rust 的
async和 C++ 的协程在设计上是趋同的,但又有很大不同:- 两者本质都是无栈协程,由编译器转换为状态机,都只定义了接口和语义,没有具体的调度实现。
- 但 C++ 的设计语义非常一致,都是围绕协程本身的“挂起 / 恢复”这一语义。
- Rust 的视角则比较刁钻:虽然用了
async/await关键字,但其中的Future、Context、Waker其实是纯粹的 task-runtime 语义。这就导致你要理解async,就完全无法脱离 runtime。而对于初学者来说,自己去实现一个 runtime 是非常困难的。
4. 学习 Rust 需要转变的两个思维
4.1 关注每一个对象的生命周期
你需要学会从对象生命周期的角度去设计程序。 C 和 C++ 程序员已经比较习惯这种思维模式,但和其他语言的开发者交流时,会发现他们往往没有这个习惯。 另外,目前 AI 在复杂项目中,对众多对象的生命周期管理和分析能力仍然欠佳。
4.2 默认移动而非拷贝(针对 C++ 程序员)
C++ 中普通值的默认语义更偏向拷贝;当你想表达资源转移时,通常需要显式调用 std::move。
而 Rust 对非 Copy 类型的默认语义是移动,这一点在思维上需要主动转变。
rust 基础语法
1. Rust代码基本结构
fn main(){
... 代码块
}
2. use 关键字 – 导入模块或函数
2.1 功能作用
use 用于将模块、函数、结构体、枚举、trait 等引入当前作用域,避免每次使用时都写完整路径。它可以简化代码,提升可读性。
2.2 基本规则
// 导入单个模块或类型
use 路径::名称;
// 导入多个同级的项(使用花括号)
use 路径::{项1, 项2};
// 导入自身(使用 `self`)
use 路径::{self, 子模块};
// 为导入的项起别名(使用 `as`)
use 路径::名称 as 别名;
// 重新导出(使用 `pub use`)
pub use 路径::名称;
// 导入标准库常用模块
use std::io; // 导入 io 模块
use std::collections::HashMap; // 导入 HashMap 类型
use通常放在文件的顶部(mod声明之后)。use可以嵌套路径,例如use std::io::{self, Write};。- 使用
crate::表示当前 crate 的根路径,super::表示父模块。 - 外部 crate 需要在
Cargo.toml中先添加依赖,然后使用use crate_name::item;。
2.3 具体示例
// 导入标准库模块
use std::io;
use std::fmt::Result as FmtResult; // 别名,避免与 io::Result 冲突
// 导入多个项
use std::fs::{File, read_to_string};
// 导入自定义模块(假设有一个模块 utils)
mod utils {
pub fn helper() {}
}
use utils::helper;
fn main() {
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap(); // 因为导入了 io,可以简化路径
helper(); // 直接调用,无需 utils::helper
}
3. 变量
3.1 功能作用
在 Rust 中,变量用于存储数据。默认情况下,变量是不可变的,这有助于提升代码的安全性。当你需要修改变量的值时,必须显式声明为可变。
3.2 基本规则
// 定义不可变变量
let 变量名 = 值;
// 定义可变变量
let mut 变量名 = 值;
// 变量可以遮蔽(重新声明同名变量)
let 变量名 = 新值;
- 不可变变量不能被重新赋值。
- 使用
mut关键字使变量可变。 - 允许“遮蔽”(shadowing):用
let再次声明同名变量,新变量会覆盖之前的变量。
3.3 具体示例
fn main() {
let x = 5; // 不可变变量
// x = 6; // 编译错误!不能对不可变变量赋值
let mut y = 10; // 可变变量
y = 11; // 允许修改
println!("y = {}", y);
let z = 100;
let z = z + 1; // 遮蔽:基于旧值创建新值
let z = "hello"; // 遮蔽:z 的类型从整数变为字符串
println!("z = {}", z);
}
3.4 变量遮蔽(Shadowing)详解
变量遮蔽和 mut 是两种不同的机制,初学者容易混淆:
let mut | let 遮蔽 | |
|---|---|---|
| 本质 | 修改同一个变量的值 | 创建一个新变量,同名但独立 |
| 类型能否改变 | ❌ 不能改变类型 | ✅ 可以改变类型 |
| 旧变量 | 被覆盖,不存在了 | 被“藏起来“,新变量离开作用域后可能重新可见 |
是否需要 mut | 是 | 否(新变量默认不可变,也可以加 mut) |
典型使用场景:
fn main() {
// 场景1:类型转换(mut 做不到)
let value = "42";
let value: i32 = value.parse().unwrap(); // String → i32
// 后续直接用 value,不用起新名字
// 场景2:基于旧值逐步构造(避免可变中间变量)
let config = load_default_config();
let config = apply_env_overrides(config);
let config = apply_cli_args(config);
// 每一步都产生新的不可变 config,逻辑清晰
// 场景3:作用域内的临时遮蔽
let x = 5;
{
let x = x * 10; // 作用域内 x = 50
println!("内部 x = {}", x);
}
println!("外部 x = {}", x); // 回到 5,原变量还在
}
mut适合“反复修改同一个值“的场景(如循环计数器);遮蔽适合“加工转换、产生新值“的场景,且允许改变类型。两者各司其职,不必互斥——需要时也可以let mut x = ...然后后续遮蔽。
4. 类型别名 (type)
4.1 功能作用
type 关键字用于为现有类型创建一个新的名称(别名)。这可以简化复杂类型的书写,并增强代码的可读性。类型别名是同义词,不会创建新类型。
4.2 基本规则
type 别名 = 原类型;
- 别名可以用于任何类型,包括普通类型、泛型类型、函数指针等。
- 类型别名在编译时完全等同于原类型,没有运行时开销。
- 常用于简化长泛型类型(如
Result<Vec<u8>, Box<dyn Error>>)。
4.3 具体示例
// 为复杂类型起别名
type FileResult = Result<std::fs::File, std::io::Error>;
// 为函数指针起别名
type MathOp = fn(i32, i32) -> i32;
fn add(a: i32, b: i32) -> i32 { a + b }
fn sub(a: i32, b: i32) -> i32 { a - b }
// 为泛型类型起别名
type MyVec<T> = Vec<T>;
fn main() {
let mut f: FileResult = std::fs::File::open("test.txt");
let op: MathOp = add;
println!("{}", op(2, 3)); // 输出 5
}
5. 控制流
5.1 if-else 条件判断
5.1.1 功能作用
根据条件执行不同的代码分支。Rust 中的 if 是一个表达式,可以返回值(但所有分支返回值类型必须一致)。
5.1.2 基本规则
if 条件 {
// 条件为 true 时执行
} else if 另一个条件 {
// 条件为 true 时执行
} else {
// 所有条件都为 false 时执行
}
- 条件必须是
bool类型(不能像 C 语言那样用整数代替)。 - 可以用
if let简化针对某个模式的匹配。
5.1.3 具体示例
fn main() {
let number = 6;
if number % 2 == 0 {
println!("偶数");
} else {
println!("奇数");
}
// if 作为表达式赋值
let result = if number > 0 { "正数" } else { "非正数" };
println!("{}", result);
}
5.2 if let 简洁匹配
5.2.1 功能作用
当你只关心匹配某一种模式而不想写完整的 match 时,if let 提供更简洁的写法。这里只需了解基本用法即可,更深入的模式匹配将在第五章:返回值和错误处理中结合 Option 和 Result 详细展开。
5.2.2 基本规则
if let 模式 = 值 {
// 匹配成功时执行
} else {
// 可选:匹配失败时执行
}
5.2.3 具体示例
fn main() {
let some_value = Some(3);
if let Some(x) = some_value {
println!("值为 {}", x);
} else {
println!("没有值");
}
}
6. 循环
Rust 提供了三种循环结构:loop、while 和 for。它们都用于重复执行一段代码,但适用场景不同。
6.1 loop – 无限循环
6.1.1 功能作用
无限重复执行一段代码,直到遇到 break 显式退出。可以用 break 返回值。
6.1.2 基本规则
loop {
// 重复执行的代码
break; // 退出循环
}
// 带返回值的 loop
let 变量 = loop {
break 值;
};
6.1.3 具体示例
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2; // loop 表达式返回 20
}
};
println!("result = {}", result);
}
6.2 while – 条件循环
6.2.1 功能作用
每次循环前检查一个布尔条件,条件为 true 时执行,否则退出。
6.2.2 基本规则
while 条件 {
// 条件为 true 时重复执行
}
6.2.3 具体示例
fn main() {
let mut number = 3;
while number != 0 {
println!("{}!", number);
number -= 1;
}
println!("发射!");
}
6.3 for – 遍历集合
6.3.1 功能作用
安全且高效地遍历集合(数组、区间、向量等)。Rust 推荐优先使用 for,因为它不易出错且性能良好。
6.3.2 基本规则
for 变量 in 迭代器 {
// 对每个元素执行
}
6.3.3 具体示例
fn main() {
// 遍历区间(左闭右开)
for i in 1..5 {
println!("{}", i); // 打印 1 2 3 4
}
// 遍历数组
let arr = [10, 20, 30];
for element in arr.iter() {
println!("{}", element);
}
// 使用 enumerate 获取索引
for (index, value) in arr.iter().enumerate() {
println!("索引 {} 的值是 {}", index, value);
}
}
6.4 三种循环的区别
| 循环类型 | 适用场景 | 退出方式 | 是否可返回值 |
|---|---|---|---|
loop | 需要无限循环,或需要在循环体中间退出并返回一个值 | break | 是 |
while | 循环次数未知,但有一个明确的停止条件 | 条件变为 false | 否 |
for | 遍历集合或区间,循环次数已知或可迭代 | 遍历完所有元素 | 否 |
6.5 范围语法 (Range)
Rust 中 .. 和 ..= 用来创建范围(Range),它们是一个轻量级的语法结构,生成一个表示区间的值。范围最常用于 for 循环和切片索引。
两种范围运算符
| 语法 | 含义 | 示例 | 生成的区间 |
|---|---|---|---|
start..end | 左闭右开,不包含 end | 1..5 | 1, 2, 3, 4 |
start..=end | 双闭区间,包含 end | 1..=5 | 1, 2, 3, 4, 5 |
..end | 从 0 到 end(不包含) | ..5 | 0, 1, 2, 3, 4 |
start.. | 从 start 到无穷(惰性) | 3.. | 3, 4, 5, ... |
..=end | 从 0 到 end(包含) | ..=5 | 0, 1, 2, 3, 4, 5 |
.. | 完整范围(起点到无穷) | .. | 0, 1, 2, ... |
常见使用场景
fn main() {
// 1. for 循环中使用范围
for i in 0..5 {
print!("{} ", i); // 0 1 2 3 4
}
println!();
for i in 1..=3 {
print!("{} ", i); // 1 2 3
}
println!();
// 2. 用于切片索引(详见[第 14 节](#14-切片-slice))
let arr = [10, 20, 30, 40, 50];
let slice = &arr[1..4]; // 索引 1~3 → [20, 30, 40]
let inclusive = &arr[1..=4]; // 索引 1~4 → [20, 30, 40, 50]
// 3. 配合 .rev() 反向迭代
for i in (1..=5).rev() {
print!("{} ", i); // 5 4 3 2 1
}
println!();
// 4. 范围配合 .step_by() 设置步长(需要稳定版,此处为示意)
for i in (0..10).step_by(3) {
print!("{} ", i); // 0 3 6 9
}
}
记忆技巧:
..(两点)= 不包含右边的数;..=(两点一横)= 包含右边的数。这个约定与切片索引(第 14 节)的规则一致,记住“两点不包右“就够了。
7. 函数
7.1 功能作用
函数用于封装可复用的代码块。Rust 代码使用 fn 关键字定义函数,主函数 main 是程序的入口。
7.2 基本规则
// 无返回值(实际上返回单元类型 ())
fn 函数名(参数名: 参数类型, ...) {
// 函数体
}
// 有返回值(必须用箭头 -> 指定返回类型)
fn 函数名(参数名: 参数类型, ...) -> 返回类型 {
// 函数体,最后一条表达式不带分号作为返回值
// 或者使用 return 语句提前返回
}
- 参数声明:每个参数必须注明类型,多个参数用逗号分隔。
- 返回值:在
->后声明类型。函数体的最后一个表达式的值会被隐式返回,也可以使用return提前返回。 - 语句与表达式:以分号结尾的是语句(不返回值);不以分号结尾的是表达式(返回值)。
7.3 具体示例
// 无返回值,打印 hello
fn say_hello(name: &str) {
println!("Hello, {}!", name);
}
// 有返回值,返回两个整数之和
fn add(a: i32, b: i32) -> i32 {
a + b // 注意没有分号,这是一个表达式
}
// 使用 return 提前返回
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
return None;
}
Some(numerator / denominator)
}
fn main() {
say_hello("Rust");
let sum = add(3, 5);
println!("sum = {}", sum);
}
8. Option、Result 和 match
说明:本节仅介绍
Option、Result和match的基本语法和大致功能。它们是 Rust 错误处理体系的核心,第五章:返回值和错误处理 会深入讲解unwrap/expect、?操作符、错误传播、自定义错误类型等完整内容。
8.1 Option 类型
8.1.1 功能作用
表示一个值可能存在也可能不存在,用于避免空指针问题。Option<T> 有两种变体:
Some(T):包含一个类型为T的值。None:表示没有值。
8.1.2 基本规则
// 创建
let some_val = Some(具体值);
let none_val: Option<类型> = None;
// 取值前通常需要用 match 或 if let 解构
8.1.3 具体示例
fn find_first_char(s: &str) -> Option<char> {
s.chars().next() // 如果 s 为空则返回 None
}
fn main() {
let text = "hello";
match find_first_char(text) {
Some(ch) => println!("第一个字符是 {}", ch),
None => println!("字符串为空"),
}
}
8.2 Result 类型
8.2.1 功能作用
表示一个操作可能成功也可能失败,用于错误处理。Result<T, E> 有两种变体:
Ok(T):操作成功,包含结果值T。Err(E):操作失败,包含错误信息E。
8.2.2 基本规则
// 通常由可能失败的函数返回
let result: Result<类型, 错误类型> = 可能失败的函数();
// 处理时使用 match 或 ? 操作符
8.2.3 具体示例
#![allow(unused)]
fn main() {
use std::fs::File;
fn open_file() {
let file_result = File::open("hello.txt");
match file_result {
Ok(file) => println!("文件打开成功: {:?}", file),
Err(e) => println!("打开文件失败: {}", e),
}
}
}
8.3 match 控制流运算符
8.3.1 功能作用
将一个值与多个模式逐一匹配,并执行匹配到的分支代码。match 是 Rust 中最强大的模式匹配工具,要求必须穷尽所有可能的情况。
8.3.2 基本规则
match 值 {
模式1 => 表达式1,
模式2 => 表达式2,
...
_ => 默认表达式, // 下划线匹配所有未列出的情况
}
- 每个分支由
模式 => 表达式组成,分支之间用逗号分隔。 match本身也是表达式,可以返回值(所有分支返回值类型必须一致)。
8.3.3 具体示例(结合 Option 和 Result)
fn divide(x: f64, y: f64) -> Option<f64> {
if y == 0.0 { None } else { Some(x / y) }
}
fn main() {
let result = divide(10.0, 2.0);
match result {
Some(value) => println!("结果是 {}", value),
None => println!("除数不能为零"),
}
let num_str = "42";
let parse_result: Result<i32, _> = num_str.parse();
match parse_result {
Ok(num) => println!("解析成功: {}", num),
Err(e) => println!("解析失败: {}", e),
}
}
9. 强制类型转换 (as)
9.1 功能作用
as 运算符用于在 Rust 中进行显式类型转换(又称“类型铸造”)。它可以将一种原始类型转换为另一种原始类型(如整数、浮点数、字符),也可以做某些指针相关转换。as 不会破坏内存安全,但不等于“语义上安全”:它可能截断整数、丢失浮点精度,或改变数值含义。
9.2 基本规则
值 as 目标类型
- 只能转换原始类型(
i8、u16、f32、char、bool等)以及某些指针/引用之间的转换。 - 整数截断:将大整数转换为小整数时,高位会被丢弃(相当于取模)。
- 浮点转整数会向零舍入(截断小数部分);超出目标整数范围时会饱和到边界值。
- 整数转浮点可能丢失精度。
as不会用于自定义类型之间的转换(应使用From/Intotrait)。
9.3 具体示例
fn main() {
// 整数之间的转换
let a: i32 = 300;
let b = a as i8; // 300 转换为 i8 → 44 (因为 300 % 256 = 44)
println!("b = {}", b);
// 浮点转整数(截断)
let pi = 3.14159;
let int_pi = pi as i32; // 3
println!("int_pi = {}", int_pi);
// 整数转浮点
let x = 5;
let y = x as f64; // 5.0
// 字符转整数(得到 Unicode 码点)
let c = 'A';
let code = c as u32; // 65
// u8 转字符(得到对应的 Unicode 标量值)
let code: u8 = 97;
let ch = code as char; // 'a'
// bool 转整数(false→0, true→1)
let t = true;
println!("true as i32 = {}", t as i32); // 1
}
10. 普通数组 (Array)
10.1 功能作用
数组是固定长度、相同类型的元素集合,存储在栈上。数组的长度在编译时已知,不可改变。当你需要存储多个相同类型且数量固定的数据时,数组比向量(Vec)更高效。
10.2 基本规则
#![allow(unused)]
fn main() {
// 类型声明:[类型; 长度]
let 数组名: [类型; 长度] = [元素1, 元素2, ...];
// 自动推导类型
let 数组名 = [元素1, 元素2, ...];
// 初始化所有元素为相同值
let 数组名 = [初始值; 长度];
// 访问元素(索引从 0 开始)
let 元素 = 数组名[索引];
}
- 数组长度是类型的一部分,例如
[i32; 5]和[i32; 6]是不同的类型。 - 访问数组元素时,Rust 会进行边界检查,越界访问会导致程序 panic。
- 可以使用
数组名.len()获取长度。 - 遍历数组通常使用
for循环或迭代器。
10.3 具体示例
fn main() {
// 显式指定类型和长度
let arr1: [i32; 3] = [1, 2, 3];
// 自动推断
let arr2 = [10, 20, 30, 40];
// 所有元素初始化为 0,长度 5
let zeros = [0; 5]; // [0, 0, 0, 0, 0]
// 访问和修改(如果数组可变)
let mut arr3 = [5, 6, 7];
arr3[1] = 60; // 修改第二个元素
println!("arr3[1] = {}", arr3[1]);
// 获取长度
println!("arr2 有 {} 个元素", arr2.len());
// 越界访问(会 panic,演示时注释掉)
// let out = arr2[10]; // 编译通过,运行时 panic
// 遍历数组
for elem in &arr2 {
print!("{} ", elem);
}
println!();
// 使用索引遍历
for i in 0..arr2.len() {
print!("arr2[{}] = {} ", i, arr2[i]);
}
}
11. String 基础使用
说明:本节覆盖
String的常用操作,快速上手够用。String作为 Rust 的核心集合类型之一,它的内存布局(栈上的指针+堆上的数据)、与&str的关系、UTF-8 编码带来的遍历注意事项等,第十章:容器 会做更系统的讲解。
String 是 Rust 中可增长的、拥有所有权的 UTF-8 字符串类型。与字符串字面量 &str 不同,String 可以在运行时修改。
11.1 创建
| 方法/函数 | 接口签名 | 说明 |
|---|---|---|
String::new() | pub fn new() -> String | 创建一个空的 String |
String::from() | pub fn from(s: &str) -> String | 从字符串字面量创建 |
to_string() 方法 | pub fn to_string(&self) -> String | 将实现了 Display 的类型转为 String |
一行调用示例:
#![allow(unused)]
fn main() {
let empty = String::new(); // 创建空字符串
let hello = String::from("Hello"); // 从 &str 创建
let num_str = 42.to_string(); // 数字转 String
}
11.2 增加(追加内容)
| 方法 | 接口签名 | 说明 |
|---|---|---|
push_str() | pub fn push_str(&mut self, string: &str) | 追加一个字符串切片 |
push() | pub fn push(&mut self, ch: char) | 追加一个字符 |
+ 运算符 | fn add(self, other: &str) -> String | 拼接,会消耗左侧 String |
一行调用示例:
#![allow(unused)]
fn main() {
let mut s = String::from("Hello");
s.push_str(" World"); // 追加字符串
s.push('!'); // 追加字符
let s2 = s + " Rust"; // 拼接(注意 s 的所有权被移动)
}
11.3 转换(与其他类型互转)
| 转换方向 | 常用方法/函数 | 一行调用示例 |
|---|---|---|
String → &str | as_str() 或 & 解引用 | let slice: &str = s.as_str(); 或 let slice = &s; |
String → Vec<u8> | into_bytes() | let bytes = s.into_bytes(); |
&str → String | to_string() 或 String::from() | let s = "hi".to_string(); |
String → 数字 | parse() | let num: i32 = "42".parse().unwrap(); |
数字 →String | to_string() 或 format! | let s = 3.14.to_string(); |
一行调用示例:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let slice = s.as_str(); // String → &str
let bytes = s.into_bytes(); // String → Vec<u8> (消耗 s)
let back = String::from("world"); // &str → String (已演示)
let num = "100".parse::<i32>().unwrap(); // &str → i32
let num_str = 256.to_string(); // i32 → String
}
12. 借用 (Borrowing)
说明:这里介绍借用的基本概念和规则,让你能看懂和写出简单的借用代码。借用是 Rust 所有权系统的核心组成部分,第四章:Rust 内存管理 中的所有权机制章节会深入讲解移动语义、借用规则的原理、以及编译器如何通过借用检查在编译期保证内存安全。
12.1 功能作用
借用是 Rust 所有权系统中的一个核心概念。它允许你临时访问一个值而不获取其所有权。通过借用(即创建引用),你可以传递数据给函数或进行其他操作,而原变量仍然保持所有权。借用分为不可变借用(&T)和可变借用(&mut T)。
12.2 基本规则
- 不可变借用:可以同时存在多个,但不能与可变借用共存。不能通过不可变借用修改数据。
- 可变借用:同一时间只能有一个可变借用,且在可变借用存在期间,不能有任何其他借用(不可变或可变)。
- 借用必须始终有效:引用的生命周期不能超过被引用数据的生命周期(编译器通过生命周期检查确保)。
12.3 具体示例
fn main() {
let mut data = 42;
// 不可变借用(多个允许)
let ref1 = &data;
let ref2 = &data;
println!("ref1 = {}, ref2 = {}", ref1, ref2);
// ref1 和 ref2 最后一次使用之后,不可变借用结束,可以再进行可变借用
// 可变借用(只能有一个)
let ref_mut = &mut data;
*ref_mut += 1; // 通过可变引用修改原值
println!("现在 data = {}", data);
// 下面的代码会编译错误(取消注释查看):
// let ref3 = &data; // 如果后面还要继续使用 ref_mut,这里就不能再借用 data
// 当 ref_mut 不再使用后,又可以创建新的借用
}
// 函数参数使用借用,不获取所有权
fn calculate_length(s: &String) -> usize {
s.len() // s 是引用,函数结束后不会 drop 原 String
}
fn modify(s: &mut String) {
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello");
let len = calculate_length(&s); // 不可变借用
println!("长度: {}", len);
modify(&mut s); // 可变借用
println!("修改后: {}", s); // "hello world"
}
简单理解:借用就像是“借阅”一本书——你可以看(不可变借用)或者修改(可变借用),但书的所有权仍然属于原主人。多人同时看一本书没问题,但有人修改时其他人不能看或改。
13. 指针、取地址与解引用
Rust 中有三种“指针“相关的基础语法,它们和借用紧密配合使用。
13.1 取地址 / 创建引用:& 和 &mut
功能:& 操作符用来创建引用(也就是“取地址“)。它不获取数据的所有权,只是“借来看“或“借来改“。
| 语法 | 创建的引用类型 | 含义 |
|---|---|---|
&变量 | &T(不可变引用) | 借来读,不能改 |
&mut 变量 | &mut T(可变引用) | 借来读写,独占 |
示例:
#![allow(unused)]
fn main() {
let x = 42;
let r = &x; // &i32,不可变引用,可以读 x 但不能改 x
println!("r = {}", r);
let mut y = 10;
let r_mut = &mut y; // &mut i32,可变引用,可以读写 y
*r_mut += 1; // 通过可变引用修改 y
println!("y = {}", y);
}
13.2 解引用:*
功能:* 操作符用于解引用——“穿过“引用,访问或修改它指向的实际数据。
示例:
#![allow(unused)]
fn main() {
let x = 10;
let r = &x;
println!("*r = {}", *r); // 解引用:读取引用指向的值,输出 10
// *r = 20; // 编译错误:不可变引用不能修改
let mut y = 5;
let r_mut = &mut y;
*r_mut = 100; // 解引用 + 赋值:修改引用指向的值
println!("y = {}", y); // 输出 100
}
自动解引用:Rust 在某些情况下会自动插入 *,你不用手写。最常见的是 . 方法调用——编译器会自动对引用解引用以找到匹配的方法:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let r = &s;
// 下面两行等价,编译器自动帮你解引用:
println!("长度: {}", r.len()); // 自动解引用,推荐写法
println!("长度: {}", (*r).len()); // 手动解引用,多余但有效
}
13.3 裸指针:*const T 和 *mut T
除了安全的引用(&T / &mut T),Rust 还保留了类似 C 语言的裸指针(raw pointer),用于底层操作和 FFI 场景:
| 类型 | 含义 |
|---|---|
*const T | 不可变的裸指针 |
*mut T | 可变的裸指针 |
特点:
- 裸指针不受借用检查器的保护——编译器不追踪它们的生命周期,不检查它们是否有效。
- 解引用裸指针必须在
unsafe块中进行。 - 裸指针不实现自动释放(没有
Drop),可以随意拷贝,不计入所有权系统。
示例:
#![allow(unused)]
fn main() {
let x = 42;
let p: *const i32 = &x; // 从引用转换为裸指针(安全操作)
// println!("{}", *p); // 编译错误:解引用裸指针需要 unsafe
unsafe {
println!("{}", *p); // 在 unsafe 块中可以解引用
}
let mut y = 10;
let p_mut: *mut i32 = &mut y;
unsafe {
*p_mut += 1; // 通过可变裸指针修改值
println!("{}", *p_mut); // 11
}
}
何时用裸指针? 日常写 Rust 几乎用不到——引用和智能指针覆盖了绝大多数场景。裸指针主要用于:调用 C 语言的 FFI 接口、实现某些高性能数据结构、或者在不适用安全引用规则的极端场景中。如果你刚学 Rust,先专注于
&T/&mut T即可。
14. 切片 (Slice)
14.1 切片的概念
切片是对一个集合(如数组、Vec、字符串)中连续一段元素的引用。它不拥有数据,只是借用。可以把切片想象成“指向原始数据中间某一段的窗口”:
- 切片本身不复制数据,因此非常高效。
- 通过切片,我们可以用统一的方式处理不同长度的数据块。
- 切片具有运行时确定的长度,而数组的长度是编译时固定的。
生活的理解: 有一整条面包(原始数组),切片就是切下来的一段(比如第3片到第5片),但这段面包并没有被拿走,只是告诉别人“你看这段”。原始面包仍然完整。
在 Rust 中的表现形式:
- 数组切片类型:
&[T](不可变)或&mut [T](可变) - 字符串切片类型:
&str(总是不可变)
切片总是通过引用(&)来创建和传递。
14.2 切片的通用语法格式
语法 &集合[范围表达式]
&:创建一个引用(切片本质是引用)。集合:可以是数组、Vec、String等支持范围索引的类型。范围表达式:使用..或..=指定起止位置。
14.2.1 ⚠️ 重要:范围是 左闭右开(除非使用 ..=)
[start..end] 表示从 start 开始,到 end 之前结束,即包含 start,不包含 end。
这是 Rust 范围(Range)的默认行为,与很多编程语言(如 Python、Go)中的切片规则一致。
| 写法 | 含义 | 等效区间 | 是否包含 end |
|---|---|---|---|
&coll[..] | 整个集合 | 0 .. len | - |
&coll[start..] | 从索引 start 到末尾 | start .. len | - |
&coll[..end] | 从开头到索引 end(不包含 end) | 0 .. end | ❌ 不包含 |
&coll[start..end] | 从 start 到 end(不包含 end) | start .. end | ❌ 不包含 |
&coll[start..=end] | 从 start 到 end(包含 end) | start ..= end | ✅ 包含 |
重要规则:
- 范围边界必须落在集合的合法索引内,否则程序会 panic。
- 对于字符串切片,范围还必须落在 UTF-8 字符的字节边界上,否则 panic。
- 切片的结果类型取决于原始集合:
- 数组 →
&[T] Vec→&[T]String或字符串字面量 →&str
- 数组 →
示例(数组切片):
#![allow(unused)]
fn main() {
let arr = [10, 20, 30, 40];
let s1 = &arr[1..3]; // [20, 30]
let s2 = &arr[..2]; // [10, 20]
let s3 = &arr[2..]; // [30, 40]
let s4 = &arr[..]; // [10, 20, 30, 40]
}
14.3 数组切片
类型:&[T](不可变)或 &mut [T](可变)
功能:指向数组或 Vec 中一段连续元素。
14.3.1 创建数组切片
使用 &数组名[范围] 即可。
#![allow(unused)]
fn main() {
let arr = [5, 10, 15, 20, 25];
// 取索引 1~3(不含 3)→ [10, 15]
let slice1 = &arr[1..3];
// 从开头到索引 2(不含 2)→ [5, 10]
let slice2 = &arr[..2];
// 从索引 3 到末尾 → [20, 25]
let slice3 = &arr[3..];
// 整个数组切片 → [5, 10, 15, 20, 25]
let whole = &arr[..];
}
14.3.2 使用数组切片
切片支持索引访问、len() 方法、迭代等操作。
fn print_slice(s: &[i32]) {
println!("切片长度: {}, 第一个元素: {}", s.len(), s[0]);
for &val in s {
print!("{} ", val);
}
println!();
}
fn main() {
let arr = [1, 2, 3, 4, 5];
let mid = &arr[1..4]; // [2, 3, 4]
print_slice(mid);
}
14.3.3 可变数组切片
如果原始数组是可变的,可以创建可变切片 &mut [T],并通过它修改原数组中的元素。
fn add_one(slice: &mut [i32]) {
for elem in slice.iter_mut() {
*elem += 1;
}
}
fn main() {
let mut arr = [10, 20, 30];
{
let slice = &mut arr[0..2]; // 可变借用 [10, 20]
slice[0] = 99;
add_one(slice); // [100, 21]
} // slice 作用域结束
println!("{:?}", arr); // [100, 21, 30]
}
14.4 字符串切片
类型:&str(总是不可变)
功能:指向 String 或字符串字面量中的一段连续 UTF-8 字符。
14.4.1 创建字符串切片
使用 &字符串变量[范围] 或直接对字符串字面量切片。
#![allow(unused)]
fn main() {
let s = String::from("你好Rust");
// 注意:'你' 占 3 字节,'好' 占 3 字节,'R','u','s','t' 各占 1 字节
let slice1 = &s[0..3]; // "你"
let slice2 = &s[3..6]; // "好"
let slice3 = &s[6..10]; // "Rust"
let whole = &s[..]; // "你好Rust"
// 字符串字面量本身就是 &str 类型,也可切片
let literal = "Hello世界";
let hello = &literal[0..5]; // "Hello"
let world = &literal[5..]; // "世界"('世' 3 字节,'界' 3 字节)
}
14.4.2 使用字符串切片
字符串切片支持 len()(返回字节数,不是字符数)、chars() 遍历字符等。
#![allow(unused)]
fn main() {
let text = String::from("Rust编程");
let sub = &text[4..]; // "编程"
println!("子串: {}", sub);
println!("字节长度: {}", sub.len()); // 6('编'3字节 + '程'3字节)
println!("字符个数: {}", sub.chars().count()); // 2
// 遍历字符
for ch in sub.chars() {
println!("{}", ch);
}
}
一句话总结:切片就是“不拿所有权,只看一段”的引用。数组切片让你灵活处理数组片段,字符串切片让你安全高效地截取子串。掌握切片是写出高效 Rust 代码的关键一步。
15. 标准输入输出
14.1 标准输出 – println! 和 print!
14.1.1 功能作用
println! 和 print! 是宏,用于向标准输出(通常是控制台)打印文本。println! 会自动在末尾添加换行,print! 不会。
14.1.2 基本规则
println!() // 只打印一个换行
println!("文本") // 打印字符串并换行
println!("{}", 变量) // 使用 `{}` 占位符(实现了 `Display` trait)
println!("{:?}", 变量) // 使用 `{:?}` 占位符(实现了 `Debug` trait)
println!("{0} {1}", a, b) // 按索引使用参数
println!("{name}: {value}", name="Rust", value=42) // 命名参数
print!("不换行") // 类似但无换行
{}用于实现std::fmt::Display的类型(如整数、字符串)。{:?}用于实现std::fmt::Debug的类型(几乎所有类型都可派生 Debug)。- 可以使用多个占位符,按顺序匹配参数。
14.1.3 具体示例
fn main() {
let name = "Alice";
let age = 30;
println!("Hello, {}! You are {} years old.", name, age);
println!("Debug: {:?}", (name, age));
print!("同一行"); // 不会换行
println!("继续"); // 会接在上一行后面然后换行
}
14.2 标准输入 – stdin()
14.2.1 功能作用
从标准输入(通常是键盘)读取用户输入。常用方法包括 read_line 读取整行,或使用 bytes() / chars() 逐个读取。
14.2.2 基本规则
use std::io;
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取失败");
// input 现在包含用户输入的一整行(末尾包含换行符)
// 清空 String 以便重用
input.clear();
read_line将读取所有字节直到遇到换行符,并追加到给定的String中。- 返回
Result<usize, io::Error>,通常用unwrap()或expect()处理。 - 读取后记得
.trim()去掉首尾空白(包括换行符)。
14.2.3 具体示例
use std::io;
fn main() {
let mut buffer = String::new();
println!("请输入您的名字:");
io::stdin().read_line(&mut buffer).expect("读取失败");
let name = buffer.trim(); // 去掉换行符
println!("你好,{}!", name);
}
14.3 组合使用:读取并解析数字
use std::io;
fn main() {
let mut input = String::new();
println!("请输入一个整数:");
io::stdin().read_line(&mut input).unwrap();
let num: i32 = input.trim().parse().expect("请输入有效的数字");
println!("您输入的是 {}", num);
}
16. 完整示例:猜数字游戏
下面是一个完整的猜数字游戏,包含了变量、循环、控制流、函数、match、Option/Result、数组(本例未用)、借用(read_line 使用了可变引用)以及标准输入输出等几乎所有介绍过的知识点。代码中使用了 rand crate 生成随机数。
15.1 代码实现
首先在 Cargo.toml 中添加依赖:
[dependencies]
rand = "0.8"
然后编写以下代码:
#![allow(unused)]
fn main() {
use rand::Rng;
use std::io;
pub fn demo() {
println!("...............基础语法示例开始...............");
guess_number(); // 第一次游戏
guess_number(); // 第二次游戏
println!("...............基础语法示例结束...............");
}
fn guess_number() {
// 生成 1~100 之间的随机数(闭区间)
let secret_number = rand::thread_rng().gen_range(1..=100);
let mut guess = String::new(); // 可变字符串,用于存储用户输入
let mut count = 1; // 计数器
// 最多允许猜 20 次
while count <= 20 {
println!("第 {} 次猜测,请输入一个数字:", count);
// 读取一行输入,存入 guess 字符串(这里传递了 &mut guess,即可变借用)
let n = io::stdin().read_line(&mut guess).unwrap();
count += 1;
// 将输入的字符串去除首尾空白后解析为 u32 整数
let num = guess.trim().parse::<u32>();
// 使用 match 处理解析结果(Result 类型)
match num {
Ok(num) => {
// 比较猜测值与秘密数字
if num == secret_number {
println!("恭喜!猜对了!");
break; // 猜对后退出循环
} else if num > secret_number {
println!("猜大了");
} else {
println!("猜小了");
}
}
Err(_) => {
// 解析失败,输入的不是有效数字
println!("请输入一个有效的数字!");
}
}
// 清空 guess 字符串,为下一次输入做准备
guess.clear();
}
// 如果循环结束是因为超过了 20 次,则提示失败
if count > 20 {
println!("很遗憾,20 次机会用完了。正确答案是 {}", secret_number);
}
}
}
15.2 代码讲解
-
引入依赖
use rand::Rng;引入随机数生成器 trait。use std::io;引入标准库的输入输出模块。
-
demo()函数简单地调用两次guess_number(),演示游戏可以进行多轮。 -
生成随机数
rand::thread_rng().gen_range(1..=100)生成一个 1 到 100 之间的整数(包含 100)。 -
可变变量
let mut guess = String::new();– 因为需要反复清空和读入,所以guess必须是可变的。let mut count = 1;– 计数器也要递增。 -
while循环条件count <= 20控制最多猜 20 次。 -
读取用户输入
io::stdin().read_line(&mut guess)将用户输入追加到guess字符串中,这里&mut guess就是可变借用。函数返回Result<usize, io::Error>,这里用unwrap()简单处理错误(实际生产代码应更严谨)。 -
字符串解析
guess.trim().parse::<u32>()–trim()去掉换行符和空格,parse尝试将字符串转换为u32整数。返回值是Result<u32, ParseIntError>。 -
match处理ResultOk(num)表示解析成功,进入比较逻辑。Err(_)表示解析失败,提示用户重新输入。
-
内部
if-else比较判断num与secret_number的关系,并打印相应提示。 -
break退出循环猜对时立刻退出循环,不再继续猜测。 -
清空字符串
guess.clear();清空旧内容,避免下次读取时附加到旧数据后面。 -
最终判断 循环结束后,如果
count > 20说明是因为耗尽次数而退出,此时公布答案。
Cargo 构建系统
本章内容可以作为工具,初学只需要掌握如何创建二进制和库项目,如何编译,如何运行接口,其他按需查看。
1. Cargo 简介
Cargo 是 Rust 官方提供的包管理器和构建系统,它负责:
- 下载并编译项目依赖的第三方库(crate)
- 执行构建、测试、文档生成、代码检查等任务
- 管理工作空间(workspace)中的多个相关联的 crate
- 发布 crate 到 crates.io 或私有注册表
2. Cargo 常用命令(核心)
以下是最常用的 Cargo 命令,覆盖项目生命周期的各个环节。
| 命令 | 作用 | 示例 |
|---|---|---|
cargo new <name> | 创建一个新的二进制项目(默认 --bin) | cargo new my_app |
cargo new --lib <name> | 创建一个新的库项目 | cargo new --lib my_lib |
cargo init | 在当前目录初始化一个 Cargo 项目 | cargo init |
cargo build | 编译项目(默认调试模式) | cargo build |
cargo build --release | 编译项目,启用优化(发布模式) | cargo build --release |
cargo run | 编译并运行二进制可执行文件 | cargo run |
cargo check | 快速检查代码能否编译(不生成可执行文件) | cargo check |
cargo test | 运行测试 | cargo test |
cargo doc | 生成项目文档(打开 target/doc/index.html) | cargo doc --open |
cargo fmt | 自动格式化代码(需要安装 rustfmt) | cargo fmt |
cargo add <crate> | 添加一个依赖到 Cargo.toml(Rust 2021 edition 开始推荐) | cargo add serde |
cargo update | 根据语义化版本规则更新依赖的最新版本 | cargo update |
cargo clean | 删除 target 目录(清理构建产物) | cargo clean |
cargo publish | 将 crate 发布到 crates.io | cargo publish |
💡 提示:可以使用
cargo --list查看所有可用命令。
3. Cargo.toml 配置详解
Cargo.toml 是 Cargo 项目的核心配置文件,使用 TOML 格式。下面按功能分类详细解释常用配置项。
3.1 [package] 段 – 项目元信息
定义项目的基本信息,位于文件最开头。
[package]
name = "my_project" # 项目名称,也是 crate 名,发布到 crates.io 时的标识
version = "0.1.0" # 语义化版本号(SemVer)
edition = "2021" # Rust 语言版本(2015, 2018, 2021, 2024)
authors = ["Alice <alice@example.com>"] # 作者列表
description = "A brief description" # 项目简介(发布时必需)
license = "MIT" # 许可证标识(如 MIT, Apache-2.0)
repository = "https://github.com/user/repo" # 源代码仓库 URL
readme = "README.md" # README 文件路径
keywords = ["cli", "tool"] # 搜索关键词(最多 5 个)
categories = ["command-line-utilities"] # 分类标签,见 crates.io 分类表
exclude = ["ci/*"] # 打包时排除的文件(相对于项目根目录)
include = ["src/**/*"] # 强制包含的文件(会覆盖 exclude)
常用字段说明:
name:只能使用字母、数字、下划线、短横线,不能与现有 crate 冲突。edition:推荐使用"2021"或更新版本。version:遵循MAJOR.MINOR.PATCH格式,初始版本通常为0.1.0。license:如果是多个许可证,使用license-file指定文件路径。
3.2 依赖相关段
3.2.1 [dependencies] – 生产依赖
项目在运行时需要的 crate。
[dependencies]
serde = "1.0" # 指定大版本(兼容 1.0.x)
serde_json = { version = "1.0", optional = true } # 可选依赖
rand = { version = "0.8", features = ["small_rng"] } # 启用特性
tokio = { version = "1", features = ["full"] }
chrono = { git = "https://github.com/chronotope/chrono", branch = "main" } # Git 依赖
regex = { path = "../regex" } # 本地路径依赖
版本指定方式:
| 写法 | 含义 | 示例匹配 |
|---|---|---|
"0.1.0" | 精确版本 | 只 0.1.0 |
"^0.1.0" | 兼容更新(默认,不修改最左非零段) | 0.1.0 到 <0.2.0 |
"~0.1.0" | 允许最后一个非零段增加 | 0.1.0 到 <0.2.0(与 ^ 在 0.x 相同) |
"*" 或 ">=1.0" | 范围语法 | 任意版本 ≥1.0 |
3.2.2 [dev-dependencies] – 开发依赖
只在运行测试、示例、基准测试时使用(例如 assert_cmd, tempfile)。
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.0"
3.2.3 [build-dependencies] – 构建依赖
仅在构建脚本 build.rs 中使用的 crate(详见第 9 节)。
[build-dependencies]
cc = "1.0"
3.2.4 [target.'cfg(...)'.dependencies] – 平台特定依赖
只在特定目标平台上引入。
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
3.3 [features] – 特性开关
定义条件编译特性,让用户可以按需启用部分功能。
[features]
default = ["serde"] # 默认启用的特性
serde = ["dep:serde"] # 启用 serde 支持(dep: 前缀表示启用可选的依赖)
json = ["serde_json"] # json 特性启用后,会引入 serde_json 依赖
使用示例:
[dependencies]
my_crate = { version = "0.1", features = ["json"] }
⚠️
dep:前缀语法需要 Rust 2021 edition。
3.4 [profile.*] – 编译优化配置
定制不同构建模式下的编译器优化级别和调试信息。
| Profile | 对应命令 | 用途 |
|---|---|---|
dev | cargo build(默认) | 快速编译、带调试信息 |
release | cargo build --release | 高度优化、体积小 |
test | cargo test | 类似 dev,但优化测试执行速度 |
bench | cargo bench | 类似 release,用于基准测试 |
常用配置项:
[profile.dev]
opt-level = 0 # 优化级别 0-3,0 表示不优化
debug = true # 是否生成调试符号(或数字 1,2)
lto = false # 链接时优化(Link Time Optimization)
codegen-units = 256 # 并行代码生成单元数(值越大编译越快,但运行时性能可能略降)
[profile.release]
opt-level = 3 # 最大优化
debug = false # 不生成调试符号
lto = "fat" # 启用完整 LTO
codegen-units = 1 # 牺牲编译时间换取极致性能
strip = true # 从二进制中剥离符号(减小体积)
3.5 [workspace] – 工作空间配置
用于管理多个相互关联的 crate(详见第 8 节)。
[workspace]
members = ["crates/*", "core"] # 成员目录列表
exclude = ["crates/experiments"] # 排除的目录
resolver = "2" # 使用最新依赖解析器
default-members = ["crates/main"] # 默认操作的目标成员
3.6 [patch] – 依赖覆盖
临时替换依赖的来源(常用于调试或测试未发布的修改)。
[patch.crates-io]
serde = { git = "https://github.com/your-fork/serde" } # 覆盖 crates.io 上的 serde
[patch."https://github.com/rust-lang/cargo"]
cargo = { path = "../cargo" } # 覆盖 git 仓库中的依赖
3.7 [badges] – 项目徽章
在 crates.io 页面显示持续集成、代码覆盖率等徽章(仅用于元数据,不影响构建)。
[badges]
travis-ci = { repository = "user/repo" }
codecov = { repository = "user/repo" }
4. crate 的概念
在 Rust 中,crate 是编译的基本单元。每个 crate 对应一个编译单元,编译后会生成一个库文件(.rlib、.so、.dylib)或可执行文件。
- 二进制 crate:包含
main函数,可以编译为可执行程序。项目根目录下的src/main.rs或src/bin/*.rs都是二进制 crate。 - 库 crate:不包含
main函数,用于提供 API 供其他 crate 使用。项目根目录下的src/lib.rs定义了库 crate。
crate 可以依赖于其他 crate(在 Cargo.toml 中声明),这些依赖也会在编译时被一起处理。crate 也是 Rust 的命名空间,从外部访问 crate 中的项需要引入其路径。
📦 crate vs package:一个 Cargo 项目(package)可以包含一个或多个 crate(例如一个库 crate + 多个二进制 crate)。通常人们混用这两个词,但严格来说,
cargo new创建的是一个 package。
5. 标准项目结构
一个遵循规范的 Cargo 项目目录通常如下:
my_project/
├── Cargo.toml # 项目配置
├── Cargo.lock # 锁定依赖版本(自动生成,需提交到 Git 但避免合并冲突)
├── src/
│ ├── main.rs # 二进制 crate 入口(默认)
│ ├── lib.rs # 库 crate 入口(如果有)
│ ├── bin/ # 多个二进制 crate
│ │ ├── tool1.rs
│ │ └── tool2.rs
│ └── ... # 其他模块文件(.rs)
├── tests/ # 集成测试文件
│ ├── integration_test.rs
│ └── common.rs # 集成测试共享模块(不会作为测试用例运行)
├── examples/ # 示例代码,展示如何使用库
│ └── demo.rs
├── benches/ # 基准测试(需要引入 `test` 特性)
│ └── bench.rs
└── build.rs # 构建脚本(可选)
说明:
Cargo.toml中的name字段决定了生成的二进制文件或库的名称。- 如果同时存在
src/main.rs和src/lib.rs,则 package 包含一个库 crate 和一个同名的二进制 crate(二进制 crate 默认依赖库 crate)。 src/bin/*.rs中每个文件都会生成一个独立的二进制 crate,名称与文件名相同。
6. Rust 模块构建完整规则
模块系统是 Rust 组织代码的核心。Cargo 只负责编译,而模块的可见性、路径、文件结构由 Rust 本身的规则决定。下面详细解释。
6.1 基本概念
- 模块(module):使用
mod关键字声明,用于将代码分组,控制私有性。 - crate 根:编译器开始编译的入口文件(
main.rs或lib.rs)。这个文件隐式地构成一个与 crate 同名的根模块。 - 模块路径:类似文件系统路径,使用
::分隔。例如std::collections::HashMap。 - 可见性:默认所有项(函数、结构体、常量等)是私有的(
private)。父模块可以访问子模块的私有项,但子模块不能访问父模块的私有项。使用pub关键字使其变为公有。
6.2 声明模块的两种方式
6.2.1 方式一:内联模块
直接在文件中使用 mod 后跟大括号定义模块:
#![allow(unused)]
fn main() {
// src/lib.rs
mod network {
fn connect() {}
}
mod client {
fn request() {}
}
}
这种方式适合小模块,不会创建单独的文件。
6.2.2 方式二:将模块内容放到单独的文件中
- 旧式(2015 edition):创建
mod_name/mod.rs文件。 - 新式(2018 edition 及以后):创建
mod_name.rs文件(推荐)。
示例:假设要在 lib.rs 中声明一个 network 模块。
src/lib.rs:
#![allow(unused)]
fn main() {
mod network; // 声明模块,告诉编译器去查找 network.rs 或 network/mod.rs
}
src/network.rs:
#![allow(unused)]
fn main() {
pub fn connect() {
println!("connected");
}
}
如果需要模块嵌套,例如 network::client,可以:
- 创建
src/network/client.rs文件,并在src/network.rs中写mod client;。 - 或者创建
src/network/mod.rs,在其中写mod client;。
强烈推荐使用新式:每个模块一个同名的 .rs 文件,子模块放在同名目录下。例如:
src/
├── lib.rs
├── network.rs
└── network/
└── client.rs
network.rs 内容:
#![allow(unused)]
fn main() {
mod client; // 声明子模块 client,从 network/client.rs 加载
}
6.3 模块路径与 use
- 绝对路径:从 crate 根开始,以
crate关键字开头。 - 相对路径:从当前模块开始,使用
self、super或直接写标识符。
#![allow(unused)]
fn main() {
// 在 src/lib.rs 中
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
// 绝对路径调用
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
self::front_of_house::hosting::add_to_waitlist();
}
使用 use 将路径引入作用域,简化调用:
#![allow(unused)]
fn main() {
use crate::front_of_house::hosting;
hosting::add_to_waitlist();
}
重命名:use std::fmt::Result as FmtResult。
重新导出:pub use crate::some::path 使引入的项成为当前模块的公有 API。
6.4 可见性规则总结
| 修饰符 | 含义 |
|---|---|
无 pub | 仅当前模块及其子模块可访问(私有) |
pub | 任何地方都可访问 |
pub(crate) | 仅在当前 crate 内可见 |
pub(super) | 仅在父模块中可见 |
pub(in crate::some::path) | 在指定路径及其子模块中可见 |
示例:
#![allow(unused)]
fn main() {
pub mod outer {
pub fn public_fn() {}
fn private_fn() {}
pub mod inner {
pub fn inner_public() {}
pub(crate) fn inner_crate_visible() {}
pub(super) fn inner_super_visible() {} // 仅在 outer 模块可见
}
}
}
6.5 完整的模块树示例
假设文件结构:
src/
├── lib.rs
├── models.rs
└── services/
├── mod.rs
└── user.rs
lib.rs:
#![allow(unused)]
fn main() {
mod models;
mod services;
pub use services::user::UserService; // 重新导出,使外部可以直接使用 UserService
}
models.rs:
#![allow(unused)]
fn main() {
pub struct User {
pub name: String,
age: u32, // 私有字段
}
impl User {
pub fn new(name: String) -> Self { ... }
}
}
services/mod.rs:
#![allow(unused)]
fn main() {
mod user; // 加载 services/user.rs
pub use user::UserService;
}
services/user.rs:
#![allow(unused)]
fn main() {
use crate::models::User;
pub struct UserService;
impl UserService {
pub fn create_user(name: String) -> User {
User::new(name)
}
}
}
6.6 重要规则总结
mod声明模块,告诉编译器去加载文件(或内联)。use将模块或项引入当前作用域。- 默认所有项私有,使用
pub暴露 API。 - 模块文件的查找规则(2018+):
mod foo;→foo.rs或foo/mod.rs(优先foo.rs,若不存在则回退到foo/mod.rs)。mod foo { ... }→ 内联模块,无外部文件。
src/main.rs和src/lib.rs都是 crate 根,它们自动被视为crate模块。- 二进制 crate 如果包含
lib.rs,则二进制文件可以将库视为外部依赖(通过use my_package::...),其中my_package是[package]中的name。
7. 库 crate 构建
7.1 创建库 crate
cargo new --lib my_lib
cd my_lib
生成的 src/lib.rs 默认包含测试模块和示例函数。
#![allow(unused)]
fn main() {
// src/lib.rs
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
assert_eq!(add(2, 2), 4);
}
}
}
7.2 公开 API 设计
- 决定哪些函数、结构体、枚举是
pub。 - 使用
pub use重导出,隐藏内部模块层级,提供扁平化 API。 - 编写文档注释
///或//!(模块级),运行cargo doc --open生成文档。
示例:
#![allow(unused)]
fn main() {
//! 一个用于处理数学运算的库
/// 加法函数
///
/// # Examples
/// ```
/// use my_lib::add;
/// assert_eq!(add(1, 2), 3);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
}
7.3 库的类型
在 Cargo.toml 中可以通过 [lib] 配置库的生成类型(默认是 rlib)。
[lib]
name = "my_lib" # 库名,默认与 package.name 相同
crate-type = ["rlib", "cdylib"] # 生成 Rust 静态库和 C 兼容的动态库
常见的 crate-type:
rlib:Rust 静态库,用于其他 Rust crate 依赖(默认)。cdylib:C 兼容的动态库(.so,.dylib,.dll),用于从其他语言调用。staticlib:C 兼容的静态库(.a,.lib)。bin:可执行文件(通常不用于库)。
7.4 发布到 crates.io
- 注册账号并获取 API Token:
cargo login <token> - 确保
Cargo.toml中包含description、license等必要字段。 - 检查包:
cargo publish --dry-run - 发布:
cargo publish
更新版本时,修改 version 字段后再次执行 cargo publish。
8. 管理多个 crate – Workspace 详解
工作空间(Workspace) 用于组织多个相互关联的 crate,它们共享一个 Cargo.lock 和一个输出目录(target),方便协同开发和依赖管理。
8.1 创建工作空间
步骤:
- 创建一个空目录,并在其中创建
Cargo.toml(工作空间根配置)。 - 定义
[workspace]和成员。 - 创建成员 crate(可以是库或二进制)。
目录结构示例:
my_workspace/
├── Cargo.toml # 工作空间根配置
├── target/ # 共享构建输出
├── crates/
│ ├── core/ # 一个库 crate
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ ├── utils/ # 另一个库 crate
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ └── app/ # 二进制 crate
│ ├── Cargo.toml
│ └── src/main.rs
└── Cargo.lock # 自动生成,整个工作空间共享
根 Cargo.toml:
[workspace]
members = ["crates/*", "crates/core"] # 使用 glob 或明确列表
resolver = "2"
# 可选:默认成员(执行 cargo build/run 时如果不指定 -p,则针对这些成员)
default-members = ["crates/app"]
8.2 成员 crate 的配置
每个成员 crate 有自己的 Cargo.toml,但不需要单独的 [workspace] 段。它们可以相互依赖。
例如 crates/app/Cargo.toml:
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" } # 引用工作空间内的另一个 crate
utils = { path = "../utils" }
# 也可以依赖外部 crate,工作空间会统一解析版本
serde = "1.0"
8.3 工作空间操作命令
- 构建所有成员:
cargo build(在根目录执行) - 构建特定成员:
cargo build -p core - 运行特定二进制:
cargo run -p app - 测试所有成员:
cargo test - 检查依赖关系:
cargo tree
8.4 共享依赖与 Cargo.lock
- 所有成员共享同一个
Cargo.lock,确保整个工作空间使用相同版本的依赖。 - 如果成员 A 和 B 都依赖 serde 1.0,Cargo 会解析为同一个版本,避免重复编译。
- 当需要升级依赖时,在根目录执行
cargo update会更新所有成员使用的依赖。
8.5 补丁(patch)在工作空间中的使用
可以在根 Cargo.toml 中统一覆盖依赖:
[workspace]
members = ["crates/*"]
[patch.crates-io]
serde = { git = "https://github.com/your-fork/serde" }
8.6 注意事项
- 每个成员 crate 必须有一个唯一的
name。 - 根目录的
Cargo.toml不能包含[package]段(除非根目录本身也是一个 crate,但通常不推荐混合)。 - 工作空间成员可以嵌套,但通常使用扁平结构。
9. build.rs 详解
构建脚本 build.rs 位于项目根目录,在编译主 crate 之前由 Cargo 执行。它主要用于:
- 生成代码(例如根据配置文件生成 Rust 源码)。
- 编译非 Rust 代码(C/C++ 库)并链接。
- 检测系统环境,设置条件编译标志。
- 添加运行时搜索路径等。
9.1 何时需要使用 build.rs
- 需要链接本地系统库(
libfoo.so)。 - 需要根据构建时的信息(操作系统、CPU 特性)生成代码。
- 需要包装一个 C 库并生成 Rust 绑定(使用
bindgen)。 - 需要执行一些仅在编译时需要的工作(比如检查某个命令是否存在)。
如果不需要这些,就不要加 build.rs,以保持简洁。
9.2 基本用法
创建一个 build.rs 文件,写入任意 Rust 代码。Cargo 会编译并执行它。
最简单的 build.rs:
fn main() {
println!("cargo:rerun-if-changed=build.rs");
// 可以在这里输出特定指令给 Cargo
}
9.3 与 Cargo 通信的指令
通过输出特殊格式的字符串到 stdout 来与 Cargo 交互:
| 指令 | 作用 | 示例 |
|---|---|---|
cargo:rerun-if-changed=PATH | 当指定文件变化时重新运行 build.rs | println!("cargo:rerun-if-changed=src/config.json") |
cargo:rerun-if-env-changed=VAR | 当环境变量变化时重新运行 | println!("cargo:rerun-if-env-changed=TARGET_ARCH") |
cargo:rustc-link-lib=TYPE@NAME | 链接外部库(TYPE 可选 static, dylib, framework) | println!("cargo:rustc-link-lib=static=foo") |
cargo:rustc-link-search=TYPE@PATH | 添加库搜索路径 | println!("cargo:rustc-link-search=native=/usr/local/lib") |
cargo:rustc-flags=FLAGS | 传递额外的链接器标志 | println!("cargo:rustc-flags=-l z") |
cargo:rustc-cfg=KEY[="VALUE"] | 添加条件编译标志,相当于 #[cfg(KEY="VALUE")] | println!("cargo:rustc-cfg=has_feature") |
cargo:rustc-env=VAR=VALUE | 设置编译时的环境变量,可在源代码中使用 env! 读取 | println!("cargo:rustc-env=BUILD_DATE=2025-01-01") |
cargo:warning=MESSAGE | 输出警告信息 | println!("cargo:warning=Unsupported OS") |
💡 所有
cargo:前缀的指令必须以println!输出,每行一个。
9.4 完整示例
场景:项目需要链接本地 foo 库,并检测是否为 Linux 平台来启用特定功能。
build.rs:
fn main() {
// 只在 build.rs 或链接的库变化时重新运行
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=libfoo.a");
// 告诉 rustc 链接 libfoo(静态库)
println!("cargo:rustc-link-lib=static=foo");
// 添加库搜索路径
println!("cargo:rustc-link-search=native=/usr/local/lib");
// 检测操作系统
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "linux" {
println!("cargo:rustc-cfg=has_linux_support");
}
}
然后在 src/lib.rs 中:
#![allow(unused)]
fn main() {
#[cfg(has_linux_support)]
pub fn linux_specific() {
println!("Only on Linux");
}
}
9.5 依赖
如果 build.rs 需要额外的依赖,可以在 Cargo.toml 中添加 [build-dependencies] 段:
[build-dependencies]
cc = "1.0" # 用于编译 C/C++ 代码
walkdir = "2" # 用于遍历文件
注意:build-dependencies 中的 crate 只能在 build.rs 中使用,不会影响主 crate。
9.6 常见用例:编译 C 代码
使用 cc crate 可以轻松编译和链接 C 源码:
// build.rs
fn main() {
cc::Build::new()
.file("src/foo.c")
.compile("foo");
}
在 Cargo.toml 中:
[build-dependencies]
cc = "1.0"
rust 泛型
本章内容,初学只需要掌握基本泛型语法和泛型函数即可,示例代码和泛型结构体以及泛型trait等对应章节学完再学。
1. 泛型编程是什么?目的是什么?
泛型编程是一种编程范式,让你在编写代码时不指定具体的数据类型,而是使用“类型占位符”。当你实际使用这段代码时,再填入具体的类型(如 i32、f64、String 等)。
1.1 为什么需要泛型?
假设你要编写一个函数,用来交换两个整数的值:
#![allow(unused)]
fn main() {
fn swap_i32(a: i32, b: i32) -> (i32, i32) {
(b, a)
}
}
如果还要交换两个浮点数,你可能再写一个函数:
#![allow(unused)]
fn main() {
fn swap_f64(a: f64, b: f64) -> (f64, f64) {
(b, a)
}
}
这样会带来大量重复代码,而且每个新类型都需要重新实现。泛型正是为了解决这类问题而生的——你只需写一个泛型函数,就可以处理所有类型:
#![allow(unused)]
fn main() {
fn swap<T>(a: T, b: T) -> (T, T) {
(b, a)
}
}
其中 <T> 是一个类型占位符,调用时 Rust 会根据实际传入的参数自动推断出具体的 T(比如 i32、f64 或 String)。一份代码,任意类型,完美复用。
1.2 泛型的目的
- 代码复用:一份代码可以服务于多种类型。
- 类型安全:编译时仍然会进行严格的类型检查,避免运行时类型错误。
- 提高抽象能力:让你专注于算法和结构,而不是具体类型。
2. 泛型的基本语法
Rust 中用尖括号 <T> 声明泛型参数,T 是习惯使用的名称(可以任意命名,但通常用大写字母)。下面分别介绍泛型在函数、结构体以及 trait 中的用法。
2.1 泛型函数
基本语法结构
fn 函数名<类型参数>(参数名: 类型参数) -> 返回类型 {
// 函数体
}
调用示例片段(仅展示定义及调用方式)
#![allow(unused)]
fn main() {
fn identity<T>(value: T) -> T {
value
}
// 调用示例(类型自动推断)
let a = identity(42); // T 为 i32
let b = identity(3.14); // T 为 f64
}
2.2 泛型结构体
基本语法结构
struct 结构体名<类型参数> {
字段名: 类型参数,
// 其它字段...
}
调用示例片段
#![allow(unused)]
fn main() {
struct Box<T> {
item: T,
}
impl<T> Box<T> {
fn new(item: T) -> Self {
Box { item }
}
fn get(&self) -> &T {
&self.item
}
}
// 创建不同类型的实例
let int_box = Box::new(100); // Box<i32>
let str_box = Box::new(String::from("hello")); // Box<String>
}
2.3 泛型 trait
基本语法结构
trait Trait名<类型参数> {
// 方法签名中可以使用该类型参数
fn 方法名(&self, 参数: 类型参数);
}
与结构体不同,trait 的泛型参数表示该 trait 是“泛型化的”,实现时可以为同一个类型多次实现该 trait(针对不同的具体类型)。
调用示例片段
#![allow(unused)]
fn main() {
trait ToString<T> {
fn convert(&self, value: T) -> String;
}
// 为 i32 实现 ToString<f64>
impl ToString<f64> for i32 {
fn convert(&self, value: f64) -> String {
format!("整数 {} 和 浮点数 {}", self, value)
}
}
// 调用
let num = 42;
let result = num.convert(3.14); // result 为 String 类型
}
3. 综合示例:一个泛型容器(带讲解)
下面我们实现一个更完整的例子:一个可以存储两个相同类型值的对偶容器 Pair,并演示其在不同类型下的完整使用。
// 泛型结构体:存储两个相同类型的值
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
// 构造函数
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
// 获取第一个值的引用
fn first(&self) -> &T {
&self.first
}
// 获取第二个值的引用
fn second(&self) -> &T {
&self.second
}
// 交换两个值的位置
fn swap(&mut self) {
std::mem::swap(&mut self.first, &mut self.second);
}
// 消费 self,返回两个值的元组
fn into_tuple(self) -> (T, T) {
(self.first, self.second)
}
}
// 一个泛型函数:取出 Pair 的第一个字段
fn take_first<T>(pair: Pair<T>) -> T {
pair.first
}
fn main() {
// 使用整数类型
let mut int_pair = Pair::new(10, 20);
println!("({}, {})", int_pair.first(), int_pair.second());
int_pair.swap();
println!("交换后: ({}, {})", int_pair.first(), int_pair.second());
// 使用字符串类型
let str_pair = Pair::new(String::from("Hello"), String::from("World"));
let (s1, s2) = str_pair.into_tuple();
println!("字符串对偶: ({}, {})", s1, s2);
// 使用浮点类型 + 泛型函数
let float_pair = Pair { first: 3.14, second: 2.718 };
let first_val = take_first(float_pair);
println!("取出的第一个浮点数: {}", first_val);
}
Rust 内存管理
Rust 最重要的能力之一,就是在没有垃圾回收器的前提下,通过编译期规则保证内存安全。
本章会按下面的顺序展开:
- 所有权机制:理解移动、借用和可变借用。
- 智能指针:理解
Box、Rc、Weak、Arc等常用指针类型。 - 内部可变性:理解
Cell和RefCell如何在不可变外壳下修改内部数据。 - 生命周期:理解引用为什么必须有有效范围,以及什么时候需要显式标注生命周期。
这一章是后续并发和 async 的基础,建议结合 src/study 中的示例代码一起阅读。
rust所有权机制
本节内容主要是讲所有权机制,理论为主,文末给了实验代码,理解所有权机制还需配合所有的实验代码来理解
1. 为什么需要所有权?—— 从 C 语言的”内存困境”说起
要理解 Rust 的所有权机制,最好的方式不是直接看 Rust 的规则,而是先回到一个更原始的问题:在没有 GC 的语言里,堆上申请的内存到底谁来管?
1.1 C 语言的内存管理:自由背后的代价
在 C 语言中,堆内存的管理完全由程序员手动负责。来看一段最基础的代码:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int*)malloc(sizeof(int)); // 向操作系统申请一块堆内存
*p = 42;
printf("%d\n", *p);
free(p); // 用完了,手动归还
return 0;
}
这段代码看起来很正常——申请、使用、释放,三步走。但问题在于,编译器不会帮你检查这三步是否都做对了。现实中,大型 C 项目里充斥着这样的错误:
- 忘记
free→ 内存泄漏,程序跑久了内存越占越多 free了两次 → 双重释放,破坏内存分配器的内部结构,程序崩溃free之后继续用p→ 悬垂指针(use-after-free),读到垃圾数据或者直接段错误- 多线程同时读写同一块内存 → 数据竞争,结果不可预测
这些 bug 的共同特点是:编译能通过,运行时才爆炸,而且很难复现和定位。
为什么 C 语言管不好内存?不是程序员不够细心,而是有一个根本性的缺失——
1.2 问题的根源:C 语言没有”所有权”这个概念
我们用悬垂指针来具体感受一下这个”缺失”到底是什么。
假设你在写一个函数,需要返回一个字符串:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* create_greeting(const char* name) {
char buffer[100]; // 在栈上分配
snprintf(buffer, 100, "Hello, %s!", name);
return buffer; // 返回栈变量的指针!
} // buffer 在这里随着函数返回被销毁
int main() {
char *msg = create_greeting("Rust");
printf("%s\n", msg); // 悬垂指针!msg 指向已经销毁的栈内存
return 0;
}
现在停下来想一想,这段代码里,有几个关于”所有权”的问题 C 语言完全没有回答:
buffer这块内存是谁的?—— 是create_greeting这个函数的?还是main的?语言层面没有定义。- 谁负责释放
buffer?—— 编译器会自动回收栈内存,但main里的msg并不知道这件事。它拿到了一个地址,然后理所当然地用了。 - 什么时候可以安全地使用
msg?—— 没有规则告诉你这个指针还有没有效。你只能靠”脑子记”。
本质问题:C 语言只提供了
malloc和free这两个操作,但没有建立一套 ”这块内存属于谁、由谁负责释放、什么时候释放”的清晰规则。程序员在脑子里各自维护一套隐式的、不统一的约定——这就是所有内存 bug 的温床。
1.3 什么是”所有权”?
从上面的分析中,我们可以自然地推导出”所有权”应该回答的三个问题:
- 谁创建了这块资源?(资源从哪来)
- 谁持有这块资源?(在使用期间,谁是它的主人)
- 谁负责销毁这块资源?(用完了,谁来收尾)
C 语言的困境告诉我们:这三个问题如果没有明确的答案,内存管理就全靠程序员的”自觉”,bug 不可避免。
那么,怎么回答这三个问题?实际上有两种基本的解决思路:
思路一:独占所有权(一个所有者)
一块资源在任何时刻有且仅有一个所有者。 所有者独占使用权,并在自己离开作用域时负责销毁资源。如果别人需要用到这块资源,所有者可以把所有权转移出去,但转移之后自己就不再持有。
这正是 C++ 的 std::unique_ptr 和 Rust 默认变量语义采用的模型。它的好处是逻辑简单:一个人说了算,责任明确,不会出现”你以为我释放了、我以为你还拿着”的混乱。
思路二:共享所有权(多个所有者,但有明确的释放规则)
一块资源可以同时有多个所有者,但必须建立一套明确且固定的规则来决定”什么时候释放”。 最常见的规则是引用计数:每多一个人持有,计数器 +1;每有一个人退出,计数器 -1;当计数归零时,自动释放资源。
C++ 的 std::shared_ptr 和 Rust 的 Rc<T>/Arc<T> 采用的就是这个模型。它的好处是更灵活——适合那些确实需要多处共享、无法提前确定谁最后用完了的场景。
关键认知:共享所有权不是没有所有权,而是把释放规则从”唯一的那个所有者离开时释放”变成了”最后一个所有者离开时释放”。规则变了,但依然有规则——这个规则在编译期或运行期被严格执行,不允许模棱两可。
所以,所有权到底是什么?
所有权 = 对一块资源生命周期(创建→使用→销毁)的管理权责,以及一套明确的、”谁在什么时候负责释放”的规则。
不管是独占还是共享,核心都是同一件事:消灭”这块内存到底谁来管”的模糊地带。C 语言的悲剧在于,它既没有独占的约定,也没有共享的规则——每个人都在裸用指针,每个人都不知道别人会不会 free、自己该不该 free。
回到上面悬垂指针的例子:buffer 的所有者应该是 create_greeting 函数,它在函数返回时销毁了 buffer。但 main 里的 msg 拿到了一个指向已销毁内存的指针——它以为自己能用这块内存,但实际上它既不是所有者、也不知道内存已经没了。所有权不清晰,bug 就趁虚而入。
1.4 为什么必须有所有权?
因为计算机的内存资源是有限的、需要被回收的。而回收的前提是:你必须明确知道”什么时候没人用了”。
如果一块堆内存没有所有者:
- 没人知道该不该释放 → 内存泄漏
- 或者多个人都以为自己是所有者,各自释放一次 → 双重释放
- 或者一个人释放了,另一个人还在用 → 悬垂指针
如果一块内存有所有者,但规则不被强制:
- 程序员可以绕过规则 → C++ 的困境(下面会讲)
所以,GC 语言(Java、Go、Python 等)用一种方式解决了这个问题:让运行时追踪所有引用,自动判断”没人用了”。代价是运行时的 GC 开销和不可预测的停顿。
而 C 语言选择了另一个极端:完全交给程序员。代价是上面那些难以排查的 bug。
有没有第三条路?—— 在编译期就确定每一块内存的所有者,不依赖运行时 GC,也不依赖程序员的自觉。这就是 Rust 的做法。
1.5 C++ 的尝试:引入所有权思想,但不强制
在讨论 Rust 之前,有必要提一下 C++,因为它是最早在语言层面引入”所有权”概念的先行者。
C++ 在 C 的基础上做了三件重要的事:
- RAII(资源获取即初始化):利用构造函数获取资源、析构函数释放资源。对象离开作用域时,析构函数自动被调用——这是”自动回收”思想的雏形。
- 智能指针:
std::unique_ptr(独占所有权,不可拷贝只可移动)、std::shared_ptr(共享所有权,引用计数)、std::weak_ptr(弱引用,打破循环)。 - 移动语义(C++11):通过
std::move显式转移所有权,避免深拷贝。
来看一个 C++ 的例子:
#include <memory>
void demo() {
std::unique_ptr<int> p1 = std::make_unique<int>(42); // p1 独占所有权
std::unique_ptr<int> p2 = std::move(p1); // 所有权转移给 p2,p1 变为空
// p2 离开作用域时,自动释放内存——不需要手动 delete
}
这已经很接近”所有权系统”了。但 C++ 的关键问题是:这些规则只是”建议”,编译器不强制执行。你仍然可以:
- 绕过智能指针,直接用
new/delete,让 RAII 形同虚设 - 裸指针和智能指针混用,一片混乱
std::move之后继续访问原对象——编译器不会报错;对象通常仍然有效,但处于“已移动后”的状态,具体内容可能为空或未指定,很容易被误用shared_ptr循环引用导致内存泄漏——编译器也帮不了你
C++ 的所有权更像一本推荐规范——你可以遵守,也可以不遵守。而历史遗留的庞大代码库中,”不遵守”的代码比比皆是。
1.6 Rust 的答案:所有权不是建议,是法律
Rust 从 C++ 的经验中吸取了教训:所有权规则必须由编译器强制执行,否则就毫无意义。
Rust 的做法是:
- 在语言层面定义了三条硬性的所有权规则(见第 3 节)
- 通过**借用检查器(Borrow Checker)**在编译期验证所有代码是否遵守这些规则
- 违反规则的代码直接编译不通过——没有”不小心”,没有”下不为例”
从 C 的手动管理 → C++ 的建议式所有权 → Rust 的强制所有权,演进的脉络很清晰:把内存管理的正确做法从”程序员脑子里的约定”变成”编译器能检查的规则”。
2. Rust 所有权核心三原则
Rust 的所有权系统建立在三条核心规则之上。每一条规则都不是随意设计的——它们各自解决了 C 语言内存管理中的一类具体问题。
原则一:每个值都有一个所有者
每个值在 Rust 中都有一个变量,称为其所有者。(一个值对应一个 owner)
为什么这么设计?——解决“谁负责释放“的问题。
回顾 C 语言的困境:一块 malloc 出来的内存,到底谁应该 free?没有规则,全靠约定。Rust 用这条规则给出了明确的答案:每个值都绑定到一个所有者变量上,这个变量负责该值的最终释放。没有人需要猜测“这块内存归谁管“——编译器帮你追踪,所有者一目了然。
原则二:同一时刻只能有一个所有者
同一时刻只能有一个所有者。(所有权可以转移,但无法共享——此处指默认的独占所有权;共享所有权通过
Rc/Arc另行提供)
为什么这么设计?——解决“双重释放“和“数据竞争“的问题。
如果多个变量同时拥有同一块内存,每个变量离开作用域时都会尝试释放它——这就是双重释放。更隐蔽的是,如果两个所有者分别在不同的线程中修改这块内存,就产生了数据竞争。
Rust 的策略是:默认独占。一个值只有一个所有者,转移所有权后原变量立刻失效(编译器禁止你再使用它)。这从根本上杜绝了“两个人同时管一块内存“的混乱。对于确实需要共享的场景,Rust 提供了 Rc/Arc(引用计数)——这仍然是有规则的共享,而不是 C 语言那种“谁都能拿指针、谁都搞不清谁在管“的无序状态。
原则三:所有者离开作用域时自动释放
当所有者离开作用域,该值被自动丢弃。(自动调用
drop,释放资源)
为什么这么设计?——解决“忘记释放(内存泄漏)“的问题。
C 语言中,你必须在合适的时机手动调用 free。忘了?内存泄漏。在复杂的控制流(提前 return、break、异常路径)中,“合适的时机“往往很难找准。
Rust 的答案是:把释放和所有者的作用域绑定。所有者离开作用域的那一刻,编译器自动插入释放代码——不依赖程序员记得、不依赖运行时 GC、不会遗漏任何一条退出路径。这正是 C++ RAII 思想的彻底贯彻,区别在于 Rust 的编译器会验证所有路径上所有权的正确性。
三原则配合的效果
三条规则单独看都很简单,但组合起来威力巨大:
#![allow(unused)]
fn main() {
{
let s = String::from("hello"); // s 是所有者(原则一:值有了明确的主人)
// 使用 s ...
} // 作用域结束,s 被销毁,String 占用的内存自动释放(原则三:自动回收)
// 在此期间没有其他变量能同时拥有这块内存(原则二:独占,避免冲突)
}
3. Rust 的变量语义:默认移动,而非拷贝
许多主流语言(Python、Java、Go 等)在赋值或传参后,原变量通常仍然可以继续使用:有的语言复制对象引用,有的语言复制值本身,所有权关系大多被运行时或语言规则隐藏起来。Rust 走了另一条路:默认移动语义,把“这个值现在归谁负责”显式体现在类型检查里。
3.1 移动是默认,拷贝需显式声明
赋值或传参时,Rust 的行为取决于类型是否实现了 Copy trait:
- 实现了
Copy的类型(整数、布尔、字符、不可变引用等简单类型):赋值时自动拷贝,开销极小,原变量仍可用。 - 没有实现
Copy的类型(String、Vec、大多数自定义类型):赋值时发生移动——所有权转移,原变量失效。
#![allow(unused)]
fn main() {
// Copy 类型:自动拷贝
let x = 5;
let y = x; // y 是 x 的拷贝,两者独立
println!("x = {}, y = {}", x, y); // x 仍然可用
// 非 Copy 类型:移动
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到 s2
// println!("{}", s1); // 编译错误:s1 已经失效
println!("s2 = {}", s2);
}
3.2 为什么 Rust 默认移动?
回到所有权三原则的第二条:同一时刻只能有一个所有者。如果赋值默认拷贝,对于堆上数据(如 String 底层的字节数组)会发生什么?
#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 如果默认拷贝,s1 和 s2 各有一份堆数据的副本
}
这会带来两个问题:
- 性能陷阱:每次赋值都深拷贝堆数据,代价高昂。GC 语言靠运行时优化(copy-on-write 等)部分缓解,但 Rust 没有 GC。
- 语义混乱:两个变量持有两份”独立”的数据,修改一个不影响另一个——这真的是你想要的行为吗?在很多场景下,你只是想把数据传到另一个地方处理。
以 String / Vec 这类句柄型类型为例,移动时通常只是移动栈上的表示(如指针、长度、容量),不会深拷贝堆上的数据。移动之后,原变量失效,只有新变量拥有这块堆内存。对大多数拥有堆资源的类型来说,这比深拷贝便宜得多;但一般地说,move 仍可能搬动类型的栈上表示,具体成本取决于类型大小。
3.3 如果确实需要深拷贝:显式调用 .clone()
移动是默认、廉价的操作。但如果你确实需要一份独立的、完整的数据副本,就显式调用 .clone():
#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 显式深拷贝:在堆上新分配一块内存,复制数据
// s1 和 s2 现在是两个独立的 String,各自拥有一份 "hello"
println!("s1 = {}, s2 = {}", s1, s2); // 两个变量都能用
let v1 = vec![1, 2, 3];
let v2 = v1.clone(); // Vec 也一样,显式 clone 才做深拷贝
println!("v1 = {:?}, v2 = {:?}", v1, v2);
}
关键认知:Rust 并没有禁止拷贝——它只是把选择权交给了你。默认移动(零成本),需要拷贝时显式写
.clone()(有成本但语义清晰)。如果你发现代码里到处在写.clone(),那通常是一个信号:你的所有权设计可能需要重新考虑——也许你应该用借用(引用)而不是拷贝数据。
4. 借用(Borrowing)—— 暂时看一眼,不抱走
所有权转移(移动)会让原变量失去值,这太严格了。很多时候我们只需要 临时访问 一个值,而不想获取所有权。这就是 借用。
借用:通过引用(
&T或&mut T)访问一个值,而不取得其所有权。
4.1 借用的核心规则
借用有三条核心规则,每一条都是为了阻止一类特定的内存 bug:
规则一:引用的生命周期不能超过被引用值的生命周期
编译器自动检查:引用(
&T/&mut T)的有效范围必须短于被引用值的有效范围。
为什么这么设计?——阻止悬垂指针。 这是最直接的安全保证。如果引用可以比数据活得更久,那引用就变成了悬垂指针(指向已销毁的内存)。Rust 的借用检查器在编译期追踪每个引用和每个值的生命周期关系,一旦发现引用可能“活过“数据本身,直接报错。C 语言中最常见的 use-after-free 在 Rust 中连编译都过不了。
规则二:同一时刻,要么只能有一个可变引用,要么可以有任意多个不可变引用
读(
&T)可以多人同时读;写(&mut T)必须独占,写的时候别人不能读也不能写。
为什么这么设计?——同时阻止两类问题。
第一,数据竞争。如果两个线程(或同一线程的两段代码)同时写同一块内存,或者一个写一个读,结果不可预测。规则二确保了“写是独占的“,从根源消灭了数据竞争的可能。
第二,迭代器失效。在 C++ 中,如果你在遍历一个 vector 的同时修改它(比如 push_back 触发 reallocation),迭代器就会指向被释放的旧内存——这是一个经典的运行时 bug。Rust 通过规则二可以在编译期阻止这种情况:遍历时持有的是不可变引用(&T),在此期间任何试图获取可变引用(&mut T)来修改数据的操作都会被编译器拒绝。
这条规则和数据库的“读写锁“思路一致:多个读锁可以共存,写锁是排他的。区别在于 Rust 把它做在了编译期,零运行时开销。
规则三:引用必须始终有效
不能存在指向已销毁数据的引用。
为什么这么设计?——这是规则一的自然推论。 规则一保证了引用的生命周期更短,规则三从“有效性“的角度补了一刀:不仅生命周期要对,引用指向的数据本身也必须是活着的。这意味着你不能返回局部变量的引用,也不能在数据被移动/释放后还保留它的引用。编译器在每一条代码路径上验证这一点。
4.2 借用的分类
| 类型 | 语法 | 允许的操作 | 是否允许其他引用同时存在 |
|---|---|---|---|
| 不可变引用 | &T | 读(只读) | 可以有多个 |
| 可变引用 | &mut T | 读写 | 只能有一个 |
4.3 基本语法和示例
4.3.1 不可变引用
fn main() {
let s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &s; // 可以创建多个
println!("{}, {}", r1, r2);
// s 的所有权没有转移,仍然可以使用 s
} // r1, r2 超出作用域,但不会释放 s 的内存(因为只是借用)
4.3.2 可变引用
fn main() {
let mut s = String::from("hello");
let r = &mut s; // 可变引用
r.push_str(", world");
println!("{}", r);
// 在 r 存在期间,不能有其他任何引用(包括不可变引用)
}
4.3.3 错误示例:可变引用与不可变引用混用
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &mut s; // 错误:不能同时存在可变引用和不可变引用
println!("{}, {}", r1, r2);
}
4.4 深入分析所有权机制(R/W/O 模型)
我们可以用一个通俗的模型来理解所有权和借用的权限:
假设一个值有三类权限:
- R(Read):读取值的内容
- W(Write):修改值的内容
- O(Own):决定值的生命周期(释放它)
- 完全所有权:拥有 R + W + O 全部权限。
- 不可变借用:只借出 R 权限(读),不能写,不能释放。
- 可变借用:借出 R + W 权限(读写),但不能释放(O 依然属于原所有者)。
4.4.1 分析示例一:多个不可变引用同时存在
fn main() {
let mut data = vec![1, 2, 3]; // data: R + W + O
let r1 = &data; // r1: R
let r2 = &data; // r2: R (第二个不可变引用)
// 此时有两个不可变引用,data 自身的 W 被冻结
// data.push(4); // 错误:不能写,因为有不可变借用
println!("r1: {:?}, r2: {:?}", r1, r2); // 读操作 OK
// r1, r2 离开作用域后,data 的 W 恢复
}
权限分析:
data原本拥有 R+W+O。r1和r2各自借出 R(只读)。- 由于存在不可变借用,
data的 W 被冻结,直到所有不可变引用结束。 - O 始终留在
data手中,值的生命周期由其决定。
结论:多个不可变引用可以共存,每个都只读;原所有者的写权限在此期间被剥夺。
4.4.2 分析示例二:可变引用期间通过原所有者访问(被阻止)
fn main() {
let mut data = vec![1, 2, 3]; // data: R + W + O
let r = &mut data; // r: R + W (没有 O)
// 尝试通过原所有者读取或写入 —— 全部被禁止
// println!("{:?}", data); // 错误:不能读,因为存在可变借用
// data.push(4); // 错误:不能写,因为存在可变借用
r.push(4); // 只能通过可变引用修改
println!("{:?}", r); // 通过可变引用读取
// r 结束后,data 的 R+W 恢复
}
权限分析:
- 可变引用
r拿走了 R+W,但没有拿走 O。 - 在此期间,原所有者
data的 R 和 W 完全被剥夺(即使它名义上还有 O,但不可访问),这是为了独占写的安全保证。 - 只有
r能读写数据。 结论:可变引用存在时,原所有者完全冻结(不可读不可写);所有访问必须通过可变引用。
4.4.3 分析示例三:借用结束后权限自动恢复
fn main() {
let mut data = vec![1, 2, 3]; // data: R + W + O
// 第一阶段:不可变借用
{
let r = &data; // r: R
println!("{:?}", r); // 只读,OK
} // r 离开作用域,借用结束
// 第二阶段:可变借用
{
let r = &mut data; // r: R + W
r.push(4);
} // r 离开作用域,借用结束
// 第三阶段:完全所有权恢复
data.push(5); // 可以直接修改
println!("{:?}", data); // 可以直接读取
}
权限分析:
- 每个借用只在其作用域内生效。
- 借用结束后,原所有者自动恢复全部 R+W+O 权限。
- 不同借用阶段可以交替使用,只要不重叠。 结论:借用是临时的,作用域结束即归还权限;所有者始终保留 O,并在无借用时恢复全部操作能力。
4.4.4 不同借用类型对应的规则表
| 场景 | 是否存在不可变引用 | 是否存在可变引用 | 是否允许通过所有者修改 | 是否允许通过所有者读取 |
|---|---|---|---|---|
| 无借用 | 否 | 否 | ✅ 允许 | ✅ 允许 |
| 有不可变引用 | 是(≥1个) | 否 | ❌ 禁止(冻结) | ✅ 允许 |
| 有可变引用 | 否 | 是(1个) | ❌ 禁止(所有权暂时“托管”) | ❌ 禁止(除非通过可变引用) |
这个模型清晰地解释了为什么 Rust 能避免数据竞争:读和写不能同时存在。
5. 实验代码示例
//! # Rust 所有权与借用完整实验示例
//!
//! 本程序演示 Rust 所有权系统的各种核心机制。
//! 每个测试函数都独立演示一个特定场景。
//!
//! 运行方式:
//! 1. 复制整个代码到 `main.rs` 或 `lib.rs`
//! 2. 执行 `cargo run` 或 `rustc main.rs && ./main`
//!
//! 注意:部分函数(如违反借用规则的示例)会编译失败,
//! 它们已被注释掉或在 `main` 中没有调用。你可以手动取消注释来观察编译器错误。
// ========================= 1. 所有权移动 =========================
/// 测试1:基本类型与堆类型的移动差异
///
/// 行为:
/// - `i32` 实现了 `Copy` trait,赋值时会**拷贝**,原变量仍可用。
/// - `String` 未实现 `Copy`,赋值时发生**移动**,原变量失效。
///
/// 运行结果:编译通过,输出正常。
fn test_move_and_copy() {
println!("\n=== 1. 移动 vs 拷贝 ===");
// i32 是 Copy 类型
let x = 42;
let y = x; // 拷贝,x 仍然有效
println!("x = {}, y = {}", x, y);
// String 是非 Copy 类型
let s1 = String::from("hello");
let s2 = s1; // 移动所有权,s1 不再有效
// println!("{}", s1); // 取消注释会编译错误:value used here after move
println!("s2 = {}", s2);
}
/// 测试2:函数传参发生所有权移动
///
/// 行为:将变量传入函数时,所有权会移动到函数参数中。
/// 一旦移动,原变量无法再使用(除非函数将所有权返回)。
///
/// 运行结果:编译通过,展示移动后无法使用原变量。
fn take_ownership(s: String) {
println!("函数获得所有权: {}", s);
} // s 在这里被自动 drop
fn test_function_move() {
println!("\n=== 2. 函数传参移动所有权 ===");
let s = String::from("Rust");
take_ownership(s);
// println!("{}", s); // 编译错误:s 的所有权已转移
}
/// 测试3:函数返回所有权
///
/// 行为:函数返回 String 时,所有权从函数内部移出到调用者。
/// 这允许动态创建数据而不丢失所有权。
fn give_ownership() -> String {
let inner = String::from("Created inside");
inner // 所有权移出
}
fn test_return_ownership() {
println!("\n=== 3. 函数返回所有权 ===");
let s = give_ownership();
println!("得到所有权: {}", s);
// 现在 s 持有所有权,离开作用域时释放
}
// ========================= 2. 借用(引用) =========================
/// 测试4:不可变借用(只读访问)
///
/// 行为:使用 `&T` 创建不可变引用,可以同时存在多个。
/// 在不可变引用存在期间,不能修改原变量。
///
/// 运行结果:编译通过,输出正确。
fn test_immutable_borrow() {
println!("\n=== 4. 不可变借用 ===");
let s = String::from("readonly");
let r1 = &s;
let r2 = &s;
println!("r1 = {}, r2 = {}", r1, r2);
// s.push_str("!"); // 编译错误:不能修改,因为有不可变借用
// r1 和 r2 离开作用域后,s 才可修改
}
/// 测试5:可变借用(读写访问)
///
/// 行为:使用 `&mut T` 创建可变引用,同一时刻只能有一个。
/// 可变引用允许修改数据,但在其存活期间不能有其他任何引用(包括不可变引用)。
///
/// 运行结果:编译通过,数据被修改。
fn test_mutable_borrow() {
println!("\n=== 5. 可变借用 ===");
let mut s = String::from("hello");
{
let r = &mut s;
r.push_str(", world"); // 通过可变引用修改
println!("内部修改后: {}", r);
} // r 离开作用域,可变借用结束
println!("外部原变量: {}", s); // 现在可以访问原变量
}
/// 测试6:违反借用规则(演示编译错误)
///
/// 下面的代码会编译失败,演示不能同时拥有可变引用和不可变引用。
/// 取消注释来观察错误。
#[allow(dead_code)]
fn test_illegal_borrow_combining() {
let mut s = String::from("conflict");
let r1 = &s; // 不可变借用
let r2 = &mut s; // 尝试创建可变借用 → 编译错误
println!("{}, {}", r1, r2);
}
// 更安全的写法:分开作用域
fn test_legal_borrow_scope() {
println!("\n=== 6. 借用作用域分离 ===");
let mut s = String::from("scope");
{
let r1 = &s;
println!("不可变借用: {}", r1);
} // r1 结束
let r2 = &mut s;
r2.push_str(" modified");
println!("可变借用后: {}", r2);
}
// ========================= 3. 生命周期与悬垂引用 =========================
/// 测试7:悬垂引用(编译错误)
///
/// 尝试返回局部变量的引用,这会导致悬垂指针。
/// Rust 编译期会拒绝。
///
/// 以下函数无法编译,因为 `s` 在函数返回时被销毁。
#[allow(dead_code)]
fn dangling_reference() -> &String {
let s = String::from("I will die");
&s // 错误:返回局部变量的引用
}
/// 测试8:正确的返回方式 — 返回所有权而非引用
///
/// 通过返回 `String`(转移所有权),而不是返回引用,避免悬垂。
fn no_dangle() -> String {
let s = String::from("I live on");
s // 所有权移出
}
fn test_no_dangle() {
println!("\n=== 7. 避免悬垂引用:返回所有权 ===");
let s = no_dangle();
println!("{}", s);
}
// ========================= 4. 借用作为函数参数 =========================
/// 测试9:函数接受不可变引用
///
/// 这样可以借用而不获取所有权,调用后原变量仍可用。
fn print_length(s: &String) {
println!("字符串长度: {}", s.len());
} // s 是引用,不会 drop 原数据
fn test_borrow_as_param() {
println!("\n=== 8. 函数参数借用 ===");
let s = String::from("Hello, world!");
print_length(&s); // 传递不可变引用
println!("原变量仍可用: {}", s);
}
/// 测试10:函数接受可变引用
///
/// 函数可以直接修改传入的数据。
fn add_suffix(s: &mut String, suffix: &str) {
s.push_str(suffix);
}
fn test_mutable_borrow_param() {
println!("\n=== 9. 可变引用作为参数 ===");
let mut s = String::from("Hello");
add_suffix(&mut s, "!!!");
println!("修改后: {}", s);
}
// ========================= 5. 切片引用 &str 的特殊性 =========================
/// 测试11:String 与 &str 的关系
///
/// `&str` 是对字符串字面量或 String 某一部分的不可变引用(字符串切片)。
/// 它本身是一个引用,所以也遵循借用规则。
fn test_string_slice() {
println!("\n=== 10. 字符串切片引用 &str ===");
let s = String::from("Rust programming");
let hello = &s[0..4]; // &str 切片
println!("切片: {}", hello);
// 切片借用期间,原 String 不能修改
// s.clear(); // 编译错误:不能修改,因为还有切片引用存在
// 但切片结束后可以
drop(hello); // 显式结束借用(通常不需要,作用域结束自动结束)
s.clear(); // 现在可以了
println!("清空后: '{}'", s);
}
// ========================= 6. 自定义结构体所有权 =========================
struct Person {
name: String,
age: u32,
}
impl Person {
/// 方法:不可变借用 self
fn introduce(&self) {
println!("我叫 {},今年 {} 岁。", self.name, self.age);
}
/// 方法:可变借用 self,可以修改字段
fn have_birthday(&mut self) {
self.age += 1;
println!("生日快乐!现在 {} 岁了。", self.age);
}
/// 方法:取得所有权(很少用,演示用)
fn destroy(self) {
println!("{} 被销毁了", self.name);
// self 在此函数结束时被 drop
}
}
fn test_struct_ownership() {
println!("\n=== 11. 结构体与所有权 ===");
let mut p = Person {
name: String::from("Alice"),
age: 30,
};
p.introduce(); // 不可变借用
p.have_birthday(); // 可变借用
// 尝试使用 p 的字段
println!("再次访问: {} 现在 {} 岁", p.name, p.age);
// 如果调用 p.destroy(),p 的所有权会移入函数,之后 p 失效
// 这里演示但不调用,以免破坏后续示例。
// p.destroy();
// println!("{}", p.name); // 编译错误
}
// ========================= 7. 常见陷阱:可变引用独占性 =========================
/// 测试12:试图在可变引用存续期间访问原变量
///
/// 尽管原变量仍然存在,但可变借用期间不允许通过原变量读写。
fn test_mut_borrow_blocks_owner() {
println!("\n=== 12. 可变借用期间原变量被冻结 ===");
let mut data = 100;
{
let r = &mut data;
*r += 50;
// println!("原变量 = {}", data); // 编译错误:不能读取 data
println!("通过可变引用读取 = {}", r);
} // r 结束
println!("可变借用结束后,原变量 = {}", data);
}
// ========================= 8. 解引用与内部可变性(Cell/RefCell 简介) =========================
/// 测试13:不可变变量也可以借用可变引用吗?—— 不能,必须声明 mut
///
/// 以下代码编译错误。
#[allow(dead_code)]
fn test_cannot_borrow_mut_from_immut() {
let x = 5;
let y = &mut x; // 错误:不能对不可变变量创建可变引用
}
/// 测试14:对引用解引用修改值
///
/// 使用 `*` 解引用操作符来修改可变引用指向的值。
fn test_deref_mut() {
println!("\n=== 13. 解引用修改 ===");
let mut x = 10;
let r = &mut x;
*r = 20; // 解引用并赋值
println!("修改后 x = {}", x);
}
// ========================= 主函数:选择性执行 =========================
fn main() {
println!("=============================================");
println!("Rust 所有权与借用完整实验套件");
println!("=============================================");
// 所有可以正常编译运行的测试函数
test_move_and_copy();
test_function_move();
test_return_ownership();
test_immutable_borrow();
test_mutable_borrow();
test_legal_borrow_scope();
test_no_dangle();
test_borrow_as_param();
test_mutable_borrow_param();
test_string_slice();
test_struct_ownership();
test_mut_borrow_blocks_owner();
test_deref_mut();
// 以下函数会编译失败,已注释。取消注释可查看编译器错误。
// test_illegal_borrow_combining();
// dangling_reference();
// test_cannot_borrow_mut_from_immut();
println!("\n=============================================");
println!("所有可运行的测试完成!");
println!("尝试修改部分代码,观察编译器的借用检查。");
println!("=============================================");
}
5.1 实验:test_move_and_copy
#![allow(unused)]
fn main() {
fn test_move_and_copy() {
println!("\n=== 1. 移动 vs 拷贝 ===");
// i32 是 Copy 类型
let x = 42;
let y = x; // 拷贝,x 仍然有效
println!("x = {}, y = {}", x, y);
// String 是非 Copy 类型
let s1 = String::from("hello");
let s2 = s1; // 移动所有权,s1 不再有效
// println!("{}", s1); // 取消注释会编译错误:value used here after move
println!("s2 = {}", s2);
}
}
- 实验:分别对
i32和String类型变量赋值给新变量,然后尝试使用原变量。 - 现象:
i32赋值后原变量x仍可正常打印;String赋值后,若取消注释println!("{}", s1)则编译报错value used here after move。 - 分析:
i32实现了Copytrait——它只占 4 字节栈空间,没有堆资源,编译器直接按位拷贝,两个变量完全独立。String未实现Copy,因为它内部持有指向堆内存的指针。赋值时,栈上的“指针+长度+容量“三元组被拷贝到s2,同时编译器将s1标记为“已移动“(moved),后续任何对s1的访问都被静态禁止。这直接体现了所有权原则二:同一时刻只有一个所有者。 - 结论:实现了
Copy的类型赋值时自动拷贝,原变量继续有效;非Copy类型赋值时发生所有权移动,移动后原变量失效,由编译器静态保证不会出现“使用已移动值“。
5.2 实验:test_function_move
#![allow(unused)]
fn main() {
fn take_ownership(s: String) {
println!("函数获得所有权: {}", s);
} // s 在这里被自动 drop
fn test_function_move() {
println!("\n=== 2. 函数传参移动所有权 ===");
let s = String::from("Rust");
take_ownership(s);
// println!("{}", s); // 编译错误:s 的所有权已转移
}
}
- 实验:将
String变量s作为参数传入take_ownership函数,函数返回后尝试使用s。 - 现象:函数内部正常打印;函数外部使用
s的代码若取消注释则编译报错use of moved value。 - 分析:函数参数也是变量,传参等价于赋值——
let s_param = s;的移动语义在此同样生效。s的所有权移入了函数参数,函数结束后参数离开作用域,String被drop。这展示了所有权原则三(所有者离开作用域自动释放)在函数边界的自然延伸:函数获得所有权 → 函数结束时释放,整个过程清晰、无泄漏。 - 结论:函数传参同样遵循移动语义。参数获得了值的所有权,调用者失去了所有权。如果调用者后续还需要使用该值,应改用借用(传引用)。
5.3 实验:test_return_ownership
#![allow(unused)]
fn main() {
fn give_ownership() -> String {
let inner = String::from("Created inside");
inner // 所有权移出(注意没有分号,是表达式返回)
}
fn test_return_ownership() {
println!("\n=== 3. 函数返回所有权 ===");
let s = give_ownership();
println!("得到所有权: {}", s);
// 现在 s 持有所有权,离开作用域时释放
}
}
- 实验:编写函数
give_ownership,内部创建String并返回,调用者接收返回值并继续使用。 - 现象:调用者正常获得并使用返回的
String,编译通过,无泄漏。 - 分析:所有权可以跨越函数边界“向外“流动。
inner在函数内部创建,它的所有权通过返回值转移给了调用者s。整个过程没有拷贝堆数据,移动的只是栈上的指针。这是 Rust 安全传递堆数据的核心机制——函数可以“生产“数据并把所有权交给调用者,调用者继续管理数据的生命周期。 - 结论:所有权可以沿函数返回值向外转移。配合移动语义,这是一种零成本的堆数据传递方式——数据在堆上不动,只有“所有权“(栈上的指针)在动。
5.4 实验:test_immutable_borrow
#![allow(unused)]
fn main() {
fn test_immutable_borrow() {
println!("\n=== 4. 不可变借用 ===");
let s = String::from("readonly");
let r1 = &s;
let r2 = &s;
println!("r1 = {}, r2 = {}", r1, r2);
// s.push_str("!"); // 编译错误:不能修改,因为有不可变借用
// r1 和 r2 离开作用域后,s 才可修改
}
}
- 实验:对
String创建两个不可变引用&s(r1、r2),同时打印它们,然后尝试修改原变量。 - 现象:
r1和r2均可正常打印;若取消注释s.push_str("!")则编译报错cannot borrow s as mutable because it is also borrowed as immutable。 - 分析:
r1和r2各自借出了 R 权限(只读),在此期间data的 W 权限被冻结。Rust 允许多个不可变引用共存,因为多人同时读不会产生数据竞争。但一旦有人持有不可变引用,任何修改操作都被禁止——这防止了“读的时候数据被改了“的问题(即迭代器失效的根源)。这正是借用规则二的前半部分:“可以有任意多个不可变引用。” - 结论:不可变引用允许共享读取,多个可共存;但在不可变引用存在期间,原变量被冻结,不能修改。
5.5 实验:test_mutable_borrow
#![allow(unused)]
fn main() {
fn test_mutable_borrow() {
println!("\n=== 5. 可变借用 ===");
let mut s = String::from("hello");
{
let r = &mut s;
r.push_str(", world"); // 通过可变引用修改
println!("内部修改后: {}", r);
} // r 离开作用域,可变借用结束
println!("外部原变量: {}", s); // 现在可以访问原变量
}
}
- 实验:对可变的
String在内部作用域中创建可变引用&mut s,通过引用修改内容,引用结束后再访问原变量。 - 现象:内部作用域中通过
r修改成功;r离开作用域后,原变量s可正常访问,内容已更新为修改后的值。 - 分析:
r借出了 R+W 权限(读写),O(释放权)始终在s手上。根据借用规则二的后半部分:“同一时刻只能有一个可变引用”,在r存活期间,其他任何对s的访问都被禁止。当r离开作用域,R+W 权限自动归还给s,原变量恢复完整的所有权。这种“作用域隔离“避免了写冲突。 - 结论:可变引用在作用域内独占读写权限;引用结束后权限自动归还,原变量恢复可用。通过作用域控制可变引用的存活范围是 Rust 的常用模式。
5.6 实验:test_illegal_borrow_combining(编译失败)
#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn test_illegal_borrow_combining() {
let mut s = String::from("conflict");
let r1 = &s; // 不可变借用
let r2 = &mut s; // 尝试创建可变借用 → 编译错误
println!("{}, {}", r1, r2);
}
}
- 实验:不取消注释,观察代码结构。尝试在已有不可变引用
r1的情况下创建可变引用r2。 - 现象:此函数无法通过编译,报错
cannot borrow s as mutable because it is also borrowed as immutable。 - 分析:这是借用规则二的最直接演示——不可变引用(读者)和可变引用(写者)不能同时存在。如果允许这种代码,
r1读到的数据可能被r2同时修改,产生数据竞争或逻辑错误。Rust 在编译期直接禁止了这种模式,不需要运行时锁。 - 结论:Rust 的借用规则阻止可变引用与不可变引用共存。这不是语言的“限制“,而是数据安全的“保证“。
5.7 实验:test_legal_borrow_scope
#![allow(unused)]
fn main() {
fn test_legal_borrow_scope() {
println!("\n=== 6. 借用作用域分离 ===");
let mut s = String::from("scope");
{
let r1 = &s;
println!("不可变借用: {}", r1);
} // r1 结束
let r2 = &mut s;
r2.push_str(" modified");
println!("可变借用后: {}", r2);
}
}
- 实验:先用独立作用域创建不可变引用并结束它,再创建可变引用修改数据。
- 现象:编译通过,先正常打印不可变引用,后成功修改字符串。
- 分析:借用检查器检查的是同一时刻存在的引用,而不是整个函数的引用历史。
r1在内部作用域结束时归还了 R 权限,之后s的 W 权限恢复,此时再创建r2不会有任何冲突。这个技巧极其常用:用{}创建一个临时作用域来“提前结束“某个引用的生命周期——不需要等到函数末尾。 - 结论:借用检查器只关注同时存在的引用;通过作用域分离,可以依次使用不可变和可变引用,互不干扰。
5.8 实验:dangling_reference(编译失败)
#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn dangling_reference() -> &String {
let s = String::from("I will die");
&s // 错误:返回局部变量的引用
}
}
- 实验:观察
dangling_reference函数——创建一个局部String,尝试返回它的引用&s。 - 现象:此函数无法通过编译,报错
cannot return reference to local variable s。 - 分析:函数返回时,局部变量
s会被销毁(调用drop),其堆内存被释放。如果允许返回&s,调用者将拿到一个指向已释放内存的悬垂指针。这正是借用规则一(引用的生命周期不能超过被引用值)在函数边界的体现——编译器推断出&s的生命周期长于s本身(因为引用要被返回出去),违反了规则,直接拒绝编译。 - 结论:Rust 编译期阻止悬垂引用的产生。要安全地从函数传出数据,应该返回所有权(下一个实验),而不是返回局部变量的引用。
5.9 实验:test_no_dangle
#![allow(unused)]
fn main() {
fn no_dangle() -> String {
let s = String::from("I live on");
s // 所有权移出
}
fn test_no_dangle() {
println!("\n=== 7. 避免悬垂引用:返回所有权 ===");
let s = no_dangle();
println!("{}", s);
}
}
- 实验:调用
no_dangle(),它内部创建String后直接返回(转移所有权),调用者接收并打印。 - 现象:编译通过,正常打印
"I live on"。 - 分析:与 5.8 形成对比——
no_dangle返回的是String本身(所有权),而非引用。函数内部的s将所有权移出到调用者,调用者的s成为新的所有者。堆上的数据没有移动,移动的只是“所有权令牌“。调用者负责管理接收到的数据的生命周期,一切责任明确。 - 结论:返回所有权是安全传递数据的正确方式。如果函数“生产“了数据,就应该把所有权交给调用者。如果只是想“借阅“数据,使用借用参数(下一个实验)。
5.10 实验:test_borrow_as_param
#![allow(unused)]
fn main() {
fn print_length(s: &String) {
println!("字符串长度: {}", s.len());
} // s 是引用,不会 drop 原数据
fn test_borrow_as_param() {
println!("\n=== 8. 函数参数借用 ===");
let s = String::from("Hello, world!");
print_length(&s); // 传递不可变引用
println!("原变量仍可用: {}", s);
}
}
- 实验:定义
print_length接受&String(不可变引用),调用后原变量继续使用。 - 现象:函数内成功读取字符串长度;函数外
s仍可用,打印正常。 - 分析:传引用(
&s)不转移所有权,print_length只是临时借用了 R 权限(读)。函数结束时引用离开作用域,R 权限归还给s,不会触发drop。这是 Rust 中最常见的参数传递方式——当你只需要“看一眼“数据而不想抢走所有权时,传&T。 - 结论:不可变引用作为函数参数是“借阅“数据的标准方式——调用者保留所有权,被调者临时读取,互不侵犯。
5.11 实验:test_mutable_borrow_param
#![allow(unused)]
fn main() {
fn add_suffix(s: &mut String, suffix: &str) {
s.push_str(suffix);
}
fn test_mutable_borrow_param() {
println!("\n=== 9. 可变引用作为参数 ===");
let mut s = String::from("Hello");
add_suffix(&mut s, "!!!");
println!("修改后: {}", s);
}
}
- 实验:定义
add_suffix接受&mut String,调用后观察原变量的变化。 - 现象:函数内成功追加了
"!!!";调用后原变量s的内容已变为"Hello!!!"。 - 分析:
&mut s将 R+W 权限临时借给函数,函数通过可变引用修改了堆上的字符串内容。函数返回后 R+W 权限归还给s,调用者继续持有完整所有权。整个过程没有拷贝数据,也没有转移所有权。这和 5.10 的不可变借用的区别仅在于权限——&mut允许修改,因此编译器要求s必须声明为mut,且在add_suffix(&mut s, ...)调用期间不能有其他引用存在。 - 结论:可变引用参数允许函数修改外部数据而不获取所有权。这是 Rust 中“让函数帮你改东西“的标准方式。
5.12 实验:test_string_slice
#![allow(unused)]
fn main() {
fn test_string_slice() {
println!("\n=== 10. 字符串切片引用 &str ===");
let s = String::from("Rust programming");
let hello = &s[0..4]; // &str 切片
println!("切片: {}", hello);
// 切片借用期间,原 String 不能修改
// s.clear(); // 编译错误:不能修改,因为还有切片引用存在
drop(hello); // 显式结束借用(通常不需要,作用域结束自动结束)
s.clear(); // 现在可以了
println!("清空后: '{}'", s);
}
}
- 实验:对
String取切片得到&str(通过&s[0..4]),在切片存活期间尝试修改原String,然后drop切片后再修改。 - 现象:切片正常打印;
s.clear()在切片存活期间若被取消注释则编译报错;显式drop(hello)提前结束切片借用后,s.clear()编译通过。 - 分析:
&str(字符串切片)本质是不可变引用——它不拥有数据,只是指向String内部某段字节范围的“窗口“。所以切片同样遵循借用规则二:存在不可变引用(切片)时,原变量不能修改。drop(hello)是 Rust 中显式提前结束引用生命周期的技巧,通常不需要——让作用域自然结束即可。 - 结论:切片是引用的一种,完全遵守借用规则。切片存在时原集合不能修改;切片结束后立即恢复。
5.13 实验:test_struct_ownership
#![allow(unused)]
fn main() {
struct Person {
name: String,
age: u32,
}
impl Person {
fn introduce(&self) {
println!("我叫 {},今年 {} 岁。", self.name, self.age);
}
fn have_birthday(&mut self) {
self.age += 1;
println!("生日快乐!现在 {} 岁了。", self.age);
}
fn destroy(self) {
println!("{} 被销毁了", self.name);
// self 在此函数结束时被 drop
}
}
fn test_struct_ownership() {
println!("\n=== 11. 结构体与所有权 ===");
let mut p = Person {
name: String::from("Alice"),
age: 30,
};
p.introduce(); // 不可变借用
p.have_birthday(); // 可变借用
println!("再次访问: {} 现在 {} 岁", p.name, p.age);
// p.destroy(); // 若调用,p 的所有权被消耗,之后 p 失效
}
}
- 实验:定义包含
String字段的结构体Person,提供&self、&mut self和self三种方法签名,依次调用。 - 现象:
introduce()(&self)可读字段;have_birthday()(&mut self)可修改age;两次调用后p仍可用。若取消注释p.destroy()(self签名),则p的所有权被消耗,后续不能再使用。 - 分析:三种方法签名对应三种所有权级别:
&self= 不可变借用,允许多个同时存在,只读不写,调用后实例仍可用。&mut self= 可变借用,独占读写权限,调用后实例仍可用(借用归还)。self= 获取所有权,调用后实例被消耗(move 进方法),方法结束时实例被drop。实际开发中极少使用。 结构体的字段各自独立遵循所有权规则——name: String不可 Copy,age: u32可 Copy。
- 结论:方法签名精确控制对结构体的权限级别。
&self和&mut self是惯用写法,self(消耗式)仅在少数场景(如构建器模式的build())中使用。
5.14 实验:test_mut_borrow_blocks_owner
#![allow(unused)]
fn main() {
fn test_mut_borrow_blocks_owner() {
println!("\n=== 12. 可变借用期间原变量被冻结 ===");
let mut data = 100;
{
let r = &mut data;
*r += 50;
// println!("原变量 = {}", data); // 编译错误:不能读取 data
println!("通过可变引用读取 = {}", r);
} // r 结束
println!("可变借用结束后,原变量 = {}", data);
}
}
- 实验:在可变引用
r存活期间,尝试通过原变量名data读取数据。 - 现象:通过
r读取和修改正常;若取消注释println!("原变量 = {}", data)则编译报错;r离开作用域后data恢复可访问,值已变为 150。 - 分析:可变借用期间,原变量的 R+W 权限被完全剥夺——不仅不能写,也不能读。这是 Rust 比许多语言的读写锁模型更严格的地方:写锁期间连读都被禁止。为什么?因为如果允许通过原变量读取,编译器就无法保证可变引用“独占“的语义——可能存在两条路径同时访问同一数据。这种“要么全部通过引用访问,要么就等引用结束“的强制约定,彻底消除了“以为只有一个引用在操作数据“的隐患。
- 结论:可变借用期间,原变量被完全冻结(不可读不可写);所有访问必须通过可变引用;引用结束后原变量自动恢复。这一机制保证了可变引用的独占性是绝对且可验证的。
5.15 实验:test_cannot_borrow_mut_from_immut(编译失败)
#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn test_cannot_borrow_mut_from_immut() {
let x = 5;
let y = &mut x; // 错误:不能对不可变变量创建可变引用
}
}
- 实验:观察此函数——对声明为不可变(
let x = 5,无mut)的变量尝试创建可变引用。 - 现象:编译失败,报错
cannot borrow x as mutable。 - 分析:可变引用意味着“我有权通过这个引用修改数据“。但
x本身被声明为不可变,这意味着“我不希望x的值被改变“。两者矛盾。Rust 不允许通过引用来绕过变量的不可变性声明——mut不仅是编译器的提示,更是安全承诺:如果变量没有mut,那么没有任何方式可以修改它的值。这保证了代码阅读者看到let x = ...(无mut)时,就能确信x在后续代码中不会发生变化。 - 结论:
mut声明是创建可变引用的前提。不可变变量 = 完全不可变,无法通过任何引用“走后门“修改。
5.16 实验:test_deref_mut
#![allow(unused)]
fn main() {
fn test_deref_mut() {
println!("\n=== 13. 解引用修改 ===");
let mut x = 10;
let r = &mut x;
*r = 20; // 解引用并赋值
println!("修改后 x = {}", x);
}
}
- 实验:创建可变引用
r指向x,使用*r = 20修改其指向的值,然后读取x。 - 现象:
*r = 20执行后,println输出修改后 x = 20,证明x确实被修改了。 - 分析:
r是&mut i32类型,它本身是一个引用(一个指向x的指针)。要修改引用指向的值,必须用*(解引用操作符)“穿透“引用到达目标。*r = 20等价于“找到r指向的那块内存,把 20 写进去”。在 Rust 中,对&mut T的解引用修改是唯一能改变引用目标值的方式。编译器确保了在r存活期间,x本身被冻结(见 5.14),所以*r = 20和后续println!("x = {}", x)之间没有冲突——后者在r已经离开作用域之后才执行。 - 结论:通过可变引用修改数据需要显式解引用(
*r)。解引用是“穿透引用操作目标“的语法,Rust 的借用检查器保证解引用操作的安全性。
rust 智能指针
1. 什么是智能指针
在 Rust 中,普通指针(引用 &T / &mut T)只是借用数据,不拥有数据,生命周期受限于被引用值。
智能指针则是一种”升级版”的指针:它拥有指向的数据,并在此基础上附加了自动化的内存管理能力。使用体验上,智能指针和普通指针一样——可以通过 * 解引用访问内部数据,也可以通过 . 调用内部类型的方法。但在幕后,智能指针自动处理了内存的分配和释放,程序员不需要手动管理。
简单定义:智能指针 = 和普通指针一样的使用体验 + 半自动化的内存管理 + 额外的元数据和控制能力。
Rust 标准库提供了四种核心智能指针:
| 类型 | 一句话描述 |
|---|---|
Box<T> | 把数据分配在堆上,独占所有权 |
Rc<T> | 引用计数共享所有权,单线程 |
Weak<T> | Rc 的弱引用,打破循环引用 |
Arc<T> | 原子引用计数共享所有权,引用计数可跨线程安全使用 |
在深入每个智能指针之前,必须先理解它们共同的理论基础——RAII,以及它们要解决的所有权系统的局限性。
2. 智能指针的核心思想:RAII
2.1 什么是 RAII
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 提出的一种资源管理思想,Rust 完全继承并强化了它。
核心逻辑:
- 资源的获取(内存分配、文件打开、锁获取等)在对象**初始化(构造)**时完成。
- 资源的释放(内存释放、文件关闭、锁释放等)在对象**离开作用域(析构)**时自动完成。
- 资源绑定到某个值的生命周期上。当这个值离开作用域时,释放逻辑自动执行;如果多个局部值按作用域退出,释放顺序也会被确定地安排。
用一个 C++ 的例子来直观感受:
// 没有 RAII:手动管理,容易出错
void bad_example() {
int* p = new int(10); // 分配
// ... 如果这里发生异常或提前 return
delete p; // 这一行可能永远不会执行 → 内存泄漏
}
// 使用 RAII:std::unique_ptr
#include <memory>
void good_example() {
auto p = std::make_unique<int>(10); // 构造 = 分配
// ... 即使发生异常
} // 正常离开作用域或异常展开时,析构函数自动 delete
RAII 的核心价值在于:把”手动记得释放”变成了”作用域结束时自动释放”,大幅减少因忘记释放导致的泄漏。
2.2 Rust 中的 RAII:Drop trait
Rust 没有 C++ 那种构造函数 / 析构函数的语法,而是通过 Drop trait 来实现 RAII:
#![allow(unused)]
fn main() {
// Drop trait 的定义(标准库中)
pub trait Drop {
fn drop(&mut self); // 在值离开作用域时自动调用
}
}
当一个实现了 Drop 的值离开作用域时,编译器会自动调用 drop 方法,完成资源清理。这个过程是:
- 确定性的:你知道它一定会在作用域结束时执行。
- 覆盖正常退出路径:即使有提前
return、?提前传播错误,编译器也会在退出路径上插入drop调用。默认的 unwind panic 也会展开栈并执行析构;如果项目配置为panic = "abort",进程会直接终止,不会逐层运行析构。 - 只调用一次:编译器保证不会被重复释放。
以 Box<T> 为例:
fn main() {
let b = Box::new(42); // 堆上分配内存,b 是这片内存的所有者
// ... 使用 *b 访问数据
} // b 离开作用域 → Box 的 drop 自动被调用 → 堆内存被释放
2.3 Rust 对初学者隐藏了直接的内存操作
一个值得注意的事实:在 safe Rust 中,你无法直接调用类似 C 语言的 malloc / free 这样的内存分配函数。
在 C 语言中学习内存管理时,你亲手写 malloc 和 free,能直观感受到”这块内存是我申请的,我得负责还回去”。但 Rust 刻意隐藏了这些操作——Box::new(v) 背后发生了什么?堆上怎么分配的?drop 到底做了什么?这些细节被封装在了标准库的实现中。
这种隐藏是一把双刃剑:
- 好的方面:程序员不需要关心底层细节,写出来的代码默认就是内存安全的。
- 不好的方面:初学者难以建立”内存在堆上还是在栈上”的直观认知,也难以理解所有权规则为什么要那么严格——因为你感受不到”我申请了内存,不还就会泄漏”的紧迫感。
正是因为 Rust 隐藏了
malloc/free,所以本教程在讲解所有权(上一章)和智能指针时,会反复借用 C/C++ 的例子来”揭开盖子”——让你看到这些 Rust 类型背后实际在做的事情,建立起”内存到底在哪里、谁在管”的心智模型。
3. 为什么需要智能指针——从所有权系统的局限性出发
回顾上一章学到的所有权核心原则:
- 每个值有一个所有者
- 同一时刻只能有一个所有者(默认独占)
- 所有者离开作用域,值自动释放
这三条规则覆盖了大多数场景,而且是零成本的。但有些场景下,它们不够灵活:
场景一:需要在堆上分配数据,且大小在编译时无法确定
所有权规则本身不限制数据放在哪里——栈上的变量也会被自动管理。问题是,栈空间有限,且大小必须在编译时确定。递归数据结构(如链表节点、树节点)的大小是”递归的”,编译器算不出来;大对象放在栈上也可能爆栈。你需要显式地把数据放在堆上,而所有权系统需要一种”拥有堆上数据的栈上代理”。
→ 解决方案:Box<T>——把数据放堆上,Box 本身在栈上作为所有者,遵循所有权的所有规则。
场景二:多个地方需要共享同一个数据的所有权
默认所有权规则要求”同一时刻只有一个所有者”。但现实中,多个模块、多个数据结构可能需要共同持有一份数据,而且无法提前确定”谁最后一个用完”。比如一个 GUI 应用中,多个面板都需要访问同一个数据模型。
所有权转移(move)解决不了这个问题——移走了自己就没了。借用(reference)也解决不了——借用的生命周期被限制在原所有者的作用域内,无法”独立存活”。
→ 解决方案:Rc<T> / Arc<T>——引用计数允许多个所有者共享数据,当最后一个所有者离开作用域时数据自动释放。Rc 用于单线程,Arc 用于多线程。
场景三:共享所有权时可能产生循环引用
Rc<T> 的引用计数有一个致命弱点:如果 A 持有 B 的 Rc,B 也持有 A 的 Rc,它们的引用计数永远不为 0,导致内存泄漏。这在树结构(父节点 ↔ 子节点)、图结构、观察者模式等场景中非常常见。需要一个”不增加引用计数、但能访问数据”的引用。
→ 解决方案:Weak<T>——弱引用不增加强引用计数,可以被”升级”(upgrade)为临时的 Rc 来访问数据。如果原数据已经被释放,升级操作返回 None,安全地避免了悬垂指针。
总结:
所有权默认规则(独占) → 大部分场景,零成本
↓ 不够用
Box<T> → 堆分配 + 独占所有权
Rc<T> → 单线程共享所有权(引用计数)
Arc<T> → 多线程共享所有权(原子引用计数)
Weak<T> → 配合 Rc/Arc,打破循环引用
接下来,逐个深入这四个智能指针的接口和使用方式。
4. 四种智能指针概览
| 类型 | 作用 | 多所有权 | 线程安全 | 类比 C++ |
|---|---|---|---|---|
Box<T> | 堆上分配,独占所有权 | 否 | 是 | std::unique_ptr<T> |
Rc<T> | 引用计数,单线程共享所有权 | 是 | 否 | std::shared_ptr<T> |
Weak<T> | 配合 Rc 的弱引用,不增加强计数 | 是(弱引用) | 否 | std::weak_ptr<T> |
Arc<T> | 原子引用计数,多线程共享所有权 | 是 | 引用计数线程安全;共享要求 T: Send + Sync | std::shared_ptr<T> 的原子化共享场景 |
5. 四种智能指针详解
5.1 Box<T> —— 堆上分配的独占所有权
5.1.1 功能定位
Box<T> 是最简单的智能指针,它只做一件事:将数据分配在堆上,并独占所有权。Box<T> 本身在栈上(大小 = 一个指针),它指向的数据在堆上。当 Box 离开作用域时,堆上的数据自动被释放。
5.1.2 内部结构
Box<T> {
指针: *mut T // 指向堆上 T 类型数据的裸指针
}
Box<T> 的大小固定为 usize(一个指针的大小),无论 T 有多大。
5.1.3 核心接口
创建 Box — Box::new
功能:在堆上分配内存存放 x,返回的 Box<T> 成为这片内存的唯一所有者。
接口签名:
#![allow(unused)]
fn main() {
pub fn new(x: T) -> Box<T>
}
- 参数:
x— 任意类型的值,所有权移入Box。 - 返回值:
Box<T>— 指向堆上数据的智能指针。
简单调用示例:
#![allow(unused)]
fn main() {
let b = Box::new(42); // i32 分配在堆上
let s = Box::new(String::from("hello")); // String 本身也在堆上,Box 在栈上指向它
}
解引用访问内部数据 — *
功能:通过 * 解引用操作符访问 Box 内部的堆上数据,就像使用普通引用一样。Box<T> 实现了 Deref<Target = T> 和 DerefMut trait,因此 . 方法调用会自动穿透到内部类型。
简单调用示例:
#![allow(unused)]
fn main() {
let mut b = Box::new(10);
*b += 5; // 解引用修改堆上的值
println!("{}", *b); // 输出 15
let s = Box::new(String::from("hello"));
println!("{}", s.len()); // 自动解引用,直接调用 String 的 len()
}
消耗 Box 取出内部值 — *(解引用移动)
功能:对 Box<T> 使用 * 解引用,会将内部值移出(move out),Box 被消耗。这相当于把所有权从“堆上“移回“栈上“。
简单调用示例:
#![allow(unused)]
fn main() {
let b = Box::new(String::from("hello"));
let inner: String = *b; // 所有权从 Box 移出到 inner
// b 已经不再可用(被 consume 了)
println!("{}", inner);
}
5.1.4 典型使用场景
场景一:存放编译时大小未知的类型(递归类型)
#![allow(unused)]
fn main() {
// 链表节点的经典定义:Box 打破了递归大小的循环
enum List {
Cons(i32, Box<List>), // Box 让 Cons 的大小固定
Nil,
}
use List::*;
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
场景二:减少大对象在栈上的占用
#![allow(unused)]
fn main() {
let large_array = Box::new([0u8; 1_000_000]); // ~1MB,放堆上而不是栈上
}
场景三:trait 对象(动态分发)
#![allow(unused)]
fn main() {
let components: Vec<Box<dyn Draw>> = vec![
Box::new(Button { label: "OK".into() }),
Box::new(TextField { placeholder: "name".into() }),
];
}
5.2 Rc<T> —— 单线程引用计数共享所有权
5.2.1 功能定位
Rc<T>(Reference Counted)允许同一份数据同时有多个所有者。内部通过引用计数追踪所有者的数量:每次 clone 计数 +1,每个所有者离开作用域时计数 -1,计数归零时数据被释放。
Rc不是线程安全的(计数操作不是原子操作),只能用于单线程场景。
5.2.2 内部结构
Rc<T> {
指针: 指向堆上的 RcInner {
强引用计数: usize, // Rc::clone() 修改这里
弱引用计数: usize, // Weak 修改这里
数据: T,
}
}
5.2.3 核心接口
创建 Rc — Rc::new
功能:在堆上分配数据并包装为 Rc,初始强引用计数为 1。
接口签名:
#![allow(unused)]
fn main() {
pub fn new(value: T) -> Rc<T>
}
- 参数:
value— 需要共享的数据。 - 返回值:
Rc<T>— 初始强引用计数 = 1 的智能指针。
简单调用示例:
#![allow(unused)]
fn main() {
use std::rc::Rc;
let a = Rc::new(42); // 强引用计数 = 1
}
增加引用计数 — Rc::clone
功能:创建另一个指向同一份数据的 Rc<T>,强引用计数 +1。注意:这不是深度拷贝数据,只是复制指针并递增计数——成本极低(等同于拷贝一个指针 + 一次整数加法)。
接口签名:
#![allow(unused)]
fn main() {
pub fn clone(this: &Rc<T>) -> Rc<T>
}
- 参数:
this— 对已有Rc的引用。 - 返回值:
Rc<T>— 新的Rc句柄,指向同一份数据。
简单调用示例:
#![allow(unused)]
fn main() {
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a); // 强引用计数变为 2(推荐写法)
let c = a.clone(); // 等价写法,但 Rc::clone(&a) 更明确
// a、b、c 指向完全相同的 Vec,没有发生数据拷贝
}
查看引用计数 — Rc::strong_count / Rc::weak_count
功能:查询当前 Rc 的强引用计数和弱引用计数(调试和验证用)。
接口签名:
#![allow(unused)]
fn main() {
pub fn strong_count(this: &Rc<T>) -> usize
pub fn weak_count(this: &Rc<T>) -> usize
}
简单调用示例:
#![allow(unused)]
fn main() {
let a = Rc::new(10);
println!("strong = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a);
println!("strong = {}", Rc::strong_count(&a)); // 2
}
创建弱引用 — Rc::downgrade
功能:从 Rc 创建一个 Weak(弱引用),不增加强引用计数。详见 5.3 节。
接口签名:
#![allow(unused)]
fn main() {
pub fn downgrade(this: &Rc<T>) -> Weak<T>
}
尝试取出内部值 — Rc::try_unwrap
功能:如果当前强引用计数为 1(你是最后一个持有者),返回 Ok(内部值),消耗 Rc 并归还数据;如果还有其他持有者,返回 Err(self)。
接口签名:
#![allow(unused)]
fn main() {
pub fn try_unwrap(this: Rc<T>) -> Result<T, Rc<T>>
}
简单调用示例:
#![allow(unused)]
fn main() {
let a = Rc::new(42);
let inner = Rc::try_unwrap(a).unwrap(); // 只有 1 个引用,成功取出
println!("{}", inner);
let a = Rc::new(42);
let b = Rc::clone(&a);
// Rc::try_unwrap(a).unwrap(); // panic:还有 b 持有引用
}
5.2.4 综合示例
use std::rc::Rc;
fn main() {
let shared = Rc::new(String::from("共享数据"));
// 多处"克隆" Rc 句柄,共享同一份数据
let owner1 = Rc::clone(&shared);
let owner2 = Rc::clone(&shared);
let owner3 = Rc::clone(&shared);
println!("强引用计数: {}", Rc::strong_count(&shared)); // 4
println!("内容: {}", owner1); // 自动解引用到 String
// owner1, owner2, owner3 各自离开作用域,计数递减
}
// shared 最后离开,计数 1→0,数据被释放
5.3 Weak<T> —— 弱引用,解决循环引用
5.3.1 功能定位
Weak<T> 是对 Rc<T>(或 Arc<T>)内部数据的非拥有型引用。它的核心作用是打破循环引用:
- 不增加强引用计数,因此不会阻止数据被释放。
- 访问数据前需要先 “升级”(upgrade)为临时的
Rc<T>——这一步可能失败(数据已释放时返回None),从而安全地处理了“数据可能不存在“的情况。
5.3.2 内部结构
Weak<T> {
指针: 指向 RcInner(和 Rc 指向同一个内部结构)
}
与 Rc<T> 的区别:Weak 不参与强引用计数的增减,只修改弱引用计数。
5.3.3 核心接口
创建空 Weak — Weak::new
功能:创建一个不指向任何数据的空弱引用(等价于已经“过期“的弱引用)。
接口签名:
#![allow(unused)]
fn main() {
pub fn new() -> Weak<T>
}
- 返回值:
Weak<T>— 空的弱引用,upgrade()永远返回None。
从 Rc 创建 Weak — Rc::downgrade
功能:从已有的 Rc 创建一个 Weak 弱引用,弱引用计数 +1,强引用计数不变。
接口签名:
#![allow(unused)]
fn main() {
pub fn downgrade(this: &Rc<T>) -> Weak<T>
}
- 参数:
this— 对已有Rc的引用。 - 返回值:
Weak<T>— 指向同一份数据的弱引用。
升级为强引用 — Weak::upgrade
功能:尝试将弱引用“升级“为临时的 Rc<T>。如果原数据还存在(强引用计数 > 0),返回 Some(Rc<T>),此时你可以安全访问数据;如果原数据已被释放(强引用计数 = 0),返回 None。
接口签名:
#![allow(unused)]
fn main() {
pub fn upgrade(&self) -> Option<Rc<T>>
}
- 参数:
&self。 - 返回值:
Option<Rc<T>>—Some表示数据仍存活,临时获得一个强引用;None表示数据已被释放。
简单调用示例:
#![allow(unused)]
fn main() {
use std::rc::{Rc, Weak};
let strong = Rc::new(42);
let weak = Rc::downgrade(&strong);
// 升级成功:数据还在
match weak.upgrade() {
Some(rc) => println!("数据: {}", *rc), // 输出 42
None => println!("数据已释放"),
}
drop(strong); // 释放唯一的强引用,数据被销毁
// 升级失败:数据已释放
match weak.upgrade() {
Some(_) => println!("还存在"),
None => println!("数据已释放"), // 输出这个
}
}
5.3.4 循环引用问题详解
没有 Weak 时会发生什么:
#![allow(unused)]
fn main() {
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
next: RefCell<Option<Rc<Node>>>,
}
let a = Rc::new(Node { next: RefCell::new(None) });
let b = Rc::new(Node { next: RefCell::new(Some(Rc::clone(&a))) });
*a.next.borrow_mut() = Some(Rc::clone(&b)); // a → b, b → a,循环引用!
// a 和 b 的强引用计数各有 2,离开作用域后各降为 1(互相持有)
// 永远不会归零 → 内存泄漏
}
用 Weak 打破循环(以树结构的父子关系为例):
#![allow(unused)]
fn main() {
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // 父节点用弱引用
children: RefCell<Vec<Rc<Node>>>, // 子节点用强引用
}
}
设计原则:父子关系中,父持有子的 Rc(强引用),子持有父的 Weak(弱引用)。这样当父被释放时,子的 Weak 升级失败,自然知道“父已消失“;而子被释放时,父的 Rc 计数减少,不会阻止父的释放。没有循环,引用计数正常归零。
完整示例见 src/study/smartpointer_demo.rs。
5.4 Arc<T> —— 多线程安全的原子引用计数
5.4.1 功能定位
Arc<T>(Atomic Reference Counted)是 Rc<T> 的线程安全版本。和 Rc 一样允许共享所有权,但内部引用计数使用原子操作(atomic),因此 Arc 句柄本身可以安全地在多线程之间传递和克隆。
需要注意:Arc 只保证“引用计数”本身是线程安全的,不会自动让内部数据变成可并发修改。如果多个线程需要修改同一份数据,通常要配合 Mutex<T>、RwLock<T> 或原子类型使用。
原子操作有性能开销(比普通整数操作慢),因此单线程中优先用
Rc,只在需要跨线程共享时才用Arc。
5.4.2 内部结构
Arc<T> {
指针: 指向堆上的 ArcInner {
强引用计数: AtomicUsize, // 原子操作
弱引用计数: AtomicUsize, // 原子操作
数据: T,
}
}
5.4.3 核心接口
Arc<T> 的接口与 Rc<T> 几乎完全一致,可以对照学习:
创建 Arc — Arc::new
功能:在堆上分配数据,初始强引用计数为 1,可以安全地在线程间共享。
接口签名:
#![allow(unused)]
fn main() {
pub fn new(data: T) -> Arc<T>
}
- 参数:
data— 需要共享的数据。 - 返回值:
Arc<T>— 初始强引用计数 = 1 的原子引用计数指针。
增加引用计数 — Arc::clone
功能:创建另一个指向同一份数据的 Arc,强引用计数原子地 +1。和 Rc::clone 一样,不拷贝数据本身,代价极低。
接口签名:
#![allow(unused)]
fn main() {
pub fn clone(this: &Arc<T>) -> Arc<T>
}
- 参数:
this— 对已有Arc的引用。 - 返回值:
Arc<T>— 新的共享句柄。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::Arc;
let a = Arc::new(vec![1, 2, 3]);
let b = Arc::clone(&a); // 原子地增加计数
// a 和 b 可在不同线程中使用
}
查看引用计数 — Arc::strong_count / Arc::weak_count
功能:查询当前强/弱引用计数。注意:在多线程环境中,返回值是“瞬时快照“,在你使用这个值时计数可能已经变了。
接口签名:
#![allow(unused)]
fn main() {
pub fn strong_count(this: &Arc<T>) -> usize
pub fn weak_count(this: &Arc<T>) -> usize
}
创建弱引用 — Arc::downgrade
功能:从 Arc 创建 Weak<T>,用法与 Rc::downgrade 完全一致,也是线程安全的。
接口签名:
#![allow(unused)]
fn main() {
pub fn downgrade(this: &Arc<T>) -> Weak<T>
}
5.4.4 多线程示例
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];
for i in 0..3 {
let data_clone = Arc::clone(&data); // 每个线程获得一份 Arc 句柄
let handle = thread::spawn(move || {
println!("线程 {} 看到的数据: {:?}", i, data_clone);
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
// 所有线程完成,最后一个 Arc 离开作用域,数据被释放
}
配合 Mutex / RwLock 实现多线程修改:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0)); // Arc<Mutex<i32>>
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1; // 互斥地修改
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("最终计数: {}", *counter.lock().unwrap()); // 10
}
模式:
Arc<Mutex<T>>是 Rust 中最常见的多线程共享可变数据模式。Arc负责“让多个线程共享所有权“,Mutex负责“同一时刻只有一个线程能修改“。
6. Rust 智能指针 – 和 C++ 作对照
看到这里,如果你学过 C++,会发现 Rust 的智能指针思想和机制很接近,但 Rust 会把更多约束放进类型系统里。
一个典型区别是:C++ 中 std::shared_ptr<T> 的引用计数操作本身是线程安全的,但它不自动保证 T 的并发访问安全;Rust 则进一步区分单线程的 Rc<T> 和可跨线程的 Arc<T>,编译器不会让你把 Rc<T> 发送到其他线程。
| 功能 | C++ 智能指针 | Rust 智能指针 |
|---|---|---|
| 唯一所有权,堆上分配 | std::unique_ptr<T> | Box<T> |
| 多个所有权(引用计数) | std::shared_ptr<T> | Rc<T>(单线程) |
| 弱引用,解决循环引用 | std::weak_ptr<T> | Weak<T> |
| 原子引用计数(线程安全) | std::atomic<std::shared_ptr<T>> / 原子化 shared_ptr 操作 | Arc<T>(多线程) |
7. 实验代码示例
配套代码位于 src/study/smartpointer_demo.rs 。
7.1 Box 与 Drop — 观察堆内存的自动释放
#![allow(unused)]
fn main() {
use std::rc::Rc;
struct Data {
data: String,
}
// 为 Data 实现 Drop trait,在释放时打印日志
impl Drop for Data {
fn drop(&mut self) {
println!("drop data: {}", self.data);
}
}
pub fn demo() {
println!("...............智能指针示例开始.................");
// ===== 实验1:Box<T> 堆分配 + Drop 观察 =====
let data = Box::new(Data {
data: "hello world".to_string(),
});
println!("data: {}", data.data);
// data 离开作用域 → Drop::drop 被调用 → 打印 "drop data: hello world"
// → Box 释放堆内存
}
}
- 实验:定义一个实现了
Droptrait 的Data结构体,用Box::new将它的实例分配在堆上。访问数据后不做任何手动释放。 - 现象:运行程序,在
demo()函数结束时会自动打印drop data: hello world。 - 分析:
Box::new在堆上分配了Data实例,Box成为这片内存的唯一所有者。当Box离开作用域时,编译器自动调用Drop::drop(释放String内部的堆内存),然后回收Box指向的堆空间。整个过程没有手写free调用,但释放是确定的,并覆盖正常作用域退出路径。这正是 RAII 在 Rust 中的体现:资源获取在Box::new,资源释放由编译器在作用域结束时自动插入。 - 结论:
Box<T>让堆上数据也享有“自动释放“的待遇。配合Droptrait,你可以精确观察和控制释放时机。
7.2 Rc<T> — 观察引用计数的变化
#![allow(unused)]
fn main() {
pub fn demo() {
// ===== 实验2:Rc<T> 引用计数 =====
let str = std::rc::Rc::new("hello world".to_string());
println!("rc count is {}", Rc::strong_count(&str)); // 1
let str1 = str.clone(); // 克隆 Rc 句柄,计数 +1
{
let str2 = str.clone(); // 计数 +1
let str3 = str.clone(); // 计数 +1
println!("rc count is {}", Rc::strong_count(&str)); // 4
} // str2, str3 离开作用域,计数各 -1
println!("rc count is {}", Rc::strong_count(&str)); // 2
println!("...............智能指针示例结束.................");
} // str1 离开,计数 -1;str 离开,计数 1→0,数据被释放
}
- 实验:创建一个
Rc<String>,通过Rc::clone创建多个共享句柄(str1、str2、str3),在不同作用域中观察Rc::strong_count的变化。 - 现象:
- 初始计数 = 1
- 在内部作用域中,4 个句柄同时存在,计数 = 4
- 离开内部作用域后,
str2和str3被 drop,计数降为 2 - 函数结束时,
str1和str依次 drop,计数从 2 → 1 → 0,数据被释放
- 分析:
Rc::clone不拷贝String内部的堆数据——只是复制了指针并递增了引用计数。计数归零时,Rust 自动释放堆上的String和RcInner结构。整个过程明确且可追踪:每个Rc句柄都是一个所有者,最后一个所有者离开时负责释放——这是“共享所有权,明确规则“的完美演示。 - 结论:
Rc<T>用引用计数实现了多所有权共享。Rc::clone是轻量操作(不拷贝数据),引用计数的增减是自动且确定的。
rust 内部可变性
1. 从一个“不能编译“的场景出发
我们有一个用 Rc<T> 共享的数据,希望多个持有者都能读取甚至修改它:
#![allow(unused)]
fn main() {
// 期望的行为(伪代码):
let shared_data = Rc::new(100); // 多个模块共享一个计数器
// 在模块 A 中:
*shared_data += 1; // 想通过 Rc 修改数据 → 编译错误!
// 在模块 B 中:
*shared_data += 1; // 也想修改 → 编译错误!
}
为什么 Rust 编译器不允许这样做?用所有权和借用规则来分析:
Rc<T>允许多个所有者共享数据,但它只提供不可变访问(&T)。- 要修改数据,你需要可变引用(
&mut T),但借用规则规定:同一时刻只能有一个可变引用。 - 然而
Rc的场景中,多个持有者同时存在——编译器无法在编译期确定“此刻是否只有一个持有者想修改数据“。于是编译器拒绝了所有修改操作。
这就带来了一个困境:逻辑上“多个持有者轮流修改同一份数据“是完全合理的需求(比如计数器、缓存、共享配置),但借用规则在编译期无法区分“轮流修改“和“同时修改“,于是一刀切地禁止了。
如果 Rust 只有编译期借用检查,代码的灵活性就到此为止了。为此,Rust 引入了内部可变性(Interior Mutability)。
2. 什么是内部可变性?
内部可变性是一种设计模式:允许你通过不可变引用(&self / &T)来修改内部数据。
这听起来像是在“破坏规则“,但 Rust 的策略不是“开个后门就完事了“,而是:把借用规则的检查从编译期推迟到运行时。
编译期借用检查(默认):
&T → 一定不会修改数据 ← 编译器静态保证
内部可变性(推迟到运行时):
&T → 可能修改内部数据 ← 运行时检查借用规则
如果违规 → panic!(而不是未定义行为)
设计理念:编译器不是万能的——有些正确的代码模式在编译期无法被证明安全。与其禁止这些模式,不如提供一种受控的方式,让程序员在运行时承担检查责任。这很像是数据库中的事务隔离级别:编译期检查 = 严格模式,零误判但有局限;运行时检查 = 宽松模式,能覆盖更多场景,但违规会 panic。
Rust 标准库提供了两种单线程内部可变性类型:
| 类型 | 适用场景 | 检查时机 | 性能 |
|---|---|---|---|
Cell<T> | 整体读写/替换,尤其适合 Copy 类型(整数、bool 等) | 无运行时借用检查 | 接近普通读写成本 |
RefCell<T> | 任意类型 | 运行时检查借用规则 | 轻微运行时开销 |
3. Cell<T> —— 整体读取和替换
3.1 功能定位
Cell<T> 可以存放任意 T,但它最常用于实现了 Copy trait 的类型(如 i32、bool、char、指针等)。它的核心思路是:不提供内部数据的引用,只允许整体读取(get,要求 T: Copy)和整体替换(set / replace / take)。
因为不暴露 &T 或 &mut T,所以不存在运行时借用冲突:Copy 类型可以通过 get() 读出拷贝,非 Copy 类型则通常用 replace() / take() 整体搬出或替换。Cell<T> 本身仍是单线程内部可变性工具,不实现 Sync。
3.2 内部结构
Cell<T> {
value: UnsafeCell<T>, // 底层使用 UnsafeCell 绕过编译期借用检查
}
Cell<T> 的大小等于 T 的大小,直接内联存储数据(无额外堆分配)。
3.3 核心接口
创建 Cell — Cell::new
功能:创建一个新的 Cell,用 value 初始化内部数据。
接口签名:
#![allow(unused)]
fn main() {
pub const fn new(value: T) -> Cell<T>
}
- 参数:
value— 初始值。 - 返回值:
Cell<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::cell::Cell;
let c = Cell::new(42);
}
读取值 — Cell::get
功能:返回内部值的拷贝。要求 T 实现了 Copy trait。
接口签名:
#![allow(unused)]
fn main() {
pub fn get(&self) -> T
where
T: Copy,
}
- 参数:
&self(不可变引用即可调用)。 - 返回值:
T— 内部值的拷贝。
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(10);
let val = c.get(); // val = 10
println!("{}", c.get()); // 可以多次调用
}
修改值 — Cell::set
功能:用新值整体替换内部数据。旧值被丢弃。
接口签名:
#![allow(unused)]
fn main() {
pub fn set(&self, val: T)
}
- 参数:
&self(不可变引用)+val— 新值。 - 返回值:无。
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(10);
c.set(20); // 内部值变为 20
c.set(c.get() + 1); // 读-改-写:内部值变为 21
}
替换并取出旧值 — Cell::replace
功能:用新值替换内部数据,并返回旧值。
接口签名:
#![allow(unused)]
fn main() {
pub fn replace(&self, val: T) -> T
}
- 参数:
&self+val— 新值。 - 返回值:
T— 被替换出来的旧值。
取出值 — Cell::take
功能:取出内部值,并用 T::default() 替换。要求 T: Default。
接口签名:
#![allow(unused)]
fn main() {
pub fn take(&self) -> T
where
T: Default,
}
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(String::from("hello"));
// 注意:String 不是 Copy,但可以用 replace/take
let old = c.take(); // old = "hello", 内部值变为 ""
let old2 = c.replace(String::from("world")); // old2 = "", 内部值变为 "world"
}
3.4 典型示例:不可变计数器
use std::cell::Cell;
struct Counter {
count: Cell<i32>, // 不是 mut count: i32
}
impl Counter {
fn new() -> Self {
Counter { count: Cell::new(0) }
}
fn increment(&self) { // &self, 不是 &mut self!
let current = self.count.get();
self.count.set(current + 1);
}
fn value(&self) -> i32 {
self.count.get()
}
}
fn main() {
let counter = Counter::new(); // 不需要 mut
counter.increment(); // 通过 &self 修改了内部状态
counter.increment();
println!("Count: {}", counter.value()); // 2
}
关键观察:counter 不需要声明为 mut,increment 方法的签名是 &self 而非 &mut self,但它成功修改了内部计数器的值。这就是“内部可变性“的含义——数据在“不可变的外壳“下拥有“可变的内核“。
4. RefCell<T> —— 运行时借用检查
4.1 功能定位
RefCell<T> 适用于任意类型。与 Cell<T> 不同,RefCell 允许你获取内部数据的引用(&T 和 &mut T),但借用规则在运行时检查:
borrow()→ 返回Ref<T>(不可变借用),允许多个同时存在borrow_mut()→ 返回RefMut<T>(可变借用),同一时刻只能有一个- 如果违反规则(如在已有不可变借用时调用
borrow_mut()),运行时 panic
4.2 内部结构
RefCell<T> {
borrow_flag: Cell<isize>, // 运行时借用计数器(0=无借用,>0=不可变借用数,-1=有一个可变借用)
value: UnsafeCell<T>, // 内部数据
}
RefCell<T> 的大小等于 T 的大小 + 一个 isize(借用标志位),数据直接内联存储。
4.3 核心接口
创建 RefCell — RefCell::new
功能:创建一个新的 RefCell,用 value 初始化内部数据。
接口签名:
#![allow(unused)]
fn main() {
pub const fn new(value: T) -> RefCell<T>
}
- 参数:
value— 初始值。 - 返回值:
RefCell<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
let rc = RefCell::new(vec![1, 2, 3]);
}
获取不可变借用 — RefCell::borrow
功能:获取内部数据的不可变引用 Ref<T>(它实现了 Deref<Target = T>,可以直接当 &T 用)。运行时检查:如果当前已存在可变借用,则 panic。允许多个不可变借用同时存在。
接口签名:
#![allow(unused)]
fn main() {
pub fn borrow(&self) -> Ref<'_, T>
}
- 参数:
&self。 - 返回值:
Ref<'_, T>— 一个包装了不可变借用的守卫,离开作用域时自动归还借用。
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let r1 = rc.borrow();
let r2 = rc.borrow(); // 多个不可变借用 OK
println!("{} {}", *r1, *r2);
}
获取可变借用 — RefCell::borrow_mut
功能:获取内部数据的可变引用 RefMut<T>(实现了 DerefMut)。运行时检查:如果当前已存在任何借用(不可变或可变),则 panic。
接口签名:
#![allow(unused)]
fn main() {
pub fn borrow_mut(&self) -> RefMut<'_, T>
}
- 参数:
&self。 - 返回值:
RefMut<'_, T>— 一个包装了可变借用的守卫,离开作用域时自动归还借用。
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let mut r = rc.borrow_mut();
*r += 1;
println!("{}", *r); // 43
// r 离开作用域,归还借用
}
安全的不可变借用 — RefCell::try_borrow
功能:和 borrow() 一样,但失败时不 panic,而是返回 Err。适用于生产环境中不确定借用状态的场景。
接口签名:
#![allow(unused)]
fn main() {
pub fn try_borrow(&self) -> Result<Ref<'_, T>, BorrowError>
}
- 返回值:
Ok(Ref<T>)表示借用成功;Err(BorrowError)表示当前不满足借用规则。
安全可变借用 — RefCell::try_borrow_mut
功能:和 borrow_mut() 一样,但失败时返回 Err 而非 panic。
接口签名:
#![allow(unused)]
fn main() {
pub fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError>
}
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let r = rc.borrow();
// 尝试获取可变借用——此时不可变借用还在,应该失败
match rc.try_borrow_mut() {
Ok(_) => println!("获取成功"),
Err(_) => println!("已经存在不可变借用,无法获取可变借用"),
}
}
4.4 运行时借用规则演示
use std::cell::RefCell;
fn main() {
let data = RefCell::new(42);
// ✅ 多个不可变借用可以共存
let b1 = data.borrow();
let b2 = data.borrow();
println!("{} {}", b1, b2);
// ❌ 不可变借用未释放时尝试可变借用 → panic!
// let b3 = data.borrow_mut(); // 运行时 panic: already borrowed
// ✅ 正确做法:先释放不可变借用
drop(b1);
drop(b2);
let mut b3 = data.borrow_mut(); // OK
*b3 += 1;
println!("{}", b3);
}
4.5 典型示例:带缓存的延迟计算
#![allow(unused)]
fn main() {
use std::cell::RefCell;
struct DataCache {
computed: RefCell<Vec<i32>>, // 缓存:可能被延迟填充
raw_data: Vec<i32>, // 原始数据:不可变
}
impl DataCache {
fn new(data: Vec<i32>) -> Self {
DataCache {
computed: RefCell::new(Vec::new()),
raw_data: data,
}
}
fn get_computed(&self) -> Vec<i32> { // &self, 不是 &mut self!
let mut cache = self.computed.borrow_mut();
if cache.is_empty() {
// 首次访问,执行计算并填充缓存
for &val in &self.raw_data {
cache.push(val * 2);
}
}
// 释放可变借用,切换为不可变借用以返回结果
drop(cache);
self.computed.borrow().clone()
}
}
}
关键观察:get_computed 的签名是 &self,外部调用者以为自己只是在“读取“数据。但在方法内部,首次调用时悄悄地修改了 computed 缓存。后续调用发现缓存已有数据,跳过计算直接返回。整个过程中,调用者完全不需要 &mut self——这就是内部可变性在“惰性初始化“场景中的经典应用。
5. 内部可变性不止于 Cell/RefCell
5.1 Rust 的 Mutex<T> 和 RwLock<T> 也是内部可变性
很多从 Java、C++ 过来的开发者习惯这样的模式:
// Java:锁和数据是分离的
class SharedData {
private int value;
private final Lock lock = new ReentrantLock();
void increment() {
lock.lock();
try { value++; } finally { lock.unlock(); }
}
}
在这个模式中,**数据(value)和锁(lock)**是独立的对象。锁只是“门卫“,数据本身没有任何保护——如果你忘了加锁就访问数据,编译器和运行时都不会阻止你。
Rust 的 Mutex<T> 和 RwLock<T> 采用了完全不同的设计:
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let data = Mutex::new(42); // 数据被"包裹"在 Mutex 内部
let mut guard = data.lock().unwrap(); // lock() 返回一个智能指针
*guard += 1; // 通过守卫访问/修改数据
// guard 离开作用域 → 自动解锁
}
核心差异:
- 数据和锁是一体的:
Mutex<T>包裹了数据T。你无法绕过锁直接访问数据——不调用lock()就看不到里面的值。 lock()返回的是一个智能指针守卫(MutexGuard<T>),它实现了Deref和DerefMut,让你可以像使用普通引用一样读写内部数据。守卫离开作用域时自动解锁——永远不会忘记unlock()。Mutex<T>本身提供内部可变性:你可以通过&Mutex<T>(不可变引用)来修改内部数据。lock()方法的签名是&self,不是&mut self。
这和 RefCell<T> 的设计理念一脉相承:
Cell<T> → 单线程,整体读写,无运行时借用检查
RefCell<T> → 单线程,运行时借用检查
Mutex<T> → 多线程,通过操作系统锁保证独占访问
RwLock<T> → 多线程,读写锁(多读单写)
它们都实现了同一个模式:在“不可变“的外壳下提供“可变“的内部。区别只在于保证安全的方式不同——RefCell 用运行时计数器,Mutex 用操作系统锁,RwLock 用读写锁。
5.2 Rust 的设计理念
Rust 这种“用容器包裹数据、通过守卫访问“的设计,体现了一条核心哲学:
把安全约束编码为类型,让编译器强制执行。数据的所有权和访问权限,不应该依赖于程序员的记忆或编码规范,而应该是类型系统的一部分。
- 传统语言:锁和数据分离 → 安全靠“记得加锁“
- Rust:
Mutex<T>包裹数据 → 不加锁就拿不到数据,安全靠类型系统保证
同样的哲学也贯穿在 Cell/RefCell 中:你想修改不可变引用背后的数据?用 RefCell<T> 包裹它,借用规则从编译期推迟到运行时,但依然被检查——违规即 panic,不会静默地产生未定义行为。
6. 实验代码示例
配套代码位于 src/study/interior_mutability.rs。
6.1 Cell<T> — 不可变结构体的内部计数
#![allow(unused)]
fn main() {
use std::cell::Cell;
struct Counter {
count: Cell<i32>,
}
impl Counter {
fn new() -> Self {
Self { count: Cell::new(0) }
}
fn increment(&self) {
let current = self.count.get();
self.count.set(current + 1);
}
fn value(&self) -> i32 {
self.count.get()
}
}
fn cell_demo() {
let counter = Counter::new(); // 不需要 mut
counter.increment(); // &self 也能修改内部值
counter.increment();
println!("Cell 计数器结果: {}", counter.value()); // 2
}
}
- 实验:定义
Counter结构体,内部用Cell<i32>而非i32,所有方法签名均为&self。创建不可变的counter实例,调用increment()多次。 - 现象:编译通过,输出
Cell 计数器结果: 2。counter未声明为mut,但值确实被修改了。 - 分析:
Cell::get()返回值的拷贝,Cell::set()整体替换内部值——这两个操作都不需要引用内部数据,因此不存在引用冲突。Cell不提供&T或&mut T,只提供值语义的读写,所以无需运行时借用检查,成本接近普通读写。 - 结论:
Cell<T>适合Copy类型的内部可变性场景(计数器、标志位等)。使用体验接近“正常的可变变量“,但允许你通过&self修改它。
6.2 RefCell<T> — 带缓存的延迟计算
#![allow(unused)]
fn main() {
use std::cell::RefCell;
struct DataCache {
computed: RefCell<Vec<i32>>,
raw_data: Vec<i32>,
}
impl DataCache {
fn new(data: Vec<i32>) -> Self {
Self { computed: RefCell::new(Vec::new()), raw_data: data }
}
fn get_computed(&self) -> Vec<i32> {
let mut cache = self.computed.borrow_mut();
if cache.is_empty() {
for &val in &self.raw_data {
cache.push(val * 2);
}
}
drop(cache); // 释放可变借用
self.computed.borrow().clone() // 获取不可变借用,克隆返回
}
}
fn refcell_cache_demo() {
let cache = DataCache::new(vec![1, 2, 3]);
println!("第一次计算缓存: {:?}", cache.get_computed()); // [2, 4, 6]
println!("第二次直接复用缓存: {:?}", cache.get_computed()); // [2, 4, 6]
}
}
- 实验:
DataCache内部用RefCell<Vec<i32>>缓存计算结果。首次调用get_computed()(&self)时填充缓存,后续调用直接返回缓存内容。 - 现象:两次调用输出相同结果
[2, 4, 6]。首次调用触发了计算(在&self方法内修改了computed),第二次调用跳过计算直接复用缓存。调用者始终只需要&self,完全感知不到内部的修改行为。 - 分析:
borrow_mut()在运行时检查——首次调用时没有其他借用,成功获取可变引用并填充缓存。drop(cache)显式释放可变借用后,borrow()才能成功。如果忘记drop(cache),下一行的borrow()会 panic(因为可变借用还未归还)。这种局部的“先写后读“模式在编译期无法被证明安全,但RefCell让你在运行时安全地执行它。 - 结论:
RefCell<T>允许任意类型享受内部可变性。运行时检查确保了“违规即 panic“,代价是轻微的运行开销和需要程序员注意借用归还的时机。
6.3 RefCell 的运行时借用规则验证
#![allow(unused)]
fn main() {
use std::cell::RefCell;
fn refcell_borrow_rule_demo() {
let data = RefCell::new(42);
let b1 = data.borrow();
let b2 = data.borrow();
println!("多个不可变借用可以共存: {} {}", b1, b2);
// 不可变借用还在 → try_borrow_mut 返回 Err,而不是 panic
if data.try_borrow_mut().is_err() {
println!("已经存在不可变借用,无法获取可变借用");
}
drop(b1);
drop(b2);
let mut b3 = data.borrow_mut();
*b3 += 1;
println!("不可变借用释放后,可变借用成功: {}", b3);
}
}
- 实验:先获取多个不可变借用,用
try_borrow_mut()尝试获取可变借用,释放不可变借用后再获取可变借用并修改。 - 现象:不可变借用共存正常;
try_borrow_mut()返回Err而非 panic;释放后borrow_mut()成功,值从 42 变为 43。 - 分析:这个实验直接验证了
RefCell的运行时借用规则——和编译期借用规则完全一致(多读或单写),区别只是违规时编译期报错 vs 运行期 panic(或try_*返回Err)。try_borrow_mut()是生产环境推荐的做法——因为 panic 会终止线程,而Err可以被优雅处理。 - 结论:
RefCell的运行时借用规则和编译期规则完全对应。try_borrow/try_borrow_mut提供了不 panic 的安全替代方案。
7. 总结:内部可变性的选择指南
| 场景 | 使用 | 原因 |
|---|---|---|
内部数据是 Copy 类型,只需整体读写 | Cell<T> | 无运行时借用检查,成本接近普通读写 |
| 内部数据是任意类型,需要获取引用 | RefCell<T> | 运行时借用检查,灵活 |
| 想避免 panic,优雅处理借用冲突 | RefCell + try_borrow / try_borrow_mut | 失败返回 Err |
| 多线程共享可变数据 | Mutex<T> / RwLock<T> | 同样基于内部可变性模式 |
核心理解:内部可变性不是 Rust 的“妥协“,而是一种精心的设计——把无法在编译期证明的安全规则推迟到运行时执行,在灵活性和安全性之间找到了平衡点。
Cell、RefCell、Mutex、RwLock共享同一个设计哲学:数据被容器包裹,访问通过守卫,守卫离开时自动归还权限。
rust 生命周期
1. 理解生命周期
生命周期 指的是一个变量在内存中从创建到销毁所经历的时间范围(即变量“存活”的代码区域)。在 Rust 中,每个引用(&T 或 &mut T)背后都关联着一个生命周期,用于保证引用的有效性。
我们先从一个简单的例子开始,没有任何复杂引用。
fn main() {
let x = 5; // ------- x 的生命周期开始
let y = &x; // ------- y 的生命周期开始
println!("{}", y); // |
} // ------- 两者同时结束
x从定义开始存活到main函数结束。y是x的引用,它的生命周期不能超过x的生命周期。这里y在x销毁之前就使用完了,完全安全。
1.1 问题场景:函数返回引用(为什么需要生命周期注解?)
现在考虑一个函数,它接收两个字符串切片,并返回较长的那个。直觉写法可能是:
#![allow(unused)]
fn main() {
fn longer(s1: &str, s2: &str) -> &str {
if s1.len() > s2.len() { s1 } else { s2 }
}
}
这段代码无法编译!编译器报错:缺少生命周期注解。
Rust 编译器在检查这个函数时,需要回答一个关键问题:函数返回的引用,到底应该存活多久?(即它指向的数据什么时候会被释放)
- 如果返回
s1,那么返回引用不能比s1活得更久。 - 如果返回
s2,那么返回引用不能比s2活得更久。
但是,函数体内部使用了 if 条件来决定返回哪一个。编译器在编译函数时,不会去模拟运行那个 if,它只知道返回的引用可能是 s1 也可能是 s2。因此,Rust 无法确定返回值的生命周期应该与 s1 绑定,还是与 s2 绑定。
更危险的是,如果调用者这样使用:
#![allow(unused)]
fn main() {
let result;
{
let s1 = String::from("hello");
let s2 = String::from("world");
result = longer(&s1, &s2);
} // 这里 s1 和 s2 都被销毁
println!("{}", result); // 危险!result 已经变成悬垂引用
}
如果一门语言允许这种函数签名在没有任何生命周期约束的情况下通过编译,调用者就可能写出上面这样的悬垂引用:result 指向的内存已经被释放,却仍然被继续使用。在 C++ 中,这类悬垂引用是典型的未定义行为,可能导致程序崩溃或安全漏洞。
Rust 的设计哲学是:绝对不允许任何悬垂引用通过编译。 所以 Rust 拒绝接受没有生命周期注解的 longer 函数,要求程序员显式标注:返回的引用与 s1 和 s2 到底有什么关系(通常是用同一个生命周期参数表示“返回引用的存活时间不得超过 s1 和 s2 中较短的那个”)。这样编译器就能静态验证所有调用是否安全。
加上生命周期注解后的正确版本:
#![allow(unused)] fn main() { fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } }这个注解告诉编译器:返回的引用存活时间不超过
s1和s2中生命期较短的那个。如果调用者试图在某个引用失效后继续使用返回值,编译器就会报错。
2. 学会关注变量的生命周期
2.1 有 GC 语言的视角
在 Java、C#、Go 等带有垃圾回收的语言中,你很少需要操心变量的具体存活时间。GC 会跟踪所有活跃的引用,自动回收不再使用的内存。你只管“用引用”,GC 保证你不访问到已释放的内存(虽然仍可能访问到 null,但那不是内存安全问题)。因此,大多数 GC 语言开发者养成了不关心对象生命周期细节的习惯。
2.2 无 GC 语言的思维转变
在 C、C++ 和 Rust 这类无 GC 的语言中,内存的分配和释放完全由程序员管理(或通过所有权系统自动管理)。如果不主动思考生命周期,极容易产生悬垂指针或use-after-free:
- 函数返回了局部变量的指针/引用 → 调用者访问到已被销毁的内存。
- 一个对象被释放后,另一处仍然持有它的指针。
Rust 的所有权/借用系统强制你在编译期证明引用始终有效。因此,写 Rust 代码时,你需要时刻问自己:
这个引用指向的数据,会不会在我使用它之前就被销毁了?
这种思维方式会伴随你编写所有无 GC 语言的代码,也是写出健壮系统程序的基础。Rust 通过生命周期注解,让这种思考显式地体现在代码中,并由编译器帮你验证。
3. 泛型生命周期注解语法
3.1 功能说明
生命周期注解(Lifetime Annotations)并不改变任何变量的实际存活时间,它只是给引用之间的有效范围关系起名字。比如 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 表示:返回引用不能比 x 和 y 中较短的那个活得更久。编译器根据这些关系进行静态检查。
3.2 基本语法结构
- 生命周期名称以单引号开头,通常用小写字母,如
'a,'b,'ctx。 - 声明:在泛型参数列表中使用
<'a>。 - 使用:标注在引用类型上,如
&'a T(不可变引用)、&'a mut T(可变引用)。
#![allow(unused)]
fn main() {
// 函数上的生命周期注解
fn 函数名<'a>(参数1: &'a 类型, 参数2: &'a 类型) -> &'a 类型 { ... }
// 结构体上的生命周期注解
struct 结构体名<'a> {
字段: &'a 类型,
}
}
3.3 调用示例
#![allow(unused)]
fn main() {
// 函数示例:明确返回值不能比两个参数中较短的那个活得更久
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// 结构体示例:存储一个引用,要求被引用数据至少和结构体实例一样长
struct Excerpt<'a> {
part: &'a str,
}
// 使用(片段)
let novel = String::from("Call me Ishmael...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let excerpt = Excerpt { part: first_sentence };
}
生命周期省略规则:在常见模式下(如只有一个输入引用,或
&self等),编译器能自动推断,不需要显式写注解。比如fn first_word(s: &str) -> &str实际等价于fn first_word<'a>(s: &'a str) -> &'a str。
4. 几个问题
4.1 什么时候需要显式标注?
Rust 的生命周期省略规则(Lifetime Elision Rules)能覆盖大多数简单情况,但在以下场景中,你必须手动标注生命周期:
-
函数返回引用,且参数中有多个引用编译器无法推断返回的引用到底来自哪个参数,因此必须标注关联关系。
#![allow(unused)] fn main() { // 必须标注:因为有两个输入引用 fn choose<'a>(a: &'a str, b: &'a str, selector: bool) -> &'a str { if selector { a } else { b } } } -
函数返回引用,但参数没有可借出的引用返回的引用不能指向函数内的局部变量,必须来自某个仍然有效的数据源。普通关联函数如果没有引用参数,通常不能返回普通引用,除非返回的是
&'static;方法中的fn get_ref(&self) -> &T则由生命周期省略规则覆盖,返回值默认和self的借用关联。 -
结构体持有引用 任何包含引用字段的结构体定义,都必须为每个引用字段标注生命周期。
#![allow(unused)] fn main() { struct Holder<'a> { data: &'a i32, // 必须标注 } }
4.2 何时使用同一个生命周期参数?
当你希望建立多个引用之间的有效范围关联时,使用相同的生命周期参数。它不是要求所有引用“实际活得一样久”,而是告诉编译器:这些引用在当前函数或类型签名中共享同一个约束,最终可用范围不能超过其中最短的那个。最常见的情况:
- 函数返回的引用来源于多个输入引用之一,且返回后还要继续使用。你要求输出引用同时受多个输入引用约束,这样返回的引用不会超出任何一个输入引用的有效范围。
#![allow(unused)]
fn main() {
// 返回值不能比两个参数中较短的那个活得更久
fn max<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x > y { x } else { y }
}
}
- 结构体持有多个引用,且这些引用指向同一块数据的不同部分(或多个数据但要求必须同时有效)。通常使用同一个生命周期,表示结构体实例不能活得比任何一个引用所指向的数据更长。
#![allow(unused)]
fn main() {
struct SlicePair<'a> {
first: &'a [u8],
second: &'a [u8],
}
}
4.3 何时使用不同的生命周期参数?
当两个引用彼此独立,没有相互约束时,应该使用不同的生命周期参数。常见场景:
- 函数接收两个独立的引用,返回的值只依赖其中一个,与另一个无关。那么返回值生命周期只需与相关参数一致,另一个参数可以有自己的生命周期。
#![allow(unused)]
fn main() {
// 返回的是 x,与 y 无关;y 可以有自己的生命周期 'b
fn take_first<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
x
}
}
- 函数执行的操作不涉及返回引用,或者返回不依赖某些引用时,让各引用有独立的生命周期,避免不必要的约束。
#![allow(unused)]
fn main() {
// 只比较长度,不返回引用,两个生命周期可以不同
fn compare_len<'a, 'b>(x: &'a str, y: &'b str) -> bool {
x.len() > y.len()
}
}
- 结构体中有多个引用,但某些引用是独立于其他引用的(例如一个用于内部缓冲区,另一个用于外部数据)。此时分别标注可以更精确地描述生命周期关系。
#![allow(unused)]
fn main() {
struct TwoSources<'a, 'b> {
internal: &'a str,
external: &'b str,
}
}
一句话总结:
- 相同生命周期 → 建立共同约束,返回值或结构体实例不能超过相关引用中最短的有效范围。
- 不同生命周期 → 各引用生命周期无关,可以自由缩短/延长,不互相影响。
5. 综合示例
// 定义一个存放字符串片段的结构体,它的生命周期参数 'a 表示:
// Excerpt 实例不能存活得比它内部引用的原始数据更长
struct Excerpt<'a> {
text: &'a str,
start: usize,
end: usize,
}
impl<'a> Excerpt<'a> {
// 创建一个新的 Excerpt,要求传入的引用至少和结构体一样长
fn new(text: &'a str, start: usize, end: usize) -> Self {
Excerpt { text, start, end }
}
// 返回片段本身(一个引用),生命周期与结构体内的引用一致
fn as_str(&self) -> &'a str {
&self.text[self.start..self.end]
}
}
// 一个函数,接收两个字符串引用,返回其中较长的那个。
// 生命周期注解表明:返回值不能比两个参数中较短的那个活得更久。
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
// 外部数据:一个拥有所有权的字符串
let whole = String::from("Rust programming language");
// 创建 Excerpt 结构体,借用 whole 的一部分
let excerpt = Excerpt::new(&whole, 0, 4); // 取 "Rust"
println!("Excerpt: {}", excerpt.as_str()); // 安全,因为 whole 在 excerpt 之后才销毁
// 演示 longer 函数
let s1 = String::from("hello");
let s2 = "world";
let result;
{
let s3 = String::from("rust");
// 注意:result 的生命周期将限定在 s1 和 s3 中较短的那个(即 s3 的作用域内)
result = longer(&s1, &s3);
println!("Longer inside block: {}", result);
} // s3 销毁,result 在这里之后不能再被使用
// 下面这行如果取消注释,会编译错误,因为 result 的生命周期已在上面块结束后失效
// println!("Longer outside: {}", result);
// 正确用法:s2 是字符串字面量,拥有 'static 生命周期(永久存活)
let long = longer(&s1, &s2);
println!("Longer with static: {}", long);
}
5.1 代码讲解
- 结构体
Excerpt<'a>:它存储了一个字符串引用text,以及两个索引。生命周期'a表示text指向的数据必须至少和Excerpt实例活得一样久。Rust 会确保你不会让Excerpt超出text的存活范围。 impl<'a>块:为特定生命周期'a实现方法。new和as_str中的&'a str确保返回的引用与结构体内的引用具有相同的生命周期。longer函数:最常见的生命周期场景:两个输入引用,一个输出引用。注解<'a>将三个引用的生命周期关联起来,表示输出引用的存活时间不超过两个输入引用中存活时间较短的那个。main函数中的演示:excerpt借用whole,在whole被销毁(main 结束)之前使用,安全。- 嵌套作用域中调用
longer,result的生命周期被限制在s3的作用域内,之后无法使用,编译器阻止了潜在悬垂。 - 字符串字面量
"world"的类型是&'static str,生命周期为整个程序运行期间,因此总是安全的。
通过这个例子,你可以看到生命周期注解如何帮助编译器验证引用的有效性,从而杜绝内存错误。
6. 为什么要有显式生命周期注解
在 C++ 中,返回局部变量的引用或指针是非常容易犯的错误:
int& getLocal() {
int x = 42;
return x; // 返回局部变量的引用 – 未定义行为!
}
调用者得到的是一个悬垂引用,指向已被销毁的栈内存。更隐蔽的是,当函数接收多个引用并返回其中一个时,C++ 编译器完全不会检查返回的引用是否有效:
const string& longer(const string& a, const string& b) {
return a.size() > b.size() ? a : b;
}
// 看起来没问题,但调用者可能传入临时对象,导致悬垂。
Rust 的设计目标:将所有内存安全问题消灭在编译期,而不是留到运行时崩溃或未定义行为。
- 显式生命周期注解迫使程序员明确表达引用之间的关系。
- 编译器根据这些关系进行静态分析,拒绝任何可能产生悬垂引用的代码。
- 零运行时开销:注解只用于编译检查,不会影响生成的机器码。
rust 返回值与错误处理
1. Rust 的错误处理概述
1.1 传统 OOP 语言的异常机制(以 Java 为例)
在 Java 中,方法可以“抛出”异常。调用者要么捕获(try-catch),要么继续向上抛出。
// Java 示例
public String readFile(String path) throws IOException {
// 可能抛出 IOException
return Files.readString(Path.of(path));
}
public void process() {
try {
String content = readFile("data.txt");
System.out.println(content);
} catch (IOException e) {
System.err.println("读取失败:" + e.getMessage());
}
}
问题:
- 异常控制流不透明:一个方法可能悄悄抛出几十种异常,但签名只声明了一部分(如
throws)。 - 性能开销:异常展开(unwinding)成本较高,尤其在不该用异常控制的普通逻辑中(如“用户未找到”)。
- 容易被忽略:不强制处理,程序员可能忘记
catch或记录。
1.2 Rust 的错误处理哲学
Rust 没有异常机制。它遵循以下设计原则:
- 错误也是值 —— 用普通枚举类型(
Option和Result)表示可能缺失或失败的结果。 - 显式优于隐式 —— 函数的返回值类型必须明确表达“可能出错”或“可能无值”,调用者必须处理这些可能性。
- 区分可恢复错误与不可恢复错误
- 可恢复:如文件不存在、数字解析失败,用
Result处理。 - 不可恢复:如数组越界、违反断言、程序进入非法状态,用
panic!崩溃并清理。
- 可恢复:如文件不存在、数字解析失败,用
- 低开销且显式 ——
Option和Result是普通枚举,很多场景会被编译器优化得非常紧凑(例如利用 niche optimization),但具体布局和成本取决于类型;它们的核心优势是把错误路径显式放进类型系统。
1.3 Rust 的错误处理方式一览
| 机制 | 用途 |
|---|---|
panic! | 不可恢复错误,程序直接退出(或通过 catch_unwind 捕获)。 |
Option<T> | 值可能缺失(有或无),无错误细节。例如:从哈希表取值。 |
Result<T, E> | 操作可能失败(成功值 T 或错误值 E),可携带错误信息。 |
match | 模式匹配,用于解构 Option 或 Result 并分支处理。 |
? 操作符 | 简化 Result 或 Option 的传递,失败时提前返回。 |
unwrap / expect | 快速取出成功值,若失败则 panic!(用于原型或确信不会出错的场景)。 |
本教程重点讲解 match、Option、Result 和 panic!,? 操作符会在进阶内容中学习。
2. match —— 强大的模式匹配工具
match 是 Rust 的控制流结构,用于将一个值与多个模式逐一比较,并执行第一个匹配的模式对应的代码。它类似其他语言的 switch,但强大得多。
2.1 基本语法结构
#![allow(unused)]
fn main() {
match 值 {
模式1 => 表达式1,
模式2 => 表达式2,
...
_ => 默认表达式, // 下划线匹配所有情况
}
}
要点:
- 所有可能情况必须被覆盖(编译器会检查完整性)。
- 每个
=>右边可以是一个代码块(用{}包裹)。 - 默认分支用
_表示,相当于default。 match本身是一个表达式,可以返回值。
2.2 返回值与示例
match 的每个分支必须返回相同类型的值(除非分支是 {} 块且块最后有返回值,或使用 break / return 等跳转)。
示例1:匹配数字
#![allow(unused)]
fn main() {
let x = 3;
let description = match x {
1 => "one",
2 => "two",
3 => "three",
_ => "other",
};
println!("{}", description); // 输出: three
}
示例2:匹配枚举(即将用到的 Option 和 Result)
#![allow(unused)]
fn main() {
let some_value = Some(42);
match some_value {
Some(value) => println!("Got {}", value),
None => println!("Got nothing"),
}
}
示例3:带块的分支
#![allow(unused)]
fn main() {
let num = 10;
let result = match num {
0 => {
println!("zero");
0
}
n if n > 0 => {
println!("positive");
n * 2
}
_ => {
println!("negative");
-1
}
};
// 输出: positive
// result = 20
}
3. Option <T> —— 表示值可能存在或缺失
3.1 定义和内在含义
Option<T> 是 Rust 标准库内置枚举,定义如下:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T), // 存在一个类型为 T 的值
None, // 没有值
}
}
它解决了一个常见问题:如何安全地表示“可能没有值”?其他语言常用 null 或 nullptr,但 Tony Hoare 称 null 引用是他的“十亿美元错误”。Rust 彻底抛弃了 null,用 Option 强制你处理缺失情况。
3.2 使用场景
- 从哈希表
get一个键(可能不存在)。 - 查找字符串中是否包含某个子串的位置(可能没有)。
- 链表或树结构的后继节点(可能为空)。
- 将
T转换为Option<T>表示该值可能不存在。
3.3 示例
创建 Option:
#![allow(unused)]
fn main() {
let some_number = Some(5); // 类型推断为 Option<i32>
let some_string = Some("hello".to_string());
let absent_number: Option<i32> = None; // 必须标注类型,因为 None 无法推断
}
安全地取出值(配合 match):
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
fn main() {
let result = divide(10.0, 2.0);
match result {
Some(value) => println!("结果: {}", value),
None => println!("除数不能为零"),
}
}
常用方法:
unwrap():取出Some的值,若为None则panic!。expect(msg):类似unwrap,但可自定义panic消息。unwrap_or(default):取出值或返回默认值。is_some()/is_none():判断。
#![allow(unused)]
fn main() {
let x: Option<i32> = None;
let y = x.unwrap_or(100); // y = 100
}
3.4 与 match 的密切关系
几乎每次使用 Option 都会涉及 match(或 if let / ?),因为编译器强制你处理 None 分支。
4. Result<T, E> —— 表示操作可能成功或失败
4.1 定义和内在含义
Result<T, E> 是另一个内置枚举,用于可能失败的操作,并携带错误信息:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T), // 成功,包含值 T
Err(E), // 失败,包含错误 E
}
}
与 Option 的区别:Option 只关心“有没有”,Result 关心“为什么会失败”。E 可以是任何类型,通常用标准库的 String、自定义错误枚举或 std::io::Error 等。
4.2 使用场景
- 文件打开、读取、写入。
- 网络请求。
- 字符串解析为数字(
"42".parse::<i32>()返回Result<i32, ParseIntError>)。 - 数据库查询。
4.3 示例
读取文件并返回 Result:
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::Read;
fn read_username_from_file(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path); // 返回 Result<File, std::io::Error>
let mut file = match file {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
}
这样写很冗长,稍后会介绍 ? 简化。但这里完美展示了 match 处理 Result 的方式。
解析数字:
fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
s.parse()
}
fn main() {
let result = parse_number("42a");
match result {
Ok(num) => println!("数字是 {}", num),
Err(e) => println!("解析错误: {}", e),
}
}
常用方法:
unwrap():成功返回T,失败panic!。expect(msg):类似。unwrap_or(default):失败返回默认值(注意此时丢失错误信息)。is_ok()/is_err():判断。ok():将Result<T,E>转为Option<T>(丢弃错误)。err():将Result<T,E>转为Option<E>(丢弃成功值)。
5. Option 和 Result 的表达语义区别
很多初学者会混淆两者,事实上它们表达的是完全不同层次的概念。下面从四个维度对比。
| 维度 | Option <T> | Result<T, E> |
|---|---|---|
| 核心语义 | 值可能存在也可能不存在。强调“有无”。 | 操作可能成功也可能失败。强调“成败”并附原因。 |
| 错误信息 | 无。None 只表示缺失,不解释为什么。 | 有。Err(e) 携带错误类型 E,可包含详细信息。 |
| 典型场景 | - 从容器中查找元素 <br>- 可选配置项 <br>- 链表末尾 | - 文件 I/O <br>- 网络请求 <br>- 解析字符串 |
| 合并错误处理 | 不适用,因为没有错误细节。 | 可链式传递错误,例如使用 ? 将 Err 向上传播。 |
| 是否包含失败原因 | 否 | 是 |
5.1 实例对比:查找 vs 解析
使用 Option(查找)
#![allow(unused)]
fn main() {
let map = std::collections::HashMap::from([("key", 42)]);
let value = map.get("key"); // Option<&i32>,可能为 None
match value {
Some(v) => println!("找到: {}", v),
None => println!("没有这个键"),
}
}
如果键不存在,你不需要知道为什么(比如是用户输入错误还是内部逻辑问题),只需要知道“没有”。这就是 Option 的合适场景。
使用 Result(解析)
#![allow(unused)]
fn main() {
let input = "42a";
let number: Result<i32, _> = input.parse(); // Result<i32, ParseIntError>
match number {
Ok(n) => println!("解析成功: {}", n),
Err(e) => println!("解析失败,原因: {}", e),
}
}
解析可能因为格式不对而失败,你需要知道失败的具体原因(例如包含了非数字字符),以便向用户报告或重试。这就是 Result 的场景。
5.2 相互转换
你可以根据需要在两者间转换:
Option::ok_or(E)将Option<T>转为Result<T, E>(None变为Err(E))。Result::ok()将Result<T,E>转为Option<T>(丢弃错误)。Result::err()转为Option<E>(丢弃成功值)。
#![allow(unused)]
fn main() {
let opt = Some(42);
let res = opt.ok_or("缺失"); // Result<i32, &str> -> Ok(42)
let res2: Result<i32, &str> = Err("error");
let opt2 = res2.ok(); // Option<i32> -> None
}
5.3 何时使用哪一个?
- 如果你只关心“有没有”,不关心“为什么没有” → 用
Option。 - 如果操作可能因为多种外部原因失败,并且你需要传达失败原因 → 用
Result。 - 编写库函数时,推荐使用
Result并提供有意义的错误类型,这样调用者可以做出恰当处理。 - 内部辅助函数或普通查找,
Option足够。
6. panic! —— 不可恢复错误
6.1 定义与行为
panic! 是一个宏,当程序遇到无法处理的错误时,它会:
- 打印错误信息(以及可选的自定义消息)。
- 展开(unwind)栈或直接中止(取决于编译配置)。
- 退出当前线程(如果是主线程则程序退出)。
fn main() {
panic!("发生了致命错误!"); // 程序输出错误信息后崩溃
println!("这行永远不会执行");
}
6.2 什么时候应该 panic?
Rust 的哲学:宁可崩溃,也不要继续执行非法状态。以下情况适合使用 panic!:
- 违反契约,例如数组索引越界(Rust 会自动
panic!)。 - 不可恢复的错误,比如违反关键业务不变量、程序进入无法继续的非法状态。
- 示例代码或原型中快速失败。
- 当你知道错误不可能发生时,对
Option或Result调用unwrap()/expect()(但生产代码应慎用)。
6.3 示例:显式 panic 与自动 panic
#![allow(unused)]
fn main() {
// 显式 panic
fn check_age(age: i32) {
if age < 0 {
panic!("年龄不能为负数!");
}
println!("年龄有效");
}
// 自动 panic:数组越界
let arr = [1, 2, 3];
println!("{}", arr[5]); // 编译可通过,运行时会 panic!
}
6.4 与 Result 的对比
Result用于可预见的、调用者应该处理的错误(如文件不存在)。panic!用于不可恢复的、调用者无法合理处理的错误(如程序逻辑错误)。
例如:如果 Vec::get 越界,它返回 Option,而不是 panic!(因为调用者可能想检查边界)。但直接用索引 vec[5] 就会 panic!,因为语言假设你确信索引有效。
6.5 如何捕获 panic(不推荐用于常规错误处理)
Rust 提供了 std::panic::catch_unwind,允许在当前线程捕获 panic!,但这主要用于需要保护不崩溃的场景(如 C++ 异常边界),不应该用来替代 Result。
#![allow(unused)]
fn main() {
use std::panic;
let result = panic::catch_unwind(|| {
panic!("崩溃了");
});
assert!(result.is_err());
}
通常你不需要这样做,保持“使用 Result 处理错误,使用 panic! 处理 bug”的原则即可。
7. 综合示例:从文件读取数字并求和
下面是一个完整的程序,综合运用了 match、Option、Result 和 panic!。程序从 numbers.txt 读取每行一个整数,计算总和,并处理各种错误。
use std::fs::File;
use std::io::{self, BufRead, BufReader};
/// 读取文件并返回每行解析后的 i32 向量,若文件不存在则返回错误。
fn read_numbers_from_file(path: &str) -> Result<Vec<i32>, io::Error> {
let file = File::open(path)?; // ? 遇到 Err 则提前返回
let reader = BufReader::new(file);
let mut numbers = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line?; // 读取行时可能出错
match line.trim().parse::<i32>() {
Ok(num) => numbers.push(num),
Err(e) => {
eprintln!("警告:第 {} 行解析失败: {} (内容: {})", line_num + 1, e, line);
// 继续处理其他行,而不是 panic
}
}
}
Ok(numbers)
}
fn main() {
let filename = "numbers.txt";
match read_numbers_from_file(filename) {
Ok(nums) => {
let sum: i32 = nums.iter().sum();
println!("成功读取 {} 个数字,总和为 {}", nums.len(), sum);
}
Err(e) => {
eprintln!("致命错误:无法读取文件 {}: {}", filename, e);
panic!("程序无法继续运行,因为缺少关键文件"); // 这里 panic! 表示不可恢复
}
}
}
解释:
- 用
Result<Vec<i32>, io::Error>表示可能 I/O 错误。 - 在
main中,若文件打开失败,输出错误并panic!(因为文件名是硬编码的,缺少则表明环境问题)。 - 解析每行时使用
match,对解析失败的行只警告,不中断整个程序(可恢复)。 - 使用
?操作符简化Result的传播
rust 结构体(Struct)
结构体是 Rust 中自定义复合数据类型的基础工具。它将多个可能不同类型的值组合成一个有意义的整体,就像现实中的“表单”或“档案”。
1. 结构体是什么?有什么作用?
功能作用结构体允许你把相关的数据打包成一个新的类型。例如,描述一个“学生”需要姓名、年龄、学号等数据,结构体就能把这些字段捆绑在一起。这样做的好处是:
- 代码更清晰,数据关系一目了然。
- 可以针对这个结构体实现专属的行为(方法)。
- 类型安全,编译器能帮你检查字段使用是否正确。
2. 结构体的基本规则(抽象结构)
// 定义结构体
struct 结构体名称 {
字段名1: 类型1,
字段名2: 类型2,
...
}
// 创建结构体实例-赋值(不可变)
let 实例 = 结构体名称 {
字段名1: 值1,
字段名2: 值2,
...
};
// 创建可变实例(可以修改字段)
let mut 实例 = 结构体名称 { ... };
实例.字段名1 = 新值;
// 访问字段
实例.字段名
- 每个字段必须声明类型,字段之间用逗号分隔。
- 默认情况下实例是不可变的,要修改字段需要将实例声明为
mut。 - 可以使用 结构体更新语法(
..other_instance)从另一个实例复制剩余字段。
3. 结构体示例
3.1 定义与实例化
// 定义一个用户结构体
struct User {
username: String,
email: String,
active: bool,
sign_in_count: u64,
}
fn main() {
// 创建不可变实例
let user1 = User {
email: String::from("alice@example.com"),
username: String::from("alice"),
active: true,
sign_in_count: 1,
};
println!("用户名: {}", user1.username);
// 创建可变实例,并修改 email
let mut user2 = User {
email: String::from("bob@example.com"),
username: String::from("bob"),
active: false,
sign_in_count: 0,
};
user2.email = String::from("bob_new@example.com");
println!("新邮箱: {}", user2.email);
}
3.2 使用字段初始化简写
当函数参数名与结构体字段名相同时,可以省略重复:
#![allow(unused)]
fn main() {
fn build_user(email: String, username: String) -> User {
User {
email, // 等价于 email: email
username, // 等价于 username: username
active: true,
sign_in_count: 1,
}
}
}
3.3 结构体更新语法
从已有实例快速创建新实例,只修改部分字段:
#![allow(unused)]
fn main() {
let user3 = User {
email: String::from("charlie@example.com"),
..user1 // 其余字段与 user1 相同(username、active、sign_in_count)
};
}
注意:更新语法会发生所有权的转移。如果原字段类型没有实现
Copytrait(如String),原实例将不再可用(这里user1的username被移动到了user3)。
4. 为结构体定义方法(impl 块)
方法类似于其他语言的成员函数,定义在 impl 块中。第一个参数通常是 &self(不可变引用)、&mut self(可变引用)或 self(获取所有权)。
4.1 方法的基本规则
impl 结构体名称 {
// 关联函数(没有 self 参数,通常用作构造器)
fn 函数名(参数列表) -> Self { ... }
// 实例方法(只读)
fn 方法名(&self, 其他参数) -> 返回类型 { ... }
// 实例方法(可修改)
fn 方法名(&mut self, 其他参数) { ... }
}
Self(大写 S)是当前结构体类型的别名。&self是self: &Self的缩写,表示对实例的不可变引用。- 调用方法时使用点语法:
实例.方法名()。
4.2 方法示例
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// 关联函数:构造函数
fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
// 实例方法:计算面积(只读)
fn area(&self) -> u32 {
self.width * self.height
}
// 实例方法:判断是否能容纳另一个矩形(只读)
fn can_hold(&self, other: &Rectangle) -> bool {
self.width >= other.width && self.height >= other.height
}
// 实例方法:放大两倍(修改自身)
fn double(&mut self) {
self.width *= 2;
self.height *= 2;
}
}
fn main() {
let rect = Rectangle::new(30, 50);
println!("面积: {}", rect.area());
let mut rect2 = Rectangle::new(10, 20);
rect2.double();
println!("double 后宽度: {}", rect2.width); // 20
}
5. 完整示例
下面的示例定义了一个 MyStruct 结构体,包含三个字段,并实现了关联函数 new 和实例方法 demo。最后在 demo 函数中演示了如何使用。
5.1 代码
#![allow(unused)]
fn main() {
// 定义一个结构体 MyStruct,包含 a、b、c 三个字段
// a、b 为整数类型,c 为字符串类型
struct MyStruct {
a: i32,
b: i32,
c: String,
}
// impl 关键字用于给结构体实现方法
impl MyStruct {
// 关联函数 new,用作构造函数
// Self 关键字代表当前结构体类型(即 MyStruct)
fn new() -> Self {
Self {
a: 0,
b: 0,
c: String::from("hello"),
}
}
// 实例方法 demo
// &self 表示当前结构体实例的不可变引用(类似于其他语言的 this)
fn demo(&self) {
let res = self.a + self.b;
println!("res: {} , c: {}", res, self.c);
}
}
pub fn demo() {
println!("...............结构体示例开始.................");
// 创建 MyStruct 实例,使用 :: 调用关联函数 new
let ms = MyStruct::new();
// 调用实例方法 demo
ms.demo();
println!("...............结构体示例结束.................");
}
}
trait 特征
trait 是 Rust 中表达“某个类型具备某种能力”的核心机制。
本章分两部分:
- trait 的基础语法:定义 trait、实现 trait、调用 trait 方法。
- trait 用于泛型约束:用 trait bound 约束泛型参数,让泛型代码既通用又安全。
如果你熟悉其他语言,可以暂时把 trait 理解成“接口”,但 Rust 的 trait 还承担了泛型约束、静态分发、动态分发等更多职责。
rust Trait
在 Rust 中,trait 是一个非常核心的概念。如果你接触过其他面向对象语言(比如 Java、C# 或 C++),可以把 trait 理解为 接口(interface) 的升级版。
它允许你定义一些共同的行为,然后让不同的类型去实现这些行为。
通过 trait,Rust 实现了多态和代码复用,同时保持了极高的运行效率。
1. 什么是 Trait?
trait 是 Rust 中用于定义共享行为的语言特性。
它描述了一个类型能做什么,而不是它是什么。
举个例子:
- 动物可以“吃”和“喝”,这是一个行为集合,可以定义为一个
trait。 - 狗、猫、人都可以“吃”和“喝”,所以他们都可以实现这个
trait。
通过 trait,你可以编写与具体类型无关的通用代码,只要传入的类型实现了所需的行为(trait)即可。
2. Trait 的本质是什么?
理解 trait 的本质,有助于你更深入地使用它:
- 组合优于继承:Rust 没有类的继承,而是通过
trait来组合行为。你可以为一个类型实现多个trait。 - 可以包含默认实现:
trait中的方法可以提供默认实现,这样实现trait的类型可以选择重写或不重写。 - 行为契约:
trait定义了一组必须实现的方法签名。实现该trait的类型必须提供这些方法的具体实现。 - 静态分发:Rust 默认使用泛型 + trait bound 实现静态分发。编译器会在编译时确定具体调用哪个方法,没有运行时开销(不像 Java 的虚表调用)。
- 可以作为参数约束:函数可以接受“实现了某个
trait的任何类型”。
3. Trait 的基本语法
3.1 定义 Trait
使用 trait 关键字,后面跟着名字和大括号。在大括号内定义方法签名(可以没有具体实现)。
语法结构(中文描述):
trait 特征名 {
fn 方法名(&self, 其他参数...) -> 返回值类型;
// 也可以提供默认实现
fn 另一个方法(&self) {
// 默认实现代码
}
}
注意:
&self表示方法的接收者,等同于self: &Self。它代表调用该方法的实例(不可变借用)。- 也可以用
&mut self(可变借用)或self(转移所有权)。 - 如果没有
self参数,那就是关联函数(类似其他语言的静态方法)。
3.2 为类型实现 Trait
使用 impl TraitName for TypeName 语法,然后在代码块中实现 trait 定义的所有方法(未提供默认实现的方法)。
语法结构:
impl 特征名 for 类型名 {
fn 方法名(&self, ...) {
// 具体实现
}
}
3.3 调用 Trait 方法
当类型实现了某个 trait 后,可以直接在实例上调用该 trait 提供的方法(就像调用普通方法一样)。
此外,Rust 的自动引用解引用规则会帮助你在需要时自动借用。
4. 语法调用示例
下面通过几个简单的例子,展示 trait 的定义和实现。
4.1 示例:简单的 trait 与实现
// 定义一个叫“发出声音”的 trait
trait Sound {
fn make_sound(&self);
}
// 定义两个类型:猫和狗
struct Cat;
struct Dog;
// 为 Cat 实现 Sound
impl Sound for Cat {
fn make_sound(&self) {
println!("喵~");
}
}
// 为 Dog 实现 Sound
impl Sound for Dog {
fn make_sound(&self) {
println!("汪汪!");
}
}
fn main() {
let cat = Cat;
let dog = Dog;
cat.make_sound(); // 输出:喵~
dog.make_sound(); // 输出:汪汪!
}
4.2 示例:带默认实现的方法
trait Greeting {
// 默认实现
fn greet(&self) {
println!("Hello!");
}
// 没有默认实现,必须被实现
fn greet_personally(&self, name: &str);
}
struct Person;
impl Greeting for Person {
// 可以不实现 greet,使用默认的
fn greet_personally(&self, name: &str) {
println!("Hi, {}!", name);
}
}
fn main() {
let p = Person;
p.greet(); // 调用默认实现:Hello!
p.greet_personally("Tom"); // 调用自己的实现:Hi, Tom!
}
4.3 示例:Trait 作为函数参数(trait bound)
#![allow(unused)]
fn main() {
fn notify(item: &impl Sound) {
item.make_sound();
}
// 等价写法(使用 where 子句更清晰)
fn notify2<T: Sound>(item: &T) {
item.make_sound();
}
}
5. 完整示例代码及详细讲解
下面我们结合你给出的代码,逐段分析一个完整的 trait 使用场景。
#![allow(unused)]
fn main() {
// 定义一个 trait,用于描述动物的基本行为
trait BaseBehavior {
fn eat(&self);
fn drink(&self);
}
// 定义一个狗结构体,包含名字
struct Dog {
name: String,
}
// 为狗实现 BaseBehavior
impl BaseBehavior for Dog {
fn eat(&self) {
println!("dog {} eat", self.name);
}
fn drink(&self) {
println!("dog {} drink", self.name);
}
}
// 定义一个人结构体,也包含名字
struct Person {
name: String,
}
// 为人实现 BaseBehavior
impl BaseBehavior for Person {
fn eat(&self) {
println!("person {} eat", self.name);
}
fn drink(&self) {
println!("person {} drink", self.name);
}
}
pub fn demo() {
println!("...............trait示例开始...................");
// 创建 Dog 实例
let dog = Dog { name: String::from("wxh") };
dog.eat(); // 输出:dog wxh eat
dog.drink(); // 输出:dog wxh drink
// 创建 Person 实例
let person = Person { name: String::from("zhangsan") };
person.eat(); // 输出:person zhangsan eat
person.drink(); // 输出:person zhangsan drink
println!("...............trait示例结束...................");
}
}
5.1 代码详细讲解
5.1.1 步骤:定义 trait BaseBehavior
#![allow(unused)]
fn main() {
trait BaseBehavior {
fn eat(&self);
fn drink(&self);
}
}
- 这里定义了一个名为
BaseBehavior的 trait。 - 它规定:任何实现
BaseBehavior的类型,必须提供eat和drink两个方法。 - 方法参数中的
&self表示方法不可变借用自身,意味着调用方法不会取得所有权,也不会修改实例。
5.1.2 步骤:定义结构体 Dog 和 Person
#![allow(unused)]
fn main() {
struct Dog {
name: String,
}
struct Person {
name: String,
}
}
- 两个简单的结构体,各自拥有一个
name字段(String类型)。 - 它们本身没有定义任何方法,但是可以通过
impl BaseBehavior for ...来获得行为。
5.1.3 步骤:为 Dog 实现 BaseBehavior
#![allow(unused)]
fn main() {
impl BaseBehavior for Dog {
fn eat(&self) {
println!("dog {} eat", self.name);
}
fn drink(&self) {
println!("dog {} drink", self.name);
}
}
}
impl BaseBehavior for Dog告诉 Rust:我们现在为Dog类型实现BaseBehavior这个 trait。- 在大括号内,分别实现
eat和drink方法,使用self.name访问狗的名字(注意self是&Dog类型,所以字段可以直接访问)。 - 两个方法只是打印一句话,展示哪个狗正在吃/喝。
5.1.4 步骤:为 Person 实现 BaseBehavior
同理,为 Person 提供实现,方法内容稍有不同,打印 person ...。
5.1.5 步骤:在 demo 函数中使用
#![allow(unused)]
fn main() {
pub fn demo() {
let dog = Dog { name: String::from("wxh") };
dog.eat();
dog.drink();
let person = Person { name: String::from("zhangsan") };
person.eat();
person.drink();
}
}
- 创建
Dog实例时,需要传入name的所有权(String::from在堆上分配字符串)。 - 调用
dog.eat():Rust 会自动查找Dog上可用的方法,发现它实现了BaseBehavior中的eat,于是执行对应的代码。 person同理。- 整个过程非常直观:
dog.eat()和person.eat()虽然名字相同,但实际执行的是各自独立的实现。
rust 泛型约束入门指南
1. 什么是泛型约束?
泛型约束是对泛型参数施加的“限制”或“要求”。它规定了该泛型类型必须实现哪些 trait(或者满足其他条件),从而允许在泛型代码内部调用这些 trait 提供的方法。
简单理解:泛型 <T> 表示“任意类型”,而带约束的泛型 <T: SomeTrait> 表示“必须是实现了 SomeTrait 的某个类型”。
1.1 示例对比
- 无约束:你只能对
T做最基本的操作(移动、借用、赋值等)。 - 有约束:你可以调用
T实现的所有 trait 方法,例如T: Display允许println!("{}", value)。
2. 为什么需要泛型约束?
假设我们要编写一个函数,找出两个值中较大的一个并返回。直觉上可以这样写:
#![allow(unused)]
fn main() {
fn max<T>(a: T, b: T) -> T {
if a > b { a } else { b }
}
}
但这段代码无法编译,因为 Rust 不知道 T 能否进行比较(> 操作)。编译器会报错说 T 没有实现 std::cmp::PartialOrd trait。
这就是需要泛型约束的原因:你必须明确告诉 Rust,T 必须具备可比较的能力。
2.1 约束的目的
- 让编译器接受操作:只有当你声明了约束,编译器才允许调用相应的方法(如
>、+、to_string()等)。 - 提供更精确的 API:约束明确了函数/结构体对类型的要求,调用者可以清楚知道需要传入什么样的类型。
- 保持类型安全:编译时检查约束,避免运行时因为缺少方法而崩溃。
3. 泛型约束的语法
3.1 基本语法结构
// 单个约束
fn 函数名<T: Trait名>(参数: T) { ... }
// 多个约束(使用 +)
fn 函数名<T: Trait1 + Trait2>(参数: T) { ... }
// where 子句(更清晰)
fn 函数名<T>(参数: T)
where
T: Trait1 + Trait2,
{ ... }
同样的约束语法适用于:
- 泛型函数
- 泛型结构体的 impl 块
- 泛型 trait 实现
3.2 简单调用示例片段
3.2.1 示例:函数中使用 PartialOrd 约束进行比较
#![allow(unused)]
fn main() {
// 约束 T 必须能比较大小(实现 PartialOrd)
fn max<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
// 调用示例
let largest = max(10, 20); // T 为 i32(实现了 PartialOrd)
let largest_float = max(3.14, 2.71); // T 为 f64
}
3.2.2 示例:使用 Debug 约束打印调试信息
#![allow(unused)]
fn main() {
use std::fmt::Debug;
// 约束 T 必须实现 Debug trait,才能用 {:?} 打印
fn print_debug<T: Debug>(value: T) {
println!("值是: {:?}", value);
}
// 调用示例
print_debug(42);
print_debug("hello");
}
3.2.3 示例:结构体上的约束(多约束 + where 子句)
#![allow(unused)]
fn main() {
use std::fmt::{Debug, Display};
struct Wrapper<T> {
value: T,
}
impl<T: Debug + Display> Wrapper<T> {
fn show(&self) {
println!("Display: {}, Debug: {:?}", self.value, self.value);
}
}
// 或者使用 where 子句(推荐)
impl<T> Wrapper<T>
where
T: Debug + Display,
{
fn show(&self) {
println!("Display: {}, Debug: {:?}", self.value, self.value);
}
}
}
3.2.4 示例:trait 实现中的类型约束
#![allow(unused)]
fn main() {
trait Speaker {
fn speak(&self);
}
// 约束泛型参数 T 必须实现 Speaker
fn announce<T: Speaker>(item: T) {
item.speak();
}
// 假设某个类型实现了 Speaker...
struct Dog;
impl Speaker for Dog {
fn speak(&self) {
println!("汪汪!");
}
}
// 调用示例
announce(Dog);
}
4. 综合示例:带约束的泛型容器
下面是一个更完整的例子:实现一个可存储值的容器,并要求该值能够进行显示输出(Display)和比较(PartialOrd),并实现一个求最大值的方法。
use std::fmt::Display;
use std::cmp::PartialOrd;
// 结构体:存储一个值,约束 T 必须能比较大小和显示
struct Container<T> {
item: T,
}
impl<T: PartialOrd + Display> Container<T> {
// 构造函数(无额外约束)
fn new(item: T) -> Self {
Container { item }
}
// 返回当前值
fn get(&self) -> &T {
&self.item
}
// 比较当前值和另一个值,返回较大的那个(并打印信息)
fn max_with(&self, other: &T) -> T
where
T: Clone, // 额外约束:需要克隆能力来返回新值
{
if self.item > *other {
println!("当前值 {} 更大", self.item);
self.item.clone()
} else {
println!("比较值 {} 更大", other);
other.clone()
}
}
}
// 一个泛型函数:找出三个值中最大的(约束:PartialOrd + Display)
fn max_of_three<T: PartialOrd + Display>(a: T, b: T, c: T) -> T {
let max_ab = if a > b { a } else { b };
if max_ab > c { max_ab } else { c }
}
fn main() {
// 整数类型演示
let container = Container::new(42);
let other = 100;
let bigger = container.max_with(&other);
println!("较大者是: {}", bigger);
// 浮点数演示
let c1 = Container::new(3.14);
let max_val = max_of_three(2.71, 3.14, 2.99);
println!("三个数中最大的是: {}", max_val);
// 尝试用不支持 PartialOrd 的类型(如结构体)会导致编译错误
// struct NotComparable;
// let bad = Container::new(NotComparable); // 错误:NotComparable 未实现 PartialOrd
}
4.1 代码讲解
- 结构体定义:
struct Container<T>本身没有约束(因为只需存储)。约束加在impl块上:impl<T: PartialOrd + Display> Container<T>,表示只有同时实现了这两个 trait 的类型,才能拥有这些方法。 - 方法的额外约束:
max_with方法内部需要返回一个T类型的新值。因为item是借用,不能直接返回所有权,所以要求T: Clone,这样可以通过.clone()复制一份返回。注意这里使用了where T: Clone子句,清晰表达额外需求。 - 泛型函数:
max_of_three要求T: PartialOrd + Display,这样就能安全地比较和使用println!打印。 - 运行时行为:
max_with比较时打印了信息,体现了Display的作用。 - 错误示例注释:如果尝试使用没有实现
PartialOrd的类型(例如自定义结构体),编译会失败,这展示了约束的类型安全保障。
4.2 补充:给学过 C++20 Concepts 的读者
如果你已经熟悉 C++20 的 Concepts(概念),那么 Rust 的泛型约束本质上就是概念约束
如果你会用 C++20 的 concept + requires,那么 Rust 的 trait + 约束语法会让你感到非常自然,只是多了一个“接口/多态”的额外用途。
| 特性 | C++20 Concepts | Rust Trait 约束 |
|---|---|---|
| 定义约束 | concept 关键字 + 布尔表达式 | trait 关键字 + 方法签名集合 |
| 应用于泛型 | template<C T> 或 requires C<T> | <T: Trait> 或 where T: Trait |
| 编译时检查 | 要求类型满足概念的所有条件 | 要求类型实现 trait 的所有方法 |
| 错误信息 | 清晰提示哪个约束未满足 | 同样清晰,指出缺失的 trait 实现 |
相似点:
- 两者都是编译时约束,确保泛型参数具备所需能力。
- 都支持多约束组合(
T: Trait1 + Trait2类似conjunction)。 - 都能显著改善编译错误信息,避免模板/泛型爆出无法理解的错误。
- 都可以为现有类型“添加”约束(通过实现 trait / 特化概念)。
差异点:
- Rust 的 trait 既是约束(concept),也是一种接口抽象(类似 C++ 抽象基类),而 C++20 concept 纯粹是编译期约束,不能定义接口并且实现。
rust 模块
1. 模块基础概念
1.1 什么是模块
模块(module)是 Rust 中组织代码的基本单元。一个模块可以包含函数、结构体、枚举、常量、trait 甚至其他模块。模块形成树状结构,根模块是 crate 根(通常是 main.rs 或 lib.rs)。
1.2 定义模块 – mod 关键字
功能:声明一个模块,并可以内联模块内容或从外部文件加载。
语法:
#![allow(unused)]
fn main() {
// 内联模块
mod my_module {
// 模块内容
}
// 模块可以嵌套
mod parent {
mod child {
// ...
}
}
}
- 模块名使用
snake_case命名规范。 - 模块内容默认私有(不可从外部访问),需要使用
pub暴露。
1.3 将模块拆分到文件
当模块内容很长时,可以将模块放在单独的文件中。
- 传统方式(Rust 2015/2018 兼容):
- 创建
my_module.rs,内容直接写模块内的代码。 - 在
main.rs或lib.rs中写mod my_module;(注意分号,没有花括号),会自动加载同名的.rs文件。
- 创建
- 现代方式(Rust 2018+ 推荐):
- 在
main.rs同目录下创建my_module.rs,内容和上面一样。 - 如果模块内部还有子模块,可以创建
my_module/目录,并在其中放置mod.rs或使用同名文件。但 Rust 2018 引入了新的路径规则:父模块文件parent.rs,子模块放在parent/child.rs中。
- 在
示例:
// main.rs
mod my_module; // 寻找 my_module.rs 或 my_module/mod.rs
fn main() {
my_module::hello();
}
#![allow(unused)]
fn main() {
// my_module.rs
pub fn hello() {
println!("Hello from module!");
}
}
2. 模块引用与路径
2.1 绝对路径与相对路径
Rust 使用类似文件系统的路径来引用模块中的项。路径的分隔符是双冒号 ::。
- 绝对路径:从 crate 根开始,使用
crate::。 - 相对路径:从当前模块开始,可以使用
self::(当前模块)或super::(父模块)。
2.2 路径语法 – ::
:: 是路径分隔符,用于在不同层级之间导航。
示例:
mod a {
pub mod b {
pub fn f() {}
}
}
fn main() {
// 绝对路径
crate::a::b::f();
// 相对路径
a::b::f();
}
crate::表示当前 crate 的根。super::表示父模块。self::表示当前模块(通常可省略)。
2.3 使用 use 引入路径
use 可以将路径绑定为一个短名称,避免每次书写完整路径。
基本用法:
use crate::a::b::f;
// 或者
use a::b::f;
fn main() {
f(); // 直接调用,无需 a::b::f
}
引入多个项:
#![allow(unused)]
fn main() {
use std::io::{self, Read, Write}; // self 表示模块本身
use std::collections::{HashMap, HashSet};
}
重命名:
#![allow(unused)]
fn main() {
use std::fmt::Result as FmtResult;
}
重新导出(pub use):将引入的项作为当前模块的公共接口暴露出去。
#![allow(unused)]
fn main() {
mod my_module {
pub use std::collections::HashMap; // 外部可以通过 my_module::HashMap 访问
}
}
3. 访问控制(可见性)
Rust 的所有项(函数、结构体、字段、模块等)默认是私有的,只有同一模块或子模块可以访问。使用 pub 关键字可以公开。
3.1 pub – 公开可见性
功能:使一个项对外部模块可见。
mod outer {
pub fn public_fn() {} // 外部可见
fn private_fn() {} // 仅 outer 模块内可见
pub mod inner {
pub fn inner_public() {}
fn inner_private() {}
}
}
fn main() {
outer::public_fn(); // 可以
// outer::private_fn(); // 错误:私有
outer::inner::inner_public(); // 可以(因为 inner 是 pub,且 inner_public 也是 pub)
}
3.2 结构体字段的可见性
结构体字段默认也是私有。即使结构体本身是 pub,字段仍需要单独 pub 才能从外部访问。
#![allow(unused)]
fn main() {
pub struct User {
pub name: String, // 公开字段
age: u8, // 私有字段
}
impl User {
pub fn new(name: String, age: u8) -> Self {
User { name, age }
}
pub fn age(&self) -> u8 { self.age } // 提供 getter 访问私有字段
}
}
3.3 枚举变体的可见性
枚举如果标记为 pub,其所有变体自动公开。
#![allow(unused)]
fn main() {
pub enum Color {
Red,
Green,
Blue,
}
// 外部可以访问 Color::Red 等
}
3.4 pub(crate) – crate 内可见
功能:限制可见性仅当前 crate 内,对外部 crate 不可见。
#![allow(unused)]
fn main() {
pub(crate) fn internal_helper() {} // 整个 crate 内可访问
}
3.5 pub(super) – 父模块可见
#![allow(unused)]
fn main() {
mod parent {
pub(super) fn visible_in_parent() {} // 仅父模块内可见
}
}
3.6 pub(in path) – 指定路径内可见
#![allow(unused)]
fn main() {
pub(in crate::some::path) fn restricted() {} // 仅在 crate::some::path 模块内可见
}
4. crate 根与执行入口
4.1 什么是 crate
crate 是 Rust 的编译单元。每个 crate 对应一个库或可执行文件。crate 有一个根模块,所有其他模块都挂在根下。
4.2 可执行 crate(binary crate)
- 根文件默认为
main.rs(也可以通过在Cargo.toml中指定)。 - 根模块中包含
main函数,作为程序入口。 - 使用
mod声明的其他模块,编译器会寻找对应文件。
4.3 库 crate(library crate)
- 根文件默认为
lib.rs。 - 没有
main函数,而是对外导出 API。 - 可以被其他 crate 依赖。
4.4 crate:: 根路径
在任何模块中,crate:: 都指向当前 crate 的根模块(即 main.rs 或 lib.rs 的顶层)。
#![allow(unused)]
fn main() {
// 在任意模块中
crate::some_function(); // 调用根模块中的函数
}
4.5 外部 crate 的引用
使用 extern crate(Rust 2018 后通常不需要显式写,因为 use 会自动引入)。但为了兼容或特殊需求,仍可使用。
#![allow(unused)]
fn main() {
// 在 Cargo.toml 中依赖后,直接 use
use rand::Rng;
use std::collections::HashMap; // 标准库
}
5. 模块文件组织示例
5.1 项目结构
假设一个项目如下:
my_project/
├── Cargo.toml
└── src/
├── main.rs
├── lib.rs
├── graphics.rs
├── math/
│ ├── mod.rs
│ └── vector.rs
└── utils/
├── mod.rs
└── logging.rs
5.2 文件内容示例
src/main.rs(可执行 crate 根):
mod graphics; // 加载 graphics.rs
mod math; // 加载 math/mod.rs
mod utils; // 加载 utils/mod.rs
fn main() {
graphics::render();
math::add(1, 2);
utils::logging::info("Hello");
}
src/graphics.rs:
#![allow(unused)]
fn main() {
pub fn render() {
println!("Rendering...");
}
}
src/math/mod.rs:
#![allow(unused)]
fn main() {
mod vector; // 加载 math/vector.rs
pub use vector::Vector; // 重新导出
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
}
src/math/vector.rs:
#![allow(unused)]
fn main() {
pub struct Vector {
pub x: f64,
pub y: f64,
}
}
src/utils/mod.rs:
#![allow(unused)]
fn main() {
pub mod logging; // 加载 utils/logging.rs
}
src/utils/logging.rs:
#![allow(unused)]
fn main() {
pub fn info(msg: &str) {
println!("[INFO] {}", msg);
}
}
5.3 路径引用示例
#![allow(unused)]
fn main() {
// 在 main.rs 中
crate::graphics::render();
crate::math::Vector { x: 1.0, y: 2.0 };
crate::utils::logging::info("start");
// 在 math/mod.rs 中
use crate::utils::logging; // 绝对路径
super::graphics::render(); // 相对路径(父模块)
}
6. 完整示例:模块化计算器
下面是一个使用模块组织的简单计算器,展示模块定义、可见性、路径和 use。
6.1 项目结构
calculator/
├── Cargo.toml
└── src/
├── main.rs
└── operations.rs
6.2 src/operations.rs
#![allow(unused)]
fn main() {
// 子模块(内联)
mod internal {
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn sub(a: i32, b: i32) -> i32 { a - b }
fn helper() { /* private */ }
}
pub use internal::{add, sub}; // 重新导出,对外公开 add 和 sub
pub fn mul(a: i32, b: i32) -> i32 {
a * b
}
pub fn div(a: i32, b: i32) -> Option<i32> {
if b == 0 { None } else { Some(a / b) }
}
}
6.3 src/main.rs
mod operations; // 加载 operations.rs
use operations::{add, sub, mul, div}; // 引入这些函数
fn main() {
let x = 10;
let y = 5;
println!("{} + {} = {}", x, y, add(x, y));
println!("{} - {} = {}", x, y, sub(x, y));
println!("{} * {} = {}", x, y, mul(x, y));
match div(x, y) {
Some(v) => println!("{} / {} = {}", x, y, v),
None => println!("除数不能为零"),
}
}
Rust 函数与闭包
1. Rust 函数是第一公民
在深入闭包之前,我们需要先理解一个更根本的事实:在 Rust 中,函数本身就是第一公民(first-class citizen)。这意味着函数不只是一段顶层的声明——它是值,可以被绑定到变量、作为参数传递、从其他函数返回。
1.1 函数是值
每一个 fn 定义的函数都有一个独特的、编译期确定的函数项类型(function item type)。此类型的零大小(ZST)值就是该函数本身:
#![allow(unused)]
fn main() {
fn add(x: i32, y: i32) -> i32 { x + y }
let f = add; // f 的类型是 fn(i32, i32) -> i32 的函数指针
let result = f(1, 2); // 调用 f,等价于 add(1, 2)
assert_eq!(result, 3);
}
add 本身是一个函数项,当它被绑定到变量 f 时,会自动强制转换(coerce)为函数指针类型 fn(i32, i32) -> i32。函数指针是一个普通的值——它可以被复制、比较(==)、作为参数传递。
1.2 函数作为参数
既然函数指针是值,我们就可以把它传给另一个函数:
#![allow(unused)]
fn main() {
fn add(x: i32, y: i32) -> i32 { x + y }
fn mul(x: i32, y: i32) -> i32 { x * y }
// 接受一个函数指针作为参数
fn apply(f: fn(i32, i32) -> i32, a: i32, b: i32) -> i32 {
f(a, b)
}
assert_eq!(apply(add, 2, 3), 5);
assert_eq!(apply(mul, 2, 3), 6);
}
也可以用泛型来接受任何可调用类型(函数指针、闭包、甚至自定义的 callable 结构体):
#![allow(unused)]
fn main() {
fn apply_generic<F>(f: F, a: i32, b: i32) -> i32
where
F: Fn(i32, i32) -> i32,
{
f(a, b)
}
// 函数指针和闭包都能传进来
assert_eq!(apply_generic(add, 2, 3), 5);
assert_eq!(apply_generic(|x, y| x - y, 5, 2), 3);
}
1.3 函数作为返回值
函数可以返回另一个函数:
#![allow(unused)]
fn main() {
fn select_op(kind: &str) -> fn(i32, i32) -> i32 {
match kind {
"add" => add,
"mul" => mul,
_ => |_, _| 0, // 无捕获的闭包可以转为函数指针
}
}
let op = select_op("add");
assert_eq!(op(10, 20), 30);
}
注意:这里只能返回无捕获的闭包(它可转为 fn 指针)或者其他顶层函数。想返回携带状态的闭包,需要用 impl Fn 或 Box<dyn Fn>(后面会讲)。
1.4 函数的局限:无法携带状态
函数指针很轻量(8 字节),但有一个根本性的限制:它只能访问参数,无法携带任何上下文。
#![allow(unused)]
fn main() {
fn make_adder(base: i32) -> ??? {
// 我们想返回一个”把 base 加到参数上”的可调用物
// 但 fn 指针无法捕获外部变量 base
// 这里 fn(i32) -> i32 做不到!
}
}
这就引出了闭包——函数携带上环境。闭包不是什么全新的概念,它只是在函数的基础上增加了”捕获外部变量”的能力。理解了函数是第一公民之后,闭包就是”能带数据的函数”。
2. 闭包:带环境的函数
2.1 直观感受
闭包(Closure)是匿名函数 + 捕获的环境的组合体。它的语法轻量,最常见的定义方式是直接用 |参数| 表达式:
#![allow(unused)]
fn main() {
let base = 10;
let add_base = |x: i32| x + base; // 捕获了外部变量 base
assert_eq!(add_base(5), 15);
}
你看这个 add_base,它做的事跟函数一样(接受参数、返回结果),但它记住了定义时所在作用域里的 base。这就是闭包比函数多出来的能力。
在 Rust 中,闭包几乎无处不在:
.map(|x| x * 2)——迭代器适配.filter(|x| *x > 0)——条件过滤thread::spawn(move || { ... })——跨线程传递Option::unwrap_or_else(|| ...)——惰性默认值
2.2 语法结构
闭包语法的完整形式是:
|param1, param2, ...| -> ReturnType {
// 闭包体(多条语句)
expression
}
具体规则:
- 参数放在一对竖线
| |之间。类型通常省略,由编译器从调用上下文推断;必要时可以显式标注,如|x: i32, y: i32| -> i32 { x + y }。 - 花括号
{}在闭包体只有一条表达式时可以省略:|a, b| a + b。 - 返回值的类型由闭包体最后一行表达式决定,自动推导。想返回单元值
(),可以写分号或者显式{ ...; }。 - 类型锁定:参数和返回值的类型在第一次调用时就被确定下来,后续调用必须沿用。同一个闭包不能上一次当
i32 -> i32用,下一次又当f64 -> f64用。
2.3 简单示例
#![allow(unused)]
fn main() {
// 无参数,不返回值
let say_hello = || println!("Hello");
say_hello();
// 单表达式,类型全部由编译器推断
let add = |a, b| a + b;
let result = add(3, 5); // result: i32 = 8
// 带显式类型注解
let mul = |x: f64, y: f64| -> f64 { x * y };
let product = mul(2.5, 4.0); // 10.0
// 多语句闭包
let compute = |x: i32| {
let y = x + 10;
y * 2
};
assert_eq!(compute(5), 30);
}
3. 函数与闭包的全面对比
3.1 定义方式对比
#![allow(unused)]
fn main() {
// 函数:顶层定义,必须有名字
fn add_fn(a: i32, b: i32) -> i32 {
a + b
}
// 闭包:匿名定义,绑定到变量
let add_cl = |a: i32, b: i32| -> i32 { a + b };
}
3.2 本质差异分析
| 维度 | 函数(fn) | 闭包 |
|---|---|---|
| 名称 | 必须有函数名(项级函数) | 匿名,通常绑定到变量或直接传参 |
| 捕获环境 | 无法捕获外部变量(只能通过参数传入) | 可捕获定义域中的变量,支持不可变借用、可变借用、移动所有权三种方式 |
| 类型 | 拥有唯一的函数指针类型(如 fn(i32) -> i32) | 每个闭包表达式生成一个独特的匿名类型,即便签名相同,两个闭包的类型也不同 |
| trait 实现 | 所有同签名的函数共享同一个 fn 指针类型,且自动实现 Fn、FnMut、FnOnce | 根据捕获方式自动实现 FnOnce,可能同时实现 FnMut 或 Fn(见第五节) |
| 大小 | 零大小(函数项)或指针(fn 指针 = 8 字节) | 大小由捕获的环境决定,可能为 0(无捕获)、8(引用)、或更大(移动的值) |
| 内联优化 | 编译器可内联 | 编译器同样可内联(类型已知),通常与泛型配合实现零成本抽象 |
3.3 关键理解:闭包 ≠ 函数指针
闭包不是函数指针。它是一个编译器生成的匿名结构体,同时包含了函数指针和捕获的变量。当一个闭包完全不捕获任何环境变量时,它可以被强制转换为函数指针:
#![allow(unused)]
fn main() {
let no_cap = |x: i32| x + 1;
let f: fn(i32) -> i32 = no_cap; // 无捕获的闭包 → 函数指针
}
一旦闭包捕获了环境,这种强制转换就不允许——此时闭包携带了数据,不再是一个纯指针。这种场景只能用泛型(F: Fn(...))或 trait 对象(Box<dyn Fn(...)>)来接收。
3.4 什么时候用函数,什么时候用闭包?
| 场景 | 推荐 | 原因 |
|---|---|---|
| 独立逻辑、不需要外部数据 | 函数 | 有名字、可复用、类型清晰 |
迭代器适配(.map、.filter) | 闭包 | 轻量、就地定义、代码紧凑 |
| 需要携带上下文 | 闭包 | 函数做不到 |
作为 HashMap 的 key | 函数指针 fn | 实现了 Eq + Hash |
| 与 C FFI 交互 | 函数指针 fn | ABI 稳定,extern "C" fn |
| 作为 trait 约束 | 泛型 F: Fn(...) | 同时接受函数和闭包,最灵活 |
4. 闭包的捕获方式
Rust 编译器会根据闭包体对捕获变量的使用方式,自动选择开销最小、权限最低的捕获方式:
4.1 不可变借用(&T)
当闭包体只需要读取外部变量时,编译器会为其生成一个捕获 &T 的闭包结构体。此时闭包实现了 Fn trait(以及 FnMut、FnOnce)。
#![allow(unused)]
fn main() {
let msg = String::from("hello");
let print_msg = || println!("{}", msg); // 只读,捕获 &msg
print_msg();
println!("{}", msg); // 外部仍可使用 msg
}
4.2 可变借用(&mut T)
当闭包体需要修改外部变量时,捕获方式变为 &mut T,闭包因此实现 FnMut(以及 FnOnce),但不实现 Fn。这也意味着该闭包在借用期间会排他性地持有变量,外部不能同时访问。
let mut counter = 0; let mut inc = || { counter += 1; // 需要修改 counter,捕获 &mut counter println!(“{}”, counter); }; inc(); // println!(“{}”, counter); // 错误:counter 仍被可变借用 inc(); // 可再次调用 drop(inc); // 释放闭包,归还借用 println!(“{}”, counter); //
4.3 获取所有权(移动 T)
当闭包需要接管变量所有权(例如将变量移动到另一线程、或者变量在闭包内被消费、drop)时,编译器会直接将变量移动进闭包结构体。该闭包仅实现 FnOnce,因为调用它即消耗闭包内的值。
#![allow(unused)]
fn main() {
let owned_str = String::from("world");
let consume = || {
let s = owned_str; // 强制移动,捕获所有权
println!("{}", s);
// s 在此被 drop
};
consume();
// consume(); // 错误:consume 已被消耗(FnOnce)
}
4.4 move 关键字强制移动
可以在闭包声明前加上 move 关键字,强制闭包获取捕获变量的所有权,无论闭包体是否真的需要所有权。这在跨线程传递、异步任务中非常常见。
#![allow(unused)]
fn main() {
let name = String::from("Alice");
let printer = move || println!("{}", name);
// name 已被移动,此处不能再使用
printer();
}
捕获方式总结:
- &T → 实现
Fn(以及FnMut、FnOnce) - &mut T → 实现
FnMut(以及FnOnce),不实现Fn - T(所有权移动)→ 仅实现
FnOnce
编译器优先选择借用,只有在必须时才升级为可变借用或所有权移动。
5. 闭包的类型
每个闭包表达式都拥有一个唯一的、不可显式书写的匿名类型。该类型根据捕获方式自动实现以下三个 trait 中的某几个。这三个 trait 定义在标准库的 std::ops 模块中:
5.1 FnOnce —— 调用即消耗
#![allow(unused)]
fn main() {
pub trait FnOnce<Args> {
type Output;
// 签名:消耗 self,返回 Output
fn call_once(self, args: Args) -> Self::Output;
}
}
- 形参:
self(按值接收闭包自身),以及调用参数args。 - 返回值:关联类型
Output,即闭包返回值类型。 - 所有闭包至少实现该 trait。如果一个闭包会从环境中移动值,则它仅实现
FnOnce,只能被调用一次。
5.2 FnMut —— 可重复调用,且可以修改环境
#![allow(unused)]
fn main() {
pub trait FnMut<Args>: FnOnce<Args> {
// 签名:&mut self,返回 Output
fn call_mut(&mut self, args: Args) -> Self::Output;
}
}
- 继承自
FnOnce。 - 调用时需要
&mut self,所以要求闭包本身是可变的。 - 如果一个闭包会可变借用环境变量(但未移动所有权),它实现
FnMut而不实现Fn。
5.3 Fn —— 纯函数式,仅不可变借用
#![allow(unused)]
fn main() {
pub trait Fn<Args>: FnMut<Args> {
// 签名:&self,返回 Output
fn call(&self, args: Args) -> Self::Output;
}
}
- 继承自
FnMut(进而继承FnOnce)。 - 调用时仅需
&self,允许多个不可变引用同时存在。 - 如果闭包只对捕获变量进行不可变访问,则它实现
Fn。
trait 约束应用示例:
#![allow(unused)]
fn main() {
// 要求闭包实现 Fn,可以多次调用且不会修改环境
fn apply_three_times<F>(f: F, x: i32) -> i32
where
F: Fn(i32) -> i32,
{
f(f(f(x)))
}
let add_one = |n| n + 1;
let result = apply_three_times(add_one, 1); // 4
// 要求闭包实现 FnMut,允许闭包在内部修改变量
fn for_each<F>(slice: &[i32], mut action: F)
where
F: FnMut(i32),
{
for &item in slice {
action(item);
}
}
let mut total = 0;
for_each(&[1, 2, 3], |val| total += val);
println!("{}", total); // 6
// 要求闭包仅 FnOnce(例如需要移动所有权)
fn consume_once<F>(f: F)
where
F: FnOnce() -> String,
{
let s = f();
println!("Consumed: {}", s);
// 不能再调用 f
}
let msg = String::from("done");
consume_once(move || msg); // 所有权转移
}
说明:编写接受闭包参数的函数时,尽量从 Fn 开始,若编译器报需要 FnMut 则放宽,若仍需所有权则使用 FnOnce。这给调用者最大的灵活性。
6. 闭包作为回调函数
回调是闭包最典型的应用场景。我们通常将“未来才执行的逻辑”封装为闭包传递给某个函数或结构体。根据是否需要返回值、是否可能多次调用,选择适当的 trait 约束。
6.1 明确返回值类型的回调
若回调的返回类型在编译时即确定,使用泛型加 Fn/FnMut/FnOnce 约束:
#![allow(unused)]
fn main() {
// 对一个值应用某种转换
fn transform<T, F>(val: T, f: F) -> T
where
F: FnOnce(T) -> T,
{
f(val)
}
let double = |x| x * 2;
assert_eq!(transform(21, double), 42);
}
6.2 不明确返回值类型(异质回调)
有时我们希望回调的返回值类型不固定,甚至是不同的具体类型,但仍需类型安全。常见方案是:
a) 使用 trait 对象,将返回值擦除为统一 trait(如 Box<dyn Display>)
#![allow(unused)]
fn main() {
// 接受返回任意可 Display 类型的闭包
fn log_and_return<F>(f: F) -> Box<dyn std::fmt::Display>
where
F: FnOnce() -> Box<dyn std::fmt::Display>,
{
let result = f();
println!("log: callback executed");
result
}
let num = log_and_return(|| Box::new(42));
let text = log_and_return(|| Box::new("hello".to_string()));
println!("{}, {}", num, text);
}
b) 使用枚举实现“内聚”的类型归并
如果返回值类型的集合已知且有限,可定义枚举:
#![allow(unused)]
fn main() {
enum CallbackResult {
Int(i32),
Str(String),
}
fn run_cb<F>(f: F) -> CallbackResult
where
F: FnOnce() -> CallbackResult,
{
f()
}
}
c) 返回 impl Trait + 泛型闭包(同一闭包返回具体类型)
若是单个闭包但希望调用者不需要写出具体类型,可在函数内部使用 impl Trait,但场景有限:
#![allow(unused)]
fn main() {
fn apply<F, R>(f: F) -> impl std::fmt::Display
where
F: FnOnce() -> R,
R: std::fmt::Display,
{
f()
}
// 调用时闭包只需返回一个实现了 Display 的具体类型,外层并不失 type info
}
d) 不确定返回值且不想使用 trait 对象时,可用 any 或动态分发库,但 Rust 惯用方式仍是 trait 对象或泛型。
6.3 异步回调
异步任务常将闭包与 Future 结合。tokio::spawn 接受的是一个 Future,而 async move { ... } 会生成一个捕获环境所有权的匿名 Future。
#![allow(unused)]
fn main() {
tokio::spawn(async move {
// 捕获环境所有权
});
}
这类被 spawn 的 Future 通常需要满足 Send + 'static,因为运行时可能把它调度到其他线程,并且任务可能活得比当前函数作用域更久。
7. 传闭包和函数
7.1 传递闭包
当 API 需要“可调用物”时,推荐使用泛型与 Fn 系列 trait,而非函数指针。这样做可以接受闭包、函数指针、甚至实现了相应 trait 的自定义类型,且编译器可进行内联优化。
#![allow(unused)]
fn main() {
fn call_twice<F>(mut f: F)
where
F: FnMut(),
{
f();
f();
}
let mut count = 0;
call_twice(|| count += 1);
assert_eq!(count, 2);
}
7.2 传递函数指针
函数指针类型 fn(Args) -> Ret 既可以接受普通函数,也可以接受不捕获任何变量的闭包。
#![allow(unused)]
fn main() {
fn plain_fn(x: i32) -> i32 { x * x }
fn apply_fn_ptr(f: fn(i32) -> i32, val: i32) -> i32 {
f(val)
}
// 传入普通函数
apply_fn_ptr(plain_fn, 10);
// 传入无捕获的闭包
apply_fn_ptr(|x| x + 1, 10);
}
一旦闭包开始捕获环境,就无法再转换为 fn 指针,此时必须改为泛型 F: Fn(...) 参数。
最佳实践:编写库代码时,优先使用泛型 + Fn trait 约束,这给调用者最大灵活性。仅在确实需要函数指针的场景(如与 C 交互、存储一组同签名的函数指针表)时使用 fn 类型。
8. 闭包作为返回值
因为每个闭包的匿名类型对调用者不可知,函数无法直接返回一个闭包的具体类型。常用的解决方式有两种:
8.1 使用 impl Trait(静态分发)
#![allow(unused)]
fn main() {
fn make_adder(base: i32) -> impl Fn(i32) -> i32 {
move |x| base + x
}
let add_five = make_adder(5);
assert_eq!(add_five(10), 15);
}
impl Fn(i32) -> i32告诉编译器:“这个函数返回某个实现了Fn(i32) -> i32的具体类型,但不必显式写出”。- 闭包环境通常需要移动所有权,因此闭包体前常加
move。 - 局限:同一个函数只能返回一种具体类型,不能根据运行时条件返回不同闭包。
8.2 使用 trait 对象(动态分发)
#![allow(unused)]
fn main() {
fn make_counter(init: i32) -> Box<dyn FnMut() -> i32> {
let mut count = init;
Box::new(move || {
count += 1;
count
})
}
let mut counter = make_counter(0);
assert_eq!(counter(), 1);
assert_eq!(counter(), 2);
}
- 返回
Box<dyn FnMut() -> i32>,将闭包类型擦除,仅保留 vtable。 - 可以在运行时选择返回不同的闭包(只要它们都满足
FnMut() -> i32)。 - 代价是一次堆分配和动态分发带来的间接调用开销。
9. 示例代码
以下示例综合展示了闭包捕获、作为参数、作为返回值,以及 Fn/FnMut/FnOnce 的实际使用。
use std::collections::HashMap;
/// 缓存型计算器:接受一个闭包作为计算公式,并返回缓存结果的闭包。
fn memoize<F, K, V>(calc: F) -> impl FnMut(K) -> V
where
F: Fn(K) -> V,
K: std::cmp::Eq + std::hash::Hash + Clone,
V: Clone,
{
// 移动 calc 和新建的 HashMap 到返回闭包环境中
let mut cache = HashMap::new();
move |key: K| {
if let Some(val) = cache.get(&key) {
println!("Cache hit!");
val.clone()
} else {
let val = calc(key.clone());
cache.insert(key, val.clone());
val
}
}
}
// 一个接受 FnOnce 并以回调方式处理的函数
fn with_cleanup<F, T>(produce: F) -> T
where
F: FnOnce() -> T,
{
let result = produce();
println!("Cleanup performed");
result
}
fn main() {
// 1. 闭包捕获与类型
let factor = 10;
let multiply = |x| x * factor; // 捕获 &factor,实现 Fn
// 2. 作为参数传递
let results: Vec<i32> = (1..=3).map(multiply).collect();
println!("{:?}", results); // [10, 20, 30]
// 3. 缓存闭包(FnMut)
let mut cached_fib = memoize(|n: u64| -> u64 {
if n <= 1 { n }
else { cached_fib(n-1) + cached_fib(n-2) } // 递归调用(栈溢出仅演示概念)
});
println!("fib(5) = {}", cached_fib(5)); // 计算并缓存
println!("fib(5) = {}", cached_fib(5)); // 命中缓存
// 4. 所有权移动的回调(FnOnce)
let data = vec![1, 2, 3];
let result = with_cleanup(move || {
data.iter().sum::<i32>() // data 所有权被移动
});
println!("Sum: {}", result);
// 5. 闭包作为返回值
let adder = make_adder(100);
println!("100 + 1 = {}", adder(1));
}
// 辅助函数:返回一个闭包
fn make_adder(base: i32) -> impl Fn(i32) -> i32 {
move |x| base + x
}
rust 容器
Rust 标准库提供了很多常用容器。本章先介绍最常见的四个:
String:拥有所有权的 UTF-8 字符串。Vec<T>:连续内存上的动态数组。HashMap<K, V>:基于哈希表的键值映射。VecDeque<T>:双端队列,适合头尾两端高效插入和删除。
这几个容器都遵守 Rust 的所有权规则。理解它们不仅是学习标准库 API,也是继续理解所有权、借用、迭代器和集合操作的基础。
1. String
1.1 概述
String 是标准库提供的可增长字符串类型,内容使用 UTF-8 编码。
它和字符串字面量 &str 的区别是:
String拥有字符串内容,可以增长、修改、移动。&str是字符串切片,只是借用一段 UTF-8 字符串内容。
1.2 创建字符串
功能:创建一个新的 String。
常用方式:
#![allow(unused)]
fn main() {
let mut s1 = String::new();
let s2 = String::from("hello");
let s3 = "world".to_string();
}
String::new():创建空字符串。String::from(...):从字符串字面量创建。.to_string():把实现了Display的值转换成字符串。
1.3 追加内容
功能:向字符串末尾追加内容。
接口签名:
#![allow(unused)]
fn main() {
pub fn push_str(&mut self, string: &str)
pub fn push(&mut self, ch: char)
}
push_str追加字符串切片。push追加单个字符。
简单调用示例:
#![allow(unused)]
fn main() {
let mut s = String::from("Rust");
s.push(' ');
s.push_str("book");
println!("{}", s);
}
1.4 遍历字符串
Rust 字符串是 UTF-8,不能随便按字节索引字符。
常见遍历方式:
#![allow(unused)]
fn main() {
let s = String::from("你好 Rust");
for ch in s.chars() {
println!("{}", ch);
}
for byte in s.bytes() {
println!("{}", byte);
}
}
chars():按 Unicode 标量值遍历。bytes():按 UTF-8 字节遍历。
1.5 解析字符串
功能:把字符串解析成目标类型。
接口签名:
#![allow(unused)]
fn main() {
pub fn parse<F>(&self) -> Result<F, F::Err>
where
F: FromStr
}
简单调用示例:
#![allow(unused)]
fn main() {
let number = "42".parse::<i32>().unwrap();
println!("{}", number);
}
parse 返回 Result,因为字符串内容不一定能成功解析成目标类型。
2. Vec<T>
2.1 概述
Vec<T> 是动态数组,所有元素类型必须相同。它把元素放在连续内存中,因此按索引访问很快。
适合场景:
- 按顺序存储一组元素。
- 需要尾部追加。
- 需要通过索引读取元素。
2.2 创建 Vec
常用方式:
#![allow(unused)]
fn main() {
let mut v1 = Vec::<i32>::new();
let v2 = vec![1, 2, 3];
}
Vec::new():创建空 Vec。vec![]:用宏直接创建带初始元素的 Vec。
2.3 添加元素
接口签名:
#![allow(unused)]
fn main() {
pub fn push(&mut self, value: T)
}
简单调用示例:
#![allow(unused)]
fn main() {
let mut numbers = Vec::new();
numbers.push(10);
numbers.push(20);
}
push 会把元素追加到尾部。如果容量不够,Vec 会重新分配更大的内存。
2.4 读取元素
常见方式:
#![allow(unused)]
fn main() {
let numbers = vec![10, 20, 30];
let first = numbers[0];
let maybe = numbers.get(10);
}
numbers[index]:越界会panic。numbers.get(index):返回Option<&T>,越界返回None。
2.5 删除元素
常用接口:
#![allow(unused)]
fn main() {
pub fn pop(&mut self) -> Option<T>
pub fn remove(&mut self, index: usize) -> T
}
pop从尾部删除,返回Option<T>。remove删除指定位置元素,并移动后续元素填补空位。
简单调用示例:
#![allow(unused)]
fn main() {
let mut numbers = vec![10, 20, 30];
let last = numbers.pop();
let first = numbers.remove(0);
}
3. HashMap<K, V>
3.1 概述
HashMap<K, V> 是键值映射,适合通过 key 快速查找 value。
使用 HashMap 时,key 需要实现:
Eq:能判断两个 key 是否相等。Hash:能计算哈希值。
3.2 创建 HashMap
#![allow(unused)]
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::<String, i32>::new();
}
3.3 插入元素
接口签名:
#![allow(unused)]
fn main() {
pub fn insert(&mut self, k: K, v: V) -> Option<V>
}
- 如果 key 不存在,插入新键值对,返回
None。 - 如果 key 已存在,覆盖旧值,返回
Some(old_value)。
简单调用示例:
#![allow(unused)]
fn main() {
scores.insert(String::from("Alice"), 95);
scores.insert(String::from("Bob"), 82);
}
3.4 查找元素
接口签名:
#![allow(unused)]
fn main() {
pub fn get<Q>(&self, k: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized
}
简单调用示例:
#![allow(unused)]
fn main() {
if let Some(score) = scores.get("Alice") {
println!("{}", score);
}
}
get 返回 Option<&V>,因为 key 可能不存在。
3.5 entry API
entry 适合“如果没有就插入,如果有就修改”的场景。
#![allow(unused)]
fn main() {
scores.entry(String::from("Carol")).or_insert(90);
*scores.entry(String::from("Alice")).or_insert(0) += 1;
}
3.6 遍历和删除
#![allow(unused)]
fn main() {
for (key, value) in &scores {
println!("{} => {}", key, value);
}
scores.remove("Bob");
}
注意:HashMap 的遍历顺序不稳定,不要依赖输出顺序。
4. VecDeque<T>
4.1 概述
VecDeque<T> 是双端队列,支持从头部和尾部高效插入/删除。
它适合:
- 队列:从尾部
push_back,从头部pop_front。 - 双端任务列表:头尾都可能插入或删除。
VecDeque 内部使用环形缓冲区,不保证所有元素在内存中完全连续。
4.2 创建 VecDeque
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
let mut queue = VecDeque::<String>::new();
}
4.3 头尾插入
接口签名:
#![allow(unused)]
fn main() {
pub fn push_back(&mut self, value: T)
pub fn push_front(&mut self, value: T)
}
简单调用示例:
#![allow(unused)]
fn main() {
queue.push_back(String::from("task-1"));
queue.push_front(String::from("urgent-task"));
}
4.4 头尾删除
接口签名:
#![allow(unused)]
fn main() {
pub fn pop_front(&mut self) -> Option<T>
pub fn pop_back(&mut self) -> Option<T>
}
简单调用示例:
#![allow(unused)]
fn main() {
while let Some(task) = queue.pop_front() {
println!("handle {}", task);
}
}
5. 迭代器
5.1 为什么需要迭代器?
前四节我们分别介绍了 String、Vec、HashMap、VecDeque。它们结构不同,但有一个共同需求:遍历元素。如果每种容器都用自己的方式遍历,代码就会变成这样:
#![allow(unused)]
fn main() {
// 不同容器,不同遍历方式——很割裂
for i in 0..v.len() { let x = v[i]; } // Vec: 手动索引
for (k, v) in &map { /* ... */ } // HashMap: 解构键值对
while let Some(x) = q.pop_front() { /* ... */ } // VecDeque: 消费式遍历
}
Rust 的做法是:所有容器统一通过迭代器提供遍历能力。迭代器是一个标准化的抽象层——无论容器内部是什么数据结构,对外都暴露同一套操作接口(map、filter、collect 等)。
5.2 Iterator trait
迭代器的核心是 Iterator trait:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item; // 迭代产出的元素类型
fn next(&mut self) -> Option<Self::Item>; // 核心方法:取下一个元素
// ... 其余 70+ 个方法都有默认实现
}
}
只需要实现 next,其余所有方法(map、filter、collect、fold 等)自动获得。
next 的返回值表达了迭代器的状态:
Some(item)→ 还有元素,返回下一个。None→ 迭代结束,停止。
5.3 三种迭代形式
Rust 容器对“遍历“这件事有三种不同的权限模型:
| 方法 | 产出类型 | 所有权 | 使用场景 |
|---|---|---|---|
.iter() | &T | 借用,不消费容器 | 只读遍历,之后还能用容器 |
.iter_mut() | &mut T | 可变借用,不消费容器 | 遍历中修改元素 |
.into_iter() | T | 消费容器,转移所有权 | 遍历后不再需要原容器 |
#![allow(unused)]
fn main() {
let mut v = vec![1, 2, 3];
// 只读遍历——容器原样保留
for x in v.iter() {
println!("{}", x);
}
println!("容器还在: {:?}", v); // OK
// 可变遍历——可以修改元素
for x in v.iter_mut() {
*x *= 10;
}
println!("元素翻倍: {:?}", v); // [10, 20, 30]
// 消费式遍历——所有权转移,原容器不可再用
let sum: i32 = v.into_iter().sum();
println!("{}", sum);
// println!("{:?}", v); // 编译错误:v 已被消费
}
for循环的语法糖:for x in &v等价于for x in v.iter(),for x in &mut v等价于for x in v.iter_mut(),for x in v等价于for x in v.into_iter()。for循环会自动调用对应的迭代器方法,写起来更简洁。
5.4 迭代器适配器(adaptor)
迭代器的真正威力在于链式组合。适配器接收一个迭代器,返回一个新的迭代器——零拷贝的流水线处理:
#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
// 链式处理:每个适配器返回一个新的迭代器
let result: Vec<i32> = numbers
.iter() // 产生 &i32
.filter(|&&x| x % 2 == 0) // 只保留偶数
.map(|&x| x * 10) // 每个元素乘以 10
.collect(); // 收集到 Vec 中
println!("{:?}", result); // [20, 40, 60]
}
惰性求值:迭代器适配器不会立即执行——只有当你调用 collect()、sum()、for 循环等消费器(consumer)时,整个流水线才开始计算。这意味着中间步骤不分配临时 Vec。
常用适配器一览:
| 适配器 | 作用 | 示例 |
|---|---|---|
.map(|x| ...) | 转换每个元素 | .map(|x| x * 2) |
.filter(|x| ...) | 保留满足条件的元素 | .filter(|x| *x > 0) |
.filter_map(|x| ...) | 过滤 + 转换一步完成 | .filter_map(|x| x.parse().ok()) |
.take(n) | 只取前 n 个 | .take(5) |
.skip(n) | 跳过前 n 个 | .skip(1) |
.enumerate() | 附加索引 (index, item) | .enumerate() |
.chain(other) | 拼接两个迭代器 | .chain(b.iter()) |
.zip(other) | 成对组合 | .zip(b.iter()) |
.rev() | 反向遍历(仅双端迭代器) | .rev() |
5.5 消费器(consumer)
消费器触发流水线执行,并产出最终结果:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3, 4, 5];
// collect: 收集到任意集合类型
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
let doubled_set: std::collections::HashSet<i32> = v.iter().map(|x| x * 2).collect();
// fold: 带初始值的累加
let sum = v.iter().fold(0, |acc, x| acc + x); // 15
// reduce: 无初始值的累加(返回 Option)
let product = v.iter().copied().reduce(|a, b| a * b); // Some(120)
// sum / product: 内置的求和/求积
let total: i32 = v.iter().sum(); // 15
// any / all: 条件判断
let has_even = v.iter().any(|x| x % 2 == 0); // true
let all_positive = v.iter().all(|x| *x > 0); // true
// count: 计数
let n = v.iter().filter(|x| **x > 2).count(); // 3
// find / position: 查找
let first = v.iter().find(|x| **x > 3); // Some(&4)
let idx = v.iter().position(|x| *x == 3); // Some(2)
// max / min: 极值
let max = v.iter().max(); // Some(&5)
let min = v.iter().min(); // Some(&1)
}
5.6 和四种容器的配合
迭代器与本章四种容器的常见用法:
String:
#![allow(unused)]
fn main() {
let s = String::from("Rust");
let upper: String = s.chars().map(|c| c.to_ascii_uppercase()).collect();
// chars() 返回 chars 迭代器,collect() 收集回 String
}
Vec:
#![allow(unused)]
fn main() {
let words = vec!["hello", "world"];
let lengths: Vec<usize> = words.iter().map(|w| w.len()).collect();
}
HashMap:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
let scores = HashMap::from([("Alice", 95), ("Bob", 82)]);
let names: Vec<&str> = scores.keys().copied().collect();
let high_scores: HashMap<_, _> = scores
.into_iter()
.filter(|(_, v)| *v > 80)
.collect();
}
VecDeque:
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
let q = VecDeque::from([1, 2, 3]);
let doubled: VecDeque<i32> = q.iter().map(|x| x * 2).collect();
}
5.7 迭代器的性能
Rust 迭代器是零成本抽象——经过编译器优化后,链式迭代器调用往往被内联展开成与手写 for 循环等效甚至更优的机器码。你可以放心使用迭代器,不用为“抽象开销“担心。
举个例子,下面两段代码生成的机器码几乎一样:
#![allow(unused)]
fn main() {
// 迭代器版本
let sum: i32 = v.iter().filter(|x| **x > 0).map(|x| x * 2).sum();
// 手写循环版本
let mut sum = 0;
for x in &v {
if *x > 0 {
sum += x * 2;
}
}
}
迭代器版本可读性更好,性能没有损失——这正是 Rust “无代价抽象” 哲学的体现。
6. 综合示例:任务看板
这个示例把四种容器放在一起使用:
String保存原始输入。VecDeque<String>保存待处理任务队列。HashMap<String, String>保存任务负责人。Vec<String>保存已完成任务。
#![allow(unused)]
fn main() {
use std::collections::{HashMap, VecDeque};
pub fn demo() {
let raw_tasks = String::from("learn rust,write demo,read book");
let mut waiting = VecDeque::<String>::new();
let mut finished = Vec::<String>::new();
let mut owners = HashMap::<String, String>::new();
for task in raw_tasks.split(',') {
waiting.push_back(task.trim().to_string());
}
owners.insert(String::from("learn rust"), String::from("Alice"));
owners.insert(String::from("write demo"), String::from("Bob"));
owners.insert(String::from("read book"), String::from("Carol"));
while let Some(task) = waiting.pop_front() {
let owner = owners
.get(&task)
.map(String::as_str)
.unwrap_or("unknown");
println!("{} is handling {}", owner, task);
finished.push(task);
}
println!("finished tasks = {:?}", finished);
}
}
rust 的 OOP 写法
1. 封装(Encapsulation)
1.1 什么是封装
封装是将数据(属性)和操作数据的方法(行为)捆绑在一起形成一个独立的单元,并对外隐藏内部实现细节,仅暴露必要的接口。
1.2 封装的本质
两件事:
- 数据聚合:把相关的数据字段捆绑成一个复合类型(如 C 语言的结构体)。
- 访问控制:决定外部代码能看到什么、能修改什么,从而保护内部状态的不变性。
1.3 Rust 实现封装的方法
Rust 通过 结构体(struct) 实现数据聚合,通过 模块系统(mod) 和 可见性(pub) 实现访问控制。
技术点:
- 结构体(struct):将零个或多个不同类型的字段组合成一个新的类型。这是数据封装的最基本形式。
- 可见性规则:
- 默认情况下,结构体的字段是 私有的(private),只能在定义它的模块及其子模块内访问。
- 使用
pub关键字可以公开字段或方法。 - 结构体本身可以选择公开(
pub struct)或不公开。 - 还可以使用
pub(crate)、pub(super)、pub(in path)实现更细粒度的可见性。
- 方法(impl 块):为结构体定义关联函数和实例方法,这些方法构成了对外操作的接口。
与传统 OOP 语言不同:Rust 没有
class关键字,但struct + impl的组合完全等价于类。
1.4 代码实现
// 定义一个模块,模拟一个“类”
mod bank_account {
// 公开结构体,但字段私有 —— 这是数据聚合
pub struct Account {
owner: String, // 私有字段
balance: i32, // 私有字段
}
impl Account {
// 公开的构造函数(通常称为 new)
pub fn new(owner: String) -> Self {
Account {
owner,
balance: 0,
}
}
// 公开的方法:存款
pub fn deposit(&mut self, amount: i32) {
if amount > 0 {
self.balance += amount;
}
}
// 公开的方法:查询余额(只读)
pub fn balance(&self) -> i32 {
self.balance
}
// 私有方法:内部计算逻辑,外部不可见
fn can_withdraw(&self, amount: i32) -> bool {
self.balance >= amount
}
// 公开方法:取款(内部调用私有方法)
pub fn withdraw(&mut self, amount: i32) -> bool {
if self.can_withdraw(amount) {
self.balance -= amount;
true
} else {
false
}
}
}
}
fn main() {
let mut acc = bank_account::Account::new(String::from("Alice"));
acc.deposit(100);
println!("余额: {}", acc.balance()); // 通过方法访问
acc.withdraw(30);
println!("取款后余额: {}", acc.balance());
// 以下代码无法编译,因为字段是私有的,无法直接访问:
// println!("{}", acc.balance); // 错误:字段 balance 私有
// println!("{}", acc.owner); // 错误:字段 owner 私有
// acc.can_withdraw(10); // 错误:私有方法
}
1.5 代码分析
- 数据聚合(struct):
Account结构体将owner和balance捆绑在一起,形成一个自包含的单元。 - 访问控制(可见性):
pub struct Account使得其他模块可以创建该类型的变量、将其作为参数等,但内部的字段依然是私有的。- 没有
pub的字段(owner、balance)外部无法直接读写,只能通过公开的方法(deposit、balance、withdraw)间接操作。 - 私有方法
can_withdraw是内部辅助逻辑,可以随时修改实现而不影响外部。
- 方法接口:
new、deposit、balance、withdraw构成了这个“类”的公共接口。使用者不需要知道内部用balance这个字段存储,将来可以改为HashMap或其他数据结构,只要保持方法签名不变即可。 - 不变性保护:
deposit方法只允许正数存款,withdraw保证余额不会为负,这些业务规则被封装在方法内部,外部无法绕过。
结论:Rust 通过 struct(数据聚合) + 可见性控制(pub/私有) 实现了完全且安全的封装,比许多 OOP 语言更严格(字段默认私有)。
2. 组合优于继承(Composition over Inheritance)
2.1 传统继承想解决什么问题
在很多面向对象语言中,继承通常用来表达两件事:
- 代码复用:子类复用父类字段和方法。
- 类型关系:子类被看作父类的一种,例如
Dog is an Animal。
这种模型在简单场景下很直观,但大型系统里容易出现几个问题:
- 父类和子类耦合过深,父类改动可能影响一串子类。
- 一个实体经常有多种能力,很难用单一继承树描述。
- 为了复用一点行为,不得不建立并不自然的父子关系。
2.2 Rust 的选择:不用类继承
Rust 没有类,也没有“结构体继承结构体”的机制。一个 struct 不能继承另一个 struct 的字段或方法。
这不是缺陷,而是 Rust 有意选择的设计方向:组合优于继承。
Rust 更鼓励你把系统拆成小的实体、小的能力、小的组件,然后按真实关系组合起来:
- 需要复用数据:把一个结构体作为另一个结构体的字段。
- 需要复用行为:把行为写在组件自己的
impl中。 - 需要统一接口:使用
trait描述能力。 - 需要运行时多态:使用
dyn Trait。
这种方式不要求所有类型都塞进一棵继承树里。一个实体可以自由拥有多个组件,也可以实现多个 trait,从而表达更灵活的关系。
2.3 组合的核心思想
组合表达的是 has-a 关系,而不是传统继承里的 is-a 关系。
例如:
- 汽车有发动机和 GPS。
- 配送机器人有电池和 GPS。
- 汽车和机器人都能定位,但它们不是彼此的父类或子类。
在 Rust 中,这种关系可以直接写成字段组合。
2.4 代码实现(用组合表达实体关系)
// 发动机是一个独立组件。
struct Engine {
horsepower: u32,
}
impl Engine {
fn start(&self) {
println!("engine start, horsepower = {}", self.horsepower);
}
}
// 电池也是一个独立组件。
struct Battery {
percent: u8,
}
impl Battery {
fn status(&self) {
println!("battery status = {}%", self.percent);
}
}
// GPS 是可复用组件。
// 汽车可以有 GPS,机器人也可以有 GPS。
struct Gps {
position: String,
}
impl Gps {
fn locate(&self) {
println!("current position = {}", self.position);
}
}
// Car 不是继承 Vehicle,而是组合需要的组件。
struct Car {
name: String,
engine: Engine,
gps: Gps,
}
impl Car {
fn drive(&self) {
println!("car {} is ready", self.name);
self.engine.start();
self.gps.locate();
}
}
// DeliveryRobot 和 Car 没有继承关系,
// 但它可以复用同一个 Gps 组件,再组合自己的 Battery。
struct DeliveryRobot {
id: u32,
battery: Battery,
gps: Gps,
}
impl DeliveryRobot {
fn deliver(&self) {
println!("delivery robot {} is working", self.id);
self.battery.status();
self.gps.locate();
}
}
fn main() {
let car = Car {
name: String::from("city-car"),
engine: Engine { horsepower: 160 },
gps: Gps {
position: String::from("garage"),
},
};
car.drive();
let robot = DeliveryRobot {
id: 7,
battery: Battery { percent: 87 },
gps: Gps {
position: String::from("warehouse"),
},
};
robot.deliver();
}
2.5 代码分析
Engine、Battery、Gps都是独立组件,各自管理自己的数据和行为。Car组合了Engine和Gps,因此它能启动发动机,也能定位。DeliveryRobot组合了Battery和Gps,因此它能查看电量,也能定位。Car和DeliveryRobot复用了Gps,但不需要建立共同父类。- 如果以后需要新增
Drone,只要组合Battery、Gps或其他组件即可,不需要修改继承层级。
结论:Rust 不使用类继承来组织对象关系。Rust 更倾向于把状态和能力拆成可组合的实体,通过
struct字段组合和trait接口约束来表达关系。这种模型更显式、更灵活,也更符合 Rust 对所有权和类型边界的要求。
3. 多态(Polymorphism)
3.1 什么是多态
多态允许同一段代码操作不同的具体类型,而无需在编译时知道它们的具体类型。最常见的多态是 子类型多态(例如,基类指针指向派生类对象)。
3.2 多态的本质
统一接口,不同实现。核心是“延迟绑定”——在运行时决定调用哪个具体实现。
3.3 Rust 实现动态多态的方法
Rust 使用 trait 对象(trait object) 实现动态多态。 Trait 对象是一个胖指针:指向数据本身的指针 + 指向虚表(vtable)的指针。
两种常用的 trait 对象形式:
Box<dyn Trait>:拥有所有权的 trait 对象,存放在堆上。&dyn Trait:借用(引用)的 trait 对象,不拥有所有权。
技术要点:
- 只能对 对象安全(object-safe) 的 trait 创建 trait 对象。常见限制是:可通过 trait 对象调用的方法不能有泛型参数,不能直接返回
Self,也不能要求Self: Sized。如果某个方法写了where Self: Sized,它就不能通过 trait 对象调用,但通常不会阻止整个 trait 被做成 trait 对象。 - Trait 对象必须通过某种指针(
&、Box、Rc等)包装。 - 动态分发有少量运行时开销(一次间接跳转),但相比静态分发(单态化)可以减小二进制体积,并允许异构集合。
3.4 代码实现(动态多态 + 静态分发的对比)
// 定义一个对象安全的 trait
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn name(&self) -> &str {
"Circle"
}
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> &str {
"Rectangle"
}
}
// 静态分发:编译时确定具体类型(单态化)
fn print_area_static<T: Shape>(shape: &T) {
println!("{} area = {}", shape.name(), shape.area());
}
// 动态分发:通过 trait 对象在运行时决定
fn print_area_dynamic(shape: &dyn Shape) {
println!("{} area = {}", shape.name(), shape.area());
}
// 拥有所有权的 trait 对象(放在堆上)
fn create_shape(kind: &str) -> Box<dyn Shape> {
match kind {
"circle" => Box::new(Circle { radius: 1.0 }),
"rect" => Box::new(Rectangle { width: 2.0, height: 3.0 }),
_ => panic!("unknown shape"),
}
}
fn main() {
// 静态分发示例
let circle = Circle { radius: 2.0 };
let rect = Rectangle { width: 3.0, height: 4.0 };
print_area_static(&circle);
print_area_static(&rect);
// 动态分发:&dyn Shape(借用)
let shapes: Vec<&dyn Shape> = vec![&circle, &rect];
for s in shapes {
print_area_dynamic(s);
}
// 动态分发:Box<dyn Shape>(拥有所有权,堆分配)
let shape1 = create_shape("circle");
let shape2 = create_shape("rect");
let shape_list: Vec<Box<dyn Shape>> = vec![shape1, shape2];
for s in shape_list {
println!("{} area = {}", s.name(), s.area());
}
}
3.5 代码分析
3.5.1 静态分发(print_area_static)
- 函数泛型参数
T: Shape会在编译时对每个具体类型生成一个单独的函数副本(单态化)。 - 没有运行时开销,但会导致二进制体积增大(不过通常可接受)。
- 优点是编译器可以进行内联等优化。
3.5.2 动态分发(&dyn Shape 或 Box<dyn Shape>)
- 函数接收
&dyn Shape或Box<dyn Shape>,是一个胖指针:数据指针 + 虚表指针。 - 调用
shape.area()时,实际通过虚表找到具体类型的area函数,有少量运行时开销(一次间接跳转)。 - 可以将不同类型的 trait 对象放入同一个
Vec中,实现真正的“异质集合”。 Box<dyn Trait>拥有数据的所有权,当其离开作用域时会自动释放堆上的数据。
3.5.3 对象安全条件
Shape的area(&self)和name(&self)返回f64和&str,没有使用Self作为返回值(除了self本身),因此对象安全。- 如果 trait 中有
fn new() -> Self这样的方法,并且没有加where Self: Sized把它排除在 trait 对象调用之外,就无法创建 trait 对象。
rust 宏 入门指南
1. 什么是宏与 Rust 宏的分类
1.1 什么是宏?
在 Rust 中,宏 是一种编写“编写代码的代码”的方式。你可能听说过这个词:元编程。 如果普通的函数是在运行时处理数据,那么宏就是在编译时处理代码。宏允许你通过一段代码来生成另一段代码,这极大地减少了重复劳动,并且能够做到普通函数做不到的事情(例如操作语法结构本身)。
1.2 Rust 宏的分类
Rust 中的宏主要分为两大类:
- 声明宏
- 定义方式:使用
macro_rules!定义。 - 特点:这是最常用、最基础的宏。它像“模式匹配”一样,匹配你传入的代码模式,然后展开成新的代码。它主要用于定义语法层面的替换。
- 别名:有时也被老一辈开发者称为“示例宏”或“宏_rules 宏”(Mbe)。
- 定义方式:使用
- 过程宏
- 定义方式:编写独立的 Rust 函数(通常是单独的包/Crate)。
- 特点:它更像是一个函数,接收代码作为输入,经过处理后输出新的代码。它允许你操作 Rust 的抽象语法树(AST)。
- 细分:
- 函数式过程宏:看起来像函数调用,例如
make_fn!(name, "msg")。 - 派生宏:用于结构体和枚举,自动实现 trait,例如
#[derive(Debug)]。 - 属性宏:用于标记函数或模块,例如
#[route(GET, "/")]。
- 函数式过程宏:看起来像函数调用,例如
2. 声明宏
2.1 声明宏的概念
声明宏的核心思想是**“模式匹配”**。你可以把它想象成老式 C 语言预处理器的“查找并替换”的超级进化版。它不仅匹配文本,还匹配 Rust 的语法结构。
2.2 声明宏最基本的原理
当你调用一个宏(例如 macro_demo!())时,编译器会在编译的早期阶段进行以下操作:
- 解析:编译器读取宏调用传入的代码。
- 匹配:将传入的代码与
macro_rules!中定义的每一个“分支”进行比对。 - 展开:一旦匹配成功,将宏体中右侧的代码模板生成出来,替换掉宏调用。
- 编译:将展开后的代码当作普通的 Rust 代码进行后续的编译。
2.3 声明宏最基本结构
一个标准的声明宏结构如下:
#![allow(unused)]
fn main() {
macro_rules! 宏的名字 {
// 分支 1: (模式) => { 展开的代码 };
(模式1) => {
// 展开代码1
};
// 分支 2
(模式2) => {
// 展开代码2
};
}
}
macro_rules!是定义宏的入口。- 括号
(){}[]在宏定义中只作为分组符号,通常使用()用于匹配模式,{}用于展开代码块。 - 分支之间使用分号
;分隔。
2.4 Fragment Specifier (片段指定符)
在声明宏的模式中,我们不能只写死一个变量名,而是需要捕获某种类型的代码片段。这时就需要用到片段指定符。它告诉编译器:“这里请匹配一个表达式”或者“这里请匹配一个标识符”。 常见指定符:
| 指定符 | 匹配内容 | 解释 | 示例 |
|---|---|---|---|
expr | 表达式 | 任何计算值的代码,如 1 + 2, func(), "hello" | x + 1 |
ident | 标识符 | 变量名、函数名、类型名等 | my_var, String |
literal | 字面量 | 常量值,如字符串、数字 | "abc", 100 |
ty | 类型 | 类型定义 | i32, Vec<u8> |
stmt | 语句 | 通常指以分号结尾的代码行或声明 | let x = 1; |
path | 路径 | 类似模块路径,如 std::collections::HashMap | super::foo |
tt | 标记树 | 单个标记或括号包围的整个树 | (a, b), { x } |
block | 代码块 | 大括号包围的语句块 | { ... } |
2.5 重复语法
如果我们想匹配多个参数(比如打印多个变量),就需要用到重复语法。 基本格式:
#![allow(unused)]
fn main() {
$()*
// 或者
$()+
}
$:代表重复的起始。(...):括号内是要重复的模式,可以包含片段指定符(如$x:expr)。*:代表重复 0 次或多次。+:代表重复 1 次或多次。?:代表重复 0 次或 1 次(可选)。
2.6 重复语法的宏展开
当宏在展开时,重复部分会像“解包”一样被展开。例如:
#![allow(unused)]
fn main() {
($($name:expr),*) => {
$(
println!("{}", $name);
)*
};
}
如果你调用 macro_demo!(a, b, c),它会展开成:
#![allow(unused)]
fn main() {
println!("{}", a);
println!("{}", b);
println!("{}", c);
}
2.7 分隔符
在重复语法中,我们经常需要指定参数之间的分隔符(比如逗号)。
格式:$($var:expr),*
- 这里的
,就是分隔符。 - 它表示匹配到的每一个
expr之间必须有一个逗号。 - 最后一个元素后面通常允许没有逗号(但在宏定义解析中,逗号是用来区分不同匹配项的)。 常见示例:
$(x),*:a, b, c(逗号分隔)$(x);*:a; b; c;(分号分隔)$(x)*:a b c(空格分隔)
2.8 代码示例讲解
让我们通过你提供的代码来详细理解声明宏的用法。
#![allow(unused)]
fn main() {
macro_rules! macro_demo {
// 1. 无参数匹配
// 如果调用 macro_demo!(),匹配到这里
() => {
println!("hello world!");
};
// 2. 有参数匹配-字面量
// $name:literal 捕获一个字面量(如字符串或数字)
($name:literal)=>{
println!("iteral match:{}", $name);
};
// 3. 有参数匹配-任意表达式
// $name:expr 捕获一个表达式(可以是函数调用、加减乘除等)
// 注意:这里我们把 $name 当作语句执行了一次 $name;
($name:expr)=>{
println!("有参数匹配-任意表达式(示例传入函数)");
$name; // 展开后直接调用传入的函数
};
// 4. 重复匹配-任意数量表达式
// $($name:expr),* 捕获 0 个或多个由逗号分隔的表达式
($($name:expr),*)=>{
println!("重复匹配-任意数量表达式:");
// $(...)* 内部是对每一个捕获到的表达式进行重复展开
$(
$name; // 对每一个传入的函数进行调用
)*
};
}
}
调用演示:
#![allow(unused)]
fn main() {
fn func1(){
println!("func1 called");
}
pub fn demo(){
// 1. 匹配第一个分支
macro_demo!();
// 输出: hello world!
// 2. 匹配第二个分支 (字面量 "this is a literal")
macro_demo!("this is a literal");
// 输出: iteral match:this is a literal
// 3. 匹配第三个分支 (表达式 func1())
macro_demo!(func1());
// 输出:
// 有参数匹配-任意表达式(示例传入函数)
// func1 called
// 4. 匹配第四个分支 (重复匹配)
macro_demo!(func1(), func2(), func3());
// 输出:
// 重复匹配-任意数量表达式:
// func1 called
// func2 called
// func3 called
}
}
3. 过程宏
过程宏比声明宏更高级,它允许你像写普通 Rust 代码一样去操作代码结构。
3.1 过程宏的概念与分类
过程宏本质上是一个从 TokenStream 到 TokenStream 的函数。
它在编译阶段运行,接收源代码的 Token 流,可以对其做任意复杂的逻辑分析、修改,最后输出新的 Token 流给编译器。
分类:
- 函数式过程宏
- 形式:
function_name!(input) - 用途:类似声明宏,但处理逻辑更灵活,能处理复杂的输入解析。
- 形式:
- 派生宏
- 形式:
#[derive(MyTrait)] - 用途:自动为结构体或枚举实现 Trait。这是最常用的一种。
- 形式:
- 属性宏
- 形式:
#[my_attribute]或#[my_attribute(args)] - 形式:可以作用在函数、模块、结构体等上面,用于修改或增强其功能。
- 形式:
3.2 过程宏的基本语法结构
定义过程宏的函数签名非常固定:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro] // 函数式宏注解
pub fn my_macro(input: TokenStream) -> TokenStream {
// 1. 解析 input
// 2. 处理逻辑
// 3. 返回新的 TokenStream
}
}
input:编译器传给你的原始代码(Token 流形式)。TokenStream:这是过程宏唯一能理解和操作的数据结构。
3.3 如何写一个过程宏?
过程宏不能写在普通的 src/main.rs 或普通库的 src/lib.rs 中,它们必须单独放在一个特殊的 Proc Macro Crate 中。
步骤:
- 创建新 Crate:
在你的项目目录下,使用 Cargo 创建一个新的库:
cargo new my_macro_lib --lib - 修改
Cargo.toml: 必须声明这个库是一个过程宏库,并添加依赖syn和quote。[lib] proc-macro = true # 关键配置!告诉编译器这是过程宏库 [dependencies] syn = { version = "2.0", features = ["full"] } # 用于解析 TokenStream quote = "1.0" # 用于生成 TokenStream proc-macro2 = "1.0" # syn 和 quote 依赖它,通常间接引入 - 引入包:
在
src/lib.rs中:#![allow(unused)] fn main() { use proc_macro::TokenStream; use syn::{parse_macro_input, DeriveInput}; // 常用引入 use quote::quote; }
3.4 过程宏最基本的原理与编译流程
要理解过程宏,必须先简单了解 Rust 编译器是如何把你的源代码变成可执行程序的。
3.4.1 编译流程简介与 Token/AST
- 词法分析:
编译器读取源代码字符串,把它们切割成一个个“单词”或“符号”,我们称之为 Token(标记)。
- 例如:
fn main() {}会被切分成fn(标识符),main(标识符),((标点),)(标点),{(标点),}(标点)。 - Token Tree (标记树):Token 不是散乱排列的,它们被括号(
(),[],{})组织成树状结构。这对于宏来说很重要,因为宏在完全解析语法之前就能看到这些树。
- 例如:
- 语法分析:
编译器根据语法规则,将 Token 组织成一颗巨大的树,这就是 AST (抽象语法树)。
- AST 结构:它描述了代码的逻辑结构。比如,“这是一个函数定义,名字叫 main,函数体是一个块,块里有一条语句”。
- 在 AST 中,我们知道
a + b是一个二元表达式,而不是三个无关的词。
- 中间代码生成: AST 被转化为更底层的表示,常被称为 HIR (High-level IR) 或 MIR,最后变成机器码。
3.4.2 过程宏介入了哪一步?
过程宏介入在语法分析之后,正式的 AST 构建之前(或者说是在 AST 处理的早期阶段,针对特定的 Item)。
更准确地说,编译器先将输入源代码解析为 TokenStream。过程宏接收这个 TokenStream。
- TokenStream 是什么? 它是一串 Token 序列的包装器。对于过程宏来说,它本质上是代表代码的“扁平”序列或树结构,但还没具备完整的语义信息。
3.4.3 核心组件的作用:syn 和 quote
在 Rust 生态中,我们几乎不直接操作原始的 TokenStream,而是通过两个神器:syn 和 quote。
syn(Syntax 的缩写):解析器- 作用:将
TokenStream解析成 Rust 具体的数据结构(也就是 AST 的具体表现形式,如DeriveInput,ItemFn)。 - 为什么需要它?因为直接判断“第一个 token 是 fn,第二个是 main”太累了。
syn帮我们把输入变成了结构体,让我们可以用input.ident这种方式直接获取名字。
- 作用:将
quote:代码生成器- 作用:将 Rust 数据结构转换回
TokenStream。 - 为什么需要它?因为写一大堆
.push(Token)来生成代码非常反人类。quote!宏允许我们直接写看起来像 Rust 代码的模板,它会自动帮我们转换成TokenStream。
- 作用:将 Rust 数据结构转换回
3.4.4 它们是怎么配合的?
流程如下:
Input (TokenStream) –> [ syn ] –> Rust Struct (AST) –> [ 你的逻辑处理 ] –> 修改后的 Rust Struct –> [ quote ] –> Output (TokenStream)
为什么input和output都是tokenstream,却要把toekenstream解析成ast再去操作再生成tokenstream
- 因为 TokenStream 本质上只是一个扁平、无语义的符号序列,就像散乱的砖块和字母,很难直接进行处理。
- 而AST(抽象语法树)最大的特点是它拥有层级结构和明确的语义分类。这种树状结构天然地就像一个个容器,能够根据代码的逻辑关系(如“谁在谁里面”、“这是什么类型的代码”),自动将散乱的数据归类并放入对应的结构层级中。
- 通过 syn 解析成 AST 后,我们就不需要手动去匹配括号或计算优先级,代码中复杂的嵌套和关联关系都已经被整理成了清晰的结构,数据自然就“各就各位”了,这样我们后续的修改和生成逻辑才能直观地进行。
3.5 动手写过程宏
接下来我们详解你提供的三种过程宏代码。假设你已经创建了上面的 my_macro_lib。
3.5.1 函数式过程宏
目标:调用 make_fn!(hello, "world"); 自动生成一个名为 hello 的函数。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse::{Parse, ParseStream}, // 用于自定义解析逻辑
parse_macro_input,
Ident, // 标识符类型
LitStr, // 字符串字面量类型
Token, // 标点符号类型
};
// 1. 定义一个结构体来保存我们解析出来的数据
struct MakeFnInput {
name: Ident, // 对应 "hello"
_comma: Token![,], // 对应 "," (下划线前缀表示我们要读取它但不使用它)
message: LitStr, // 对应 "world"
}
// 2. 为这个结构体实现 Parse trait,告诉 syn 如何解析 TokenStream
impl Parse for MakeFnInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self {
name: input.parse()?, // 尝试解析一个标识符
_comma: input.parse()?, // 尝试解析一个逗号
message: input.parse()?, // 尝试解析一个字符串
})
}
}
// 3. 定义宏入口函数
#[proc_macro]
pub fn make_fn(input: TokenStream) -> TokenStream {
// 使用 parse_macro_input! 宏,自动调用我们上面实现的 Parse 逻辑
// 如果解析失败,会直接报错并优雅退出
let MakeFnInput { name, message, .. } =
parse_macro_input!(input as MakeFnInput);
// 4. 使用 quote! 生成我们要返回的代码
quote! {
// #name 会把变量 name 的值填进去
fn #name() {
println!("{}", #message);
}
}
.into() // 5. 将 quote 生成的代码转换为 TokenStream 返回
}
}
使用方式:
// 在另一个 crate 的 main.rs 中
use my_macro_lib::make_fn;
make_fn!(hello, "world"); // 这一行被展开成了函数定义
fn main() {
hello(); // 直接调用生成的函数
}
3.5.2 Derive 过程宏
目标:使用 #[derive(HelloDerive)] 自动为结构体实现 Hello trait。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
// 定义我们的宏类型
#[proc_macro_derive(HelloDerive)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
// 1. 解析输入
// DeriveInput 是 syn 提供的现成结构体,专门用于解析结构体/枚举的输入
let input = parse_macro_input!(input as DeriveInput);
// 获取结构体/枚举的名字 (例如 struct MyStruct)
let name = input.ident;
// 获取泛型信息 (例如 struct MyStruct<T>)
let generics = input.generics;
// 将泛型拆分为三部分,用于在 impl 块中正确引用
let (impl_generics, ty_generics, where_clause) =
generics.split_for_impl();
// 2. 生成代码
quote! {
// 为 #name 实现Hello trait
impl #impl_generics Hello for #name #ty_generics #where_clause {
fn hello(&self) {
// stringify! 是 Rust 内置宏,在编译时把代码转为字符串
println!("Hello from {}", stringify!(#name));
}
}
}
.into()
}
}
使用方式:
// 注意:Hello trait 需要在某个地方定义(通常是过程宏库的同名依赖中,或者用户自己定义)
trait Hello {
fn hello(&self);
}
#[derive(HelloDerive)]
struct MyStruct;
fn main() {
let s = MyStruct;
s.hello(); // 输出: Hello from MyStruct
}
3.5.3 属性过程宏
目标:使用 #[log_call] 自动给函数添加打印日志的功能。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse_macro_input,
ItemFn, // 代表一个函数的 AST 结构
Token,
};
#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
// 1. 解析输入的 item (即被标记的那个函数)
let input_fn = parse_macro_input!(item as ItemFn);
// 2. 提取函数的各个部分
let attrs = input_fn.attrs; // 函数上原本的其他属性 (如 #[inline])
let vis = input_fn.vis; // 可见性
let sig = input_fn.sig; // 函数签名 (fn name(args) -> ret)
let block = input_fn.block; // 函数体代码块 { ... }
// 获取函数名
let fn_name = &sig.ident;
// 3. 生成新的函数
// 我们不修改原函数逻辑,而是把它包裹在一个新的代码块里
quote! {
// 把原来的属性放回去
#(#attrs)*
#vis #sig {
// 在函数体最开始插入日志打印
println!("calling {}", stringify!(#fn_name));
// 执行原本的函数体
#block
}
}
.into()
}
}
使用方式:
use my_macro_lib::log_call;
#[log_call]
fn my_function() {
println!("Inside function");
}
fn main() {
my_function();
}
// 展开后的效果类似于:
// fn my_function() {
// println!("calling my_function");
// println!("Inside function");
// }
并发编程
Rust 的并发模型建立在所有权、借用和线程安全 trait 之上。
本章按下面的顺序组织:
- 并发编程核心:线程、锁、条件变量、mpsc 通道和生产者消费者模型。
- Rust 的线程安全:理解
Send、Sync和内部可变性与线程安全的关系。 - 并发编程扩展:读写锁、死锁规避、线程构建器、作用域线程、线程局部存储。
- 原子操作:理解无锁编程、内存序和 CAS。
阅读本章前,建议先完成所有权、智能指针和 trait 相关章节。
rust并发编程基础
1. 并发编程相关基础概念
1.1 并发和并行
- 并发(Concurrency):指多个任务在同一时间段内交替执行(宏观同时,微观串行)。例如单核CPU上快速切换多个线程,给人一种“同时运行”的错觉。
- 并行(Parallelism):指多个任务在同一时刻真正同时执行,需要多核CPU的支持。
💡 简单记忆:并发是“逻辑上的同时”,并行是“物理上的同时”。并发是并行的基础,但并发不一定并行。
1.2 进程和线程
- 进程:操作系统资源分配的基本单位,拥有独立的内存空间、文件句柄等。进程间通信(IPC)开销较大。
- 线程:CPU调度的基本单位,共享所属进程的内存和资源。线程间通信更高效,但也带来了数据竞争的风险。
💡 进程就像一个独立的房子,有自己的水电煤气。线程就像房子里不同房间的人,共用同一个房子的资源。
1.3 系统线程和绿色线程
- 系统线程:由操作系统内核管理,创建、切换、销毁都需要内核参与,开销相对较大。Rust标准库中的
std::thread就是系统线程。 - 绿色线程:由用户态运行时管理(如Go的goroutine),创建和切换开销极小,但需要运行时支持。Rust早期有绿色线程,但最终选择暴露系统线程作为标准。
✅ Rust线程是什么:Rust标准库直接使用1:1模型,即一个Rust线程对应一个操作系统线程。这样做的优点是:与系统交互简单、无额外运行时开销、可预测的性能。对于需要大量轻量级任务,可以选择
tokio或async-std等异步运行时(基于绿色线程思想)。
2. 线程基本操作
2.1 创建新线程 – std::thread::spawn
功能:创建一个新的OS线程,并立即开始执行传入的闭包。
接口签名:
#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
}
- 参数:
f– 一个闭包,在新线程中执行,闭包必须实现Send+'static(通常使用move转移所有权)。 - 返回值:
JoinHandle<T>– 代表新线程的句柄,可用于等待线程结束并获取返回值。
简单调用示例:
#![allow(unused)]
fn main() {
let handle = std::thread::spawn(|| {
println!("Hello from a new thread!");
});
}
2.2 等待线程结束 – JoinHandle::join
功能:阻塞当前线程,直到对应的线程执行完毕,并返回线程闭包的返回值。
接口签名:
#![allow(unused)]
fn main() {
pub fn join(self) -> Result<T, Box<dyn Any + Send + 'static>>
}
- 参数:无(消耗
self)。 - 返回值:
Result<T, Box<dyn Any + Send + 'static>>– 成功返回Ok(T),T是闭包返回值;失败返回Err(Box<...>)包含panic信息。
简单调用示例:
#![allow(unused)]
fn main() {
let handle = std::thread::spawn(|| 42);
let result = handle.join().unwrap(); // result = 42
}
2.3 线程闭包中的 move语义
功能:将闭包捕获的外部变量所有权转移到闭包内部,从而安全地在另一个线程中使用这些变量。
说明:不使用 move时,闭包会借用外部变量,但新线程可能存活超过变量所在作用域,导致悬垂引用。move强制转移所有权,确保变量在线程执行期间始终有效。
简单调用示例:
#![allow(unused)]
fn main() {
let data = vec![1, 2, 3];
let handle = std::thread::spawn(move || {
println!("{:?}", data); // data所有权已移入线程
});
// println!("{:?}", data); // 编译错误:data已移动
handle.join().unwrap();
}
3. 线程同步
3.1 什么是线程同步
当多个线程同时访问共享数据时,为了防止数据竞争、保证一致性和正确性,需要采用某种协调机制,这就是线程同步。例如:互斥锁、条件变量、信号量、读写锁等。
3.2 为什么需要线程同步 – 无同步的例子
考虑一个没有同步的例子:两个线程同时对一个共享计数器进行递增操作。
#![allow(unused)]
fn main() {
static mut COUNTER: i32 = 0;
let t1 = std::thread::spawn(|| {
for _ in 0..1000 {
unsafe { COUNTER += 1; }
}
});
let t2 = std::thread::spawn(|| {
for _ in 0..1000 {
unsafe { COUNTER += 1; }
}
});
t1.join().unwrap();
t2.join().unwrap();
unsafe { println!("{}", COUNTER); } // 可能不是2000,而是例如1987等随机值
}
由于线程交错执行,COUNTER += 1并非原子操作(实际上是读-改-写三步),导致丢失更新,结果不可预测。这就是典型的数据竞争,需要同步来避免。
3.3 线程同步常用手段
- 互斥锁(Mutex):保证同一时刻只有一个线程访问数据。
- 读写锁(RwLock):允许多个读线程或一个写线程同时访问。
- 条件变量(Condvar):让线程等待某个条件满足后再继续执行。
- 原子类型(Atomic):对简单类型提供无锁的原子操作。
- 消息传递(Channel):通过发送/接收消息进行线程间通信,Rust中的
mpsc是典型。
3.4 Rust互斥锁(Mutex<T>)
3.4.1 Rust的 Mutex与Java/C++的不同
- Java/C++:互斥锁通常是一个独立对象,你需要手动
lock()/unlock(),并且锁与数据是分离的。容易忘记释放锁,或者持有锁时间过长。 - Rust:
Mutex<T>是一个容器,它包裹了数据T。你无法直接访问内部数据,而必须通过lock()方法得到一个MutexGuard(类似智能指针),该守卫在作用域结束时自动释放锁。这种设计保证了数据受锁的保护,且不会忘记解锁。
✅ 核心思想:Rust的
Mutex<T>与RefCell<T>类似,都提供内部可变性——即通过不可变引用也能修改内部数据。只是RefCell在运行时检查借用规则,而Mutex通过阻塞线程来保证独占访问。
3.4.2 创建互斥锁 – Mutex::new
功能:创建一个新的互斥锁,内部包裹初始值 data。
接口签名:
#![allow(unused)]
fn main() {
pub fn new(data: T) -> Mutex<T>
}
- 参数:
data– 需要被保护的数据。 - 返回值:
Mutex<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let m = Mutex::new(100);
}
3.4.3 加锁 – Mutex::lock
功能:阻塞当前线程,直到获得互斥锁,返回一个 MutexGuard智能指针。如果持有锁的线程panic,lock会返回错误。
接口签名:
#![allow(unused)]
fn main() {
pub fn lock(&self) -> LockResult<MutexGuard<'_, T>>
}
- 参数:无(通过
&self获取锁)。 - 返回值:
LockResult<MutexGuard<'_, T>>,通常调用.unwrap()获取MutexGuard。该守卫实现了Deref和DerefMut,可直接访问内部数据;离开作用域时自动解锁。
简单调用示例:
#![allow(unused)]
fn main() {
let guard = m.lock().unwrap(); // 获取锁
*guard += 1; // 修改内部值
println!("{}", *guard); // 读取
// guard 离开作用域,自动解锁
}
3.4.4 多线程共享 Mutex<T> – 配合 Arc
由于 Mutex<T>本身不实现 Copy,且多个线程需要共享所有权,因此通常将 Mutex<T>放入 Arc(原子引用计数)中。Arc允许多个线程同时拥有同一个 Mutex的所有权。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
3.5 条件变量(Condvar)
条件变量用于线程间的等待-通知机制:一个线程等待某个条件成立,另一个线程满足条件后发出通知。Rust的 Condvar总是与 Mutex配合使用。
3.5.1 创建条件变量 – Condvar::new
功能:创建一个新的条件变量。
接口签名:
#![allow(unused)]
fn main() {
pub fn new() -> Condvar
}
- 参数:无。
- 返回值:
Condvar实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex, Condvar};
let pair = Arc::new((Mutex::new(false), Condvar::new()));
}
3.5.2 条件等待 – Condvar::wait_while
功能:在持有 Mutex锁的情况下,检查等待条件(闭包)。如果闭包返回 true,表示“还需要继续等待”,它会自动释放锁并阻塞当前线程;当被其他线程通知后重新获取锁并再次检查。循环直到闭包返回 false。
接口签名:
#![allow(unused)]
fn main() {
pub fn wait_while<'a, T, F>(
&self,
guard: MutexGuard<'a, T>,
condition: F
) -> LockResult<MutexGuard<'a, T>>
where
F: FnMut(&mut T) -> bool,
}
- 参数:
guard– 已经获得的锁守卫。condition– 一个闭包,接收&mut T,返回bool。返回true表示继续等待,返回false表示停止等待并返回锁守卫。
- 返回值:返回一个新的
MutexGuard<'a, T>,此时等待条件已经不再成立。
简单调用示例:
#![allow(unused)]
fn main() {
let mut guard = lock.lock().unwrap();
guard = condvar.wait_while(guard, |data| data.is_empty()).unwrap();
// 现在 guard 中的队列非空
}
3.5.3 唤醒等待线程 – Condvar::notify_one / Condvar::notify_all
功能:唤醒一个或所有等待在该条件变量上的线程。
接口签名:
#![allow(unused)]
fn main() {
pub fn notify_one(&self)
pub fn notify_all(&self)
}
- 参数:无。
- 返回值:无。
简单调用示例:
#![allow(unused)]
fn main() {
condvar.notify_one(); // 唤醒一个线程
condvar.notify_all(); // 唤醒所有等待线程
}
3.6 多生产者单消费者通道(mpsc)
mpsc代表“Multiple Producer, Single Consumer”。它是Rust标准库提供的一个消息传递同步工具,允许多个线程发送消息,但只有一个线程接收消息。
3.6.1 创建通道 – std::sync::mpsc::channel
功能:创建一个新的异步通道,返回发送端和接收端。发送端可以克隆(多生产者),接收端独占。
接口签名:
#![allow(unused)]
fn main() {
pub fn channel<T>() -> (Sender<T>, Receiver<T>)
}
- 参数:无(通过泛型
T指定消息类型)。 - 返回值:元组
(Sender<T>, Receiver<T>)。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
}
3.6.2 发送消息 – Sender::send
功能:将消息发送到通道。如果接收端已经关闭,返回错误。
接口签名:
#![allow(unused)]
fn main() {
pub fn send(&self, t: T) -> Result<(), SendError<T>>
}
- 参数:
t– 要发送的消息(消耗所有权)。 - 返回值:成功返回
Ok(()),失败返回Err(SendError(t))(将消息返回)。
简单调用示例:
#![allow(unused)]
fn main() {
tx.send(42).unwrap();
}
3.6.3 接收消息 – Receiver::recv
功能:阻塞当前线程,直到通道中有消息可接收,或所有发送端已关闭(此时返回错误)。
接口签名:
#![allow(unused)]
fn main() {
pub fn recv(&self) -> Result<T, RecvError>
}
- 参数:无。
- 返回值:成功返回
Ok(T),失败返回Err(RecvError)(表示没有更多消息)。
简单调用示例:
#![allow(unused)]
fn main() {
let msg = rx.recv().unwrap();
println!("Received: {}", msg);
}
3.6.4 完整示例片段
#![allow(unused)]
fn main() {
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("Hello from thread").unwrap();
});
println!("{}", rx.recv().unwrap());
}
4. 综合示例代码:生产者消费者模型实现
下面我们使用前面介绍的 Mutex、Condvar、Arc和 AtomicBool实现一个安全、可停止的生产者-消费者队列。该示例完整展示了线程同步的多项技术。
4.1 代码结构分析
Queue<T>:队列数据结构,内部包含:data: Mutex<VecDeque<T>>– 互斥锁保护的队列。condvar: Condvar– 条件变量,用于等待队列非空或停止信号。stopped: AtomicBool– 原子标志位,指示是否停止生产/消费。
push:生产者调用,添加数据并通知消费者。pop:消费者调用,如果队列为空则等待,直到有数据或停止信号。stop:设置停止标志并唤醒所有等待线程。
4.2 完整代码
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, Condvar, atomic::{AtomicBool, Ordering}};
struct Queue<T> {
data: Mutex<VecDeque<T>>,
condvar: Condvar,
stopped: AtomicBool,
}
impl<T> Queue<T> {
fn new() -> Arc<Queue<T>> {
Arc::new(Queue {
data: Mutex::new(VecDeque::new()),
condvar: Condvar::new(),
stopped: AtomicBool::new(false),
})
}
fn stop(&self) {
self.stopped.store(true, Ordering::Release);
self.condvar.notify_all();
}
fn push(&self, data: T) {
let mut guard = self.data.lock().unwrap();
if self.stopped.load(Ordering::Acquire) { return; }
guard.push_back(data);
self.condvar.notify_one();
}
fn pop(&self) -> Option<T> {
let mut guard = self.data.lock().unwrap();
// 等待条件:队列非空 或者 已经停止
guard = self.condvar.wait_while(guard, |g| {
g.is_empty() && !self.stopped.load(Ordering::Acquire)
}).unwrap();
if guard.is_empty() && self.stopped.load(Ordering::Acquire) {
None
} else {
guard.pop_front()
}
}
}
pub fn demo() {
println!("...............生产者消费者模型示例开始...................");
let queue = Queue::<i32>::new();
let produce_queue = queue.clone();
let produce = std::thread::spawn(move || {
let mut count = 0;
while count < 11 {
produce_queue.push(count);
count += 1;
}
produce_queue.stop();
});
let consume_queue1 = queue.clone();
let consume1 = std::thread::spawn(move || {
while let Some(data) = consume_queue1.pop() {
println!("consumer1 consume data: {}", data);
}
});
let consume_queue2 = queue.clone();
let consume2 = std::thread::spawn(move || {
while let Some(data) = consume_queue2.pop() {
println!("consumer2 consume data: {}", data);
}
});
produce.join().unwrap();
consume1.join().unwrap();
consume2.join().unwrap();
println!("...............生产者消费者模型示例结束...................");
}
}
rust线程安全底层原理:Send与Sync
1. 线程安全问题的本质
在多线程环境下,数据竞争(data race)是主要的安全隐患:多个线程同时访问同一内存位置,至少有一个是写操作,且没有同步机制。C/C++ 中的数据竞争属于未定义行为。Rust 在 safe Rust 中通过所有权系统和类型系统在编译期杜绝数据竞争;如果使用 unsafe,则需要程序员自行维护这些安全不变量。
1.1 Rust的线程安全模型核心
- 所有权转移:通过
move闭包将数据所有权转移到新线程,避免共享。 - 借用规则:要么多个不可变借用(
&T),要么一个可变借用(&mut T),但不能同时存在。 Send和Synctrait:标记哪些类型可以安全地在线程间传递或共享。
2. Send trait
2.1 概念
Send是一个标记trait(无方法),表示类型的值可以安全地从一个线程转移到另一个线程。换句话说,实现了 Send的类型的所有权可以跨线程边界传递。
2.2 自动推导规则
- 绝大多数Rust类型都实现了
Send,包括所有基本类型(整数、浮点、bool、char)、String、Vec、Box等。 - 一个类型**仅当它所有字段都实现了
Send**时,才会自动实现Send。 - 裸指针(
*const T/*mut T)不实现Send,因为它们没有所有权语义。 Rc<T>(引用计数指针)不实现Send,因为它的引用计数不是原子操作,跨线程会导致计数错误。Arc<T>实现了Send(要求T: Send + Sync,见下文)。这里的线程安全首先指引用计数本身安全;内部数据是否能被并发访问仍由T的Send/Sync决定。
2.3 手动实现 Send
Send是一个 unsafe trait,因为手动标记意味着你承诺该类型在线程间传递是安全的。通常不需要手动实现,除非自定义类型包含非 Send字段但你能证明它跨线程转移不会造成未定义行为。
#![allow(unused)]
fn main() {
// 示例:自定义类型包含裸指针。裸指针默认不是 Send,
// 如果手动实现 Send,必须由类型作者保证跨线程转移是安全的。
struct MyData {
ptr: *mut u8, // 裸指针不实现Send
}
// 不安全的手动标记,需内部保证线程安全
unsafe impl Send for MyData {}
}
2.4 核心原理
Rust编译器在编译时检查:当你将数据所有权通过 thread::spawn转移到新线程时,要求闭包捕获的所有数据都必须是 Send的。否则编译错误。这防止了诸如 Rc被移动到另一个线程导致引用计数损坏。
#![allow(unused)]
fn main() {
use std::rc::Rc;
let rc = Rc::new(5);
std::thread::spawn(move || {
println!("{}", rc); // 编译错误:`Rc<i32>` 不能在线程间安全传递
});
}
3. Sync trait
3.1 概念
Sync也是一个标记trait,表示类型的值可以安全地在多个线程之间共享(即通过不可变引用 &T被多个线程同时访问)。Sync是 Send的补充:T是 Sync当且仅当 &T是 Send。
3.2 自动推导规则
- 基本类型都是
Sync的。 - 一个类型**仅当它所有字段都是
Sync**时,才会自动实现Sync。 Cell<T>和RefCell<T>不实现Sync(因为它们的内部可变性不是线程安全的,没有使用原子操作)。Mutex<T>和RwLock<T>实现了Sync(要求T: Send),因为它们内部使用原子操作实现同步。Rc<T>不实现Sync(引用计数非原子)。Arc<T>实现了Sync(要求T: Sync + Send)。
3.3 手动实现 Sync
与 Send类似,Sync也是 unsafe trait,通常不需要手动实现。
3.4 核心原理
当你在多线程中通过不可变引用 &T访问数据时,编译器要求 T: Sync。例如,Mutex的 lock方法返回的 MutexGuard虽然内部有可变引用,但锁机制保证了线程安全。
#![allow(unused)]
fn main() {
use std::cell::RefCell;
let refcell = RefCell::new(5);
// 错误:`RefCell<i32>` 不能在线程间共享
std::thread::spawn(|| {
let _ = &refcell;
});
}
4. Send和 Sync的关系与常见类型
| 类型 | Send | Sync | 原因 |
|---|---|---|---|
i32, bool | ✅ | ✅ | 基本类型,无共享状态 |
String | ✅ | ✅ | 堆数据,所有权唯一 |
*mut T | ❌ | ❌ | 裸指针,无安全保证 |
Rc<T> | ❌ | ❌ | 非原子引用计数 |
Arc<T> | ✅ | ✅ | 引用计数是原子的;跨线程共享要求 T: Send + Sync |
RefCell<T> | ✅ | ❌ | 内部可变性非线程安全 |
Mutex<T> | ✅ | ✅ | 使用锁保证线程安全 |
RwLock<T> | ✅ | ✅ | 同上 |
Cell<T> | ✅ | ❌ | 内部可变性非线程安全 |
注意:RefCell<T>和 Cell<T>都实现了 Send(如果 T: Send),但没有实现 Sync。因此你可以将 RefCell的所有权移动到另一个线程(Send),但不能跨线程共享 &RefCell。
5. 内部可变性与线程安全
5.1 什么是内部可变性
内部可变性允许你通过不可变引用修改内部值。Rust提供了 Cell<T>和 RefCell<T>(单线程)以及 Mutex<T>和 RwLock<T>(多线程)来实现。
5.2 Sync与内部可变性的关系
- 单线程内部可变性:
Cell<T>/RefCell<T>不实现Sync,因为它们的运行时借用检查不是原子操作;如果绕过类型系统把它们跨线程共享,就可能导致数据竞争。 - 线程安全内部可变性:
Mutex<T>和RwLock<T>通过锁机制实现了Sync,因此可以被多个线程同时共享&Mutex<T>。
5.3 示例对比
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::sync::Mutex;
use std::thread;
// RefCell 不能在线程间共享引用
let refcell = RefCell::new(0);
// thread::spawn(|| {
// *refcell.borrow_mut() = 1; // 错误:`RefCell` 不是 `Sync`
// });
// Mutex 可以
let mutex = Mutex::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
let mut guard = mutex.lock().unwrap();
*guard += 1;
});
}
});
// 编译通过,因为 Mutex 实现了 Sync
}
6. 编译器如何检查线程安全
Rust的 std::thread::spawn函数签名要求闭包捕获的数据必须满足 Send + 'static:
#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
}
Send:闭包本身必须可安全转移给新线程(因为闭包被移动到新线程)。闭包内捕获的所有变量必须都是Send。'static:闭包不能借用局部变量,因为新线程可能活得比当前作用域长。所以通常使用move转移所有权。
编译器在调用 spawn时自动检查这些约束,如果捕获了 Rc或普通引用,会给出清晰的错误信息。
7. Send和 Sync的自动推导与手动标记
7.1 自动推导原理
Rust编译器为每个类型自动实现 Send和 Sync,基于其成员的实现。这个过程称为自动trait(auto trait)。例如:
#![allow(unused)]
fn main() {
struct MyStruct {
a: i32,
b: String,
}
// 编译器自动实现 Send 和 Sync,因为 i32 和 String 都实现了。
}
7.2 手动禁用 Send或 Sync
使用 PhantomData(零大小类型)可以“伪造”字段,从而阻止自动推导。
#![allow(unused)]
fn main() {
use std::marker::PhantomData;
struct NotSend {
_marker: PhantomData<*mut ()>, // 裸指针使编译器不自动实现Send
}
// 现在 NotSend 既不是 Send 也不是 Sync
}
7.3 为什么需要手动禁用
某些类型虽然在字段层面满足 Send/Sync,但逻辑上不应该(例如,一个包含线程ID的类型,不应该被传递到其他线程)。通过 PhantomData可以手动阻止。
8. MutexGuard与 Send/Sync的特殊性
MutexGuard<'a, T>是一个智能指针,表示持有锁的守卫。它的实现中,MutexGuard 不是 Send(因为锁应该在同一线程中释放)。这防止了你将锁守卫移动到另一个线程并解锁,破坏了锁的语义。
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let mutex = Mutex::new(0);
let guard = mutex.lock().unwrap();
// 错误:`MutexGuard` 不能发送到其他线程
std::thread::spawn(move || {
// 因为 guard 不是 Send
});
}
9. 总结:Rust线程安全的底层哲学
- 所有权 + 借用规则:消除了数据竞争的根本可能性。
Send和Sync:编译器可检查的标记,将线程安全责任从程序员转移到类型系统。- 无数据竞争:在 safe Rust 中,编译器和类型系统会阻止数据竞争;如果使用
unsafe,则需要程序员自己维护这些安全不变量。 - 零成本抽象:
Send/Sync仅用于编译期检查,运行时无任何开销。
掌握 Send和 Sync是理解Rust并发编程进阶内容(如异步、自定义数据结构)的基础。当你设计自己的并发类型时,应当仔细考虑这些trait的实现。
最终建议:在99%的实践中,你不需要手动实现 Send/Sync;依赖编译器自动推导即可。只有当实现自定义并发原语或包裹裸指针时才需要深入了解。
rust并发编程扩展
1. 读写锁(RwLock<T>)
1.1 概述
读写锁是一种更细粒度的同步原语,它允许多个读线程同时持有锁,但只允许一个写线程独占访问。适用于读多写少的场景,可以提高并发度。
Rust的 RwLock<T>与 Mutex<T>类似,也是一个容器,包裹内部数据。通过 read()和 write()方法分别获取读锁(共享)和写锁(独占),返回的守卫离开作用域自动释放。
1.2 创建读写锁 – RwLock::new
功能:创建一个新的读写锁,内部包裹初始值 data。
接口签名:
#![allow(unused)]
fn main() {
pub fn new(data: T) -> RwLock<T>
}
- 参数:
data– 需要被保护的数据。 - 返回值:
RwLock<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::RwLock;
let lock = RwLock::new(5);
}
1.3 获取读锁 – RwLock::read
功能:阻塞当前线程,直到获得读锁(共享锁),返回 RwLockReadGuard。多个线程可同时持有读锁。
接口签名:
#![allow(unused)]
fn main() {
pub fn read(&self) -> LockResult<RwLockReadGuard<'_, T>>
}
- 参数:无(通过
&self获取)。 - 返回值:
LockResult<RwLockReadGuard<'_, T>>,通常调用.unwrap()获取守卫。该守卫实现了Deref,可以只读访问内部数据。
简单调用示例:
#![allow(unused)]
fn main() {
let guard = lock.read().unwrap();
println!("value = {}", *guard);
// 读锁释放
}
1.4 获取写锁 – RwLock::write
功能:阻塞当前线程,直到获得写锁(独占锁),返回 RwLockWriteGuard。若已有其他读锁或写锁,当前线程会阻塞。
接口签名:
#![allow(unused)]
fn main() {
pub fn write(&self) -> LockResult<RwLockWriteGuard<'_, T>>
}
- 参数:无。
- 返回值:
LockResult<RwLockWriteGuard<'_, T>>。该守卫实现了Deref和DerefMut,可读写内部数据。
简单调用示例:
#![allow(unused)]
fn main() {
let mut guard = lock.write().unwrap();
*guard += 1;
}
1.5 多线程共享 RwLock<T> – 配合 Arc
与 Mutex相同,RwLock也需要配合 Arc实现多线程共享所有权。
完整示例:
#![allow(unused)]
fn main() {
use std::sync::{Arc, RwLock};
use std::thread;
let data = Arc::new(RwLock::new(0));
let mut handles = vec![];
// 多个读线程
for _ in 0..3 {
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
let guard = data.read().unwrap();
println!("read: {}", *guard);
});
handles.push(handle);
}
// 一个写线程
{
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
let mut guard = data.write().unwrap();
*guard += 10;
println!("write: added 10");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
1.6 读写锁的陷阱:写锁饥饿
在频繁读的场景下,写锁可能长时间无法获得(读锁不断被新读者获取)。Rust标准库的 RwLock实现不保证写锁优先,需要开发者注意。
2. 死锁(Deadlock)
2.1 什么是死锁
当两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的状态,称为死锁。
2.2 死锁的四个必要条件
死锁必须同时满足以下四个条件:
- 互斥条件:资源不能被共享,只能由一个线程使用。
- 持有并等待条件:线程持有至少一个资源,同时等待获取其他线程持有的资源。
- 不可剥夺条件:资源只能由持有它的线程主动释放,不能被强制剥夺。
- 循环等待条件:存在一个线程循环链,每个线程都在等待链中下一个线程持有的资源。
2.3 Rust中常见的死锁示例
2.3.1 示例:同一线程重复获取 Mutex(递归锁问题)
Rust 的标准库 Mutex 不是递归锁,同一线程重复 lock 的行为不要依赖:标准库不保证它会成功,实际实现中可能阻塞自己,也可能 panic。
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let lock = Mutex::new(0);
let _g1 = lock.lock().unwrap();
let _g2 = lock.lock().unwrap(); // 不要这样做:可能阻塞自己或 panic
}
原理:标准库
Mutex不按“同一线程可重复进入”的递归锁语义设计。需要重复进入时,应重新设计锁的作用域,或明确选择支持递归锁语义的同步原语。
2.3.2 示例:两个线程互相持有对方需要的锁
#![allow(unused)]
fn main() {
use std::sync::{Mutex, Arc};
use std::thread;
use std::time::Duration;
let a = Arc::new(Mutex::new(1));
let b = Arc::new(Mutex::new(2));
let a1 = Arc::clone(&a);
let b1 = Arc::clone(&b);
let t1 = thread::spawn(move || {
let _ga = a1.lock().unwrap();
thread::sleep(Duration::from_millis(100));
let _gb = b1.lock().unwrap(); // 等待t2释放b
});
let a2 = Arc::clone(&a);
let b2 = Arc::clone(&b);
let t2 = thread::spawn(move || {
let _gb = b2.lock().unwrap();
thread::sleep(Duration::from_millis(100));
let _ga = a2.lock().unwrap(); // 等待t1释放a
});
t1.join().unwrap();
t2.join().unwrap(); // 死锁,程序无法结束
}
2.4 Rust提供的解决死锁的方法
Rust语言层面没有自动避免死锁的机制,但标准库和生态提供了一些工具和约定来预防和检测死锁:
2.4.1 方法1:使用 try_lock避免阻塞
功能:非阻塞地尝试获取锁,如果不能立即获得则返回错误,让线程有机会做其他事或释放已有资源。
2.4.1.1 Mutex::try_lock / RwLock::try_read / RwLock::try_write
接口签名(以 Mutex::try_lock为例):
#![allow(unused)]
fn main() {
pub fn try_lock(&self) -> TryLockResult<MutexGuard<'_, T>>
}
- 参数:无。
- 返回值:
TryLockResult<MutexGuard<'_, T>>– 成功返回Ok(guard),失败返回Err(TryLockError)。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let lock = Mutex::new(0);
if let Ok(mut guard) = lock.try_lock() {
*guard += 1;
} else {
println!("锁被占用,稍后重试");
}
}
解决原理分析:通过非阻塞 try_lock,线程可以在获取失败时释放已持有的锁(通过 drop),破坏“持有并等待”条件,从而避免死锁。
2.4.2 方法2:固定锁获取顺序(避免循环等待)
通过全局约定所有线程以相同的顺序获取多个锁,可以打破循环等待条件。
示例:
#![allow(unused)]
fn main() {
// 约定:总是先锁a,再锁b
let _ga = a.lock().unwrap();
let _gb = b.lock().unwrap();
}
这样任何线程都不会出现“先锁b再锁a”的情况,循环等待被消除。
2.4.3 方法3:使用 parking_lot crate(扩展)
虽然不是标准库,但值得提及:parking_lot库提供了更轻量、功能更丰富的 Mutex和 RwLock,生态中也有配套方式辅助做死锁检测。不过它不会自动替你消除死锁,锁顺序、作用域控制和 try_lock 这类设计仍然是主要手段。
2.4.4 方法4:使用 std::sync::TryLockError模式配合超时(标准库无直接超时锁,可通过 thread::sleep配合 try_lock实现)
#![allow(unused)]
fn main() {
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
let lock = Mutex::new(0);
let start = std::time::Instant::now();
loop {
if let Ok(mut guard) = lock.try_lock() {
*guard += 1;
break;
}
if start.elapsed() > Duration::from_secs(1) {
println!("超时放弃");
break;
}
thread::sleep(Duration::from_millis(10));
}
}
3. 线程构建器(std::thread::Builder)
3.1 概述
Builder允许在创建线程时配置其属性,例如线程名称、栈大小。默认使用 spawn创建线程无法设置这些属性。
3.2 创建线程构建器 – std::thread::Builder::new
功能:创建一个新的线程构建器实例,用于配置新线程的属性。
接口签名:
#![allow(unused)]
fn main() {
pub fn new() -> Builder
}
- 参数:无。
- 返回值:
Builder结构体。
简单调用示例:
#![allow(unused)]
fn main() {
use std::thread::Builder;
let builder = Builder::new();
}
3.3 设置线程名称 – Builder::name
功能:为将要创建的线程设置一个名称(主要用于调试,/proc/self/task/tid/comm下可见)。
接口签名:
#![allow(unused)]
fn main() {
pub fn name(self, name: String) -> Builder
}
- 参数:
name– 线程名称(String类型)。 - 返回值:
Builder(支持链式调用)。
简单调用示例:
#![allow(unused)]
fn main() {
let builder = Builder::new().name("my-worker-thread".to_string());
}
3.4 设置线程栈大小 – Builder::stack_size
功能:设置新线程的栈大小(字节)。默认栈大小与平台相关(通常是2MB)。
接口签名:
#![allow(unused)]
fn main() {
pub fn stack_size(self, size: usize) -> Builder
}
- 参数:
size– 栈大小(字节数)。 - 返回值:
Builder。
简单调用示例:
#![allow(unused)]
fn main() {
let builder = Builder::new().stack_size(4 * 1024 * 1024); // 4MB栈
}
3.5 创建并启动线程 – Builder::spawn
功能:使用配置好的参数创建并启动一个新线程,返回 JoinHandle。
接口签名:
#![allow(unused)]
fn main() {
pub fn spawn<F, T>(self, f: F) -> io::Result<JoinHandle<T>>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
}
- 参数:
f– 线程执行闭包(与spawn相同)。 - 返回值:
io::Result<JoinHandle<T>>– 成功返回Ok(handle),失败(如栈大小非法)返回Err。
简单调用示例:
#![allow(unused)]
fn main() {
use std::thread::Builder;
let handle = Builder::new()
.name("answer-thread".to_string())
.stack_size(1024 * 1024)
.spawn(|| {
println!("Hello from named thread");
42
})
.unwrap();
let result = handle.join().unwrap();
}
3.6 完整示例
#![allow(unused)]
fn main() {
use std::thread::{Builder, current};
let builder = Builder::new()
.name("my-thread".to_string())
.stack_size(3 * 1024 * 1024);
let handle = builder.spawn(|| {
println!("Thread name: {:?}", current().name());
}).unwrap();
handle.join().unwrap();
}
4. 结构化并发(Structured Concurrency)
4.1 概念
结构化并发是一种编程范式,保证所有子线程在父作用域结束前全部完成。它不是Rust标准库的一个具体类型,而是一种编码模式,通常通过作用域线程(std::thread::scope)实现。Rust标准库从1.63版本开始支持作用域线程(scoped threads)。
4.2 作用域线程 – std::thread::scope
功能:创建一个作用域,在该作用域内生成的线程可以安全地借用作用域外的变量(无需 move)。所有作用域内线程在 scope调用返回前一定会被 join,保证没有线程泄漏。
接口签名:
#![allow(unused)]
fn main() {
pub fn scope<'env, F, T>(f: F) -> T
where
F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,
}
- 参数:
f– 一个闭包,接收一个Scope对象,在闭包内可通过Scope::spawn创建线程。 - 返回值:闭包
f的返回值。
简单调用示例:
#![allow(unused)]
fn main() {
use std::thread;
let local = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| {
println!("first = {}", local[0]); // 可以借用local,无需move
});
s.spawn(|| {
println!("len = {}", local.len());
});
});
// 这里两个线程都已结束,local仍然有效
println!("{:?}", local);
}
4.3 作用域内创建线程 – Scope::spawn
功能:在作用域内生成一个新线程,该线程可以安全借用外部变量(生命周期受作用域限制)。
接口签名:
#![allow(unused)]
fn main() {
pub fn spawn<'scope, 'env, F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>
where
F: FnOnce() -> T + Send + 'scope,
T: Send + 'scope,
}
- 参数:
f– 闭包,可以借用'scope生命周期的变量。 - 返回值:
ScopedJoinHandle<'scope, T>,可调用join()等待线程结束。
简单调用示例:
#![allow(unused)]
fn main() {
thread::scope(|s| {
let handle = s.spawn(|| {
println!("scoped thread");
100
});
let result = handle.join().unwrap();
});
}
4.4 结构化并发的优势
- 防止线程泄漏:作用域结束前自动等待所有线程。
- 允许借用外部变量:无需
move所有权,避免不必要的Arc。 - 更清晰的代码组织:父子线程关系明确。
4.5 完整示例
#![allow(unused)]
fn main() {
use std::thread;
let mut data = vec![1, 2, 3, 4];
thread::scope(|s| {
// 从data中借用切片,每个线程处理一部分
// split_at_mut 可以证明两个可变切片互不重叠
let (chunk1, chunk2) = data.split_at_mut(2);
s.spawn(move || {
for item in chunk1.iter_mut() {
*item *= 2;
}
});
s.spawn(move || {
for item in chunk2.iter_mut() {
*item *= 2;
}
});
});
// 两个线程都已结束,data被安全修改
println!("{:?}", data); // [2, 4, 6, 8]
}
5. 线程局部存储(thread_local!)
5.1 概述
线程局部存储(TLS)允许每个线程拥有变量的独立副本,互不干扰。Rust提供了 thread_local!宏来定义线程局部变量。
5.2 定义线程局部变量 – thread_local!
功能:声明一个线程局部变量,每个线程首次访问时获得一个独立初始化的实例。
宏语法结构:
#![allow(unused)]
fn main() {
thread_local! {
static NAME: Type = Expression;
// 可以有多个
}
}
static– 表示静态线程局部变量。NAME– 变量名。Type– 类型。Expression– 初始化表达式,在每个线程中独立执行。
5.3 访问线程局部变量 – with 方法
每个线程局部变量自动生成一个 with方法,用于获取该线程本地实例的引用。
功能:在当前线程上获取线程局部变量的引用,并调用传入的闭包。闭包参数是 &T。
接口签名(由宏生成,通常形式):
#![allow(unused)]
fn main() {
pub fn with<F, R>(&'static self, f: F) -> R
where
F: FnOnce(&T) -> R,
}
- 参数:
f– 接受&T并返回R的闭包。 - 返回值:闭包
f的返回值R。
简单调用示例:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
thread_local! {
static COUNTER: RefCell<u32> = RefCell::new(0);
}
COUNTER.with(|c| {
*c.borrow_mut() += 1;
println!("Count: {}", *c.borrow());
});
}
5.4 使用 LocalKey的 try_with方法
功能:尝试获取线程局部变量,如果当前线程的TLS已销毁(可能在销毁期间调用),则返回错误。
接口签名(LocalKey::try_with):
#![allow(unused)]
fn main() {
pub fn try_with<F, R>(&'static self, f: F) -> Result<R, AccessError>
where
F: FnOnce(&T) -> R,
}
- 参数:
f– 闭包。 - 返回值:
Result<R, AccessError>。
简单调用示例:
#![allow(unused)]
fn main() {
COUNTER.try_with(|c| {
println!("Value: {}", *c.borrow());
}).unwrap_or_else(|_| println!("TLS already destroyed"));
}
5.5 完整示例:每个线程维护独立的计数器
#![allow(unused)]
fn main() {
use std::thread;
use std::cell::RefCell;
thread_local! {
static COUNT: RefCell<u32> = RefCell::new(0);
}
fn increment() {
COUNT.with(|c| {
*c.borrow_mut() += 1;
});
}
fn show() {
COUNT.with(|c| {
println!("Count in {:?}: {}", thread::current().id(), *c.borrow());
});
}
let t1 = thread::spawn(|| {
increment();
increment();
show(); // 输出 Count in ThreadId(1): 2
});
let t2 = thread::spawn(|| {
show(); // 输出 Count in ThreadId(2): 0
increment();
show(); // 输出 Count in ThreadId(2): 1
});
t1.join().unwrap();
t2.join().unwrap();
}
5.6 线程局部变量与普通静态变量的区别
| 特性 | static mut | thread_local! |
|---|---|---|
| 共享性 | 所有线程共享 | 每个线程独立 |
| 数据竞争 | 需要unsafe | 安全(因为不共享) |
| 初始化 | 编译期确定 | 每个线程首次访问时初始化 |
rust原子操作与无锁编程
1. 原子操作概念
原子操作是指对某一个原子变量执行的不可分割读/写/读改写操作。在多线程环境下,其他线程不会看到这个操作的“中间状态”。注意:多个原子操作连在一起并不会自动变成一个整体原子事务;如果需要维护多个变量之间的不变量,通常仍然需要锁或精心设计的无锁算法。CPU 层面会通过特殊指令(如 CAS、XADD)或等价机制来保证单次原子操作的原子性。
Rust标准库在 std::sync::atomic模块中提供了与平台相关的原子类型,例如:
AtomicBoolAtomicI8/AtomicI16/AtomicI32/AtomicI64/AtomicIsizeAtomicU8/AtomicU16/AtomicU32/AtomicU64/AtomicUsizeAtomicPtr<T>
这些类型的方法通常接收一个内存序(Ordering)参数,控制操作的可见性和重排序行为。
2. 原子操作与锁的区别
| 特性 | 互斥锁(Mutex) | 原子操作 |
|---|---|---|
| 粒度 | 通常保护一段代码或一个数据结构 | 保护单个内存位置(变量) |
| 开销 | 可能较高,竞争时可能阻塞线程 | 通常较低,但强内存序、CAS 重试和高竞争也会有明显成本 |
| 可组合性 | 易组合(多个锁可保护多个资源) | 复杂,需要手动处理ABA等边缘情况 |
| 适用场景 | 临界区复杂、修改多个变量 | 简单的计数器、标志位、无锁数据结构 |
| 阻塞 | 会阻塞线程 | 不阻塞(通过重试CAS实现非阻塞) |
简单记忆:锁适合大粒度、复杂操作;原子操作适合小粒度、高性能计数/标志。
3. 原子操作基本使用
3.1 创建原子变量 – AtomicXXX::new
功能:创建一个新的原子变量,初始化为给定值。
接口签名(以 AtomicUsize为例):
#![allow(unused)]
fn main() {
pub const fn new(v: usize) -> AtomicUsize
}
- 参数:
v– 初始值。 - 返回值:
AtomicUsize实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::sync::atomic::AtomicUsize;
let counter = AtomicUsize::new(0);
}
3.2 加载值 – load
功能:原子地读取当前值,不可与写操作交错。
接口签名:
#![allow(unused)]
fn main() {
pub fn load(&self, order: Ordering) -> usize
}
- 参数:
order– 内存序(见第四节)。 - 返回值:当前值。
简单调用示例:
#![allow(unused)]
fn main() {
let value = counter.load(std::sync::atomic::Ordering::SeqCst);
}
3.3 存储值 – store
功能:原子地写入新值,覆盖旧值。
接口签名:
#![allow(unused)]
fn main() {
pub fn store(&self, val: usize, order: Ordering)
}
- 参数:
val– 要写入的值。order– 内存序。
- 返回值:无。
简单调用示例:
#![allow(unused)]
fn main() {
counter.store(42, std::sync::atomic::Ordering::SeqCst);
}
3.4 交换值 – swap
功能:原子地将新值写入,并返回原来的旧值(读-改-写原子操作)。
接口签名:
#![allow(unused)]
fn main() {
pub fn swap(&self, val: usize, order: Ordering) -> usize
}
- 参数:
val– 新值。order– 内存序。
- 返回值:旧值。
简单调用示例:
#![allow(unused)]
fn main() {
let old = counter.swap(100, std::sync::atomic::Ordering::SeqCst);
}
3.5 获取并修改(fetch操作族)
3.5.1 fetch_add / fetch_sub
功能:原子地将当前值加上(或减去)一个值,返回修改前的值。
接口签名:
#![allow(unused)]
fn main() {
pub fn fetch_add(&self, val: usize, order: Ordering) -> usize
}
- 参数:
val– 要加的值。order– 内存序。
- 返回值:修改前的值。
简单调用示例:
#![allow(unused)]
fn main() {
let previous = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
类似的还有 fetch_and, fetch_or, fetch_xor, fetch_max, fetch_min等。
4. 内存序(Memory Ordering)
内存序定义了原子操作的可见性和重排序约束,用于平衡性能和正确性。Rust使用 std::sync::atomic::Ordering枚举,包含以下变体:
4.1 Relaxed(松散序)
- 保证:单个原子操作是原子的,但不对其他操作的顺序做任何保证。
- 用途:仅需要原子性,不关心同步(例如仅用作计数器,不加锁统计次数)。
4.2 Release(释放) & Acquire(获取)
Release:禁止当前线程中它之前的读写被重排到该操作之后,并把之前的写入“发布”出去。Acquire:禁止当前线程中它之后的读写被重排到该操作之前;如果它读取到了某个Release操作写入的值,就与那个Release建立同步关系,从而能看到对方在Release之前的写入。- 用途:实现锁、传递数据的所有权(例如
Mutex的内部实现)。
4.3 AcqRel(获取-释放)
- 保证:同时具有
Acquire和Release的语义,通常用于读-改-写操作(如swap,fetch_add, CAS)。对读改写操作来说,成功路径才同时具备读和写的同步语义;失败路径通常只按失败内存序处理。 - 用途:同时需要读和写的操作。
4.4 SeqCst(顺序一致)
- 保证:最强的常用约束,所有
SeqCst原子操作参与同一个全局顺序,便于推理。 - 用途:简单推理,性能有开销,但当不确定时首选。
4.5 内存序对比示例
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
static FLAG: AtomicBool = AtomicBool::new(false);
static DATA: AtomicUsize = AtomicUsize::new(0);
// 线程1:写数据,然后设置标志
thread::spawn(move || {
DATA.store(42, Ordering::Relaxed); // 原子写入数据本身,用 Relaxed 即可
FLAG.store(true, Ordering::Release); // 释放屏障
});
// 线程2:等待标志,然后读数据
thread::spawn(move || {
while !FLAG.load(Ordering::Acquire) {} // 获取屏障
let data = DATA.load(Ordering::Relaxed);
assert_eq!(data, 42); // 因为Release-Acquire同步,保证看到42
});
}
5. CAS操作(Compare And Swap)
CAS是“比较并交换”的缩写,是大多数无锁数据结构的基石。
5.1 compare_exchange
功能:原子地比较当前值与 current是否相等;若相等则设置为 new,否则不做修改。返回结果并告知是否成功。
接口签名:
#![allow(unused)]
fn main() {
pub fn compare_exchange(&self, current: usize, new: usize, success: Ordering, failure: Ordering) -> Result<usize, usize>
}
- 参数:
current– 期望的当前值。new– 要设置的新值。success– 当CAS成功时的内存序。failure– 当CAS失败时的内存序(不能强于success,且不能是Release或AcqRel)。
- 返回值:
Ok(old)– 成功,old等于current,值已被更新。Err(old)– 失败,old是实际的当前值。
简单调用示例:
#![allow(unused)]
fn main() {
let mut current = counter.load(Ordering::SeqCst);
loop {
match counter.compare_exchange(current, current + 1, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => break,
Err(actual) => current = actual, // 更新期望值,重试
}
}
}
5.2 compare_exchange_weak
功能:与 compare_exchange类似,但允许在某些平台上虚假失败(即使值相等也返回 Err)。通常用于循环重试中,性能更高。
接口签名:
#![allow(unused)]
fn main() {
pub fn compare_exchange_weak(&self, current: usize, new: usize, success: Ordering, failure: Ordering) -> Result<usize, usize>
}
- 参数、返回值:同
compare_exchange。 - 注意:必须放在循环中,因为虚假失败时需重试。
简单调用示例:
#![allow(unused)]
fn main() {
let mut current = counter.load(Ordering::SeqCst);
loop {
match counter.compare_exchange_weak(current, current + 1, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => break,
Err(actual) => current = actual,
}
}
}
6. ABA问题及其解决方法
6.1 什么是ABA问题
假设有一个共享变量 V,值为 A。线程T1读取 V得到 A,然后被操作系统挂起。线程T2将 V改为 B,再改回 A。当T1恢复执行并执行CAS(A→新值)时,会成功,因为当前值仍然是 A。但T1不知道 V已经经历过 A→B→A的变化,这可能导致数据不一致(例如指针指向的内存已被释放又重新分配)。
6.2 解决ABA问题的方法
常见的解决方案是使用带有版本号的指针或双字CAS。思路是:CAS 时不只比较指针本身,还比较一个随修改递增的版本号。
由于 Rust 标准库没有提供通用的双字 CAS(需要平台支持),下面用 AtomicUsize 演示“指针 + 版本号”打包思路:假设平台指针只使用低 48 位,高位用于保存版本号。这个假设不是可移植的生产方案,只适合帮助理解 ABA 问题。
6.3 模拟带版本号的CAS示例
下面不用裸指针,而是用“槽位索引 + 版本号”演示 ABA 的解决思路。
假设低 16 位保存槽位索引,高位保存版本号。每次更新索引时,版本号都加 1。这样即使索引从 A -> B -> A 回到原值,版本号也已经变化,旧的 CAS 就不会误判成功。
use std::sync::atomic::{AtomicUsize, Ordering};
const INDEX_BITS: usize = 16;
const INDEX_MASK: usize = (1 << INDEX_BITS) - 1;
fn pack(version: usize, index: usize) -> usize {
(version << INDEX_BITS) | (index & INDEX_MASK)
}
fn unpack(value: usize) -> (usize, usize) {
let version = value >> INDEX_BITS;
let index = value & INDEX_MASK;
(version, index)
}
struct VersionedIndex {
state: AtomicUsize,
}
impl VersionedIndex {
fn new(index: usize) -> Self {
Self {
state: AtomicUsize::new(pack(0, index)),
}
}
fn load(&self) -> (usize, usize) {
unpack(self.state.load(Ordering::SeqCst))
}
fn update_index(&self, expected: (usize, usize), new_index: usize) -> bool {
let expected_raw = pack(expected.0, expected.1);
let new_raw = pack(expected.0 + 1, new_index);
self.state
.compare_exchange(expected_raw, new_raw, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
}
fn main() {
let index = VersionedIndex::new(7);
let old = index.load();
assert_eq!(old, (0, 7));
assert!(index.update_index(old, 9));
assert_eq!(index.load(), (1, 9));
// 即使试图把索引改回 7,版本号也会继续增加。
assert!(index.update_index((1, 9), 7));
assert_eq!(index.load(), (2, 7));
// 旧观察值 (0, 7) 已经过期,CAS 不会成功。
assert!(!index.update_index(old, 10));
}
注:这个例子只演示版本号如何避免 ABA 误判。真实无锁数据结构还要处理内存回收、缓存行竞争、平台位宽等问题,生产环境建议使用成熟库。
7. 完整代码示例:SPSC 有界无锁队列
SPSC 是 Single Producer Single Consumer 的缩写,表示“单生产者、单消费者”。
这个约束非常关键:
- 只有一个生产者线程调用
push。 - 只有一个消费者线程调用
pop。 - 生产者只负责写入
tail指向的槽位。 - 消费者只负责读取
head指向的槽位。
如果想做一个通用的 SpscQueue<T>,通常需要处理“槽位尚未初始化”和“从槽位中移动出 T”的问题,标准库教学代码会变得明显更复杂。为了让本章示例保持完全安全,这里实现一个受限但安全的版本:
- 队列元素固定为
i32。 - 每个槽位用
AtomicI32存储。 - 保留
i32::MIN作为空槽位哨兵值,因此不能发送这个值。
这个版本不适合当作通用生产级队列,但很适合学习原子变量、环形队列和 Acquire/Release 同步。
7.1 设计思路
这个队列使用固定大小的环形缓冲区:
buffer: [ AtomicI32, AtomicI32, AtomicI32, ... ]
head: 下一个要读的位置
tail: 下一个要写的位置
判断规则:
head == tail:队列为空。next_tail == head:队列已满。
为了区分“空”和“满”,环形队列通常会空出一个槽位,所以如果内部数组长度是 N,实际可用容量是 N - 1。
7.2 内存序选择
这个例子主要使用 Acquire / Release:
- 生产者写入槽位后,用
tail.store(..., Ordering::Release)发布新元素。 - 消费者用
tail.load(Ordering::Acquire)观察生产者发布的新元素。 - 消费者取走数据并清空槽位后,用
head.store(..., Ordering::Release)发布空槽位。 - 生产者用
head.load(Ordering::Acquire)观察消费者释放出来的空槽位。
Relaxed 用在“只由当前线程修改”的索引读取上:
tail只由生产者修改,所以生产者读取自己的tail可以用Relaxed。head只由消费者修改,所以消费者读取自己的head可以用Relaxed。
槽位本身也是原子变量。槽位的 Relaxed 写入和读取,依靠 tail/head 上的 Release/Acquire 建立跨线程可见性。
7.3 代码示例
#![allow(unused)]
fn main() {
use std::array;
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::thread;
const EMPTY: i32 = i32::MIN;
// 一个完全使用安全 Rust 写成的教学版 SPSC 无锁队列。
//
// 为了做到完全安全,这里不存放泛型 T,也不使用未初始化内存,
// 而是把每个槽位设计成 AtomicI32,并保留 i32::MIN 作为“空槽位”哨兵值。
// 这让示例更适合学习原子操作,但也意味着它不是通用队列。
pub struct SpscQueue<const N: usize> {
// 每个槽位都是一个原子 i32。EMPTY 表示该槽位当前没有有效数据。
buffer: [AtomicI32; N],
// head 指向下一个要读取的位置,只由消费者线程更新。
head: AtomicUsize,
// tail 指向下一个要写入的位置,只由生产者线程更新。
tail: AtomicUsize,
}
impl<const N: usize> SpscQueue<N> {
pub fn new() -> Self {
assert!(N > 1, "SPSC 队列容量必须大于 1");
Self {
buffer: array::from_fn(|_| AtomicI32::new(EMPTY)),
head: AtomicUsize::new(0),
tail: AtomicUsize::new(0),
}
}
fn next(index: usize) -> usize {
(index + 1) % N
}
// 实际可用容量是 N - 1。
// 空出一个槽位后,head == tail 表示空,next_tail == head 表示满。
pub fn capacity(&self) -> usize {
N - 1
}
pub fn push(&self, value: i32) -> Result<(), i32> {
assert_ne!(value, EMPTY, "i32::MIN 被保留为空槽位哨兵值");
// tail 只由生产者修改,生产者读自己的 tail 用 Relaxed 即可。
let tail = self.tail.load(Ordering::Relaxed);
let next_tail = Self::next(tail);
// head 由消费者发布。Acquire 保证生产者能看到消费者释放的槽位。
let head = self.head.load(Ordering::Acquire);
if next_tail == head {
return Err(value);
}
// 写入数据本身。这个写入会被下面的 tail.store(Release) 发布出去。
self.buffer[tail].store(value, Ordering::Relaxed);
// Release 发布新的 tail。
// 消费者用 Acquire 读到这个 tail 值后,就能看到上面对槽位的写入。
self.tail.store(next_tail, Ordering::Release);
Ok(())
}
pub fn pop(&self) -> Option<i32> {
// head 只由消费者修改,消费者读自己的 head 用 Relaxed 即可。
let head = self.head.load(Ordering::Relaxed);
// tail 由生产者发布。Acquire 保证消费者能看到生产者写入的数据。
let tail = self.tail.load(Ordering::Acquire);
if head == tail {
return None;
}
let value = self.buffer[head].load(Ordering::Relaxed);
debug_assert_ne!(value, EMPTY);
// 把槽位重新标记为空。这个写入会被下面的 head.store(Release) 发布出去。
self.buffer[head].store(EMPTY, Ordering::Relaxed);
// Release 发布新的 head,让生产者知道这个槽位可以复用。
self.head.store(Self::next(head), Ordering::Release);
Some(value)
}
pub fn is_empty(&self) -> bool {
self.head.load(Ordering::Acquire) == self.tail.load(Ordering::Acquire)
}
}
pub fn demo() {
println!("...............原子操作 SPSC 无锁队列示例开始.................");
let queue = Arc::new(SpscQueue::<8>::new());
println!("队列可用容量: {}", queue.capacity());
let producer_queue = Arc::clone(&queue);
let producer = thread::spawn(move || {
for value in 0..20 {
let mut value = value;
loop {
match producer_queue.push(value) {
Ok(()) => break,
Err(returned) => {
// 队列满了,拿回发送失败的值,稍后重试。
value = returned;
thread::yield_now();
}
}
}
}
// 用 -1 作为结束标记,告诉消费者可以退出。
let mut end = -1;
while let Err(returned) = producer_queue.push(end) {
end = returned;
thread::yield_now();
}
});
let consumer_queue = Arc::clone(&queue);
let consumer = thread::spawn(move || {
let mut count = 0;
let mut sum = 0;
loop {
match consumer_queue.pop() {
Some(-1) => break,
Some(value) => {
count += 1;
sum += value;
println!("consumer pop: {}", value);
}
None => thread::yield_now(),
}
}
(count, sum)
});
producer.join().unwrap();
let (count, sum) = consumer.join().unwrap();
println!("消费者共收到 {} 个数据,求和结果 {}", count, sum);
println!("队列是否为空: {}", queue.is_empty());
println!("...............原子操作 SPSC 无锁队列示例结束.................");
}
}
7.4 代码讲解
生产者写入元素的顺序是:
- 读取
tail,找到要写入的槽位。 - 读取
head,确认队列没有满。 - 把数据写入
buffer[tail]。 - 用
Release更新tail,把新元素发布给消费者。
消费者读取元素的顺序是:
- 读取
head,找到要读取的槽位。 - 用
Acquire读取tail,确认生产者已经发布了元素。 - 从
buffer[head]读取数据。 - 把
buffer[head]重新写回EMPTY。 - 用
Release更新head,把空槽位发布给生产者复用。
这个示例能保持完全安全,原因是:
- 队列不存放泛型
T,只存放AtomicI32。 - 所有槽位从一开始就初始化为
EMPTY。 - 读取和写入槽位都通过安全的原子 API 完成。
7.5 使用限制
这个示例是教学版 SPSC 队列,不是通用队列:
- 不能有多个生产者同时调用
push。 - 不能有多个消费者同时调用
pop。 - 只能存放
i32。 - 不能存放
i32::MIN,因为它被保留为空槽位哨兵值。 - 队列是固定容量,满了以后
push会返回Err(value)。 - 如果要实现通用的
SpscQueue<T>或 MPMC 无锁队列,建议使用成熟库,例如crossbeam。
rust文件I/O与文件系统操作
1. 文件I/O
文件I/O主要包括文件的打开、关闭、读取、写入等操作。Rust通过 std::fs::File和 std::io模块提供这些功能。
1.1 打开文件
1.1.1 功能:打开现有文件 – File::open
功能:以只读模式打开一个已存在的文件。如果文件不存在,返回错误。
接口签名:
#![allow(unused)]
fn main() {
pub fn open<P: AsRef<Path>>(path: P) -> Result<File>
}
- 参数:
path– 文件路径(可以是字符串、Path或PathBuf)。 - 返回值:
Result<File>– 成功返回File对象,失败返回std::io::Error。
简单调用示例:
#![allow(unused)]
fn main() {
use std::fs::File;
let file = File::open("hello.txt").expect("文件打开失败");
}
1.1.2 功能:创建或覆盖文件 – File::create
功能:以只写模式打开文件。如果文件不存在则创建;如果存在则**截断(覆盖)**原有内容。
接口签名:
#![allow(unused)]
fn main() {
pub fn create<P: AsRef<Path>>(path: P) -> Result<File>
}
- 参数:
path– 文件路径。 - 返回值:
Result<File>。
简单调用示例:
#![allow(unused)]
fn main() {
let file = File::create("output.txt").expect("创建文件失败");
}
1.1.3 功能:自定义选项打开文件 – OpenOptions
功能:通过 OpenOptions可以精细控制打开方式(读/写/追加/创建/截断等)。
1.1.3.1 OpenOptions::new
功能:创建一个空的 OpenOptions结构体。
接口签名:
#![allow(unused)]
fn main() {
pub fn new() -> Self
}
- 返回值:
OpenOptions实例。
1.1.3.2 OpenOptions::read / write / append / create / truncate
功能:设置打开选项(链式调用)。
接口签名(以 read为例):
#![allow(unused)]
fn main() {
pub fn read(&mut self, read: bool) -> &mut Self
}
- 参数:
read–true表示启用读权限。 - 返回值:
&mut Self,支持链式调用。
1.1.3.3 OpenOptions::open
功能:根据配置打开文件。
接口签名:
#![allow(unused)]
fn main() {
pub fn open<P: AsRef<Path>>(&self, path: P) -> Result<File>
}
- 参数:
path– 文件路径。 - 返回值:
Result<File>。
简单调用示例:
#![allow(unused)]
fn main() {
use std::fs::OpenOptions;
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.append(true)
.open("log.txt")
.expect("打开失败");
}
1.2 读取文件
1.2.1 功能:读取全部内容为字符串 – std::fs::read_to_string
功能:将整个文件的内容读入一个 String中(便捷函数,不需要手动打开文件)。
接口签名:
#![allow(unused)]
fn main() {
pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String>
}
- 参数:
path– 文件路径。 - 返回值:
Result<String>– 文件内容。
简单调用示例:
#![allow(unused)]
fn main() {
let content = std::fs::read_to_string("data.txt").unwrap();
println!("{}", content);
}
1.2.2 功能:读取全部内容为字节数组 – std::fs::read
功能:将整个文件读入 Vec<u8>中。
接口签名:
#![allow(unused)]
fn main() {
pub fn read<P: AsRef<Path>>(path: P) -> Result<Vec<u8>>
}
- 参数:
path– 文件路径。 - 返回值:
Result<Vec<u8>>。
简单调用示例:
#![allow(unused)]
fn main() {
let bytes = std::fs::read("image.png").unwrap();
}
1.2.3 功能:从 File中读取 – std::io::Read trait
File实现了 Read trait,可以使用 read方法逐步读取。
1.2.3.1 Read::read
功能:从文件中读取数据到缓冲区,返回读取的字节数。
接口签名:
#![allow(unused)]
fn main() {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>
}
- 参数:
buf– 可变字节切片,用于存放读取的数据。 - 返回值:
Result<usize>– 实际读取的字节数,0表示EOF。
简单调用示例:
#![allow(unused)]
fn main() {
use std::io::Read;
let mut file = File::open("data.bin").unwrap();
let mut buffer = [0; 1024];
let n = file.read(&mut buffer).unwrap();
println!("读取了 {} 字节", n);
}
1.2.3.2 Read::read_to_end
功能:读取所有剩余内容直到EOF,追加到 Vec<u8>中。
接口签名:
#![allow(unused)]
fn main() {
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize>
}
- 参数:
buf– 存放数据的Vec。 - 返回值:
Result<usize>– 读取的总字节数。
简单调用示例:
#![allow(unused)]
fn main() {
let mut file = File::open("data.txt").unwrap();
let mut contents = Vec::new();
file.read_to_end(&mut contents).unwrap();
}
1.2.3.3 Read::read_to_string
功能:读取所有剩余内容,追加到 String中。
接口签名:
#![allow(unused)]
fn main() {
fn read_to_string(&mut self, buf: &mut String) -> Result<usize>
}
- 参数:
buf– 存放字符串的String。 - 返回值:
Result<usize>。
简单调用示例:
#![allow(unused)]
fn main() {
let mut file = File::open("hello.txt").unwrap();
let mut content = String::new();
file.read_to_string(&mut content).unwrap();
}
1.3 写入文件
1.3.1 功能:写入全部内容 – std::fs::write
功能:将数据(字节切片或字符串)一次性写入文件。如果文件不存在则创建;存在则覆盖。
接口签名:
#![allow(unused)]
fn main() {
pub fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()>
}
- 参数:
path– 文件路径。contents– 要写入的数据(&[u8]或&str)。
- 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
std::fs::write("output.txt", "Hello, Rust!").unwrap();
}
1.3.2 功能:向 File写入 – std::io::Write trait
File实现了 Write trait。
1.3.2.1 Write::write
功能:将缓冲区中的数据写入文件,返回写入的字节数。
接口签名:
#![allow(unused)]
fn main() {
fn write(&mut self, buf: &[u8]) -> Result<usize>
}
- 参数:
buf– 要写入的数据切片。 - 返回值:
Result<usize>– 实际写入的字节数。
简单调用示例:
#![allow(unused)]
fn main() {
use std::io::Write;
let mut file = File::create("out.txt").unwrap();
let bytes = file.write(b"Hello").unwrap();
}
1.3.2.2 Write::write_all
功能:尝试写入整个缓冲区,直到所有数据都写完或出错。
接口签名:
#![allow(unused)]
fn main() {
fn write_all(&mut self, buf: &[u8]) -> Result<()>
}
- 参数:
buf– 要写入的数据。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
file.write_all(b"Entire content").unwrap();
}
1.3.2.3 Write::flush
功能:刷新缓冲区,确保所有数据都写入底层系统。
接口签名:
#![allow(unused)]
fn main() {
fn flush(&mut self) -> Result<()>
}
- 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
file.flush().unwrap();
}
1.4 关闭文件
Rust中 File在离开作用域时会自动关闭(Drop trait),无需显式调用 close。如果需要提前关闭,可以调用 std::mem::drop(file)。
1.5 文件I/O综合示例:复制文件
下面是一个完整的示例,演示打开源文件、读取内容、创建目标文件并写入。
#![allow(unused)]
fn main() {
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;
fn copy_file(src: &str, dst: &str) -> std::io::Result<()> {
// 打开源文件(只读)
let mut src_file = File::open(src)?;
// 创建目标文件(只写,覆盖)
let mut dst_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(dst)?;
// 分配缓冲区
let mut buffer = [0; 4096];
loop {
let n = src_file.read(&mut buffer)?;
if n == 0 { break; } // EOF
dst_file.write_all(&buffer[..n])?;
}
Ok(())
}
pub fn demo_io() {
match copy_file("source.txt", "destination.txt") {
Ok(_) => println!("文件复制成功"),
Err(e) => eprintln!("复制失败: {}", e),
}
}
}
2. 文件系统操作
文件系统操作包括创建/删除目录、遍历目录、获取文件元数据(路径、权限、大小等)。
2.1 目录操作
2.1.1 功能:创建目录 – std::fs::create_dir
功能:创建一个空目录。如果父目录不存在,则返回错误。
接口签名:
#![allow(unused)]
fn main() {
pub fn create_dir<P: AsRef<Path>>(path: P) -> Result<()>
}
- 参数:
path– 要创建的目录路径。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
std::fs::create_dir("my_folder").unwrap();
}
2.1.2 功能:递归创建目录 – std::fs::create_dir_all
功能:创建目录及其所有不存在的父目录。
接口签名:
#![allow(unused)]
fn main() {
pub fn create_dir_all<P: AsRef<Path>>(path: P) -> Result<()>
}
- 参数:
path– 目录路径。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
std::fs::create_dir_all("a/b/c/d").unwrap();
}
2.1.3 功能:删除空目录 – std::fs::remove_dir
功能:删除一个空目录。如果目录非空,返回错误。
接口签名:
#![allow(unused)]
fn main() {
pub fn remove_dir<P: AsRef<Path>>(path: P) -> Result<()>
}
- 参数:
path– 目录路径。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
std::fs::remove_dir("empty_folder").unwrap();
}
2.1.4 功能:递归删除目录及其内容 – std::fs::remove_dir_all
功能:删除目录以及内部所有文件和子目录。
接口签名:
#![allow(unused)]
fn main() {
pub fn remove_dir_all<P: AsRef<Path>>(path: P) -> Result<()>
}
- 参数:
path– 目录路径。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
std::fs::remove_dir_all("non_empty_folder").unwrap();
}
2.2 路径操作
2.2.1 功能:获取当前工作目录 – std::env::current_dir
功能:返回当前进程的工作目录。
接口签名:
#![allow(unused)]
fn main() {
pub fn current_dir() -> Result<PathBuf>
}
- 返回值:
Result<PathBuf>– 当前目录的绝对路径。
简单调用示例:
#![allow(unused)]
fn main() {
let cur_dir = std::env::current_dir().unwrap();
println!("{}", cur_dir.display());
}
2.2.2 功能:获取文件的绝对路径 – std::fs::canonicalize
功能:将相对路径解析为绝对路径,并解析所有符号链接。
接口签名:
#![allow(unused)]
fn main() {
pub fn canonicalize<P: AsRef<Path>>(path: P) -> Result<PathBuf>
}
- 参数:
path– 原始路径。 - 返回值:
Result<PathBuf>– 规范化后的绝对路径。
简单调用示例:
#![allow(unused)]
fn main() {
let abs_path = std::fs::canonicalize("data/file.txt").unwrap();
}
2.3 文件元数据
2.3.1 功能:获取文件元数据 – std::fs::metadata
功能:获取文件或目录的信息(大小、权限、修改时间等)。
接口签名:
#![allow(unused)]
fn main() {
pub fn metadata<P: AsRef<Path>>(path: P) -> Result<Metadata>
}
- 参数:
path– 文件或目录路径。 - 返回值:
Result<Metadata>。
简单调用示例:
#![allow(unused)]
fn main() {
let metadata = std::fs::metadata("file.txt").unwrap();
println!("文件大小: {} bytes", metadata.len());
}
2.3.2 Metadata常用方法
2.3.2.1 metadata.is_file
功能:判断是否为普通文件。
接口签名:
#![allow(unused)]
fn main() {
pub fn is_file(&self) -> bool
}
- 返回值:
bool。
2.3.2.2 metadata.is_dir
功能:判断是否为目录。
接口签名:
#![allow(unused)]
fn main() {
pub fn is_dir(&self) -> bool
}
- 返回值:
bool。
2.3.2.3 metadata.len
功能:返回文件大小(字节数)。
接口签名:
#![allow(unused)]
fn main() {
pub fn len(&self) -> u64
}
- 返回值:
u64。
2.3.2.4 metadata.permissions
功能:返回文件的权限信息。
接口签名:
#![allow(unused)]
fn main() {
pub fn permissions(&self) -> Permissions
}
- 返回值:
Permissions。
简单调用示例:
#![allow(unused)]
fn main() {
if metadata.is_file() {
println!("大小: {} bytes", metadata.len());
println!("权限: {:?}", metadata.permissions());
}
}
2.4 修改权限
2.4.1 功能:设置文件权限 – std::fs::set_permissions
功能:修改文件或目录的权限。
接口签名:
#![allow(unused)]
fn main() {
pub fn set_permissions<P: AsRef<Path>>(path: P, perm: Permissions) -> Result<()>
}
- 参数:
path– 路径。perm– 新的权限。
- 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
use std::fs::{self, Permissions};
use std::os::unix::fs::PermissionsExt; // Unix平台
let mut perms = fs::metadata("script.sh").unwrap().permissions();
perms.set_mode(0o755); // 设置 rwxr-xr-x
fs::set_permissions("script.sh", perms).unwrap();
}
注意:Windows平台权限模型不同,可以使用
PermissionsExttrait或其他平台特定方法。
2.5 读取目录内容
2.5.1 功能:读取目录条目 – std::fs::read_dir
功能:返回目录中所有条目的迭代器。
接口签名:
#![allow(unused)]
fn main() {
pub fn read_dir<P: AsRef<Path>>(path: P) -> Result<ReadDir>
}
- 参数:
path– 目录路径。 - 返回值:
Result<ReadDir>,可以迭代出DirEntry。
简单调用示例:
#![allow(unused)]
fn main() {
for entry in std::fs::read_dir(".").unwrap() {
let entry = entry.unwrap();
let file_name = entry.file_name();
println!("{}", file_name.to_string_lossy());
}
}
2.5.2 DirEntry常用方法
2.5.2.1 DirEntry::path
功能:获取该条目的完整路径。
接口签名:
#![allow(unused)]
fn main() {
pub fn path(&self) -> PathBuf
}
- 返回值:
PathBuf。
2.5.2.2 DirEntry::file_name
功能:获取文件名(不含路径)。
接口签名:
#![allow(unused)]
fn main() {
pub fn file_name(&self) -> OsString
}
- 返回值:
OsString。
2.5.2.3 DirEntry::metadata
功能:获取该条目的元数据。
接口签名:
#![allow(unused)]
fn main() {
pub fn metadata(&self) -> Result<Metadata>
}
- 返回值:
Result<Metadata>。
2.6 文件系统操作综合示例:目录树遍历与统计
下面是一个完整示例,递归遍历目录,统计文件数量、总大小,并输出文件路径及权限。
#![allow(unused)]
fn main() {
use std::fs::{self, DirEntry, Metadata};
use std::io;
use std::path::Path;
fn walk_dir(dir: &Path) -> io::Result<(usize, u64)> {
let mut file_count = 0;
let mut total_size = 0;
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_file() {
file_count += 1;
total_size += metadata.len();
// 输出文件信息
println!("文件: {}", path.display());
println!(" 大小: {} bytes", metadata.len());
println!(" 权限: {:?}", metadata.permissions());
} else if metadata.is_dir() {
let (sub_count, sub_size) = walk_dir(&path)?;
file_count += sub_count;
total_size += sub_size;
}
}
}
Ok((file_count, total_size))
}
pub fn demo_fs() {
let start_dir = Path::new(".");
match walk_dir(start_dir) {
Ok((files, size)) => {
println!("\n总计: {} 个文件, {} 字节", files, size);
}
Err(e) => eprintln!("遍历出错: {}", e),
}
}
}
rust 网络I/O
1. TCP(面向连接)
TCP提供可靠的、面向连接的字节流传输。Rust通过 TcpStream(客户端/服务端连接)和 TcpListener(服务端监听)实现。
1.1 TCP 客户端 API
1.1.1 连接服务器 – TcpStream::connect
功能:创建一个TCP流并连接到远程服务器。
接口签名:
#![allow(unused)]
fn main() {
pub fn connect<A: ToSocketAddrs>(addr: A) -> Result<TcpStream>
}
- 参数:
addr– 服务器地址,如"127.0.0.1:8080"或("localhost", 8080)。 - 返回值:
Result<TcpStream>– 成功返回已连接的流对象。
简单调用示例:
#![allow(unused)]
fn main() {
use std::net::TcpStream;
let stream = TcpStream::connect("127.0.0.1:8080").expect("连接失败");
}
1.1.2 从流中读取数据 – Read trait
TcpStream实现了 std::io::Read trait,可以使用 read、read_to_string等方法。
1.1.2.1 Read::read
功能:从流中读取数据到缓冲区,返回读取的字节数(0表示对方关闭连接)。
接口签名:
#![allow(unused)]
fn main() {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>
}
简单调用示例:
#![allow(unused)]
fn main() {
use std::io::Read;
let mut buf = [0; 1024];
let n = stream.read(&mut buf).unwrap();
}
1.1.3 向流中写入数据 – Write trait
TcpStream实现了 std::io::Write trait,可以使用 write、write_all、flush等方法。
1.1.3.1 Write::write_all
功能:尝试写入整个缓冲区。
简单调用示例:
#![allow(unused)]
fn main() {
use std::io::Write;
stream.write_all(b"Hello, server!").unwrap();
}
1.1.4 关闭连接 – shutdown 或自动关闭
TcpStream在离开作用域时会自动关闭(Drop)。也可以手动调用 shutdown部分关闭。
1.1.4.1 TcpStream::shutdown
功能:关闭连接的读端、写端或两端。
接口签名:
#![allow(unused)]
fn main() {
pub fn shutdown(&self, how: Shutdown) -> Result<()>
}
- 参数:
how–Shutdown::Read,Shutdown::Write或Shutdown::Both。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
use std::net::Shutdown;
stream.shutdown(Shutdown::Write).unwrap();
}
1.2 TCP 服务器端 API
1.2.1 绑定监听地址 – TcpListener::bind
功能:创建 TcpListener并绑定到本地地址,开始监听。
接口签名:
#![allow(unused)]
fn main() {
pub fn bind<A: ToSocketAddrs>(addr: A) -> Result<TcpListener>
}
- 参数:
addr– 本地地址,如"127.0.0.1:8080"。 - 返回值:
Result<TcpListener>。
简单调用示例:
#![allow(unused)]
fn main() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:8080").expect("绑定失败");
}
1.2.2 接受客户端连接 – TcpListener::accept
功能:阻塞等待一个客户端的连接请求,返回 (TcpStream, SocketAddr)。
接口签名:
#![allow(unused)]
fn main() {
pub fn accept(&self) -> Result<(TcpStream, SocketAddr)>
}
- 返回值:
Result<(TcpStream, SocketAddr)>– 流和对方地址。
简单调用示例:
#![allow(unused)]
fn main() {
let (stream, addr) = listener.accept().unwrap();
println("客户端{}已连接", addr);
}
1.2.3 获取监听器本地地址 – TcpListener::local_addr
功能:返回监听器的本地套接字地址。
接口签名:
#![allow(unused)]
fn main() {
pub fn local_addr(&self) -> Result<SocketAddr>
}
简单调用示例:
#![allow(unused)]
fn main() {
let addr = listener.local_addr().unwrap();
}
1.2.4 迭代接入连接 – TcpListener::incoming
功能:返回一个迭代器,每次迭代接受一个新连接,返回 Result<TcpStream>。
接口签名:
#![allow(unused)]
fn main() {
pub fn incoming(&self) -> Incoming<'_>
}
- 返回值:
Incoming迭代器,每个元素是Result<TcpStream>。
简单调用示例:
#![allow(unused)]
fn main() {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
// 处理每个连接
}
}
1.3 TCP 客户端与服务端操作逻辑分析
在实际使用TCP时,客户端和服务端遵循典型的“请求-响应”模式。下面分别说明操作流程。
1.3.1 客户端操作逻辑
- 创建连接:调用
TcpStream::connect,传入服务器IP和端口。该函数会阻塞直到与服务器建立TCP三次握手成功或超时。 - 发送请求:通过
write或write_all向流中写入数据。由于TCP是字节流,可能需要多次写入才能发送完整消息(应用层需自行处理消息边界)。 - 接收响应:通过
read循环读取数据,直到满足预期长度或收到EOF(对方关闭写端)。 - 关闭连接:显式调用
shutdown或让stream离开作用域自动关闭,发送FIN包。
1.3.2 服务端操作逻辑
- 创建监听器:调用
TcpListener::bind绑定到一个本地端口,内核开始监听。 - 循环接受连接:在循环中调用
accept或使用incoming迭代器,每个新连接产生一个新的TcpStream。 - 处理每个连接:通常为每个客户端生成一个新线程(或使用异步任务)来处理,避免阻塞其他客户端。处理步骤:
- 读取客户端请求数据。
- 业务处理。
- 发送响应数据。
- 关闭连接:处理完毕后,流离开作用域自动关闭,或主动调用
shutdown。
1.4 TCP 完整示例
下面实现一个简单的回声服务器(Echo Server):客户端发送一行文本,服务器原样返回。
1.4.1 服务端代码(tcp_server.rs)
#![allow(unused)]
fn main() {
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
// 处理单个客户端连接
fn handle_client(mut stream: TcpStream) {
let addr = stream.peer_addr().unwrap();
println!("[服务器] 新连接: {}", addr);
let mut buffer = [0; 512];
loop {
match stream.read(&mut buffer) {
Ok(0) => {
// 客户端关闭了连接
println!("[服务器] {} 已断开", addr);
break;
}
Ok(n) => {
// 将接收到的数据原样写回
let msg = &buffer[..n];
println!("[服务器] 收到 {} 字节,发送回去", n);
if let Err(e) = stream.write_all(msg) {
eprintln!("[服务器] 写入错误: {}", e);
break;
}
}
Err(e) => {
eprintln!("[服务器] 读取错误: {}", e);
break;
}
}
}
}
pub fn run_server() {
let listener = TcpListener::bind("127.0.0.1:8080").expect("绑定失败");
println!("[服务器] 监听在 127.0.0.1:8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
// 为每个客户端创建一个新线程
thread::spawn(|| handle_client(stream));
}
Err(e) => {
eprintln!("[服务器] 接受连接失败: {}", e);
}
}
}
}
}
1.4.2 客户端代码(tcp_client.rs)
#![allow(unused)]
fn main() {
use std::net::TcpStream;
use std::io::{Write, Read};
use std::thread::sleep;
use std::time::Duration;
pub fn run_client() {
let mut stream = TcpStream::connect("127.0.0.1:8080").expect("连接服务器失败");
println!("[客户端] 已连接到服务器");
// 发送几条消息
let messages = vec!["Hello", "Rust", "TCP", "再见"];
let mut buffer = [0; 512];
for msg in messages {
// 发送消息
stream.write_all(msg.as_bytes()).unwrap();
println!("[客户端] 发送: {}", msg);
// 接收回声
let n = stream.read(&mut buffer).unwrap();
let echo = String::from_utf8_lossy(&buffer[..n]);
println!("[客户端] 收到回声: {}", echo);
sleep(Duration::from_secs(1));
}
// 关闭写端,通知服务器不会再发送数据
stream.shutdown(std::net::Shutdown::Write).unwrap();
println!("[客户端] 连接关闭");
}
}
1.4.3 主函数演示
#![allow(unused)]
fn main() {
// 需要分别运行服务端和客户端,或者用两个线程演示
pub fn demo_tcp() {
// 实际使用时请分别运行 server 和 client
// 这里简单演示启动服务端线程和客户端线程(仅用于教学,注意顺序)
std::thread::spawn(|| {
run_server();
});
std::thread::sleep(Duration::from_millis(100));
run_client();
}
}
2. UDP(无连接)
UDP提供不可靠的、无连接的数据报传输。Rust通过 UdpSocket实现,既可以发送也可以接收,不区分客户端/服务器。
2.1 绑定本地地址 – UdpSocket::bind
功能:创建一个UDP套接字并绑定到本地地址。
接口签名:
#![allow(unused)]
fn main() {
pub fn bind<A: ToSocketAddrs>(addr: A) -> Result<UdpSocket>
}
- 参数:
addr– 本地地址,如"0.0.0.0:8080"。 - 返回值:
Result<UdpSocket>。
简单调用示例:
#![allow(unused)]
fn main() {
use std::net::UdpSocket;
let socket = UdpSocket::bind("127.0.0.1:8080").expect("绑定失败");
}
2.2 发送数据到指定地址 – UdpSocket::send_to
功能:将数据报发送到指定的远程地址。
接口签名:
#![allow(unused)]
fn main() {
pub fn send_to<A: ToSocketAddrs>(&self, buf: &[u8], addr: A) -> Result<usize>
}
- 参数:
buf– 要发送的数据。addr– 目标地址。
- 返回值:
Result<usize>– 实际发送的字节数。
简单调用示例:
#![allow(unused)]
fn main() {
socket.send_to(b"Hello UDP", "127.0.0.1:9090").unwrap();
}
2.3 接收数据并获取发送方地址 – UdpSocket::recv_from
功能:阻塞等待接收一个数据报,返回接收到的字节数和发送方地址。
接口签名:
#![allow(unused)]
fn main() {
pub fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, SocketAddr)>
}
- 参数:
buf– 存放数据的缓冲区。 - 返回值:
Result<(usize, SocketAddr)>– (字节数, 发送方地址)。
简单调用示例:
#![allow(unused)]
fn main() {
let mut buf = [0; 1024];
let (len, src) = socket.recv_from(&mut buf).unwrap();
println!("收到 {} 字节,来自 {}", len, src);
}
2.4 连接模式(可选) – UdpSocket::connect
功能:将UDP套接字“连接”到一个远程地址,之后可以使用 send和 recv(不带地址参数)。
接口签名:
#![allow(unused)]
fn main() {
pub fn connect<A: ToSocketAddrs>(&self, addr: A) -> Result<()>
}
- 参数:
addr– 远程地址。 - 返回值:
Result<()>。
简单调用示例:
#![allow(unused)]
fn main() {
socket.connect("127.0.0.1:9090").unwrap();
socket.send(b"Data").unwrap();
}
2.5 UDP 完整示例:简单聊天程序
下面实现一个UDP回声程序:两个端点都绑定到 127.0.0.1:0,让操作系统自动分配空闲端口,然后互相发送消息并回应。这样示例不会因为固定端口被占用而失败。
2.5.1 代码(udp_echo.rs)
#![allow(unused)]
fn main() {
use std::io;
use std::net::UdpSocket;
use std::thread;
use std::time::Duration;
pub fn demo_udp() -> io::Result<()> {
// 使用 127.0.0.1:0 让操作系统分配空闲端口,避免固定端口被占用。
let socket_a = UdpSocket::bind("127.0.0.1:0")?;
let socket_b = UdpSocket::bind("127.0.0.1:0")?;
let addr_a = socket_a.local_addr()?;
let addr_b = socket_b.local_addr()?;
socket_a.set_read_timeout(Some(Duration::from_secs(1)))?;
socket_b.set_read_timeout(Some(Duration::from_secs(1)))?;
// 端点 B 收到 A 的消息后,原样加上 Echo 前缀发回去。
let endpoint_b = thread::spawn(move || -> io::Result<()> {
let mut buf = [0; 1024];
let (len, src) = socket_b.recv_from(&mut buf)?;
let msg = String::from_utf8_lossy(&buf[..len]);
println!("[UDP {}] 收到来自 {} 的消息: {}", addr_b, src, msg);
socket_b.send_to(format!("Echo: {}", msg).as_bytes(), src)?;
Ok(())
});
socket_a.send_to(b"Hello UDP", addr_b)?;
let mut buf = [0; 1024];
let (len, src) = socket_a.recv_from(&mut buf)?;
let msg = String::from_utf8_lossy(&buf[..len]);
println!("[UDP {}] 收到来自 {} 的消息: {}", addr_a, src, msg);
endpoint_b.join().expect("UDP 端点线程 panic")?;
Ok(())
}
}
async 编程
Rust 的 async 编程由三层内容组成:
- 语言层机制:
async fn、Future、.await、Waker和状态机。 - 三方库体验:在已有运行时或执行器的前提下,如何写日常 async 代码。
- 自定义 runtime:亲手实现一个最小运行时,加深对调度、唤醒和
poll的理解。
初学时可以先掌握“怎么用”:在 async 函数里写 .await,用运行时驱动 Future。等能写出代码后,再回头理解 Future 为什么是惰性的、运行时为什么必须存在。
Rust Async 机制入门
1. async 基础语法和使用-留下一个语法结构的印象
我们需要先建立起最直观的认识。你可以把 async 和 .await 理解为两个简单的工具:
async:给函数或代码块贴上一个“异步”标签,让它有能力在未来“暂停”和“恢复”。.await:在异步函数内部,用来“等”另一个异步任务完成,并拿到它的结果。
下面我们通过代码来看它们的具体用法。
1.1 async 函数和 async 代码块
#![allow(unused)]
fn main() {
// 用 async fn 定义一个异步函数
async fn say_hello() -> String {
"你好,世界".to_string()
}
// 用 async {} 定义一个异步代码块
let block = async {
println!("这是一个异步代码块");
};
}
关键点:当你调用 say_hello() 或者执行 async {} 时,函数体或代码块里面的代码并不会立即执行。它们只是返回一个东西,这个东西在 Rust 里叫做 Future(可以暂时理解为“一张未来会兑现的票”)。
#![allow(unused)]
fn main() {
let future = say_hello(); // 此时 "你好,世界" 还没有被创建,什么也没打印
}
💡 这个
Future对象是 惰性 的——它不会自己动起来,必须有东西去“推动”它,里面的代码才会真正执行。 (不理解“惰性”和“推动”不要紧,第二部分会详细解释背后的机制,你目前只需要记住这个现象即可。)
1.2 .await —— 等待 Future 完成
如果你正在一个 async 函数或者 async 代码块里面,想要拿到 Future 最终产出的值,就可以在它后面写上 .await:
#![allow(unused)]
fn main() {
async fn get_message() -> String {
"Hello".to_string()
}
async fn print_message() {
let msg = get_message().await; // 等待 get_message() 这个 Future 完成,拿到里面的 String
println!("{}", msg);
}
}
.await 的作用就是:等待这个 Future 完成,然后把结果取出来。你可以把它想象成从一张票里“兑现”出真正的礼物。
注意:.await 只能在 async 函数或 async 代码块内部使用。在普通函数里写 .await 会导致编译错误。
1.3 总结
- 用
async fn或async {}定义异步任务,它们返回一个Future。 Future是惰性的,调用后不会立即执行里面的代码,需要被“驱动”才会运行。- 在
async里面用.await来等待一个Future完成,并拿到它的结果。
下面的内容会深入解释 “async到底是什么” “future是什么” “为什么 Future 是惰性的”“它到底怎么被驱动的”“.await是什么“ ”.await背后做了什么”等等。如果你暂时觉得这些细节有点绕,可以先回到上面的例子,把基本用法和三个要点记住,再往后看。
2. async 机制解读
2.1 从 “async” 这个名字说起
在 Rust 中,你会看到一个关键字:async。它的中文译名是异步。
既然叫异步,那什么是异步呢?不妨先从它的反面——“同步”——开始理解。
想象两种通讯方式:
- 同步:你给朋友打电话。拨通之后,你就把电话贴在耳边,一直等到对方说完、挂断,你才放下手机做下一件事。在这段时间里,你只能等待,什么都做不了。
- 异步:你给朋友发一条微信。消息发出后,你就把手机放下,去泡咖啡、整理房间。等到朋友回复,手机“叮”的一声通知你,你再拿起手机处理回复。在等待回复的时间里,你并没有卡住,而是去做了其他事情。
程序世界里的同步和异步,和上面的场景很接近:
- 同步调用:一个函数被调用后,调用者会一直等着这个函数全部执行完、返回结果,然后才能继续往下走。等待期间,调用者什么也干不了。
- 异步调用:一个函数被调用后,它可能并不会立刻完成,而是先返回一个“未来才会完成的值”。调用者拿到这个“未来值”后,可以先去干别的事情,等这个值真正准备好再回来处理。
2.2 光靠 rust 语言的关键字和标准库的接口无法直接实现异步
假设你用 async 写了一个异步函数:
async fn do_something() {
println!("doing something");
}
fn main() {
do_something(); // 你以为会执行?
}
编译、运行,你会发现什么也没有发生,连打印都没有。就算你把异步任务用 .await 串起来:
async fn run() {
do_something().await;
}
fn main() {
run(); // 依然不会执行任何实际代码
}
run() 同样只是返回了一个代表“未来会完成的计算”的变量,并不会真正执行内部的 println!。
为什么会这样?因为 Rust 标准库只定义了异步任务的接口(Future trait)以及唤醒机制所需的基本类型,但它本身没有提供调度和执行异步任务的引擎。你可以这样理解:标准库给了你汽车的设计图纸和零件,但并没有给你发动机。没有发动机,车是开不走的。
这里的“发动机”,就是运行时(runtime)。
要理解异步运行时,可以先回想一个更熟悉的概念——操作系统的资源调度。
- 操作系统中有很多线程都想使用 CPU,但 CPU 核心有限。操作系统的调度器会按照一定的策略(比如轮流分配时间片、先来先服务等)决定下一个让哪个线程运行。
- 在 Rust 的异步世界中,我们有大量的异步任务。每一个异步任务都可以看作是一个实现了
Future接口的对象。这些任务需要在某个线程上被推进执行。运行时的作用就类似于操作系统的调度器,只不过它调度的不是线程,而是这些异步任务(也就是Future)。
具体来说,一个典型的异步运行时至少承担以下几项职责:
- 驱动任务执行:不断调用异步任务(
Future)的poll方法,让任务向前推进。 - 管理任务队列:维护一个或多个任务集合,决定下一次调用哪个任务的
poll(例如,一个任务返回Pending后,把它放到等待队列,先去poll其他就绪任务)。 - 响应唤醒通知:当一个任务被外部事件(如 I/O 完成、定时器到期)通过
Waker唤醒时,运行时将该任务重新标记为“可运行”,并在后续的循环中再次poll它。
这里你只需要建立一个总体印象:异步运行时就是一个不断挑选任务、调用其
poll方法并处理返回结果的调度循环。一个异步任务就可以看作是一个Future。poll方法就可以看做执行任务。
因此,Rust 的 async 本质上是一套面向运行时的基础设施。它规定了异步任务该如何表示、如何被推进、如何被唤醒,但把“什么时候推进、推进谁”这些调度决策完全交给了外部运行时(如 tokio、async-std)。这就是为什么我们常说 Rust 是“零成本抽象”的异步模型——语言本身不强制任何调度策略,开发者可以根据场景选择最合适的运行时。
2.3 async 的本质:无栈协程
那么,既然光靠 rust 语言的关键字和标准库的接口无法直接实现异步,那 async 关键字是什么意思?本身到底做了什么?它的本质是:无栈协程。
我们先从“协程”这个概念讲起。
2.3.1 协程是什么?
协程定义:一种可以暂停和恢复执行的程序组件。它能够在某一个点保存当前的执行状态,主动让出控制权,之后再从暂停的地方继续执行。现代编程语言,程序组件就是函数,所以可以狭义的理解成可暂停和恢复的函数。
一个生活类比:你正在看书,突然需要去接一个电话。你拿一张书签夹在当前页,合上书,去接电话。电话结束后,你重新打开书,从书签的位置继续往下读。这张书签,就是协程保存的“状态”;合上书,就是“让出控制权”;回来继续读,就是“恢复执行”。
简单总结:协程就是一段能够主动暂停、稍后继续的代码。
协程通常有两种实现方式:
- 有栈协程:每个协程拥有自己独立的调用栈(像线程那样),切换时需要保存和恢复整个栈,开销较大,但灵活性高(比如 Go 语言的 goroutine)。
- 无栈协程:协程不拥有独立的栈,而是通过编译器将代码重写成一个状态机,状态保存在一个结构体中。切换时只需要改变一下状态标记,几乎没有内存拷贝,开销极小。
Rust 的 async 采用的就是无栈协程。也就是说,编译器会把 async 函数或代码块转换成一个状态机,通过这个状态机来实现“暂停——恢复”的能力。
为了理解这是怎么做到的,我们需要先弄清楚“状态机”本身是什么。
2.3.2 状态机是什么?
状态机(State Machine)是一个很朴素的模型:一个东西可以处于若干不同的状态,在某个事件发生时,会从一个状态转换到另一个状态。
日常生活中的红绿灯就是一个简单的状态机:
- 状态:红灯、绿灯、黄灯。
- 转换规则:红灯持续一段时间后变绿灯,绿灯变黄灯,黄灯变红灯。
- 任意时刻,红绿灯一定只处于某一个确定的状态。
在程序里,状态机通常被实现为一个枚举(表示所有可能的状态),以及一套逻辑,根据当前状态和输入决定下一个状态。
2.3.2.1 一个简单的状态机代码示例
为了更具体地理解状态机在代码里长什么样,我们来看一个用 Rust 枚举实现的简单例子。假设我们要描述一个灯的开关状态转换:灯可以处于“开”或“关”两种状态,每次按下按钮,状态就在两者之间切换。
#![allow(unused)]
fn main() {
// 定义状态
enum LightState {
On,
Off,
}
// 状态机结构体,保存当前状态
struct Light {
state: LightState,
}
impl Light {
fn new() -> Self {
Light {
state: LightState::Off,
}
}
// 按下按钮,状态转换
fn press_button(&mut self) {
match self.state {
LightState::Off => {
println!("灯亮了");
self.state = LightState::On;
}
LightState::On => {
println!("灯灭了");
self.state = LightState::Off;
}
}
}
}
}
在这个例子中:
LightState枚举就是状态机的“所有可能状态”。Light结构体保存了当前状态,并在press_button方法中根据当前状态决定下一状态,完成转换。
总结一下,一个状态机至少包含两部分:
- 当前状态:记录现在处于哪个阶段;
- 转移逻辑:根据当前状态和某个触发动作,切换到下一个状态。
async 状态机也是一样的道理,只不过它的状态是“代码执行到了哪个 .await”,转移逻辑则是“当子 Future 就绪后,继续执行下一段代码;如果子 Future 还未就绪,就暂停等待”。
2.3.3 Rust 中 async 的状态机
这里你只需要建立一个总体印象:一个异步任务就可以看作是一个
Future。poll方法就可以看做执行任务的接口,后面讲future会具体介绍
异步函数的状态机原理与普通状态机类似,但更复杂一些。
普通状态机通常包含两部分:
- 当前状态;
- 根据当前状态和某个动作,切换到下一个状态的逻辑。
而 async 状态机也可以这样理解:
- 当前状态:记录这个
async fn执行到了哪里,比如还没开始、正在等待step_one、正在等待step_two、已经结束; - 转移逻辑:每次外部调用
poll时,状态机根据当前状态继续执行。如果遇到.await,就poll对应的子 Future;如果子 Future 返回Ready,就进入下一个状态;如果子 Future 返回Pending,就保存当前状态并返回Pending。
注意:下面的代码不是编译器真实生成的代码,也不是可以直接编译的 Rust 代码,而是为了帮助理解 async 状态机执行过程的伪代码。真实的编译器生成的结构要复杂得多,而且因为有 Pin 的存在,不能简单地像普通结构体那样直接改写字段。
以这样一个异步函数为例:
#![allow(unused)]
fn main() {
async fn my_task() {
step_one().await;
step_two().await;
}
}
编译器会将它转换成类似下面的状态机枚举(伪代码):
#![allow(unused)]
fn main() {
enum MyTaskStateMachine {
Start,
WaitingStepOne {
step_one_future: StepOneFuture,
},
WaitingStepTwo {
step_two_future: StepTwoFuture,
},
Done,
}
}
Start:还没开始执行。WaitingStepOne:已经创建了step_one()返回的子 Future,正在等待它完成。WaitingStepTwo:已经创建了step_two()返回的子 Future,正在等待它完成。Done:整个 async 函数执行完毕。
当我们调用 my_task() 时,返回的就是这个状态机的一个实例,初始状态为 Start。之后每次对这个实例调用 poll 方法,就会根据当前状态决定执行哪一段代码。
执行过程大致是这样的:
第一次 poll:
- 当前状态是
Start; - 调用
step_one()得到子 Future,把状态切换到WaitingStepOne,并将子 Future 保存在这个状态变体中; - 接着立即
poll这个子 Future; - 如果子 Future 返回
Ready,就继续执行,进入step_two()的等待阶段(切换到WaitingStepTwo); - 如果子 Future 返回
Pending,那么整个my_task状态机也会返回Pending,控制权交还给运行时。
第二次 poll(以及之后可能的更多次):
- 不会从函数开头重新执行,而是直接从
WaitingStepOne状态恢复; - 再次
poll之前保存的那个step_one_future; - 如果它这次返回
Ready,就继续创建step_two()的子 Future,进入WaitingStepTwo状态,并立即poll它; - 如果
step_two_future也立刻Ready,状态变为Done,整体返回Ready; - 如果任何一次子 Future 返回
Pending,当前状态机就保存好状态并返回Pending,等待下一次被唤醒后继续。
这个过程中,状态机在每次 poll 时,根据当前状态决定执行哪一段代码,并且可能多次返回 Pending,暂停在某个 .await 点。下次再被 poll 时,直接从上次暂停的状态继续执行,而不是从头开始。这就是无栈协程通过状态机实现“暂停—恢复”的核心原理。
2.3.4 协程与异步的关系
那么,协程和“异步”有什么关系?——毕竟关键字名就叫 async。
答:协程可以用来实现异步,是异步的实现方式之一
进一步理解异步的本质
- 异步是一种代码运作的模式,本身并无绑定任何底层实现机制
- 异步的实现方式不只有协程一种
- 异步也可以基于线程或者进程
异步编程的核心诉求是:当一个任务需要等待(比如等待网络数据)时,不阻塞当前线程,而是让出执行权,让线程可以先去处理其他任务。
协程的“暂停——恢复”能力,恰好完美契合了这个需求:
在 .await 处,如果子任务未就绪,当前协程就可以暂停,把执行权交还给运行时;等到子任务就绪后,运行时再恢复该协程,从上次暂停的地方继续执行。整个过程中,线程没有被阻塞,而是持续在各协程之间切换推进。
于是,Rust 选择将 async 函数编译成状态机形式的无栈协程,让它天然具备“可暂停、可恢复”的特性,再通过 Future trait 暴露给运行时。这样,开发者写异步代码时,看到的只是顺序的 .await,而背后运行时则高效地在各种任务间切换。正因为这种机制直接服务于“不阻塞等待”的异步范式,所以关键字取名为 async。
2.4 Future trait —— 异步世界的统一接口
#![allow(unused)]
fn main() {
// 用 async fn 定义一个异步函数
async fn say_hello() -> String {
"你好,世界".to_string()
}
let fut = say_hello();
}
这段代码里 被 async修饰的 say_hello的函数,他的返回值 fut你觉得会是什么类型?
fut是实现了 future trait的 future对象类型,你可能会疑惑明明函数定义的时候返回是String,这个疑问会在下面做一个解答。
future 你可以理解为未来量,或者是未来将要完成的异步任务。一个future也可以看做是一个异步任务
2.4.1 Future trait 的定义
在 Rust 标准库中,Future trait 的核心定义如下(简化版):
#![allow(unused)]
fn main() {
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
这个 trait 只有一个核心方法:poll。第一次看到这个签名,你可能会觉得里面的 Pin、Context、Poll 很陌生。别着急,我们一步一步拆开来看。
2.4.2 type Output —— 最终结果类型
type Output; 是一个关联类型,它表示这个 Future 最终完成时会产出的值的类型。例如:
#![allow(unused)]
fn main() {
async fn foo() -> i32 { 10 }
}
foo() 返回的 Future 的 Output 就是 i32。如果函数没有显式返回值,那就是 ()。
2.4.3 poll —— 推进执行的唯一入口
poll 方法的意思是:尝试推进这个 Future 的执行。你可以把它理解成异步任务的“执行按钮”,每按一次,任务就往前走一步,直到它彻底完成或者被某个等待点卡住。之前提到过future对象是惰性的——它不会自行启动,必须由异步运行时(或者手写驱动循环)通过 poll 方法不断推进,才能真正执行内部的代码。
poll 就是执行 async fn 的函数。当运行时调用一个 Future 的 poll 时,编译器生成的状态机就开始运转。
2.4.4 Poll 枚举——完成还是等待?
poll 的返回值是 Poll<Self::Output>。Poll 是一个枚举:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
Poll::Ready(T):任务已经完成,T是最终结果。Poll::Pending:任务还没有完成,现在需要等待某些条件(比如网络数据未到达),暂时无法继续。 如果poll返回Pending,运行时就知道这个任务目前必须暂停,应该转而去调度其他任务。等将来条件满足,再重新poll这个任务。
这里就解答了之前的疑惑:future调用poll以后,如果返回值是Poll::Ready(T),T就是定义async函数的时候的返回值,可以通过 match匹配Poll::Ready(T),获取到返回值
2.4.5 Context 与 Waker —— 唤醒的纽带
poll 的第二个参数是 cx: &mut Context<'_>。Context 目前主要用来提供 Waker。Waker 是一个可以被用来“唤醒任务”的句柄。
它们的作用大致如下:
- 当一个 Future 在执行过程中发现自己暂时无法继续(例如数据没到),它就会返回
Poll::Pending,并利用cx.waker()拿到Waker,把它注册到某个事件源上。 - 当事件就绪后,事件源调用
Waker上的wake()方法,通知运行时:“之前等待的那个任务现在可以继续了!” - 运行时收到通知后,会在合适的时机重新
poll这个 Future。
注意:
Waker并不是直接恢复 Future 执行的,它只是给运行时发一个“可以重试了”的信号。真正负责再次poll的仍然是运行时。
2.4.6 Pin<&mut Self> —— 为什么 poll 不能是普通的 &mut self
现在来看 poll 签名中最让人困惑的部分:self: Pin<&mut Self>。
要理解为什么需要 Pin,得从异步状态机的内部结构说起。
前面我们说过,async fn 会被编译成一个状态机。状态机在不同的 .await 点之间可能需要保存局部变量。比如:
#![allow(unused)]
fn main() {
async fn example() {
let a = 10;
let b = &a; // b 引用了 a
some_future().await; // 这里可能暂停
println!("{}", b); // 恢复后继续使用 b
}
}
在这个函数里,变量 b 是一个指向 a 的引用。编译器在生成状态机时,必须同时保存 a 和 b。这意味着状态机结构体内部可能出现自引用:一个字段是另一个字段的引用。
例如,编译器生成的状态机可能在内存中长这样:
状态机结构体 {
a: 10,
b: &a, // 指向同一结构体内的字段 a
}
如果这个状态机结构体被移动到另一个内存地址,a 的位置变了,但 b 还指向原来的地址,那么 b 就成了悬垂引用,会导致未定义行为。
常规的 Rust 引用(&)并不保证被引用对象的内存地址不变,因此这种自引用结构在可以自由移动的情况下是危险的。而 Future 可能会在多个 poll 调用之间被传递、放入队列等,如果它的内存地址发生变化,内部的自引用就会失效。
为了解决这个问题,Rust 引入了 Pin。Pin 是一个标记类型,它的核心语义是:对于被 Pin 包裹且没有实现 Unpin 的值,安全代码不能把它从当前内存位置移走。当 poll 要求 Pin<&mut Self> 时,它等于承诺:在 Future 被固定之后,它的内存地址不会再被安全代码随意改变。这样编译器就可以安全地处理可能跨 .await 保存引用的状态机。
简单来说:
- 自引用结构:异步状态机可能包含指向自身的引用。
- 移动问题:如果状态机被移动,那些自引用会变成悬垂指针。
- Pin 的保证:对
!Unpin类型来说,Pin禁止安全代码移动其被指向的值,确保内部引用不会失效。 - Unpin:大多数普通类型都是
Unpin的,Pin对它们不施加额外的移动限制;而编译器生成的 async 状态机通常是!Unpin的,所以必须通过Pin来访问。
在实际使用中,我们通常用以下方式固定一个 Future:
- 栈上固定:使用
std::pin::pin!宏,例如:#![allow(unused)] fn main() { let mut fut = std::pin::pin!(some_async_fn()); } - 堆上固定:使用
Box::pin,例如:#![allow(unused)] fn main() { let mut fut = Box::pin(some_async_fn()); }
然后通过 as_mut() 获取 Pin<&mut Future>,再调用 poll:
#![allow(unused)]
fn main() {
fut.as_mut().poll(&mut cx);
}
初学阶段不必深究 Pin 的全部细节,只需要记住:因为 async 状态机可能自引用,所以 poll 要求 Future 不能被移动,要用 Pin 固定住。使用 pin! 或 Box::pin 把 Future 固定好,然后通过 as_mut() 调用 poll 即可。
2.5 .await 的深入理解
前面我们讲了 async fn 会返回 Future,也讲了 Future 需要被 poll 推进。接下来我们结合已经学过的机制,更深入地理解 .await。
2.5.1 .await 的背后:poll 的调用链
当你写:
#![allow(unused)]
fn main() {
let value = child_future.await;
}
这里要特别注意一个容易误解的地方:.await 本身并不会“凭空”调用 poll,它是在父 Future 被 poll 的过程中才起作用的。
整个调用链是这样的:
- 运行时(或你手写的驱动循环) 调用最外层 Future(即当前这个
async fn返回的那个 Future)的poll方法。 - 在这个最外层 Future 的
poll方法内部,编译器生成的状态机代码会执行到.await的位置。此时,编译器生成的代码会去调用子 Future(即child_future)的poll方法。 - 根据子 Future 的
poll返回值决定下一步:- 如果返回
Poll::Ready(value),说明子 Future 已经完成,.await表达式的结果就是这个value,父 Future 继续执行后面的代码。 - 如果返回
Poll::Pending,说明子 Future 还没完成,于是父 Future 也会暂停,并向上返回Poll::Pending,最终把控制权交还给运行时。
- 如果返回
可以总结为:
运行时 poll 父 Future → 父 Future 在
.await点 poll 子 Future → 子 Future Ready 则父 Future 继续,子 Future Pending 则父 Future Pending
也就是说,.await 是编译器在父 Future 的状态机中插入的一段逻辑,它负责在父 Future 被驱动时去推进子 Future,但它自己不会独立地、在运行时外部去调用 poll。
2.5.2 .await 不一定会挂起
很多初学者会以为:只要写了 .await,任务就一定会挂起。这是不对的。
.await 是一个可能挂起点,不是一定挂起点。例如:
#![allow(unused)]
fn main() {
async fn simple() {
println!("simple");
}
async fn run() {
println!("run start");
simple().await;
println!("run end");
}
}
simple() 里面没有任何真正需要等待的东西,所以它第一次被 poll 时就会直接完成。因此 simple().await 会立刻拿到结果,run 会继续执行,输出:
run start
simple
run end
不会出现 Pending。只有当被 await 的子 Future 返回 Poll::Pending 时,当前 Future 才会暂停。
2.5.3 多个 .await 默认是顺序执行
例如:
async fn main_task() {
task1().await;
task2().await;
task3().await;
}
这段代码默认是顺序执行的:先等待 task1 完成,再等待 task2,最后等待 task3。如果 task1 还没有完成,那么 task2 根本不会开始执行。
这和很多人想象中的“异步自动并发”不一样。.await 的意思是等待当前这个 Future 完成,再继续往下执行。
如果你想让多个任务并发执行,需要使用运行时提供的 spawn,或者使用类似 join 的工具。例如在 tokio 中:
#![allow(unused)]
fn main() {
let h1 = tokio::spawn(task1());
let h2 = tokio::spawn(task2());
h1.await.unwrap();
h2.await.unwrap();
}
这里 task1 和 task2 才是作为两个独立任务交给运行时调度。所以要区分:
task1().await:当前任务等待task1完成。spawn(task1()):创建一个独立任务,让运行时单独调度。join:同时推进多个 Future,等待它们都完成。
2.5.4 父 Future 和子 Future 的嵌套关系
假设有嵌套的异步函数:
#![allow(unused)]
fn main() {
async fn leaf() {
println!("leaf");
}
async fn child() {
leaf().await;
}
async fn parent() {
child().await;
}
}
运行时通常只需要管理 parent() 返回的那个最外层 Future。当运行时 poll parent 时,parent 内部执行到 child().await,这时 parent 这个状态机会去 poll child。而 child 内部又会去 poll leaf。
调用链可以理解为:
runtime poll parent
parent poll child
child poll leaf
对于普通 .await 嵌套,运行时不需要直接拿到每一个子 Future。它只要 poll 最外层 Future,子 Future 会由父 Future 的状态机继续向下 poll。这也是 Rust async 状态机非常关键的地方——任务树是由状态机层层嵌套驱动,而不是由运行时逐个管理所有子任务。
2.6 示例代码及其讲解
下面,我们用示例代码亲手体验上面所讲的一切。我们完全脱离第三方运行时,用最原始的手动 poll 方式驱动 Future,看清它内部的执行顺序。
2.6.1 完整示例代码
#![allow(unused)]
fn main() {
use std::task::Poll;
// 简单异步任务
async fn simple_async_task() {
println!(" simple async task");
}
// 简单异步任务示例
async fn simple_demo() -> String {
println!("async simple demo start");
simple_async_task().await;
println!("async simple demo end");
"OK".to_string()
}
fn demo1() {
// 使用 pin 包裹 simple_demo 的返回值,simple_demo 的返回值是 Future 类型
let mut fut = std::pin::pin!(simple_demo());
// 创建一个空的 waker
let waker = std::task::Waker::noop();
// 基于 waker 构造一个 context
let mut context = std::task::Context::from_waker(&waker);
// 调用 poll 推进异步任务——此时异步任务会开始执行
let res = fut.poll(&mut context);
// match 匹配表达式
match res {
Poll::Pending => {
println!("simple demo future pending");
}
Poll::Ready(res) => {
println!("simple demo future ready: {:?}", res);
}
}
}
// 多个异步任务示例
async fn multiple_demo() -> String {
println!("async multiple demo start");
simple_async_task().await;
simple_async_task().await;
simple_async_task().await;
println!("async multiple demo end");
"OK".to_string()
}
fn demo2() {
let mut fut = std::pin::pin!(multiple_demo());
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(&waker);
loop {
let res = fut.as_mut().poll(&mut context);
match res {
Poll::Pending => {
println!("multiple demo future pending");
}
Poll::Ready(res) => {
println!("multiple demo future ready: {:?}", res);
break;
}
}
}
}
pub fn demo() {
println!("..............async 示例代码开始.....................");
demo1();
demo2();
println!("..............async 示例代码结束.....................");
}
}
2.6.2 代码讲解
我们一边看代码,一边解释它运行时的行为。
2.6.2.1 最简单的异步任务 —— simple_async_task
#![allow(unused)]
fn main() {
async fn simple_async_task() {
println!(" simple async task");
}
}
- 这是一个用
async声明的函数,编译器会为它生成状态机。 - 函数内部没有任何
.await,所以它没有暂停点。一旦被poll,就会一口气执行完,返回Poll::Ready(())。 - 只调用
simple_async_task()不会执行任何代码,仅仅返回一个 Future。
2.6.2.2 包含一次 .await 的任务 —— simple_demo
#![allow(unused)]
fn main() {
async fn simple_demo() -> String {
println!("async simple demo start");
simple_async_task().await;
println!("async simple demo end");
"OK".to_string()
}
}
- 函数内有一个
.await,因此编译器生成的状态机至少包含两个阶段。 - 因为
simple_async_task没有阻塞点,所以simple_demo被poll时会一次性走完所有流程:- 打印
async simple demo start - 执行到
.await,推动simple_async_task,它立即完成 - 打印
async simple demo end - 返回
"OK"
- 打印
- 最终
poll返回Poll::Ready("OK".to_string())。
2.6.2.3 demo1 —— 手动驱动一个 Future
#![allow(unused)]
fn main() {
let mut fut = std::pin::pin!(simple_demo());
}
simple_demo()返回一个 Future。std::pin::pin!宏把它“钉”在栈上,保证内存地址不变。这是为了满足poll的Pin要求,初学者可先当作固定写法。
#![allow(unused)]
fn main() {
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(&waker);
}
- 创建一个空的
Waker,它不执行任何实际唤醒。用它构造Context,对于这个没有真正等待点的示例完全够用。
#![allow(unused)]
fn main() {
let res = fut.poll(&mut context);
}
- 我们亲手调用了
poll此时simple_demo的状态机开始运转,顺序走完全部流程,最终返回Ready。
#![allow(unused)]
fn main() {
match res {
Poll::Pending => { println!("simple demo future pending"); }
Poll::Ready(res) => { println!("simple demo future ready: {:?}", res); }
}
}
- 因为任务不阻塞,
res必定是Ready,所以输出:simple demo future ready: "OK"
2.6.2.4 多个 .await 的任务 —— multiple_demo
#![allow(unused)]
fn main() {
async fn multiple_demo() -> String {
println!("async multiple demo start");
simple_async_task().await;
simple_async_task().await;
simple_async_task().await;
println!("async multiple demo end");
"OK".to_string()
}
}
- 虽然有三个
.await,但因为每个simple_async_task都没有等待点,所以一次poll就会连续打印三行,最后返回Ready。这说明.await只是“可能挂起点”,不一定会真的挂起。
2.6.2.5 demo2 —— 循环 poll 模拟运行时
#![allow(unused)]
fn main() {
let mut fut = std::pin::pin!(multiple_demo());
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(&waker);
loop {
let res = fut.as_mut().poll(&mut context);
match res {
Poll::Pending => {
println!("multiple demo future pending");
}
Poll::Ready(res) => {
println!("multiple demo future ready: {:?}", res);
break;
}
}
}
}
- 这里用一个
loop反复调用poll,模拟了一个极简的“异步运行时调度循环”。 - 如果某次
poll返回Pending,我们打印一行pending并继续循环(真实运行时会切换到其他任务)。 - 如果返回
Ready,打印结果并退出循环。 - 因为
multiple_demo没有真正的阻塞,第一次poll就直接完成,不会输出Pending。
使用 futures 体验 Rust async 异步编程
前面的 async 文档已经讲过:async fn 会返回 Future,.await 会等待一个 Future 完成。但初学者最容易卡住的地方是:
我写了
async,为什么代码没有自己跑起来?
答案是:Rust 语言本身只提供 Future、async/.await 这些基础机制,不自带完整运行时。你需要一个运行时去不断 poll 这些 Future,把它们推进到完成。
本文用第三方库 futures 体验最小化的 async 编程方式。
1. 添加依赖
在 Cargo.toml 中添加:
[dependencies]
futures = "0.3"
futures 不是大型业务运行时,但它提供了很多 async 基础工具:
executor::block_on:把一个 Future 跑到完成。executor::LocalPool:单线程任务池,可以提交多个 Future。join!:同时等待多个 Future。channel::oneshot/channel::mpsc:异步通道,体验事件唤醒。
2. 有运行时以后,async 代码怎么写
先看一个最简单的异步函数:
#![allow(unused)]
fn main() {
async fn load_user_name(user_id: u32) -> String {
println!("开始读取用户 {}", user_id);
format!("user-{}", user_id)
}
}
调用它时:
#![allow(unused)]
fn main() {
let fut = load_user_name(1);
}
这行代码只是创建了一个 Future,函数体还没有真正执行。要让它执行,需要运行时:
#![allow(unused)]
fn main() {
use futures::executor::block_on;
let name = block_on(load_user_name(1));
println!("{}", name);
}
block_on 可以理解为一个最小运行时入口。它会不断推动这个 Future,直到 Future 返回最终结果。
3. .await:在 async 函数里等待另一个任务
#![allow(unused)]
fn main() {
async fn build_profile(user_id: u32) -> String {
let name = load_user_name(user_id).await;
format!("profile({})", name)
}
}
这段代码看起来像同步代码:先读取用户名,再构造 profile。
但 .await 的特别之处在于:如果被等待的 Future 暂时没完成,当前任务可以暂停,把执行权还给运行时。运行时就有机会去推进其他任务。
4. join!:同时等待多个 Future
如果你这样写:
#![allow(unused)]
fn main() {
let a = delayed_task("task-A", 120).await;
let b = delayed_task("task-B", 80).await;
}
这是顺序等待:先等 A,再等 B。
如果两个任务互不依赖,可以用 join!:
#![allow(unused)]
fn main() {
use futures::join;
let (a, b) = join!(
delayed_task("task-A", 120),
delayed_task("task-B", 80),
);
}
join! 会同时等待两个 Future。只要某个 Future 暂时没有准备好,运行时可以去 poll 另一个 Future。
注意:join! 不是创建操作系统线程。它表达的是“这几个异步任务可以一起推进”。真正是否多线程执行,取决于你使用的运行时。
5. 异步通道:体验唤醒
futures::channel::oneshot 的接收端实现了 Future:
#![allow(unused)]
fn main() {
use futures::channel::oneshot;
use std::thread;
use std::time::Duration;
async fn delayed_task(name: &'static str, millis: u64) -> String {
let (tx, rx) = oneshot::channel();
thread::spawn(move || {
thread::sleep(Duration::from_millis(millis));
let _ = tx.send(format!("{} finished", name));
});
rx.await.expect("发送端提前关闭")
}
}
这段代码的过程是:
- 创建一个
oneshot通道。 - 标准线程睡一会儿,然后发送结果。
- async 任务在
rx.await处暂停。 - 发送端发送结果后,接收端 Future 被唤醒。
- 运行时再次 poll 它,拿到结果并继续执行。
这就是异步运行时和 Waker 的实际使用感:任务不是一直占着线程等,而是暂时挂起,等事件来了再继续。
6. LocalPool:把多个任务提交给运行时
block_on 一次主要驱动一个顶层 Future。如果想体验“运行时管理多个任务”,可以使用 LocalPool:
#![allow(unused)]
fn main() {
use futures::executor::LocalPool;
use futures::task::LocalSpawnExt;
let mut pool = LocalPool::new();
let spawner = pool.spawner();
spawner.spawn_local(async {
println!("任务1");
}).unwrap();
spawner.spawn_local(async {
println!("任务2");
}).unwrap();
pool.run();
}
这里有两个角色:
spawner:负责把 Future 放进任务池。pool.run():负责不断 poll 池子里的任务,直到任务都完成。
7. 完整示例代码
对应代码在:
src/study/futures_async_demo.rs
入口函数是:
#![allow(unused)]
fn main() {
pub fn demo()
}
并且已经在 src/study/mod.rs 中导出:
#![allow(unused)]
fn main() {
pub mod futures_async_demo;
}
可以在 main.rs 中调用:
#![allow(unused)]
fn main() {
study::futures_async_demo::demo();
}
8. 初学者要记住的模型
先记住这四句话就够了:
async fn返回的是Future,不是直接返回最终值。Future默认是惰性的,需要运行时推动。.await只能写在 async 上下文中,用来等待另一个 Future。- 等待期间任务可以暂停,运行时可以去推进其他任务。
如果把同步代码想象成“一个人排队逐个办事”,那么 async 更像“把等待中的事情登记好,谁准备好了就继续处理谁”。运行时就是负责登记、唤醒、调度的那个人。
动手写一个简单 Async Runtime
通过实现一个极简的异步运行时,理解 Rust 异步机制的核心原理:
Future、Waker、任务调度和唤醒。
1. 为什么需要自己实现 Runtime?
在之前的文章中,我们提到 Rust 标准库只提供了异步的接口(Future trait)和唤醒机制(Waker),但并没有提供调度执行引擎。async fn 返回的 Future 是惰性的,必须由运行时不断调用 poll 才能推进。
理解运行时内部如何工作,能帮助我们:
- 真正掌握
Future/Waker/Context的协作方式; - 理解
tokio等工业级运行时的设计基础; - 学会如何将任意异步任务适配到自定义调度器。
本文将带领你实现一个单线程、任务队列、支持主动让出的极简运行时。代码完全基于 std,不依赖任何第三方库。
2. 整体设计思路
我们的运行时需要实现以下功能:
- 任务抽象:用
Task结构体包装一个Future,并提供poll驱动能力。 - 调度器:用
Executor管理一个就绪任务队列,不断从队列中取出任务执行(poll)。 - 唤醒机制:
Future在poll过程中通过Context使用或保存Waker,当等待的事件就绪后通过Waker将任务重新放回队列。 - 运行时入口:提供
spawn提交任务,block_on阻塞运行直到根任务完成。
我们的设计采用单线程模型:
- 一个主线程作为工作线程,循环从队列取任务
poll。 - 当队列为空但还有未完成的任务时,工作线程进入
park状态,等待Waker唤醒。 Waker唤醒时会把任务重新加入队列,并unpark工作线程。
任务间的切换是协作式的:任务在 poll 中返回 Pending(例如主动让出,或等待某个事件)后,调度器才会切换到其他就绪任务。
3. 核心组件与实现详解
3.1 Executor —— 调度器
#![allow(unused)]
fn main() {
struct Executor {
queue: VecDeque<Arc<Task>>,
task_count: usize,
work_thread: Thread,
}
}
Executor 持有:
queue:就绪任务队列(双端队列)。task_count:当前未完成的任务总数(包括队列中的和正在执行的)。work_thread:工作线程的句柄。当有新任务入队时,用于唤醒工作线程。
为什么需要 task_count?
run() 主循环需要知道何时退出:当没有任何未完成任务时,block_on 应该返回。
3.2 Task —— 任务抽象
#![allow(unused)]
fn main() {
struct Task {
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>>,
is_queued: AtomicBool,
executor: Weak<Mutex<Executor>>,
}
}
future:被Mutex保护的Future对象。使用Option是因为poll_once时会将Future临时取出,poll之后如果未完成则再放回。类型擦除为dyn Future<Output = ()> + Send + 'static,意味着这个运行时只支持返回()的异步任务(为简化实现,block_on另有处理)。is_queued:原子布尔值,标记任务是否已经在队列中,避免重复入队。executor:指向持有它的Executor的弱引用,用于在schedule时将自己放回队列。
为什么需要 is_queued?
Waker 可能会被多次调用(例如多个事件源同时触发),但一个任务同时只能存在于队列中一次。该标记防止重复入队造成资源浪费和混乱。
为什么 executor 是弱引用?
防止循环引用:Task 被 Arc 持有,同时 Task 内部又持有 Executor 的强引用会导致 Executor 永远无法释放。弱引用允许在 Executor 已销毁时,schedule 操作静默失败。
3.3 Task 的核心方法
3.3.1 schedule —— 将任务放入执行队列
#![allow(unused)]
fn main() {
fn schedule(self: &Arc<Self>) {
if self.is_queued.swap(true, Ordering::SeqCst) {
return;
}
let Some(executor) = self.executor.upgrade() else { return };
let mut executor = executor.lock().unwrap();
executor.queue.push_back(self.clone());
executor.work_thread.unpark();
}
}
逻辑:
- 通过
swap(true)原子地把is_queued设为true,如果原值为true,说明任务已在队列,直接返回。 - 升级弱引用拿到
Executor(如果Executor已释放则放弃)。 - 加锁后将任务克隆一份放入队列尾部。
- 唤醒工作线程(
unpark)。如果工作线程正因队列空而park,它会立即醒来处理新任务。
3.3.2 poll_once —— 单次驱动任务
#![allow(unused)]
fn main() {
fn poll_once(self: &Arc<Self>) {
let Some(mut future) = self.future.lock().unwrap().take() else {
return;
};
let waker = Waker::from(self.clone());
let mut context = Context::from_waker(&waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(()) => {
if let Some(executor) = self.executor.upgrade() {
let mut executor = executor.lock().unwrap();
debug_assert!(executor.task_count > 0);
executor.task_count -= 1;
}
}
Poll::Pending => {
*self.future.lock().unwrap() = Some(future);
}
}
}
}
详细步骤:
- 取出 Future:从
self.future中take()取出Option,得到一个Some(future)。之后self.future变为None。 - 构造 Waker:因为
Task实现了Waketrait,Waker::from(self.clone())可以将Arc<Task>转为Waker。这个Waker的wake()会调用Task::wake,进而执行schedule。 - 构造 Context:
Context包含Waker引用。 - 调用
poll:传入Pin<&mut dyn Future>和&mut Context。- 如果返回
Ready,说明任务已完成:减少task_count。注意此时self.future已经是None,不再持有 Future。 - 如果返回
Pending:将之前取出的future重新放回self.future,以便下次poll_once再次取出。
- 如果返回
- 为什么
poll后需要把future放回?Task必须持有 Future,因为任务可能被多次poll(在 Pending 后等待唤醒)。如果不放回,下一次poll_once会因future为None而直接返回,任务将永远无法完成。
3.4 实现 Wake trait —— 让 Task 自身成为 Waker
#![allow(unused)]
fn main() {
impl Wake for Task {
fn wake(self: Arc<Self>) {
self.schedule();
}
fn wake_by_ref(self: &Arc<Self>) {
self.schedule();
}
}
}
通过实现 Wake,我们可以用 Waker::from(task_ref) 轻松创建一个唤醒器。当调用 waker.wake() 时,实际上就是调用 Task::wake,从而将任务重新放回执行队列。
这是适配的关键:任何需要唤醒任务的地方,只要持有 Task 的 Arc,就能构造 Waker 并传给 Future::poll。
3.5 Runtime —— 面向用户的接口
#![allow(unused)]
fn main() {
struct Runtime {
executor: Arc<Mutex<Executor>>,
}
}
Runtime 持有一个 Executor(用 Arc 和 Mutex 包装),提供三个方法。
3.5.1 new —— 创建运行时
#![allow(unused)]
fn main() {
fn new() -> Arc<Self> {
Arc::new(Self {
executor: Arc::new(Mutex::new(Executor {
queue: VecDeque::new(),
task_count: 0,
work_thread: thread::current(),
})),
})
}
}
注意 work_thread 被设置为当前线程(即创建 Runtime 的线程)。运行时的主循环会在同一个线程上执行。
3.5.2 spawn —— 提交任务
#![allow(unused)]
fn main() {
fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(future))),
executor: Arc::downgrade(&self.executor),
is_queued: AtomicBool::new(true),
});
let mut executor = self.executor.lock().unwrap();
executor.task_count += 1;
executor.queue.push_back(task);
executor.work_thread.unpark();
}
}
- 将传入的
Future用Box::pin固定在堆上,再放入Task::future的Mutex中。 - 初始化
is_queued为true表示创建后立刻入队(避免重复入队的检查在此无影响,因为初次入队is_queued状态应与入队同步。实际代码中入队前设为true是安全的,因为还没有任何schedule调用过。) - 增加
task_count,将任务加入队列,唤醒工作线程。
3.5.3 run —— 调度循环(核心)
#![allow(unused)]
fn main() {
fn run(&self) {
loop {
let task = {
let mut executor = self.executor.lock().unwrap();
if executor.task_count == 0 {
return;
}
let task = executor.queue.pop_front();
if let Some(task) = &task {
task.is_queued.store(false, Ordering::SeqCst);
}
task
};
match task {
Some(task) => task.poll_once(),
None => {
thread::park();
}
}
}
}
}
逻辑:
- 加锁获取
Executor。 - 如果
task_count == 0,没有未完成任务,直接退出循环(block_on即将结束)。 - 从队列头部弹出一个任务。如果弹出成功,将该任务的
is_queued设为false,表示它已不在队列中。 - 释放锁(作用域结束自动释放),然后处理任务:
- 如果有任务,调用
poll_once驱动它。 - 如果没有任务(队列空但
task_count > 0),说明所有未完成任务都处于Pending状态,没有就绪任务可执行。工作线程调用thread::park()进入休眠,等待Waker唤醒。
- 如果有任务,调用
- 循环继续。
为什么 task_count > 0 时队列也可能为空?
当一个任务返回 Pending 时,它没有被放回队列(poll_once 中只有 Ready 会减少计数,Pending 只是把 future 放回 task,但不会重新入队)。此时任务不在队列中,但 task_count 仍将其计数在内。只有当某个事件触发 Waker 调用 schedule 后,任务才会重新入队。
park/unpark 的协作:
- 工作线程在无就绪任务时
park,释放 CPU。 - 当任务被唤醒(
schedule被调用)时,unpark会唤醒工作线程,重新进入循环处理新入队的任务。
3.5.4 block_on —— 阻塞运行根任务
#![allow(unused)]
fn main() {
fn block_on<F>(&self, future: F) -> F::Output
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
let result = Arc::new(Mutex::new(None));
let result_clone = result.clone();
self.spawn(async move {
let value = future.await;
*result_clone.lock().unwrap() = Some(value);
});
self.run();
result.lock().unwrap().take().expect("block_on did not produce an output")
}
}
block_on 用于执行一个根 Future,并阻塞当前线程直到其完成,返回结果。实现技巧:
- 创建一个
Mutex<Option<Output>>用于存放结果。 - 将一个包装异步任务
spawn出去:该任务await传入的future,将结果存入result_clone。 - 调用
run()开始调度循环。当所有任务(包括这个包装任务)完成时,task_count变为0,run()返回。 - 从
result中取出结果返回。
为什么不直接用 spawn 然后 run?
run 需要知道何时退出。如果直接 spawn(future),任务完成后 task_count 归零,run 正常退出。但这里我们需要拿到 Future 的输出,所以用了一个额外的外层任务来捕获结果。
3.6 主动让出 —— YieldNow
为了让任务能够主动让出 CPU(模拟 I/O 等待),我们实现了一个特殊的 Future:YieldNow。
#![allow(unused)]
fn main() {
struct YieldNow {
is_yielded: bool,
}
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.is_yielded {
Poll::Ready(())
} else {
self.is_yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
fn yield_now() -> YieldNow {
YieldNow { is_yielded: false }
}
}
行为:
- 第一次
poll:is_yielded为false,将其设为true,然后调用cx.waker().wake_by_ref()立即唤醒自己,返回Pending。 - 第二次
poll:is_yielded为true,返回Ready。
效果:
任务在执行 yield_now().await 时,会主动让出一次,让调度器有机会运行其他就绪任务,然后在下一次循环中继续执行。这个机制模拟了异步操作中常见的“短暂让出”模式。
这里要注意 wake_by_ref 与 is_queued 的时序:
poll_once 中:
- 从
future中take()出来。 - 构造
Waker。 - 调用
future.poll()。 - 在
YieldNow::poll中,调用了wake_by_ref()->Task::wake_by_ref->schedule。- 任务从队列取出后,
run已经执行了task.is_queued.store(false)。 - 因此进入
poll_once时,is_queued是false。 schedule中的is_queued.swap(true)返回false,随后会把任务重新放入队列。
- 任务从队列取出后,
poll返回Pending。poll_once将future放回self.future。- 退出
poll_once,返回到run循环。 - 循环继续,由于队列中已经有一个任务(就是我们刚刚放回去的那个),它会立即被取出并再次
poll_once。此时YieldNow的is_yielded已经是true,于是返回Ready。
所以 yield_now().await 实际上会导致任务被 poll 两次,中间会执行一次其他任务(如果有的话)。这正是我们期望的“让出一次执行机会”的行为。
4. 示例代码运行流程详解
我们将通过 demo() 函数演示运行时如何工作。代码中包含一个 main_task,它内部依次执行子任务,并动态产生新任务。
4.1 示例任务定义
async fn sub_task(n: i32) {
println!("task{} start", n);
yield_now().await;
println!("task{} done", n);
}
async fn main_task(runtime: Arc<Runtime>) {
println!("main_task start");
sub_task(1).await;
runtime.spawn(sub_task(3));
runtime.spawn(sub_task(4));
sub_task(2).await;
println!("main_task done");
}
sub_task 打印 start,然后 yield_now().await 主动让出一次,再打印 done。
main_task 执行顺序:
- 打印
main_task start。 - 执行
sub_task(1).await:完成子任务 1(它会在第一次 poll 时让出一次,第二次 poll 完成)。 - 分别
spawn子任务 3 和 4(不等待,只是提交到运行时)。 - 执行
sub_task(2).await。 - 打印
main_task done。
4.2 执行流程模拟
我们手动推演一下 runtime.block_on(main_task(runtime.clone())) 的执行过程。
初始状态:
- 通过
block_on创建根任务(包装了main_task的匿名任务),入队。task_count = 1。 - 调用
run()进入调度循环。
循环迭代 1:
-
从队列取出根任务,
is_queued设false。 -
poll_once根任务:- 根任务内部执行
main_task的开始。 - 调用
sub_task(1).await:- 第一次
pollsub_task(1):打印task1 start,遇到yield_now().await,调用yield_now的poll→ 返回Pending并唤醒自己(任务被重新入队,但注意此时根任务还没有完成,还在poll中)。 sub_task(1)返回Pending,根任务暂停,根任务的poll也返回Pending。
- 第一次
- 根任务返回
Pending之前,会将sub_task(1)的 future 状态保存(在根任务状态机中)。 poll_once完成:由于Pending,根任务的 future 被重新放回Task中(没有减少计数)。
- 根任务内部执行
-
此时队列中有什么?
sub_task(1)是main_task中的一个.await,它拿到的Waker来自根任务的poll上下文。因此,yield_now调用cx.waker().wake_by_ref()时,唤醒的是根任务所在的Task,而不是单独的sub_task(1)。因此,
yield_now后,根任务重新放入了队列(is_queued当时是false,所以入队成功)。队列现在有根任务。
循环迭代 2:
- 取出根任务,再次
poll_once。 - 根任务继续执行:再次
pollsub_task(1),这次sub_task(1)的yield_now第二次poll返回Ready,sub_task(1)完成。接着打印task1 done。 - 根任务继续:
spawn任务 3 和 4(两个新任务入队,task_count增加 2,变为 3)。 - 接着执行
sub_task(2).await:类似步骤,sub_task(2)第一次poll会因yield_now而让出并唤醒根任务。 - 根任务再次返回
Pending,但这次sub_task(2)未完成,根任务的状态机保存其位置。 - 根任务重新入队。
同时,任务 3 和 4 也已入队。
后续迭代:
- 调度器会从队列中取出根任务、任务 3、任务 4 等,轮流驱动它们。
- 每个任务执行到
yield_now时会让出,使其他任务有机会运行。 - 最终所有子任务完成,当
main_task完成所有步骤后打印main_task done,根任务返回Ready,根任务被销毁,task_count减到 0。 run()检测到task_count == 0退出,block_on返回。
具体顺序取决于运行时队列的 FIFO 顺序和 yield_now 的时机,但总体展示了协作式多任务切换。
5. 标准库与运行时的适配契约
Rust 标准库只定义了异步的接口(Future、Poll、Context、Waker),而不提供任何调度实现。任何运行时(无论简单还是复杂)都必须遵循以下契约来适配标准库。
5.1 标准库提供了什么?
| 组件 | 作用 | 运行时需要做什么 |
|---|---|---|
Future trait | 定义异步计算单元,有一个 poll 方法。 | 运行时不需要修改 Future,只需调用其 poll 方法。 |
Poll<T> 枚举 | poll 的返回类型:Ready(T) 表示完成,Pending 表示未完成。 | 根据返回值决定任务是否继续或等待。 |
Context<'a> | 封装了 Waker,在调用 poll 时传递给 Future。 | 构造 Context,并在其中提供合适的 Waker。 |
Waker | 唤醒任务的句柄,调用 wake() 会通知运行时该任务可以再次被 poll。 | 提供自定义的 Waker,其唤醒逻辑必须能将任务重新放入调度队列。 |
Wake trait | 简化 Waker 定义的辅助 trait。 | 实现 Wake,然后在需要时通过 Waker::from 获得 Waker。 |
5.2 运行时需要实现什么?
无论运行时的内部结构如何(单线程、多线程、工作窃取等),它都必须完成以下适配工作:
5.2.1 定义任务容器(Task)
运行时需要一种方式将 Future 包装起来,以便能够:
- 存储
Future(通常需要Pin固定)。 - 提供重新调度的方法(即
wake时能够将任务放回队列)。
标准库未规定任务的具体形式,你可以用结构体、枚举、trait 对象等。但任务通常包含:
- 一个
Future(可能使用Box::pin或pin!)。 - 一个指向调度队列的引用(以便
wake时入队)。 - 可选的状态标志(如是否已在队列中,避免重复入队)。
5.2.2 实现自定义 Waker
Waker 必须能够触发任务的重新调度。标准库提供了两种方式:
- 推荐方式:为任务类型实现
std::task::Waketrait,然后使用Waker::from(arc_task)构造。Wake::wake中调用任务的调度函数(如schedule)。 - 手动方式:构造
RawWaker和RawWakerVTable,定义clone、wake、wake_by_ref、drop行为。
适配关键:wake 被调用时,运行时必须能够将对应的任务放回就绪队列,并(必要时)唤醒调度线程。
5.2.3 提供调度循环(Executor)
调度循环负责:
- 从就绪队列中取出任务。
- 为每个任务构造
Context(内含正确的Waker)。 - 调用任务的
poll方法。 - 根据
poll的返回值:Ready:丢弃任务(或将其标记为完成,减少活跃计数)。Pending:将任务保存,但不自动放回队列。任务只在被Waker唤醒时才重新入队。
常见模式:调度循环会维护一个“就绪队列”(存放可运行的任务)和一个“活跃计数”(记录尚未完成的任务)。当队列为空但计数 > 0 时,调度线程进入休眠(如 thread::park() 或条件变量等待),直到某个 Waker 唤醒它。
5.2.4 提供任务提交接口(spawn)
运行时需要提供类似 spawn 的函数,允许用户将任意 Future 包装成任务并加入调度队列。
5.3 适配的核心逻辑(伪代码)
#![allow(unused)]
fn main() {
// 1. 定义任务
struct MyTask {
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
scheduler: Weak<MyScheduler>, // 用于重新入队
in_queue: AtomicBool,
}
// 2. 实现 Wake -> 唤醒时调度
impl Wake for MyTask {
fn wake(self: Arc<Self>) {
if !self.in_queue.swap(true, Ordering::SeqCst) {
if let Some(sched) = self.scheduler.upgrade() {
sched.push_back(self);
}
}
}
}
// 3. 调度循环
fn run(&self) {
loop {
let task = self.queue.pop_front();
let task = match task {
Some(t) => { t.in_queue.store(false); t }
None => { park(); continue; }
};
let waker = Waker::from(task.clone());
let mut cx = Context::from_waker(&waker);
let mut future = task.future.lock().unwrap().take().unwrap();
match future.as_mut().poll(&mut cx) {
Poll::Ready(()) => { /* 完成,减少计数 */ }
Poll::Pending => { *task.future.lock().unwrap() = Some(future); }
}
}
}
}
5.4 适配的本质
标准库与运行时的适配点在于 Waker 如何与任务队列联动。
- 标准库只要求:
poll时需要传入一个Context,其中包含一个有效的Waker;Waker被调用时,运行时应能重新调度对应的任务。 - 至于如何实现任务存储、队列管理、并发控制、I/O 事件源集成,标准库不做任何假设,完全由运行时决定。
因此,任何运行时只需保证:
- 它能为每个任务提供一个
Waker,该Waker的wake方法能够将这个任务放回运行时的调度队列。 - 它有一个调度循环,不断从队列中取出任务,调用
poll,并根据Pending/Ready做出相应处理。
只要满足这两点,标准库提供的 Future、async/await 语法就能无缝适配。这就是 Rust 异步“零成本抽象”和“与运行时解耦”的根本原因。
6. 完整代码
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicBool, Ordering},
};
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
struct Executor {
queue: VecDeque<Arc<Task>>,
task_count: usize,
work_thread: Thread,
}
//任务结构体,runtime调度的单位
struct Task {
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>>, //类型擦除的future
is_queued: AtomicBool, //判断是否在队列里
executor: Weak<Mutex<Executor>>, //执行器的弱引用
}
impl Task {
//逻辑就是把自己放入队列
fn schedule(self: &Arc<Self>) {
// 如果之前就是 true,说明任务已经在队列里,不需要重复入队。
if self.is_queued.swap(true, Ordering::SeqCst) {
return;
}
//如果对象还存活,拿到executor
let Some(executor) = self.executor.upgrade() else {
return;
};
//加锁
let mut executor = executor.lock().unwrap();
//把自己放入队列
executor.queue.push_back(self.clone());
//唤醒线程
executor.work_thread.unpark();
}
fn poll_once(self: &Arc<Self>) {
// 从 Task 中取出 Future, self.future变为none了
let Some(mut future) = self.future.lock().unwrap().take() else {
return;
};
//基于自己构建一个waker
let waker = Waker::from(self.clone());
//基于waker构建一个context
let mut context = Context::from_waker(&waker);
//poll推进一次,如果完成,task数量-1,如果没有完成,放回task。
match future.as_mut().poll(&mut context) {
Poll::Ready(()) => {
if let Some(executor) = self.executor.upgrade() {
let mut executor = executor.lock().unwrap();
debug_assert!(executor.task_count > 0);
executor.task_count -= 1;
}
}
Poll::Pending => {
//再把future塞到 self.future里
*self.future.lock().unwrap() = Some(future);
}
}
}
}
//为task自己实现wake trait ,让他自己能够变成waker
impl Wake for Task {
//唤醒逻辑就是 就是再次把自己放入队列里等待被runtime poll
fn wake(self: Arc<Self>) {
self.schedule();
}
fn wake_by_ref(self: &Arc<Self>) {
self.schedule();
}
}
//runtime持有一个executor
struct Runtime {
executor: Arc<Mutex<Executor>>,
}
impl Runtime {
fn new() -> Arc<Self> {
Arc::new(Self {
executor: Arc::new(Mutex::new(Executor {
queue: VecDeque::new(),
task_count: 0,
work_thread: thread::current(),
})),
})
}
//异步提交task
fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
//基于传入的async 构造task
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(future))),
executor: Arc::downgrade(&self.executor),
is_queued: AtomicBool::new(true),
});
//拿到executor
let mut executor = self.executor.lock().unwrap();
executor.task_count += 1;
//加入队列
executor.queue.push_back(task);
//唤醒线程
executor.work_thread.unpark();
}
//阻塞运行的主逻辑-死循环,不断从队列拿出task进行poll
fn run(&self) {
loop {
//从队列拿出task
let task = {
let mut executor = self.executor.lock().unwrap();
if executor.task_count == 0 {
return;
}
let task = executor.queue.pop_front();
if let Some(task) = &task {
// task 已经从队列里拿出来了,所以标记为“不在队列中”。
task.is_queued.store(false, Ordering::SeqCst);
}
task
};
match task {
Some(task) => {
task.poll_once();
}
None => {
// 队列为空,但还有任务没完成。
// 说明有任务 Pending,runtime 需要等待某个 waker 唤醒它。
thread::park();
}
}
}
}
//阻塞完成
fn block_on<F>(&self, future: F) -> F::Output
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
//创建空的结果
let result = Arc::new(Mutex::new(None));
let result_clone = result.clone();
//异步提交,把结果放到result_clone
self.spawn(async move {
let value = future.await;
*result_clone.lock().unwrap() = Some(value);
});
//阻塞运行
self.run();
//返回结果
result
.lock()
.unwrap()
.take()
.expect("block_on did not produce an output")
}
}
//构造一个pending的结果,让task主动挂起
struct YieldNow {
is_yielded: bool, //是否已经主动挂起
}
impl Future for YieldNow {
type Output = ();
//poll的逻辑,第一次被 poll 时主动让出执行权并且调用waker让runtime下次还可以poll自己,第二次被 poll 时完成。
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.is_yielded {
Poll::Ready(())
} else {
self.is_yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
//构造一个yieldnow结构体
fn yield_now() -> YieldNow {
YieldNow { is_yielded: false }
}
//异步task
async fn sub_task(n: i32) {
println!("task{} start", n);
yield_now().await;
println!("task{} done", n);
}
async fn main_task(runtime: Arc<Runtime>) {
println!("main_task start");
sub_task(1).await;
runtime.spawn(sub_task(3));
runtime.spawn(sub_task(4));
sub_task(2).await;
println!("main_task done");
}
pub fn demo() {
println!("..............开始自定义异步运行时演示......................");
let runtime = Runtime::new();
runtime.block_on(main_task(runtime.clone()));
println!("..............自定义异步运行时演示结束......................");
}