Rust
Rust 区域下的其他文章:
介绍
Rust 是一门系统编程语言,专注于安全,尤其是并发安全,支持函数式和命令式以及泛型等编程范式的多范式语言。Rust 在语法上和 C++类似,但是设计者想要在保证性能的同时提供更好的内存安全。——百度百科
rust 掀起了一股 RIIR (Rewrite it in Rust) 的热潮。
Awesome Alternatives in Rust | A curated list of command-line utilities written in Rust 收录了一些 rust 优秀应用。主要是 linux cli 工具。
为什么推荐
优点
- 高性能,零成本抽象
- 安全,生命周期与所有权机制
- 开发社区激进,更新频繁,讨论环境良好(tg: @rust_zh)
- 统一的代码格式、文档、测试、打包流程
- 唯一指定顶级包管理器:cargo
- 静态检查给力,能过 能跑,
在 vibe coding 时代是非常可贵的能力
缺点
- 学习曲线陡峭
- 开发周期长
- GUI 库有待进步
- 我的其他个人暴论
如何学习
官网有详细的 QA 与你所需要的一切。资料方面,rust 的学习资料非常多,列举几个我读过的:external - book
- 我在学习初期,先读资料,然后尝试用 Rust 去解 leetcode 上的[1]题目,看题解以后去文档进一步搜关键字和用法。
- 中后期就完全在做项目了,遇到不会的就去 Telegram 群问。
其他资料:
开发环境
安装 rust
rust 的安装与配置并不难。在 windows 上可以使用官方脚本一行安装 rustup 及 rust。linux 也可以选择用包管理器,详见 Archwiki;但是最为推荐的还是 rustup,毕竟写 rust 会经常换工具链。
我目前日常使用 nightly,仅在必要的时候使用 stable。修改全局默认工具链为 rustup default nightly,在项目内更改当前项目默认工具链为 rustup override set nightly。
开发
然后我使用 vscode 作为 IDE。安装插件:
rust-analyzer,开发必备- (optional)
Rust Feature Toggler,方便切换 features - (optional)
(已改名为cratesDependi[2]),更好管理依赖版本
还有一些能够优化开发体验的选项:
- 使用 clippy 作为 check 指令。
- 安装额外的 cargo 组件
切换 vscode算了,预发布经常出 bug。rust-analyzer插件为预发布版本- 设置 cargo fmt(我常用的)
语言基础
循环
Rust 的 for 循环需要跟可迭代对象,例如:
for i in 0..100 {} // i in [0,99]
for i in 0..=100.step_by(2) {} // i in { 0,2,4,6...,98,100 }而类似 C++ 的 do while 循环可以写成:
loop {
// do something
if condition { break; }
}loop 循环还可以 break 出一个值。
输出
dbg!() 宏可以在 stderr 中输出调试信息,会消耗所有权。dbg! 返回值就是输入。dbg! 在 release 下也会输出。
ln 代表结束空行。常用的就 print(ln)! eprint(ln)!,没了。print 系列宏不消耗所有权。print! 底层是 write!。
输入
输入需要使用标准库中的 std::io(或者其他非标准库),输入是各行的字符串,需要手动处理。
use std::io;
let mut s = String::new();
io::stdin().read_line(&mut s).expect("failed to read");
let num: i32 = s.trim().parse().unwrap(); // 转换类型过程基础数据结构
这里可能会有一些帮助。
- 栈:Vec. 使用
push()&pop()管理栈。 - (双端)队列:
std::collections::VecDeque - 优先队列(堆):
std::collections::BinaryHeap- Why not use d-ary heap inside rather than binary heap,因此工程实践中可以不用 BinaryHeap。
- 字典 / Object / map(键值对):
std::collections::HashMap - 链表:
std::collections::LinkedList,但是其功能在所有权机制下被削弱了(例如,无法删除一个 iter 的值 (safe))。rust 并不推荐使用链表,如果确实需要完整链表,可以自己写去 https://crates.io 多翻翻。对于链表的实现,在 Rust 中有多种方式,比如:(摘自)- 使用 Box 实现(由于 Box 本身的限制,基本只能实现单向链表);
- 使用 Rc + RefCell 实现(由于 RefCell 的限制,迭代器无法很好的实现);
- 使用 Unsafe 实现;
不得不说手写数据结构确实是一个学习 rust 的好方式,自己写一遍,什么 *Cell 什么 Weak 全部都能吃透。
container
monad
此处特指 Option 与 Result 两种。后面的方法可以不记,实际写到再查(IDE 下拉列表看函数签名)。
- 取出值就是
unwrap(),有几个变体。注意会消耗所有权。 - 对内部映射,
map()&map_err() - 后面继续接 monad:
and_then() - 取出值的引用,可变就
as_mut(),不可变就as_ref()。- 字符串特殊一点,
Option<String>转Option<&str>需要as_deref()。
- 字符串特殊一点,
- 进阶一点,
Option<Option<T>>可以flatten(),Option<Result<T, E>>可以transpose(),等等。可以读手册。
原生字符串
Rust 的字符串所包含的问题实际上很多,此处只是冰山一角。
最主要的就是 &str 和 String 两种了,前者没有所有权,后者有。
- Rust 字符串默认支持分行。使用 \ 可以使多行字符串不换行。
- Raw String:
r#"\something"# - 字符串转换:
to_owned()orto_string()converts&str->String(造了一个所有权)。也可以用into(),更简单,但是更不直观。 - 字符串连接
std::path::{Path, PathBuf}是路径类,内部实现也是字符串(OS str)。类似str和String的区别,Path没有所有权,PathBuf有所有权。url::Urlurl 是 url 字符串。- 查看字符串进阶章节获取更多信息。
字符串修改
在 Rust 语言中,字符串采用 utf-8 编码,字符长度不一,因此 Rust 不提供下标查找字符串的方法。这让字符串的修改需要一点点的技巧。
- 转换为
Vec<char>后修改 C++程序员认为这种方式非常亲切。之后若有需要,还可将Vec<char>重新转换为字符串。注意,Rust 中的char为 4 字节,转为 Vec 后,可进行 O(1) 查找。rustlet s1:String = String::from("Hello我是绝对值_x"); let mut a : Vec<char> = s1.chars().collect(); a[5] = '你'; let s2 = a.iter().collect::<String>(); assert_eq!(s2,"Hello你是绝对值_x"); - replace_range 函数请注意,若替换范围不在 utf-8 字符的分割点上将会导致程序抛出 panic,因此不适用于变字节数的未知字符串的替换。rust
let mut s1:String = String::from("Hello我是绝对值_x"); s1.replace_range(5..=7,"你"); assert_eq!(s1,"Hello你是绝对值_x"); - as_bytes_mut 方法(unsafe)该方法异常繁琐,同样也不适用于变字节数的未知字符串的替换,但是若替换范围不在 utf-8 字符的分割点上并不会触发 panic. 例如,将第 6 行代码改为rust
let mut s1:String = String::from("Hello我是绝对值_x"); unsafe { let s1_bytes: &mut [u8] = s1.as_bytes_mut(); let s2_bytes: &[u8] = "你".as_bytes(); for i in 0..3{ s1_bytes[i + 5] = s2_bytes[i] } } assert_eq!(s1,"Hello你是绝对值_x");s1_bytes[i + 6] = s2_bytes[i]的运行结果:rust:no-line-numbersHello�你��绝对值_x
语法糖
问号
问号用于提前返回错误。do_something_that_might_fail()? 等价于
match do_something_that_might_fail() {
Ok(v) => v,
Err(e) => return Err(e),
}问号不能在正常签名的闭包中使用,例如 for_each,map 等的参数。可以用 try_for_each,try_map 等(如果有的话)。
或者等他娘的 try_blocks 稳定。
impl Trait
匿名泛型,可以让你少写点东西。
fn print(a: impl IntoIterator<Item = impl fmt::Display>) {
a.into_iter().for_each(|s| println!("{}", s));
}
// equals to:
fn print<T>(a: T)
where
T: IntoIterator,
T::Item: fmt::Display,
{
a.into_iter().for_each(|s| println!("{}", s));
}并发
如果你对 async/await 模型没有明确概念,可以看看这篇文章入门。
rust 提供 async/await 模型和线程模型。
Future
每个 async 函数返回的都是一个 Future<Output = ...>。Rust 的 Future 不像其他语言那样创建即执行,而是需要通过 poll 执行并推进。
- 手写 Future 要注意,如果返回
Poll::Pending,必需要在前面调一次 wake。 - 手动
impl Future for Xxx比较复杂,要手写状态机,因此如果不是写底层库,一般就impl Xxx { async fn call() },虽然调用时不能直接.await而需要.call().await,但是能够极大降低心智负担。
Send/Sync
关于 Send/Sync 可以看这里 或者 external articles 5.。
- 另一个理解是:Send:对象的 &mut 和析构能在别的线程访问;Sync:对象的 & 能在别的线程访问 ——包布丁
关于 Wrappers,看这里即可(我想大家应该都看得懂):
| Struct | Trait |
|---|---|
Box<T> |
Send(T) -> Send, Sync(T) -> Sync |
Arc<T> |
(Send + Sync)(T) -> (Send + Sync) |
Mutex<T> |
Send(T) -> (Send + Sync) |
Rc |
!Send + !Sync |
Cell<T>, RefCell<T> |
Send(T) -> Send, !Sync |
RwLock<T> |
(Send + Sync)(T) -> (Send + Sync), Send(T) -> Send |
此处暂不考虑 allocator.
将这些类型列在一起,可以发现,标准库没有任何包装可以将 !Send 转为 Send。(貌似有一个 crate send_wrapper 可以做到)
tokio
说到并发,目前广泛使用的异步运行时是 tokio。一般 features = ["macros", "rt", "rt-multi-thread"] 是必加的。
关于 tokio 可以看入门秘籍 13 章。
- 立即开始异步执行 Future 需要用
spawn。否则只会在 await 时执行(并且是阻塞当前函数的)。 - 计算密集型任务请用
spawn_blocking,性能提升巨大。spawn_blocking 的默认最大线程数也是很高的(约 512),必要时也可以调小 blocking 池的大小,将任务更合理地分配给 physical thread。- 也可以换用 rayon。
tokio::fs比std::fs要慢很多(10 倍以上),如果你没有高并发 IO 需求请尽可能用std::fs。- tokio 不支持为 task 设置优先级,也就是如果你提交 task 到 runtime,你的任务啥时候会被调度/窃取是说不准的。如果你的任务是延迟敏感,必须优先调度,有几种方案:
- rt 隔离,不同 rt 分配固定资源。但是这样就丧失了弹性资源的优势。
- 使用 worker 从两个 task pool (一般用 mpsc 实现)拉数据并处理,人为对 task pool 进行优先级划分,并在 worker 消耗数据时实现高优先级 task pool 的逻辑。
- 让低优先级自己调用
tokio::task::yield_now。不太现实,依赖这个行为实在是有点蠢。
简单批处理
在实际并发中经常碰到需要等待一批 Future 结束并获取返回值的情况。join! 不能 join 任意数量;tokio 有一个 JoinSet,但返回值是乱序的,并且 api 设计也不够易用。所以我们如何获取顺序的并行 Future 返回值呢?
答:用 futures / futures_util crate 的 futures::stream::FuturesUnOrdered。具体使用方法可以参考用例。一般就是将每一个 task spawn,然后将 handle collect 到 FuturesUnOrdered 里再 while let Some(x) = container.next().await 即可。
mod
rust 的 mod 确实会让初学者摸不着头脑。建议先搜几篇文章看看,例如 Rust 模块和文件 - [译],也可以问 AI。多写几次就完全掌握了。
- 每一个
.rs文件、mod块 和 带有mod.rs的文件夹 都是模块。 lib.rs(如果不是 lib target 则为main.rs)是顶层模块(crate),其他模块层级即为文件目录层级。- 整个模块结构是一颗树。
- 初始时,只有
lib.rs在模块树内,其他文件都在树外。 - 我们需要使用
mod xxx将模块添加到模块树内。只有在模块树内的模块才会参与编译。 - 添加到模块树后,在某个模块使用另一个模块的定义需要用
use xxx。这里的 xxx 可以是模块树中的“绝对路径”(也就是从顶层模块开始查找,crate::sub1::xxx),也可以是“相对路径”(从当前模块开始查找,super::sub2::xxx)。
- 初始时,只有
其他
- 可以显式调用
std::mem::drop()释放值,不过一般使用代码块,让变量自动销毁,会更加清晰。更多详细解释 - 不知道结构体多大?rust-analyzer 有选项能直接看,将光标放在结构体上,(vscode 中 Ctrl + Shift + P)选择 view memory layout 即可。
语言进阶
trait
trait 可谓是 rust 核心,不是 OOP 胜似 OOP(?),rust 学习的一大难点也是掌握 trait 的用法。
-
trait 在一些方面有点像其他语言的 interface,但脱离了继承的限制,可以随时创建,随时 implement,trait 之间不需要有联系。
-
trait 可以为现有的结构附加方法,这是多数强类型语言所不具备的。
-
trait 简化了泛型的实现。(点名 C++ (<20))
-
trait 可以“继承”,指 impl 其他 trait 后才能 impl 这个 trait。最经典的就是
Eq需要PartialEq。 -
trait 本身也是一个类型,可以
impl trait for trait:rspub trait Process { fn f(&self); } pub trait Takable { fn take(&self); } impl<T> Takable for T where T: Process, { fn take(&self) { self.f(); } }- 但是从其他模块调用 take 时需要
use <mod_name>::Takable。
- 但是从其他模块调用 take 时需要
-
TAIT:Trait alias.
-
AFIT:Async Functions in Traits,Rust 1.75 实现了这个语法糖,可以直接在 trait 里使用 async 函数,在 1.75 之前需要用 async-traits crate。
- 但是这并不意味着对于 rust >= 1.75 就可以直接去掉 async-traits,因为 AFIT 的 trait 不能用于创建 dyn object。AFIT 解糖后的结果是
-> impl Future<...>,其返回值大小未知,违反了 dyn object 需要 Object safe 的原则。而 async-traits 宏的输出是Pin<Box<dyn Future>>,这是大小已知的。
- 但是这并不意味着对于 rust >= 1.75 就可以直接去掉 async-traits,因为 AFIT 的 trait 不能用于创建 dyn object。AFIT 解糖后的结果是
dyn object
一个特殊的对象是 Trait Object,表示实现了某个 trait 的任意对象,代码里使用 dyn object 表示。
很多地方代码要求 Sized,所以一般需要用 Box 包起来:Box<dyn object>。如果在 struct 内用,经常还需要加生命周期。看起来要求比较多,不过如果泛型长了,用 dyn object 写起来还是更简单点。
- dyn object 是有成本的,开销比特定类型对象大一点(dynamic dispatch + type erasure + heap allocation)。要不要用它取决于你的程序的性能要求。
- dyn 在写异步时非常好用。异步经常需要跟
Pin<Box<T>>打交道,而我们不能直接从Pin<Box<T>>拿到 T,假设变换过程中随便加一个中间结构(举个例子,std::iter::Map),然后就会变成Map<Pin<Box<T>>>,如果又要 awaitable 又会转成Pin<Box<Map<Pin<Box<T>>>>>,套来套去泛型就很难处理了。
dyn object 其实也是 Rust DST (dynamically-sized types) 特性的一部分,有兴趣的可以读读 DSTs Are Just Polymorphically Compiled Generics。
宏
宏是很好用的东西,分为过程宏和声明宏。声明宏简单,常见用来写不定参数的函数/减少重复代码。过程宏就是纯 Token 处理,非常复杂,可以写装饰器。
学习宏,直接去看如何学习中提到的小宏书。讲的很好。
调试宏可以用 cargo-expand,不需要通过编译就能展开宏。注意!cargo-expand 不保证展开后的运行结果与原代码一致!
- 不加
#[macro_export]的话,定义的宏仅在当前 mod 可用。加了#[macro_export]并且当前模块存在于模块树中,则这个宏归属于整个 crate,crate 内使用可以不加模块树前缀。 - 在其他宏里调非卫生宏不能直接用,需要加
$crate显式指定路径。 - 可以定义同名宏重载系统宏,但是注意不能在同名宏里调用被重载的系统宏,否则递归。example
- A Note On Working With Cfg
- 有几个标签类型可以被重解释(
ident,tt等),非常强大。 - 匹配写不出来就上
$(tt)*,啥都能匹配。但是由于 tt 太强,需要注意边界条件,否则把所有 token 全吃了。
生命周期
待续
杂谈
嵌入外部资源
有时候我们要将外部文件/可执行程序嵌入代码二进制中。rust-embed 比较麻烦而且文档不太行,我决定用我自己的方式,带有 zstd 压缩(因为 zstd 解压快,可以尽可能减小运行时开销)。
引入一个 build-dependencies:
[build-dependencies]
zstd = "0.13"然后写 build.rs,编译时将这些文件压缩为 zstd 包:
use std::{env, fs::File, io::Write, path::Path};
use zstd::stream::write::Encoder;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=dwarfs-0.12.4.exe");
println!("cargo:rerun-if-changed=winfsp-x64-2.1.25156.dll");
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("dwarfs.exe.zst");
compress_to(include_bytes!("dwarfs-0.12.4.exe"), dest_path);
let dest_path = Path::new(&out_dir).join("winfsp-x64.dll.zst");
compress_to(include_bytes!("winfsp-x64-2.1.25156.dll"), dest_path);
}
fn compress_to(input: &[u8], output: impl AsRef<Path>) {
let f = File::create(output).unwrap();
let mut encoder = Encoder::new(f, 19).unwrap();
encoder.write_all(input).unwrap();
encoder.finish().unwrap();
}
在程序中使用宏引入(因为 include_bytes! 必须接受 literal,所以不能用 fn 传入 path):
/// decompress the prebuilt zst file and write to a temp file.
macro_rules! write_prebuilt_zstd {
($zst_filename:expr, $output_path:expr) => {{
let compressed_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/", $zst_filename));
let file = std::fs::File::create(&$output_path).expect("create temp file failed");
let mut decoder = zstd::stream::Decoder::new(std::io::Cursor::new(compressed_bytes))
.expect("zstd decoder create failed");
let mut writer = std::io::BufWriter::new(file);
std::io::copy(&mut decoder, &mut writer).map(|_| $output_path)
}};
}
write_prebuilt_zstd!("dwarfs.exe.zst", my_path)?;这样就可以把嵌入进二进制里的内容解出来了。
当然也有现成的库 include_assets 可以做到这一点。不过这个库基本不再维护,且 pull request 区也有指示其 bug,建议谨慎使用。
Cargo
rust 唯一官方指定包管理器:cargo,而且在一众语言包管理中是顶级的。
- cargo 的 dep 版本中,
xxx = 1.2.1指的其实是>= 1.2.1, <2.0.0,与 npm 中的^1.2.1一致。(ref)
cargo envs
常用 cargo 指令
太常用的就不说了。
cargo clippy --fix --all-targets --all-features --allow-staged --allow-dirty:用于自动修复 clippy 问题的终极命令。cargo tree -i xxx:查询某个依赖的路径,弄清引入它的罪魁祸首。cargo update:更新依赖,只写 Cargo.lock。
我的配置
创建 ~/.cargo/config.toml,参考我的配置。
然后由于现在大家都用 CI release,因此 [profile.release] 要写在项目里而不能写全局。
一些解释:
- Book: Optimizing Build Performance
- checksum-freshness:正常情况下 cargo 按 mtime 进行编译缓存,也就是即使不修改文件内容,只要按了保存,就要重新编译此 package 及所有依赖其的项。使用 checksum-freshness 可以根据文件 hash 进行编译缓存,也就是疯狂按保存而不修改内容是不会触发缓存失效的。
fmt
在 rustfmt.toml 里写代码的格式化选项。我一般会开这些:
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
merge_derives = true
unstable_features = true
wrap_comments = true懒的话也可以直接抄前辈的。
构建
cargo build 在全局获取包与依赖的源码,并编译到 target 里。rust 的包构建体积膨胀非常厉害,而且同一份源码的编译产物可能不同[3],因此没有全局缓存,还是需要为每个仓库都编出中间产物。
不过可以试试 sccache 全局缓存,可以减少重复构建。
扩展
cargo 扩展跟 git 扩展很像,只要是名为 cargo-xxx 的可执行文件都能视作 cargo 扩展。这些扩展与 cargo 一起组成了整个优秀的 rust 工具链生态。以下列举一些常用的 cargo 扩展应用。
| 名字 | 简介 |
|---|---|
| miri | rustup +nightly component add miri,用于更严格的测试,检测内存泄漏与不安全,死锁等 |
| cargo-binstall | 安装 binary,减少从源码编译 |
| cargo-bloated | 用于优化产物体积。群友作品,据说比 cargo-bloat 更好用 |
| cargo-expand | 展开宏 |
| cargo-msrv | Find the minimum supported Rust version (MSRV) for your project |
| cargo-wizard | 提供编译模板以配置为最大性能、快速编译时间或最小二进制大小。感觉一般,对于新手还行,老手不都有自己的模板吗。 |
| flamegraph | benchmark 火焰图 |
| cargo-bisect-rustc | 二分查找哪个 rustc nightly 版本引入了错误 |
| cargo-shear / cargo-machete | Remove unused Rust dependencies |
| cargo-nextest | 好用的 test 工具,有超时失败,log 筛选等特性 |
| cargo-audit | 查依赖漏洞 |
| cargo-hakari | 加速构建的黑科技 |
| cargo-selector | TUI 快速选择运行目标 |
| cargo-sweep | 部分清理编译产物 |
| cargo-depgraph | 看依赖关系图。这一个工具的依赖有点多,不能直接出图,感觉不太好用。 |
| cargo-semver-checks | 检查 API 是否遵循 semver 规范 |
| 在命令行直接看 crate 的 feature。警告:该工具为 vibe coding 产物,质量一般,不建议使用。vscode 装个 tombi 就能有 feature 提示了。 | |
| cargo-fuzz | 模糊测试工具,详见 fuzz |
三方库评价
由于本文太长了,所以拆了一篇文章,详见 crates 评价。
打包
说到打包就不得不提万恶的 openssl,我已经喷了无数次,无数次,无数次[4]…。很多库会提供 rustls feature 来绕过 openssl,例如 reqwest;但是也有库根本不提供,例如 rusqlite。所以 openssl 的问题还是得去解决。
profile
我习惯设置为这样。
[profile.release]
lto = true
opt-level = "s"
panic = "abort"
strip = trueopt-level = "s" 是综合考虑性能与二进制大小的等级。一般来说性能还是比二进制大小重要的。如果要最大化性能请设为 3;如果要最小化二进制大小请设为 "z",并且看看
min-sized-rust 文章。
交叉编译
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl我也写过 rust release CI,深知交叉编译在 link 阶段很容易出问题。解法有两个,一个是用工具链对应的链接器,还有一个就是 cargo-zigbuild,蛮好用的。不过注意,windows 和 macos 不能用 cargo-zigbuild;然后这玩意也经常出问题,要做好心理预期,我也骂了很多次。
release
说到 release,我首推我自己写的 rust simple release,优势是配置非常简单 + hack openssl,可以专注代码而无需折腾 CI。(结果折腾 CI 的变成我了,天天跟 cargo-zigbuild 打)
再说到我之前用的 taiki-e/upload-rust-binary-action,我也用了很久,说句实话还行,但是它内部用的 cross docker container,如果有除了 rust 的其他依赖,或者遇到傻逼 openssl 的问题就没辙了。
cross action:使用 docker 容器进行 build,但是不提供压缩产物。
发布
将包发布到 crates.io 上也是极其方便的。直接 cargo publish 即可。
- 发布前可以先使用
cargo package --list --allow-dirty确认一下都包含了哪些东西。 - 可以添加
#![warn(clippy::cargo)]到你的lib.rs或main.rs头部,让 clippy 帮你 check 一下有哪些 package 元数据是否正确/缺失。
我不建议使用 CI 进行 publish (具体原因)。反正 publish 是低频操作。
测试
assert 有 assert!() 和 debug_assert!() 之分,前者在 release 下仍然会进行 assert,而后者不会。
assert2 是一个全兼容 assert 的更好看的第三方库,是 pretty_assertions 进化版。
关于测试看这一篇就够了。小总结/补充:
#[cfg(test)]指非 test 情况下忽略代码,不会被编译。后面一般接mod test{ use super::*; ...}。#[test]后接函数,名称随意,就是真正的测试函数。- 如果 target 是 bin,则写在 doc 中的测试不会被运行。
cargo test默认不打印 stdout 输出,想打印需要cargo test -- --show-output。- 所有的 test 是并行测试的,如果你的程序里有用到 global mutable static 变量/系统外部依赖,请务必注意不同测试之间是否会互相影响。
- 测试中常用的库:
- serial_test:让指定的测试串行运行。
benchmark
cargo bench 是 rust 自带的 benchmark,但是还在 nightly 阶段。学习可以参考这篇文章,讲的不错。
criterion 是 rust 界最知名的第三方 benchmark 库,它可以在 stable 下使用,并且有更多的功能,例如其默认自带 3s 的 warming up,随机抽样和稳定的耗时,比 cargo bench 好用。不过也有些缺点,例如不能写在文件内部做 unit bench。一个 criterion 的例子:
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use sha2::{Digest, Sha256};
fn bench_md5(c: &mut Criterion) {
let data = b"hello world";
c.bench_function("md5", |b| {
b.iter(|| {
black_box(md5::compute(data));
})
});
}
fn bench_sha256(c: &mut Criterion) {...}
criterion_group!(benches, bench_md5, bench_sha256);
criterion_main!(benches);用户界面
GUI
GUI 是 rust 日经问题了。
一些 GUI 框架:
- tauri:electron 的竞品,据说很灵车(许多群友都说过了)。
- 2022 年我试了一下,连 example 都跑不过。
- 2024 尝试,还不错。主要是前端工具链是 GUI 界最顶级的那一批,爆杀各类原生 UI。2025 年末就用 Tauri 做了一个非常好用的客户端程序。
- 得物商家客服从 Electron 迁移到 Tauri 的技术实践
- tauri-bug-reproducer,T 黑头子(
- egui:原生 GUI,有大项目。
- 20250123 我用了一次 egui 0.3.0,太灵车了,建议别用。
- 窗口 API,还有布局等等都很差,很多地方还要自己拿 size 算(还有缩放坑),太原始,前端一个 flex 全搞定了。而且 API 变化太大,AI 没法输出有效信息,这也是比较致命的。
- slint:嗯写 DSL
- dioxus:也是嗯写 DSL。release 0.5.0 时火了一把
- native-windows-gui:非跨平台
- winio:
莓软的愚人节玩笑 - sciter:web 界面渲染
这里有一些 issue/star 数对比。Are we GUI Yet?是更多 GUI 框架简介。
我早期尝试过一下 iced,用不明白,不用了。反正自从我开始写前端后我就不再写其他非前端 GUI 了,实在是太 naive 太难受了。
他们之中有哪个能达到 electron 80% 的可用程度,称为可用。
——阿卡琳
TUI
比起 GUI,rust 重心还是在 CLI 和 TUI 上。
ratatui
一个广泛使用的 TUI 框架,教程还不错。
我读源码花了挺久时间。如果只想快速上手,建议狠狠抄这个 example。
用着发现个 bug,顺带 pr 了几行[5]。
然后 tui-realm 是基于其做的一个高层 TUI 框架,或许可以一试。
ORM
(拳打柴油机,脚踢大海洋)
我其实用 sqlx 习惯了,感觉也不一定需要 ORM 辅助开发,SQL 本身就是一个相对完备的转译兼容层,sqlx 也有编译期 SQL syntax check。
不过如果有看起来不错的 ORM,我也有一些意愿尝试。我个人还是倾向于基于 proc macro 的 ORM,结构即描述,看着比较干净一些。目前的记录:
音频处理
音频处理无论在哪个语言都是天坑之一。
Rust 虽然有万能解码器 Symphonia(Symphonia 的 bug 和兼容性也有待提高),但是编码器这块缺的可太多了,除了 wav 这种简单格式有 pure rust 的 hound,大部分编码器都还只有 bindings。
另外还有一些标榜自己是 pure rust 的音频库,但是实际上质量并不咋地,例如 opus-rs,详见评价。
嵌入向量数据库
我需要 rust 侧的轻量嵌入式向量数据库解决方案,要求是 10,000,000 个向量内查最近邻。(每个向量还有附带额外信息)
简单看了一下。我不希望将所有数据先加载到内存,最好的方案应该是 DiskANN,不过这玩意目前还没有 rust 的实现。 微软给重写到 Rust 了,牛逼。
- sqlite-vec:sqlite 跨平台扩展,但是现在并不支持最近邻算法。
r18n
去 luoxu 随便一搜,发现 i18n 模型是个自古以来的难题。
我也找了一些看,包括 rust-i18n, r18, i18n-embed, fluent-rs,最后还是感觉 rust-i18n 文档清晰,模型简单,比较适合我的项目。
奇技淫巧
nightly on stable
古语云:async 就像海洋,只有打开 nightly 的人才能到达彼岸。——Sherlock Holo
Rust nightly 有许多好用的东西,然而 nightly 工具链只有日期,没有版本,导致想要下载一个特定版本 nightly 工具链还需要去了解 rust release 流程,十分不友好。fuk
但是有一扇窗为 nightly 打开,那就是 RUSTC_BOOTSTRAP=1。只要开了这个环境变量,就可以不需要重装任何工具链,纵享丝滑 unstable featureref。
worktree 协作
如果你希望开多个 git worktree 并行推进任务(AI 时代非常常见),则可以考虑共享编译缓存,因为 Rust 的编译产物实在是太大了。比如一些大型项目本体构建就数十 GB,则多个 worktree 可以分分钟把你的磁盘干爆。
首先共享编译缓存是只有 NTFS user 或者 ext4 user 需要考虑的东西,其他的 fs 可以直接用 CoW 复用 target。四舍五入编译产物问题基本上只烦 windows 用户。
sccache 虽然可以复用缓存内容,节省 CPU,但是并不能帮你节省磁盘空间。因为匹配到构建内容后,sccache 是把缓存的构建产物 copy 到新 target 里的,没有 reflink,磁盘占用其实是双倍。
可以使用 CARGO_TARGET_DIR env 来让所有 worktree 共享一个 target folder。虽然会牺牲一点并行度(共享 target 后两个 worktree 无法同时构建),但是构建时长本来也只占开发中较小的一部分,因此可以放心使用。
external
books:
- Rust 语言圣经:圣经,文风上乘,质量高。
- tour of rust:交互授课式,基础入门。
- rust by example:注重例子。
- 小宏书:专门介绍 rust macro
- Rust Atomics and Locks:底层并发原理入门
- Rust 入门秘籍:一本既简洁又深入的书,非常值得一看(特别是 tokio 相关章节)。
- Rust 编程第一课 - 陈天:比较进阶的书,写得很好,着重讲述了难点和实战
- Rust magic patterns:针对某些狭小的知识点的深入分析
articles:
- Rust Learning Smart Pointers
- Rust 中的闭包递归与 Y 组合子
- 随机 Rust Quiz - dtolnay / Rust Quiz - boxyuwu:想成为语言律师吗?
- 为什么 Rust 需要 Pin, Unpin ?(中文翻译)
- 如何理解 rust 中的 Sync、Send?
- Rust 的 Pin 与 Unpin
- static, const, let 声明变量有什么区别?
- An introduction to advanced Rust traits and generics
- [翻译] async: 什么是 blocking
- Rust Runtime 设计与实现-科普篇 及后续系列文章
- Using Rust Macros for Custom VTables:如何创建一个 runtime object
- 金枪鱼之夜:基于完成的 Rust 异步:compio 项目及其经验
- 用 Rust 搞科研的两年
- The missing parts in Cargo
- Fast Rust Builds
- 幽灵索引类型与匿名结构体
- Rust 中常见的有关生命周期的误解
- Inside Rust's std and parking_lot mutexes - who wins?
- Making a const version of Rust's array::from_fn - How hard can it be?,图码文并茂
- Apache Iggy's migration journey to thread-per-core architecture powered by io_uring
- Interpreting near native speeds with CEL and Rust,了解一下 CEL 及其优化。
- Bugs Rust Won't Catch
- A definitive guide to sealed traits in Rust
- A 2026 Survey of Rust GUI Libraries,作为互联网喷子,我非常认同这篇文章,作者做了大量的工作来支撑自己的观点,并且文风语气都比较幽默。