Rust 入门笔记:所有权系统初探
广州十一月的风终于带上了一点凉意。宿舍里很安静,室友们大多出去实习或者准备考公了。大四的课表空得有些空荡,我看着屏幕上 VSCode 里那几行闪烁着红色波浪线的代码,端起手边的水杯喝了一口。
作为一个写了几年 JavaScript 和 TypeScript 的人,最近这段时间我把一部分精力分给了 Rust。如果说写 JS 像是在平原上开自动挡汽车,哪怕偶尔偏离车道,引擎也会包容你的随性;那么写 Rust 就更像是在悬崖边开手动挡,编译器就像一个坐在副驾驶的、比老板还要严格的考官。它不会大声斥责你,但只要你的离合松错了一毫米,它就会立刻踩下副刹车,让你寸步难行。
很多人问过我,一个前端方向的人,或者说一个普通双非民办本的学生,为什么要花这么多时间去啃 Rust。毕竟它陡峭的学习曲线在圈内是出了名的。
其实原因很简单,我想去看看水面之下的风景。
这几年端侧和前端的基础设施正在经历一场无声的重构。过去我们习惯了 Node.js 生态里的各种工具,但随着工程体积的膨胀,性能瓶颈越来越明显。后来,SWC 出现了,Turbopack 出现了,Biome 也逐渐成熟。这些工具无一例外地选择了 Rust 作为底层语言。它们带来的不仅仅是编译速度上几十倍的提升,更是生态底层逻辑的转移。
Rust 的性能无限接近于 C 和 C++,但它又通过极其严苛的编译器检查,保证了内存安全。再加上它作为 WebAssembly 的最佳搭档,几乎成为了打通 Web 和底层系统之间最坚固的桥梁。对于我这样一个即将面对秋招和残酷就业市场的大四学生来说,只停留在框架的 API 调用层面是危险的。我需要一种确定性,一种能够穿透技术周期、触及本质的确定性。
这种确定性,在 Rust 里,首先具象化为它的核心概念:所有权系统。
习惯了拥有垃圾回收机制(GC)的语言后,初看所有权系统是有些反直觉的。在 JS 里,我们习惯了随意赋值、传递对象,底层的 V8 引擎会像一个尽职尽责的清洁工,在我们看不见的地方默默回收那些不再被引用的内存。但 Rust 不一样,它没有 GC,也不需要你手动去 malloc 和 free。它靠的是一套写在基因里的规则。
每个值,在 Rust 里,有且只有一个所有者。
fn main() {
let s1 = String::from("hello");
let s2 = s1;
// println!("{}", s1); // 编译错误。s1 已无效
println!("{}", s2); // OK
}
敲下这段代码的时候,我看着终端里报出的 value borrowed here after move 陷入了短暂的思考。在 TypeScript 里,s2 = s1 只是增加了一个指向同一块内存的引用,s1 和 s2 依然可以和睦相处。但在 Rust 的世界里,这被称为“转移”(Move)。
当 s1 赋值给 s2 的那一刻,s1 就失去了对那块堆内存的所有权。它被编译器无情地宣告了死亡。这是一种非常克制且整洁的哲学。因为只有一个所有者,当这个所有者离开作用域时,Rust 就可以毫不犹豫地释放掉这块内存,而不用担心还有别人在偷偷引用它,从而避免了二次释放(Double Free)的灾难。
这种感觉,就像是你在图书馆借了一本绝版书,你把它转交给了朋友,那么你就不能再声称自己拥有它,更不能再去翻阅它。这很严苛,但极其清晰。
不过,如果每次传递变量都要交出所有权,那代码就太难写了。于是 Rust 给了我们另一种选择,那就是“借用”(Borrowing)。
fn calculate_length(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
println!("{} 的长度是 {}", s, len);
}
那个小小的 & 符号,就是借用的标志。它允许我们创建一个指向值的引用,但不获取其所有权。在 calculate_length 这个函数里,它只是拿过去看了一眼,数了数长度,然后就原封不动地还了回来。因为没有获取所有权,所以当 s 在函数末尾离开作用域时,它所指向的内存并不会被丢弃。
这让我想起大学这四年的生活。从大一到现在,我保持着 4.02 的 GPA,拿了两次国家奖学金。很多人觉得这是因为我足够聪明,但其实不是。我只是习惯了像 Rust 的借用机制一样,把时间和精力精准地分配给每一门课、每一个项目,用完即还,不让情绪和焦虑长期占据我的“内存”。我清楚地知道,那些荣誉只是我人生某个阶段的“借用”,它们不属于我,我真正拥有的,只有在这个过程中沉淀下来的思维方式。
当然,仅仅是读取是不够的,我们总会有修改数据的需求。这就引出了可变借用(Mutable Borrowing)。
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s; // 编译错误。不能同时有两个可变借用
r1.push_str(", world");
}
Rust 在这里立下了一条铁律:在特定作用域中的特定数据,同一时间只能有一个可变引用。
第一次看到这条规则时,我觉得它简直不近人情。在 JS 里,我们可以在全局定义一个对象,然后在无数个回调函数里去修改它的状态。虽然这经常导致难以排查的 bug,但写起来确实行云流水。
Rust 彻底封死了这条路。它通过编译期的静态分析,从根本上杜绝了数据竞争(Data Race)。两个指针同时访问同一块数据,其中至少有一个在写入,而且没有同步机制,这是并发编程里最可怕的噩梦。Rust 把这个噩梦扼杀在了摇篮里。
如果说动态语言的开发体验是“先甜后苦”,你可以很快地把功能堆砌出来,但要在随后的漫长岁月里,在深夜的生产环境告警中,去排查那些因为隐式类型转换或内存泄漏导致的玄学 bug。那么 Rust 就是绝对的“先苦后甜”。
它的编译器会强迫你在写代码的当下,就去面对数据流向、内存分配、并发安全这些最本质的问题。它会用满屏的错误提示阻断你的侥幸心理。这段时间,我经常为一个生命周期标注(Lifetime)和编译器搏斗上一两个小时。那种挫败感是真实的。
但同样真实的,是当满屏的红色波浪线消失,终端里终于打印出 cargo build 成功的绿色字样时,内心涌起的那种巨大的安宁。
因为你知道,只要它编译通过了,它就是安全的。它不会在运行到某一个罕见的分支时突然崩溃,也不会在处理高并发请求时悄悄搞乱你的数据。你对这块代码的正确性,有着百分之百的信心。
合上电脑,窗外的天已经完全黑了。远处的教学楼还有零星的灯光。
大四的这一年,面临着很多选择和压力。广商的背景在简历池里并不亮眼,但我并不觉得气馁。就像 Rust 一样,它没有很多语言那样华丽的语法糖,也没有那么平易近人的入门门槛,它只是在底层默默地、严丝合缝地运转着。
如果编译器是你的敌人,你会很痛苦,因为你总想绕过它的规则。但如果编译器是你的朋友,你会很安心,因为它在替你守住底线。我想,技术是这样,生活大概也是如此。接受规则,理解本质,然后在这个框架里,去写出安全而高效的代码。