起步:先把地基和你会的那门语言对齐
为什么是 Rust:既没有 GC,也不用手动 free
这套教程假设你会什么
一门带垃圾回收的语言(Java、Go、JavaScript、C#、Python 任意一门) 加上基本的命令行。不假设你写过 C,也不假设你懂内存布局。
反过来,它不会教你「什么是变量」「什么是循环」。 runoob 那套 Rust 教程用七篇讲基础语法(「运算符」「注释」各占一篇), 这里压成一篇,因为对你来说那些东西只需要核对差异,不需要从头学。
这套教程真正花篇幅的地方只有一个:编译器为什么拒绝你。
内存这件事,一共就三种办法
你已经会的那门语言用的是第一种:
| 办法 | 谁决定什么时候释放 | 代价 |
|---|---|---|
| 垃圾回收 | 运行时的回收器 | 交出控制权;停顿;额外内存开销 |
手动 free |
你 | 早了 / 晚了 / 两次,都在运行时咬你 |
| 所有权(Rust) | 编译器,在编译期算出来 | 你得把「谁拥有它」写清楚,写不清楚就编译不过 |
第三种的直接好处是:释放的时刻是确定的。
这段代码的输出顺序是写死的 ——
内层块一结束,b 立刻释放,不是「以后某个时候」。
🚨 那条对照项是个真会咬人的坑:
let _b = ... 是一次绑定,值活到块尾;而 let _ = ... 里的 _
不是名字,它不绑定任何东西,值当场就被丢弃。
拿 let _ = mutex.lock() 写互斥锁时,锁会在那一行就放掉。
⇒ 这就是整套教程要讲的东西的来源:所有权规则不是洁癖, 它是「编译期算出释放点」这个目标的必要输入。
溢出:一个能看清取舍的小例子
「安全」这个词太虚,看一个具体的。同一件事 —— 32 位有符号整数的最大值加 1 —— 在各语言里的实际行为:
| 语言 | 实际发生了什么 |
|---|---|
| Java 17 | 静默回绕成 -2147483648 |
| C# / .NET 10 | 静默回绕(默认是 unchecked 上下文) |
| Go 1.27 | 静默回绕(int32) |
| C++(Apple clang 21) | 有符号溢出是未定义行为,这次跑出来也是回绕,但下次不保证 |
| Rust(debug) | 直接 panic:attempt to add with overflow |
| Rust(release) | 回绕 —— 和上面几家一样 |
这张表是真跑出来的(npm run test:crosslang 每次部署前跑一遍,
Rust 那两行是同一段代码分别不带 -O 和带 -O 编译的)。
三件事值得从这里读出来:
- Rust 默认不让溢出静默发生 —— debug 下它 panic。 而 panic 至少给你一个栈和一行「在哪出的事」,回绕给你的是一个错的数。
- 但 release 下它回绕,和别人一样。这是性能上的妥协,不是遗漏 —— 每次加法都检查一遍在热路径上是要钱的。
- ⇒ 所以真正的做法是明说你要哪种:
wrapping_add要回绕、checked_add返回Option、saturating_add夹在边界上。 把「我知道这里会溢出」写进代码,而不是靠注释。
⚠️ 这也解释了本站 Rust 示例的一个细节:闸门跑 rustc 时不带 -O,
所以那条溢出示例的结论是「panic」。换个编译参数结论就变 ——
这是极少数「编译参数改变语义」的地方,值得单独记一笔。
另外三个「和你会的那门不一样」
不做隐式数值转换。
i32 + i64 编译不过,要转就写 as。
C 和 Java 会替你提升,代价是「什么时候提升、什么时候截断」变成一套要背的规则。
没初始化就读,是编译错误。
E0381,不是垃圾值也不是默认零值。
⭐ 注意 let x: i32; 本身合法 —— 先声明后赋值很常见,
编译器拦的是「在所有路径上都赋过值之前就读它」。
if 是表达式。
let label = if ... { "偶" } else { "奇" };
没有三元运算符,因为不需要。代价是两个分支的类型必须一致。
同理的还有 match、loop(break 能带值)、以及函数体最后那个不带分号的表达式。
「零成本抽象」到底指什么
这句口号常被误读成「Rust 写起来没有代价」。它的准确含义窄得多:
你没用到的东西不花钱;你用到的东西,手写也快不了多少。
泛型在编译期展开成多份具体代码,运行时没有查表;
迭代器链编译出来和手写 for 循环基本一样;
Option<&T> 和裸引用一样大(空指针优化)——
这一条在语法速通那篇有实测:
&i32 和 Option<&i32> 都是 8 字节,而 i32 是 4、Option<i32> 是 8。
⇒ 优化只对「有非法位模式可借用」的类型成立,不是所有 Option 都免费。
⚠️ 但它不包括编译时间和心智负担 —— 那两样是实打实的代价。 大型 Rust 项目的编译时间是一个真实的抱怨点,而借用检查器会在你 刚上手的头两周反复拦住你。这套教程存在的理由就是把第二样缩短。
下一步
先把工具链和工程骨架搭起来 ——
见《Cargo 与工程骨架》:cargo new 生成的那几个文件
各是什么、build / run / check 的分工,以及怎么把编译器版本钉死。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
释放的时刻是**确定**的,不是「以后某个时候」
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("释放 {}", self.0);
}
}
fn main() {
let _a = Noisy("a");
{
let _b = Noisy("b");
println!("内层块结束前");
}
println!("外层块结束前");
}编译通过 · 输出 "内层块结束前\n释放 b\n外层块结束前\n释放 a\n"
对照:把 `let _b` 改成 `let _`(只差一个名字)
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("释放 {}", self.0);
}
}
fn main() {
let _a = Noisy("a");
{
let _ = Noisy("b");
println!("内层块结束前");
}
println!("外层块结束前");
}编译通过,输出:"释放 b\n内层块结束前\n外层块结束前\n释放 a\n"
🚨 对照项是个真会咬人的坑:`let _b = ...` 是一次**绑定**,值活到块尾;而 `let _ = ...` 里的 `_` 不是名字、不绑定任何东西,值**当场就被丢弃**。看输出顺序就知道 —— 「释放 b」跑到了「内层块结束前」的前面。这在拿 `let _ = lock.lock()` 写互斥锁时会直接导致锁瞬间就放了。
整数溢出在 debug 下直接 panic
fn main() {
let x: i32 = "2147483647".parse().unwrap();
println!("{}", x + 1);
}编译通过,但运行时 panic · 退出码 101
运行时说了什么
thread 'main' panicked at why-overflow-panics-in-debug.rs:3:20:
attempt to add with overflow对照:改成 `x.wrapping_add(1)`(明说我要回绕)
fn main() {
let x: i32 = "2147483647".parse().unwrap();
println!("{}", x.wrapping_add(1));
}编译通过,输出:"-2147483648\n"
⚠️ 这条示例的结论**依赖编译参数**:闸门用的 `rustc` 不带 `-O`,所以溢出检查是开的。加 `-O` 之后同一段代码会静默回绕成 `-2147483648` —— 这件事由 `npm run test:crosslang` 单独跑两遍来验证(正文那张表里 Rust 占两行就是这个原因)。
不同宽度的整数不会自动转换
fn main() {
let a: i32 = 1;
let b: i64 = 2;
println!("{}", a + b);
}编译不过 · error[E0308]
rustc 原文
error[E0308]: mismatched types
--> why-no-implicit-numeric-cast.rs:4:24
|
4 | println!("{}", a + b);
| ^ expected `i32`, found `i64`
error[E0277]: cannot add `i64` to `i32`
--> why-no-implicit-numeric-cast.rs:4:22
|
4 | println!("{}", a + b);
| ^ no implementation for `i32 + i64`
|
= help: the trait `Add<i64>` is not implemented for `i32`
help: the following other types implement trait `Add<Rhs>`
--> /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/core/src/ops/arith.rs:98:8
|
= note: `i32` implements `Add`
::: /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/core/src/ops/arith.rs:113:0
|
= note: in this macro invocation
--> /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/core/src/internal_macros.rs:22:8
|
= note: `&i32` implements `Add<i32>`
::: /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/core/src/internal_macros.rs:33:8
|
= note: `i32` implements `Add<&i32>`
::: /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/core/src/internal_macros.rs:44:8
|
= note: `&i32` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)对照:明写 `a as i64`
fn main() {
let a: i32 = 1;
let b: i64 = 2;
println!("{}", a as i64 + b);
}编译通过,输出:"3\n"
C 和 Java 会替你把 `i32` 提升成 `i64`,代价是「什么时候提升、什么时候截断」变成一套要背的规则。Rust 直接不做隐式数值转换:要转就写 `as`。⇒ 多敲几个字符,换掉一整类「我以为它不会溢出」。
没初始化就用,是编译错误不是垃圾值
fn main() {
let x: i32;
println!("{x}");
}编译不过 · error[E0381]
rustc 原文
error[E0381]: used binding `x` isn't initialized
--> why-no-uninitialized-read.rs:3:16
|
2 | let x: i32;
| - binding declared here but left uninitialized
3 | println!("{x}");
| ^ `x` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let x: i32 = 42;
| ++++对照:给它一个初值
fn main() {
let x: i32 = 5;
println!("{x}");
}编译通过,输出:"5\n"
⭐ 注意 `let x: i32;` 本身是**合法**的 —— Rust 允许你先声明后赋值(在分支里给不同的值很常见)。编译器拦的是「在所有路径上都赋过值之前就读它」,这个分析叫**初始化检查**。
`if` 是表达式,所以两个分支得是同一个类型
fn main() {
let n = 7;
let label = if n % 2 == 0 { "偶" } else { "奇" };
println!("{label}");
}编译通过 · 输出 "奇\n"
对照:把 `else` 分支改成一个数字
fn main() {
let n = 7;
let label = if n % 2 == 0 { "偶" } else { 1 };
println!("{label}");
}编译不过:error[E0308]
没有三元运算符,因为不需要 —— `if` 本身就有值。代价是两个分支的类型必须一致(对照项 `E0308`)。同理的还有 `match`、`loop`(`break` 可以带值)、以及函数体最后那个不带分号的表达式。