Rust 初体验:一个全栈工程师的编译器“毒打“笔记

上周五下午,我在给组内一个日志关键词统计的小工具做重构,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 模式下编译器把计数循环自动向量化了,我什么都没做。

代价也不是没有:

  1. 编译时间 。cargo build 首次 22 秒(依赖编译占大头),增量编译 0.9 秒。Node 那种"改完就跑"确实更爽。
  2. 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 都是一节微型课程。
  3. 心智负担 。生命周期标注 <'a> 我至今没在多参数函数里写顺过,本篇的例子都侥幸绕开了。

留个尾巴

下一步打算把词频工具改成并发分块统计(读文件按 8MB 切块),顺便试 rayon(1.10 版)。到那时候 Send/Sync 大概是第四顿毒打,到时候再记。

(代码在本地仓库还没整理,等并发版写完一起放出来;如果你有更顺手的 Rust 入门路径,欢迎评论区打我。)

相关推荐
热爱生活的五柒1 小时前
zotero能导入已经下载好的pdf文件嘛,并且点击条目时,能直接看到下载的pdf文件嘛,我还希望能在里面做笔记
笔记·pdf
我就是不信2 小时前
Java8 lambda 表达式 forEach 如何提前终止?
开发语言·windows·python
Cx330❀2 小时前
深入浅出 Qt 绘图机制:从 QPainter 到四大图像类解析
开发语言·qt·ui·性能优化·图形渲染
信可维2 小时前
AI 读 Word 表格总读错?先查合并单元格和表头这 3 处
开发语言·人工智能·c#·word·软件工程
HEJOO93 小时前
Java 并发运行中的安全问题详解
java·开发语言
海绵宝宝转agent3 小时前
2026-10-19学习笔记
笔记·学习
金色光环3 小时前
【概率论详细笔记】泊松分布是怎么来的?应该怎么用?
笔记·概率论
星夜离尘563 小时前
MATLAB阶跃响应怎么量:别把最后一个采样点当成稳态
开发语言·matlab
又見山3 小时前
双向链接的本质:链接不是功能,是思考留下的痕迹
笔记·obsidian