起步:先把地基和你会的那门语言对齐

为什么是 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 编译的)。

三件事值得从这里读出来:

  1. Rust 默认不让溢出静默发生 —— debug 下它 panic。 而 panic 至少给你一个栈和一行「在哪出的事」,回绕给你的是一个错的数。
  2. 但 release 下它回绕,和别人一样。这是性能上的妥协,不是遗漏 —— 每次加法都检查一遍在热路径上是要钱的。
  3. ⇒ 所以真正的做法是明说你要哪种: 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` 可以带值)、以及函数体最后那个不带分号的表达式。