上周五下午,我在给组内一个日志关键词统计的小工具做重构,Node 版写到一半忽然烦了------读文件、切词、计数,逻辑就这么点,我却要伺候一堆异步回调和分包。正好手头有 rustup 的安装包没删,索性把这个 47 行的小活当 Rust 练手项目重写了一遍。环境:rustc 1.90.0,cargo 1.90.0,edition 2024,Windows 11 和 WSL2 都跑过。
两天被编译器按在地上摩擦。这篇笔记把三次毒打原样记下来,报错原文照抄,方便同样从 JS/TS 过来的同学对照。
第一顿毒打:E0499,两行"很正常"的代码
需求朴素到不能再朴素:分数数组里,头一个元素加 5,末尾那个也加 5。用 JS 我闭着眼写,到 Rust 我顺手写出了这个:
rust
// rustc 1.90.0, edition 2024
fn main() {
let mut scores = vec![80, 92, 75];
let first = scores.get_mut(0).unwrap();
let last = scores.get_mut(2).unwrap();
*first += 5;
*last += 5;
println!("{:?}", scores);
}
cargo build 直接糊脸:
error[E0499]: cannot borrow `scores` as mutable more than once at a time
--> src/main.rs:4:17
|
3 | let first = scores.get_mut(0).unwrap();
| ------ first mutable borrow occurs here
4 | let last = scores.get_mut(2).unwrap();
| ^^^^^^ second mutable borrow occurs here
5 | *first += 5;
| ------ first borrow later used here
第一反应是编译器有病:两个下标八竿子打不着,碍着谁了?我甚至怀疑是 Windows 版 rustc 的 bug,换 WSL2 编了一遍,报错一字不差。
翻了 The Book 第 10 章(被第 10.3 节支配的恐惧)才想明白:first 这个可变借用从第 3 行诞生,一直活到第 5 行它被使用完毕,中间窗口内编译器不允许任何新的可变借用。规则只有一条,没有例外条款。
修法反倒简单。rustc 的 NLL(non-lexical lifetimes)把借用的存续期压到了"用到哪算到哪"的使用点为止,只要别让两个可变借用的生命周期重叠即可:
rust
*scores.get_mut(0).unwrap() += 5;
*scores.get_mut(2).unwrap() += 5;
单行语句的借用当场用完当场销毁,编译通过。要是真的需要"同时"改两个位置,就得用 split_at_mut 把切片劈成互不重叠的两半------它内部是 unsafe 实现,等于标准库替你证明了"两半内存不相交"。编译器在传达一个态度:同时持有两把可变钥匙这种事,要么给出证明,要么别干。
认知反转:被拦下的原来是我在 JS 里真踩过的坑
说实话,写第一遍的时候我满脑子"官僚主义"四个字,6 行的函数要跟编译器谈判三轮。态度转变发生在当天晚上复盘的时候。
去年我们生产环境真出过一次同款 bug:一个公共 config 对象,函数 A 在 for...in 遍历它,函数 B 在同一个事件循环 tick 里 delete 了它的一个 key。结果中间一项被静默跳过------没有报错,没有堆栈,数据悄悄少了一半。查了两天,靠 diff 前后两天的日志才锁定。
E0499 拦的就是这一类东西。区别只是 JS 在运行时给你一个无声的错误结果,Rust 在编译期给你一个听起来很凶的报错。被凶两天,好过在生产上查两天。这笔账我认。
第二顿毒打:String 和 &str 不是历史包袱
词频统计的核心循环,我一上来就写"对"了(这点出乎意料):
rust
use std::collections::HashMap;
fn main() {
let text = String::from("rust is fast rust is safe");
let mut freq: HashMap<&str, u32> = HashMap::new();
for w in text.split_whitespace() {
*freq.entry(w).or_insert(0) += 1;
}
println!("{freq:?}");
}
这段能编译,我还有点小得意。得意维持了十分钟------我在循环后面补了一行 text.clear() 想提前释放内存:
error[E0502]: cannot borrow `text` as mutable because it is also borrowed as immutable
--> src/main.rs:9:5
|
5 | for w in text.split_whitespace() {
| ---- immutable borrow occurs here
...
9 | text.clear();
| ^^^^^^^^^^^^ mutable borrow occurs here
又是编译器有病系列?不是。想通的那一瞬间其实有点舒服:freq 的 key 是 &str,指向 text 的内部内存。假如允许 clear(),map 里所有 key 瞬间变成悬垂指针。这个在 C++ 里要靠 string_view 加祈祷来回避的悬垂问题,在 Rust 里就是一行编译错误。
我一开始以为 String 与 &str 的双类型设计是历史包袱,是 C 的 char* 之外又多造了层抽象。现在改观了:String 是拥有所有权的堆字符串,&str 是对某段字符串内存的借用视图,整个借用检查体系就架在这套区分上。
修法两选一:让 map 拥有数据------freq.entry(w.to_owned()).or_insert(0),key 变成 String;或者保留借用版 map,把 clear() 挪到 map 不再被使用的位置之后。我选了前者,代价是每个 key 一次堆分配。用 240MB 的真实日志跑了一遍(数据在下面),这部分开销占比小到可以忽略,别学我一开始在这上面抠。
效果与代价
最终版本 47 行,cargo build --release 之后处理 240MB、约 180 万行的日志文件,耗时 4.3 秒。同逻辑的 Node 版(Node 22.11.0,同样是 split 加 Map 计数)跑同一份文件要 14.8 秒。这个差距主要是 release 模式下编译器把计数循环自动向量化了,我什么都没做。
代价也不是没有:
- 编译时间 。
cargo build首次 22 秒(依赖编译占大头),增量编译 0.9 秒。Node 那种"改完就跑"确实更爽。 - clippy 的学费 。
cargo clippy第一次跑列了 11 条 warning,包括needless_borrow、manual_flatten,还有一条field_reassign_with_default------这条我看了三遍文档才懂:结构体let mut s = Struct::default(); s.x = 1;这种写法不如直接Struct { x: 1, ..Default::default() }。每条 warning 都是一节微型课程。 - 心智负担 。生命周期标注
<'a>我至今没在多参数函数里写顺过,本篇的例子都侥幸绕开了。
留个尾巴
下一步打算把词频工具改成并发分块统计(读文件按 8MB 切块),顺便试 rayon(1.10 版)。到那时候 Send/Sync 大概是第四顿毒打,到时候再记。
(代码在本地仓库还没整理,等并发版写完一起放出来;如果你有更顺手的 Rust 入门路径,欢迎评论区打我。)