
我从 2023 年开始真正学习 Rust。实际上,2019 年左右我就已经跃跃欲试,不过当时学到闭包(也就是能捕获外部变量的匿名函数)的时候,就放弃了。
这门语言上手确实不容易,即使到了现在,不借助大语言模型(LLM),自己手写 Rust 代码,还是要花不少时间。
即使使用 Agent,想一次就写出能正确运行的过程宏,也很不容易。
不过,随着 AI 越来越强,我也越来越愿意在后端软件项目中选择 Rust。这篇文章,我想聊聊自己喜欢的几个 Rust 特性,以及让 LLM 写代码时,编译器能帮上什么忙。
所有权和借用
Rust 的基本规则是,每个值都有一个所有者。所有权没有转移时,所有者离开作用域,值就会被自动清理,不需要垃圾回收器,也不用手动安排释放。Rc 和 Arc 则通过引用计数支持共享所有权,内部的值会在最后一个强引用被释放时清理。
如果只是想使用一个值,不需要它的所有权,就用 & 来借用。
借用检查器的规则可以这样理解:对同一个值,可以同时有多个共享引用,或者一个独占的可变引用,两者不能同时存在。
所有权和借用规则帮助安全 Rust 避免悬垂指针和重复释放。数据竞争涉及多个线程在缺少适当同步的情况下访问同一份数据,且至少一方在修改。借用规则配合 Send 和 Sync,约束值能否在线程间转移、引用能否在线程间共享,从而防止数据竞争。
当然,内存泄漏和死锁仍然可能发生。unsafe 允许解引用裸指针等额外操作,需要开发者自己保证这些操作的安全,但它不会关闭借用检查器。
默认不可变
除非显式写上 mut,否则变量绑定默认不可变。
写了这么多年代码,我认为对工程来说,默认不可变是更简洁、更安全的做法。默认可变或许降低了入门难度,但也容易增加后续的维护负担。
这个关键字很短,却能明确标出可变性:声明可变绑定时用 mut,显式创建可变引用时用 &mut。
如果需要通过共享引用修改内部数据,可以使用内部可变性:RefCell 在运行时检查借用规则,Mutex 则通过互斥锁协调访问。
用 Option 代替 null,用 Result 处理错误
在 Rust 中,"没有值"由一个明确的类型表示:Option<T>。
一个 String 就是一个实际存在的字符串。如果类型是 Option<String>,那就需要先检查它有没有值。
Rust 没有通常意义上的异常机制,失败也可以用值来表示:Result<T, E>。如果函数返回的是错误类型兼容的 Result,就可以用 ? 把错误继续向上传递。
match 必须覆盖所有可能的情况。没有用 _ 等兜底模式时,新增一个枚举变体后,哪些匹配还没处理它,编译器会直接列给你,不用自己在整个项目里到处找。
panic! 留给无法恢复的情况,而 unwrap 则是一条可能直接触发 panic 的捷径。
零成本抽象
写泛型函数时,我们可以用 trait 约束类型需要具备的能力,再写一份通用实现。比如 fn largest<T: Ord>(items: &[T]) -> &T,在约定输入非空的前提下,同一个函数体,既能处理整数切片,也能处理字符串切片。
迭代器是我每天都会用的东西。比如 items.iter().filter(|i| i.active).map(|i| i.id).collect::<Vec<_>>(),一行就能看明白它要做什么。而且,只有到了 collect 收集结果时,前面的迭代操作才会真正执行。
编译后,这两种写法都可以接近手写底层实现的性能:泛型会被单态化,也就是针对具体类型生成对应的代码;开启优化后,迭代器调用链通常可以被内联、合并,消除中间的抽象开销,具体结果取决于代码和优化情况。
成本还是有的,主要体现在构建时:编译时间可能更长,生成的二进制文件也可能更大。不过,这并不是什么不能接受的缺点。如果能用更长的编译时间换来更短的运行时间,这个买卖还是可以做的。
通常,可读性好的写法,性能也很好。我们不用为了让程序跑得快,就把代码写得难以理解。

编译错误,也能帮我们改代码
Rust 的编译错误会告诉你违反了哪条规则,标出发生冲突的代码行,而且经常会直接给出具体的修改建议。
我可以根据这些信息改代码,Agent 修正代码时也很需要这样的反馈。
比如,它可能会写出下面这样的代码,毕竟同样的逻辑放在 Python 或 JavaScript 里完全没问题:
rust
fn greet(name: String) {
println!("Hello {}", name);
}
fn main() {
let name = String::from("Thomas");
greet(name);
greet(name);
}
rustc 给出的信息如下,还是相当详细明了的:
rust
error[E0382]: use of moved value: `name`
--> greet.rs:8:11
|
6 | let name = String::from("Thomas");
| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
7 | greet(name);
| ---- value moved here
8 | greet(name);
| ^^^^ value used here after move
|
note: consider changing this parameter type in function `greet` to borrow instead if owning the value isn't necessary
--> greet.rs:1:16
|
1 | fn greet(name: String) {
| ----- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
help: consider cloning the value if the performance cost is acceptable
|
7 | greet(name.clone());
| ++++++++
一条错误信息,就把问题讲清楚了:String 没有实现 Copy,传参时会转移所有权。第一次调用已经把值交出去了,第二次自然不能再用。
编译器还标出了发生冲突的两行代码,并给了两种解决办法:把参数改成引用,或者克隆一份值。对于克隆,它既提醒了性能成本,也标出了需要插入的 .clone()。
第一种办法解决了函数设计上的问题。这个函数本来就应该写成 fn greet(name: &str),调用时传入 &name。
最初那个函数签名的问题就在这里:明明只需要读取参数,却要求拿走所有权。这样一来,每个调用方都得在克隆和交出所有权之间做选择。
Rust 会在第二次调用时拒绝编译,把问题指回函数签名,再给出修改建议。
一点想法
学 Rust 的时候,编译器经常让我卡住,但写得多了,我反而越来越看重这些约束。所有权、默认不可变和显式的错误处理,让很多问题在代码运行前就暴露出来。让 Agent 写代码时,这些规则和具体的报错也能帮它及时修正。对我来说,愿意花时间学 Rust,很大一部分原因就在这里。
This is why we love Rust!