这份笔记要解决什么
Rust 的语法并不是最难的部分。真正容易卡住的是:已经知道 let、struct、match,但一写项目就不断遇到 move、borrow、lifetime 和 trait 报错,于是开始到处加 clone(),或者把所有类型都包进智能指针。
这份第一版笔记不按语法手册逐项抄录,而是围绕一条小型数据链路建立统一模型:
文本输入 |
配套的 rust_learning_lab 是一个没有第三方依赖的 Cargo crate:读取几行机器人关节位置,解析为结构体,并计算某个关节的统计量。它很小,但足以贯穿所有权、借用、生命周期、Option、Result、模块、测试和异步的最小心智模型。
本文写于 2026-09-13,以 Rust 2024 Edition 为语言基线。配套项目将 rust-version 设为 1.85,因为 Rust 1.85 是 2024 Edition 的首个稳定版本;学习时可以使用更新的 stable 工具链。Edition 不是编译器版本,也不是生态分叉:每个 crate 可以独立选择 edition,不同 edition 的 crate 仍可互操作。
先看学习地图
| 阶段 | 核心问题 | 本文入口 | 通过标准 |
|---|---|---|---|
| 0. 工具链 | 怎样获得可重复的构建环境? | Rustup 与 Cargo | 能解释 rustc、Cargo、toolchain、edition、MSRV 的区别 |
| 1. 数据建模 | 怎样用类型表达有效状态? | 语法与类型 | 用 struct、enum、match 写出小模型 |
| 2. 所有权 | 谁负责释放值?调用后还能不能用? | 所有权 | 能预判 move、Copy、clone 和作用域结束的位置 |
| 3. 借用与生命周期 | 函数能否只看或暂时修改数据? | 借用、生命周期 | 先设计输入输出关系,再读懂 E0382、E0499、E0502 |
| 4. 失败建模 | “没有”和“失败”是否一回事? | Option 与 Result | 库层不靠 unwrap() 处理外部输入 |
| 5. 工程组织 | package、crate、module 是什么关系? | Cargo 工程 | 能拆出 lib/bin、单元测试和集成测试 |
| 6. 并发边界 | async 到底改变了什么? | 异步入门 | 知道 future、.await、executor 与线程不是同义词 |
| 7. 系统集成 | 什么时候才需要 unsafe 与 FFI? | FFI 边界 | 把 unsafe 压缩在小模块内,并提供安全包装与测试 |
学习顺序可以迭代,但不建议跳过所有权直接背生命周期标注。生命周期解决的是引用之间的有效期关系;如果还没分清“拥有”和“借用”,'a 只会变成另一种符号记忆。
1. Rustup、rustc 与 Cargo 各管什么
官方推荐用 Rustup 管理 Rust 工具链。安装完成后,常见的三个命令分别承担不同职责:
| 工具 | 责任 | 常用命令 |
|---|---|---|
rustup |
安装、选择和更新 toolchain / target / component | rustup toolchain install stable、rustup show |
rustc |
编译单个 crate,给出语言级诊断 | rustc --version、rustc --explain E0382 |
cargo |
管理 package、依赖、构建、运行、测试和发布 | cargo check、cargo test、cargo run |
第一次准备环境时,可以按官方安装页完成 Rustup 安装,然后执行:
rustup toolchain install stable --component rustfmt --component clippy |
不要在还没看清脚本来源时复制任意“curl 后直接执行”的安装命令;本文只维护工具的使用方法,安装入口以 Rust 官方安装页 为准。
Edition、stable 与 MSRV 是三件事
配套项目的清单包含:
[package] |
edition = "2024"选择同一门 Rust 语言的一组兼容性规则;- stable 是 Rustup 管理的发布通道,会随升级移动到更新编译器;
rust-version = "1.85"声明这个 package 支持的最低 Rust 版本,也称 MSRV;publish = false防止这个练习包被误发到 crates.io。
新项目最好明确写出 edition。若 Cargo.toml 完全省略它,Cargo 为兼容历史项目会按 2015 Edition 处理,这和当前 cargo new 默认生成最新版 edition 不是一回事。
日常反馈环
把“写完再编译”缩短成以下循环:
cargo check |
cargo check 会完成类型检查和借用检查,但通常不生成最终机器码,所以适合高频运行;cargo test 才是行为验收。fmt 统一格式,Clippy 补充编译器之外的惯用写法检查。四者职责不同,不能互相替代。
2. 基础语法的重点:用类型缩小无效状态
先看一段完整但很小的建模代码:
|
这里已经包含几项基础规则:
- 变量和字段默认不可变,需要修改时显式写
mut; struct把同时存在的数据组织成乘积类型;enum表示几个互斥分支,是和类型;每个分支还可以携带自己的数据;String拥有一段可增长的 UTF-8 文本,&str是对 UTF-8 文本的借用视图;#[derive(...)]让编译器为类型生成可调试输出、克隆和相等比较等 trait 实现。
表达式、语句与返回值
Rust 的 if、match 和代码块都可以是表达式。函数最后一个表达式不写分号时,就是返回值:
fn classify(position_rad: f64) -> &'static str { |
在 "near zero" 后加分号会把这个分支变成 (),从而与函数声明的 &str 不匹配。初学时看到“expected &str, found ()”,先检查尾表达式是否被分号变成了语句。
match 应该穷尽状态
match 要覆盖所有可能分支:
fn error_hint(kind: &ParseErrorKind) -> &'static str { |
这不是语法负担,而是演进提醒:将来新增错误类型时,编译器能指出哪些地方还没决定如何处理。若用 _ 一口吞掉所有剩余分支,就会失去这种帮助;只有当剩余值确实同义时再使用通配模式。
String、&str 与集合的默认选择
一个实用的接口经验是:
- 结构体需要长期保存文本时,先考虑拥有的
String; - 函数只读字符串时,优先接收
&str,它同时兼容字符串字面量和String的切片; - 默认可变长序列先考虑
Vec<T>; - 不要因为借用暂时难写,就给所有字段加
'static或把所有数据泄漏到全局。
3. 所有权:先问“谁负责释放”
Rust 的所有权规则可以压缩成三句话:
- 每个值都有一个当前 owner;
- 同一时刻只有一个 owner;
- owner 离开作用域时,该值会被
drop。
对 String、Vec<T> 这类拥有堆资源的值,普通赋值通常是 move,不是深拷贝:
let samples = vec![Sample::new("hip", 0.4)]; |
移动后仍然只有一个 owner,因此不会发生两个变量重复释放同一资源。移动通常只复制栈上的指针、长度和容量等小型元数据,不会自动复制整段堆数据。
一段程序可以先共享读取、再独占修改、最后移动所有权;关键是这些访问阶段不要非法重叠。引用不负责释放被借用的值。
Copy、move 与 clone()
整数、布尔值和由 Copy 类型组成的简单值常常实现 Copy:
let count = 3_u32; |
赋值后两者都可用,因为按位复制就是完整且安全的复制。String 没有实现 Copy,需要真实复制内容时必须显式 clone():
let joint = String::from("front_left_hip"); |
clone() 不是“修复 borrow checker 的万能键”。在循环、图结构或大缓冲区上,它可能改变性能和数据语义。每次添加前问:
- 这里真的需要两个独立拥有者吗?
- 函数是否只需接收
&str或&[T]? - 能否让被调用者返回所有权,或者调整数据拥有关系?
- 如果复制是产品语义,是否应该在函数名和测试中体现?
函数签名就是所有权协议
fn inspect(samples: &[Sample]) { /* 只读借用 */ } |
只看签名,就应该能判断调用后原值是否还能使用。Rust API 设计的第一步不是写函数体,而是先决定这四种关系中的哪一种准确表达意图。
4. 借用:多读或一写
创建引用称为借用。最重要的规则是:在同一段有效时间里,可以有任意多个共享引用 &T,或者一个可变引用 &mut T;两组不能重叠,并且引用必须始终有效。
let mut samples = vec![Sample::new("hip", 0.4)]; |
借用的有效范围通常到引用的最后一次使用,不必机械地持续到整个花括号结束。这就是为什么上面先读后写可以通过编译。
接口优先借用切片
只需要查看一组元素时,&[Sample] 通常比 &Vec<Sample> 更通用:
pub fn latest_for<'a>(samples: &'a [Sample], joint: &str) -> Option<&'a Sample> { |
调用者可以传入 Vec 的全部或局部切片,也可以传数组切片。函数不关心集合如何扩容,只关心“连续的一段 Sample”。可变版本相应写成 &mut [Sample]。
常见借用错误怎么读
| 错误码 | 常见含义 | 首先检查 |
|---|---|---|
| E0382 | 值已经 move,之后又被使用 | 哪个调用取得了所有权;是否本应传引用 |
| E0499 | 同一个值被同时可变借用多次 | 第一个 &mut 的最后一次使用在哪里 |
| E0502 | 不可变借用与可变借用重叠 | 能否把“读”和“写”拆成两个阶段 |
| E0515 / E0597 | 返回或保存的引用活得比来源久 | 是否应该返回拥有的值;来源是否在函数内被释放 |
先画 owner 与引用的来源,再改签名。不要看到生命周期错误就盲目添加 'a,也不要先上 Rc<RefCell<_>> 绕过静态检查。
5. 生命周期:描述引用关系,不是延长变量寿命
每个引用都有生命周期,大多数时候编译器可以推断。需要显式标注时,标注表达的是“输入引用与输出引用之间有什么关系”,不会让任何值活得更久。
配套项目中:
pub fn latest_for<'a>(samples: &'a [Sample], joint: &str) -> Option<&'a Sample> { |
'a 的含义是:如果返回 Some(&Sample),其中的引用来自 samples,不能比 samples 的借用更久。joint 只用于比较,不可能成为返回值来源,所以它不需要与 'a 绑定。
为什么下面无法返回引用
fn invalid() -> &str { |
name 在函数末尾被释放,返回的引用将悬空。加 'static 不能把局部 String 变成静态数据。正确选择通常是返回拥有的 String:
fn valid() -> String { |
只有字符串字面量、真正的静态项,或者有意长期保留的数据才适合 'static。把 'static 当作“最长所以最好”会把资源释放、测试隔离和接口复用都变差。
结构体为什么有生命周期参数
pub struct JointStats<'a> { |
这个结果不复制关节名,而是借用某个 Sample 内的字符串。因此 JointStats 不能脱离原 samples 长期保存。若要跨线程队列、缓存或异步任务长期持有它,更自然的设计可能是把字段改成 String,让结果拥有名字。借用版减少复制,拥有版降低耦合;选择取决于数据需要活多久,而不是哪一种更“高级”。
6. Option、Result 与错误边界
Rust 用类型区分两种经常被混在一起的情况:
Option<T>:值可能存在,也可能正常地不存在;Result<T, E>:操作可能成功,也可能带着原因失败。
在练习项目中,“目标关节没有采样”是 None,而“一行 CSV 不是合法数字”是 Err(ParseError)。如果两者都用空字符串或 -1 表示,调用者无法可靠地区分业务空值和数据损坏。
let samples = parse_samples(&input)?; |
? 做了什么
对 Result 使用 ? 时:
- 若为
Ok(value),取出value继续执行; - 若为
Err(error),把错误转换为当前函数声明的错误类型并提前返回。
它不是忽略错误,也不是 panic。? 迫使当前函数本身也有一个能表达失败的返回类型。对 Option 使用时同理:None 会提前传播 None。
库层返回信息,应用边界决定呈现
配套项目为解析错误保存物理行号和具体原因:
|
错误类型实现 Display 供用户阅读,实现 Error 供错误链组合。解析库不擅自退出进程;main、HTTP handler 或 ROS 节点边界再决定日志、退出码、重试或降级策略。
什么时候可以 unwrap()
谨慎使用,但不必把它绝对禁止:
- 单元测试中,失败本来就应该立即暴露;
- 编译器不能证明、但程序已经由紧邻逻辑保证的内部不变量,可用带解释的
expect(); - 外部输入、文件、网络、用户参数和传感器数据不应靠
unwrap()处理。
若错误确实不可恢复,可以 panic;但“懒得建模”不等于“不可恢复”。
7. trait、泛型与迭代器:先表达能力,再复用实现
impl Into<String> 让 Sample::new 同时接收字符串字面量和 String:
impl Sample { |
这里的重点不是追求最抽象,而是让边界接收多种可转换输入,同时在结构体内部统一拥有 String。泛型和 trait bound 应来自实际复用需求;只有一个实现时,不必为了“设计模式”提前制造层级。
解析器使用迭代器把数据流写成几步变换:
input |
collect 在这里会遇到第一个 Err 时停止并返回错误;全部成功才得到 Ok(Vec<Sample>)。迭代器通常是惰性的:构造适配器不会自动执行,消费器(如 collect、sum、for_each)才推动数据流。
不要为了“一行链式调用”牺牲诊断信息。如果闭包太复杂,就提取成命名函数;配套项目把单行解析单独放到 parse_line,便于测试每一种失败。
8. Cargo 工程:package、crate、module 不要混用
这三个词处在不同层级:
| 概念 | 含义 | 本项目对应物 |
|---|---|---|
| package | 一个 Cargo.toml 描述的发布与构建单元 |
rust-learning-lab/ |
| crate | 一次编译形成的库或可执行目标 | src/lib.rs 的 library crate;src/main.rs 的 binary crate |
| module | crate 内部的命名空间与可见性结构 | model、parser、report、async_intro |
项目结构如下:
rust_learning_lab/ |
src/lib.rs 是公开 API 的门面:
pub mod async_intro; |
mod 把文件纳入模块树,pub 决定外部可见性,use 只是在当前作用域引入名字,pub use 则可以重新导出、塑造更稳定的公共入口。文件夹结构不应该机械等同于公开 API 结构。
为什么同时保留 lib 与 bin
把解析和统计放入 library crate 后:
- 命令行程序只处理参数、文件 I/O 和输出;
- 集成测试可以像外部使用者一样调用公开 API;
- 以后接 HTTP、ROS2 或 FFI 时可复用核心逻辑;
main.rs不会膨胀成无法隔离测试的全部实现。
单元测试、集成测试与示例
- 模块里的
#[cfg(test)] mod tests可以检查私有细节,适合边界条件; tests/*.rs各自是独立 crate,只能通过公开 API 验收工作流;examples/*.rs是可运行的学习材料,cargo test --all-targets还会确保它们持续可编译;- 文档代码块如果需要长期保证,也应逐步改成可执行 doctest,而不是只相信文章渲染成功。
运行配套项目:
cd source/downloads/code/rust_learning_lab |
默认输出应为:
joint: front_left_hip |
注意 cargo run -- <参数> 中的 --:它把 Cargo 自己的参数与传给目标程序的参数分开。
9. 异步入门:async 产生 Future,不会自动产生并发
async fn 调用后返回一个实现 Future 的值。Future 表示“可能尚未完成的计算”,只有被轮询时才取得进展;.await 允许当前异步任务在等待时把执行机会让给别的任务。
pub async fn summarize_async<'a>(samples: &'a [Sample], joint: &str) -> Option<JointStats<'a>> { |
这个函数没有真正等待 I/O,所以只是用来观察类型和生命周期如何进入 Future。标准库定义了 Future、Poll、Context 和 Waker 等底层协议,但常规网络/定时器应用还需要异步运行时负责 executor、I/O reactor、计时器和任务调度。
配套项目中的 run_ready 只轮询一次,并在得到 Poll::Pending 时直接失败。它能证明“调用 async 函数得到 Future,executor 对 Future 进行 poll”这个最小关系,不是通用 executor,也不能拿来做网络、文件或定时任务。
async、线程与并行的区别
| 概念 | 主要解决 | 不自动保证 |
|---|---|---|
| async task | 大量等待型工作的协作调度 | CPU 并行、阻塞调用自动变非阻塞 |
| OS 线程 | 抢占式调度、可利用多个核心 | 低内存开销、无数据竞争 |
| 数据并行 | 把可分工作分配到多个核心 | I/O 并发、确定的完成顺序 |
CPU 密集计算写成 async fn 不会自动变快;在 executor 线程里调用阻塞 API 还可能拖住其他任务。先识别工作是在等待 I/O,还是在持续计算,再选模型。
上真实运行时前要补的四个问题
- 哪些资源会跨过
.await,它们是否仍然有效? - task 是否需要跨线程移动,从而要求 Future 为
Send? - 超时、取消和部分副作用如何处理?丢弃 Future 不会撤销已经完成的外部写入;
- 阻塞的 C/C++、文件或计算调用放在哪里,是否需要专用阻塞线程池?
第一版先稳定同步核心。等确实出现多个 socket、定时器或高并发连接,再选择运行时,并把运行时依赖限制在应用边界。
10. C/C++ FFI:把 unsafe 当作待证明的边界
与现有 C/C++ 或系统组件对接时,不要把 Rust 的 String、Vec<T> 或默认布局结构体直接跨 ABI 传递。常见边界是:
|
2024 Edition 中 extern block 必须写成 unsafe extern,因为编译器无法验证另一侧函数的真实契约。#[repr(C)] 为结构体选择 C 兼容的布局规则;它不自动解决对齐、整数宽度、字节序、所有权和线程安全。
C++ 侧应提供窄的 extern "C" 包装层,避免把名称修饰、异常、STL 容器和对象生命周期直接暴露给 C ABI。Rust 侧再写安全包装函数,把原始返回码转成 Result,把裸指针的有效性、不为空、长度、对齐和生命周期检查集中在一个小模块里。
FFI 验收清单
- ABI 是否固定为 C,结构体是否使用
#[repr(C)]; - 每个指针由谁分配、谁释放、允许为空吗、有效长度是多少;
- C 字符串是否 NUL 结尾,是否可能包含内部 NUL,编码是什么;
- 回调在哪个线程执行,能否重入,context 指针活多久;
- C++ 异常是否被挡在
extern "C"边界内; - Rust panic 是否被挡在导出边界内;
- 构建脚本是否明确链接库、架构、搜索路径和静态/动态链接方式;
- 是否有正常、空指针、边界长度、错误码和析构路径测试;
- 是否用 sanitizer、Miri 或平台诊断工具补充检查;这些工具各有适用边界,不能把单次通过当成证明。
当前练习 crate 没有真实 FFI 需求,因此不引入 unsafe。等出现明确的 C API、头文件、目标架构和生命周期契约时,再建立独立 companion crate;这比为了覆盖名词而写一个无实际消费方的裸指针 demo 更可靠。
11. 一套可持续的质量门禁
建议把 Rust 项目的本地门禁固定成:
cargo fmt --all --check |
若 package 声明 MSRV,还要在那个版本上至少运行 cargo check 与测试。stable 上通过不能证明 MSRV 仍然可用;反过来,MSRV 通过也不能发现最新编译器新增的 lint 或未来不兼容警告。
本版验证记录(2026-09-13)
本机没有全局安装 Rust;为避免改变宿主环境,配套 crate 在官方 Rust 容器内验证:
| 工具链 | 执行项 | 结果 |
|---|---|---|
| Rust 1.85.1 / Cargo 1.85.1 | test --all-targets、fmt --check、Clippy -D warnings、main 与两个 example |
5 个测试通过,格式和 lint 通过,三个入口输出符合预期 |
| Rust 1.98.1(验证时的 stable 补丁版) | test --all-targets |
同一组 5 个测试通过 |
这证明当前源码同时满足声明的 MSRV 与验证时 stable;不代表未来 stable、其他 target、真实异步 I/O 或尚未建立的 FFI 链路已经通过。
编译错误也可以成为练习
所有权有一类重要知识只能从“故意失败”的代码中学习。不要把失败示例放进默认 target 破坏整套构建,可以采用:
- 在
examples/ownership.rs中保留一行注释掉的 E0382 示例; - 复制到临时文件,取消注释;
- 运行
cargo check --example ownership; - 阅读完整诊断,再执行
rustc --explain E0382; - 分别用“借用”“返回所有权”“确实 clone”三种方式修复,比较 API 含义。
目标不是消灭红字,而是学会从诊断中还原所有权图。
12. 六个迭代阶段,而不是一次读完
下面是一条可持续的首轮路线。每个阶段都以可运行结果结束,不用按自然周硬性推进。
| 阶段 | 学习与修改任务 | 产物 / 验收 |
|---|---|---|
| A. 建模 | 读基础语法、struct、enum、pattern;给 Sample 增加时间戳 |
编译通过;新增构造与边界测试 |
| B. 所有权 | 运行 ownership example;分别触发 E0382、E0499、E0502 | 用自己的话画出 owner、borrow 与最后使用点 |
| C. 错误 | 为额外列、非法单位设计错误;禁止库层 unwrap 外部输入 |
错误包含行号与原因;集成测试覆盖失败路径 |
| D. 抽象 | 增加单位转换 trait 或泛型输入,但保持公开 API 小 | Clippy 零警告;能说明为何需要该抽象 |
| E. 工程 | 拆 lib/bin;补 example、integration test、文档 | fmt + clippy + test + doc 全通过 |
| F. 集成 | 有真实等待再接 async runtime;有真实 C API 再接 FFI | 记录依赖选择、取消/所有权或 ABI 契约及端到端测试 |
每次学习记录只回答六个问题
日期 / toolchain: |
这种记录比“今天看完生命周期”更可复查。Rust 的理解往往体现在接口取舍和编译器反馈里,不体现在读了多少页。
13. 第一轮练习题
按顺序修改配套项目,每题都先加测试:
- 为输入增加
timestamp_ms,决定它用u64还是新类型,并解释选择; - 让统计函数支持任意关节前缀,返回的名字应该借用输入还是拥有新字符串?
- 把
position_rad的单位误写成degree时返回结构化错误,不用字符串包含判断做业务分支; - 增加
median。排序时是否需要 clone 整个Sample,还是只复制f64? - 写一个函数同时返回最小值和最大值的样本引用,标清输出生命周期来源;
- 将文件读取抽到应用边界,保证 parser 只接收
&str,然后用内存 fixture 测试; - 找到真实的异步 I/O 需求后,再比较“同步核心 + 异步外壳”与“全部 async”的复杂度;
- 找到真实 C API 后,先写一页 ABI/所有权契约,再写第一行
unsafe extern。
完成一道题的标准不是“能跑”,而是能用一句话说清楚:谁拥有数据、谁在借用、可能怎样失败、哪条测试证明行为。
14. 当前边界与后续主题
这份 v1 已覆盖 TODO 中的基础主线,但它不是 Rust 百科。以下内容适合作为后续 companion notes,而不是继续把所有概念堆进本文:
- 智能指针:
Box、Rc、Arc、Cell、RefCell、Mutex的所有权与运行时成本; - trait object、关联类型、闭包与更系统的泛型 API 设计;
- async runtime、stream、背压、结构化并发、取消与 tracing;
unsafe的有效性不变量、Miri、sanitizer 与并发模型;- CMake / ROS2 / ONNX Runtime 的真实 FFI companion crate;
- workspace、feature、build script、交叉编译、供应链和发布策略。
继续学习时仍沿用本文的主线:先明确问题与数据寿命,再设计类型和签名,然后让测试与工具链给出证据。
官方资料
- The Rust Programming Language:主线教材;重点阅读 ownership、error handling、packages/crates/modules 与 async;
- Rust By Example:适合把概念快速落成可运行片段;
- The Cargo Book:manifest、targets、workspace、依赖解析与构建行为;
- The Rust Reference:需要核对精确语言规则时使用;
- The Rustonomicon:进入 unsafe 与 FFI 后再读,不是第一本入门书;
- The Rust Edition Guide:edition 差异和迁移流程;
- 标准库文档:类型、trait 与 API 的最终查询入口。
配套源码入口:Cargo.toml、src/lib.rs、src/parser.rs、src/report.rs、tests/telemetry_workflow.rs。