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
- 易于打包
- 静态检查给力,能过 能跑
缺点
- 限制条件多,难以通过编译
- 学习曲线陡峭
- 开发周期长
- GUI 库有待进步
- 我的其他个人暴论
如何学习
官网有详细的 QA 与你所需要的一切。资料方面,rust 的学习资料非常多,列举几个我读过的:external - book
- 我在学习初期,先读资料,然后尝试用 Rust 去解 leetcode 上的[1]题目,看题解以后去文档进一步搜关键字和用法。
- 中后期就完全在做项目了,遇到不会的就去 Telegram 群问。
其他资料:
开发环境
安装 rust
rust 的安装与配置并不难。在 windows 上可以使用官方脚本一行安装 rustup 及 rust。linux 也可以选择用包管理器,详见 Archwiki;但是最为推荐的还是 rustup,毕竟写 rust 会经常换工具链。
使用 rustup 的好处是支持交叉编译;方便切换 nightly。坏处是不通过包管理器更新,容易忘。
开发
然后我使用 vscode 作为 IDE。安装插件:
rust-analyzer
,开发必备- (optional)
Rust Feature Toggler
,方便切换 features - (optional)
(已改名为crates
Dependi
[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()
,更简单,但是更不直观。 - 字符串连接
字符串修改
在 Rust 语言中,字符串采用 utf-8 编码,字符长度不一,因此 Rust 不提供下标查找字符串的方法。这让字符串的修改需要一点点的技巧。
- 转换为
Vec<char>
后修改 C++程序员认为这种方式非常亲切。之后若有需要,还可将Vec<char>
重新转换为字符串。注意,Rust 中的char
为 4 字节,转为 Vec 后,可进行 O(1) 查找。let 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,因此不适用于变字节数的未知字符串的替换。
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 行代码改为
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]
的运行结果:Hello�你��绝对值_x
其他字符串
std::path::{Path, PathBuf}
是路径字符串。Path
没有所有权,PathBuf
有所有权。url::Url
url 是 url 字符串。
语法糖
问号
问号用于提前返回错误。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。
简单批处理
在实际并发中经常碰到需要等待一批 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 确实会让人摸不着头脑。建议先搜几篇文章看看:
然后以下是我的一些浅薄理解,可能不正确,请自行分辨:
- 每一个
.rs
文件 和 带有mod.rs
的文件夹 都是模块。 main.rs
是顶层模块(crate
),其他模块层级即为文件目录层级。- mod 后若不跟代码块,则声明当前模块与低层级模块的依赖关系;若跟代码块,则在当前模块中声明定义一个子模块。
例如我有一个文件结构:
.
|-- config.rs
|-- core
| |-- cambridge_en_zh.rs
| `-- mod.rs
|-- main.rs
`-- request.rs
则 main.rs
为 0 级模块,config.rs
& request.rs
& core
为 1 级模块,cambridge_en_zh.rs
为 2 级模块。
在某个模块中使用 mod
语句只能声明更低级的模块关系。例如,我不能在 request.rs
中使用 mod core;
,因为它俩同级(1 级)。
那我们怎样在 request.rs
中使用 core
的内容呢?这就要通过更高级的模块进行中转:
// main.rs
mod request;
mod core;
// request.rs
use crate::core; // or `use super::core;`
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
:pub 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.
dyn object
一个特殊的对象是 dyn object,表示实现了某个 trait 的任意对象。开销比特定类型对象大一点,但是非常好用。
一般要求 Sized,所以要么 dyn object + Sized
,要么 Box<dyn object>
。如果在 struct 内用,经常还需要加生命周期。虽然要求这么多,但是真正用到才发现好用。泛型写一大串不如直接 dyn 秒了。
- dyn 在写异步时非常好用。异步经常需要跟
Pin<Box<T>>
打交道,而我们不能直接从Pin<Box<T>>
拿到 T,假设变换过程中随便加一个中间结构(例如std::iter::Map
),然后就会变成Map<Pin<Box<T>>>
,如果又要 awaitable 又会转成Pin<Box<Map<Pin<Box<T>>>>>
,套来套去泛型根本没法处理。
宏
宏是很好用的东西,分为过程宏和声明宏。声明宏简单,常见用来写不定参数的函数/减少重复代码。过程宏就是纯 Token 处理,非常复杂,可以写装饰器。
学习宏,直接去看如何学习中提到的小宏书。讲的很好。
调试宏可以用 cargo-expand,不需要通过编译就能展开宏。
- 不加
#[macro_export]
的话,定义的宏仅在当前 mod 可用。 - 在其他宏里调非卫生宏不能直接用,需要加
$crate
显式指定路径。 - 可以定义同名宏重载系统宏,但是注意不能在同名宏里调用被重载的系统宏,否则递归。example
- A Note On Working With Cfg
- 有几个标签类型可以被重解释(
ident
,tt
等),非常强大。 - 匹配写不出来就上
$(tt)*
,啥都能匹配。但是由于 tt 太强,需要注意边界条件,否则把所有 token 全吃了。
其他
- 可以显式调用
std::mem::drop()
释放值,不过一般使用代码块,让变量自动销毁,会更加清晰。更多详细解释 - 不知道结构体多大?rust-analyzer 有选项能直接看,将光标放在结构体上,(vscode 中 Ctrl + Shift + P)选择 view memory layout 即可。
Cargo
rust 唯一官方指定包管理器:cargo
,而且在一众语言包管理中是顶级的。
- cargo 的 dep 版本中,
xxx = 1.2.1
指的其实是>= 1.2.1, <2.0.0
,与 npm 中的^1.2.1
一致。(ref)
cargo envs
全局 alias
创建 ~/.cargo/config.toml
并写入:
[alias]
b = "build"
c = "check"
t = "test -- --nocapture"
r = "run"
u = "update"
f = "fix --allow-dirty"
fmt
在 rustfmt.toml
里写代码的格式化选项。我一般只开 wrap_comments
,不过也可以直接抄前辈的。
构建
cargo build 在全局获取包与依赖的源码,并编译到 target 里。rust 的包构建体积膨胀非常厉害,而且同一份源码的编译产物可能不同[3],因此没有全局缓存,还是需要为每个仓库都编出中间产物。
不过可以试试 sccache 全局缓存。
扩展
cargo 扩展跟 git 扩展很像,只要是名为 cargo-xxx
的可执行文件都能视作 cargo 扩展。以下列举一些常用的 cargo 扩展应用。
名字 | 简介 |
---|---|
miri | rustup +nightly component add miri ,用于更严格的测试,检测内存泄漏与不安全,死锁等 |
cargo-binstall | 安装 binary,减少从源码编译 |
cargo-bloat | Find out what takes most of the space in your executable. |
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 | 部分清理编译产物 |
库
多看热门项目用的库,是发现好用的库的好方法。还有一个方法是水群。
有一些库几乎成为业界标准,必需掌握。
库名 | 简介 |
---|---|
anyhow / thiserror | 错误处理 |
tokio | 异步 |
serde | 序列化 |
reqwest[4] | 简单网络 |
clap | 命令行工具 |
另外一些库则是我用过然后觉得好用。
库名 | 简介 |
---|---|
memchr | 字符串查找 |
assert2 / pretty_assertions | 全兼容的好看的 assert |
die-exit | 错误处理并退出 |
temp_testdir | 目前我在用的临时目录实现 |
tap | 函数式工具,在链式中途拿取引用操作而不影响返回值 |
enum-tools | 提供 enum 的常用方法 |
pollster | 小而美,专注于 在同步环境运行异步函数 一件事 |
clap
一般我都用 features = ["derive"]
,使用更方便,但是文档更难找,因为文档默认用的是动态添加成员。wordinfo 的 Cli 简直是我的 clap 毕生所学(,折腾了非常久。
clap 可以跟 lazy_static 一起使用,将 CLI 设为 static,可以免去到处传参之苦。带来的问题是写测试变得更加困难。
once_cell
创建 Lazy 或 OnceCell 的 static 变量。在 rustc 1.80.0 以前这是 unstable,但是现已 stabilized(std::sync::LazyLock
)。
thiserror
轻量错误库,用来创建自定义的 error 类型;可以自动 derive From another error。
不能在两个错误类型中同时 from 同一个 Error。如果确实需要,可能要手动再分 Enum 作为 suberror。
serde
除了直接 derive 外,serde 一般用得多的技巧还有:
#[serde(rename = "xx")]
和#[serde(rename_all = "kebab-case")]
,自定义序列化的名称与格式。更多宏可以看doc Field attributes。- 对于需要在缺失时使用 empty 的容器对象,
#[serde(default)]
是个不错的选择。 - 如果有的结构需要手写 parser,可以顺带实现 serialize trait,代码不会太多。
打包
说到打包就不得不提万恶的 openssl,我已经喷了无数次,无数次…。很多库会提供 rustls feature 来绕过 openssl,例如 reqwest;但是也有库根本不提供,例如 rusqlite。所以 openssl 的问题还是得去解决。
最小化二进制
一般这样够用了。我虽然敏感,但没有 no-std 那么极端。
[profile.release]
strip = true
opt-level = "z"
lto = true
panic = "abort"
或者也可以看看 cargo-wizard。
交叉编译
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
我也写过 rust release CI,深知交叉编译在 link 阶段很容易出问题。解法有两个,一个是用工具链对应的链接器,还有一个就是 cargo-zigbuild,蛮好用的。不过 windows 上不能用 cargo-zigbuild。
release
说到 release,我首推我自己写的 rust simple release,优势是配置非常简单 + hack openssl,可以专注代码而无需折腾 CI。
再说到我之前用的 taiki-e/upload-rust-binary-action,我也用了很久,说句实话还行,但是它内部用的 cross docker container,如果有除了 rust 的其他依赖,或者遇到傻逼 openssl 的问题就没辙了。
cross action:使用 docker 容器进行 build,但是不提供压缩产物。
发布
将包发布到 crates.io 上也是极其方便的。直接 cargo publish
即可。
不过我不建议使用 CI 进行 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
。
cargo bench
rust 自带的 benchmark。可以参考这篇文章,讲的不错。
感觉 criterion.rs
并不好用。。毕竟不能写在文件内部做 unit bench,单独出来做成跟 pytest 那样了,我不喜欢。
用户界面
GUI
GUI 是 rust 日经问题了。
一些 GUI 框架:
- egui:原生 GUI,有大项目,看起来不错。
- tauri:electron 的竞品,但是很灵车(许多群友都说过了)。我连 example 都跑不过。这里有一些别人对 tauri 的实践:
- slint:嗯写 DSL
- dioxus:也是嗯写 DSL。release 0.5.0 时火了一把
- native-windows-gui:非跨平台
- winio:
莓软的愚人节玩笑 - sciter:web 界面渲染
这里有一些 issue/star 数对比。Are we GUI Yet?是更多 GUI 框架简介。
我早期尝试过一下 iced,用不明白,不用了。
他们之中有哪个能达到 electron 80% 的可用程度,称为可用。
——阿卡琳
TUI
比起 GUI,rust 重心还是在 CLI 和 TUI 上。
ratatui
一个广泛使用的 TUI 框架,教程还不错。
我读源码花了挺久时间。如果只想快速上手,建议狠狠抄这个 example。
用着发现个 bug,顺带 pr 了几行[5]。
嵌入向量数据库
我需要 rust 侧的轻量嵌入式向量数据库解决方案,要求是 10,000,000 个向量内查最近邻。(每个向量还有附带额外信息)
简单看了一下。我不希望将所有数据先加载到内存,最好的方案应该是 DiskANN,不过这玩意目前还没有 rust 的实现。
- sqlite-vec:sqlite 跨平台扩展,但是现在并不支持最近邻算法。
r18n
去 luoxu 随便一搜,发现 i18n 模型是个自古以来的难题。
我也找了一些看,包括 rust-i18n
, r18
, i18n-embed
, fluent-rs
,最后还是感觉 rust-i18n
文档清晰,模型简单,比较适合我的项目。
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
- 幽灵索引类型与匿名结构体