B Rust 的"解法"

Rust 的"解法"

Oct 19, 2025

翻出 2020 年写的 C 语言博客。

数组与指针内存布局与调试。那时候写得挺认真的,后来还画内存图,标注高地址低地址,解释为什么 arr[3] = 20 能把旁边变量 i 的值也改了。文章结尾写的是:“为避免这种错误,始终要确保在使用数组时不越界访问。”

现在回头看,那句话翻译成人话大概是:你最好别写错。

因为写错了编译器不会告诉你。

学 Rust 的时候,很多东西越看越像是对 C 里那些经典问题的系统性回应。不是"Rust 比 C 好"——是 Rust 把二十年来系统编程中最常见的错误模式,逐个编码进了编译器和类型系统里。一个 Rust 新手在编译期被挡下的问题,可能比一个 C 老手十年遇到的运行时 bug 还全面。

指针三兄弟

NULL、野的、悬空——C 程序员的日常恐惧来源。

int *p = NULL; *p = 1; 编译通过。运行到这一行,操作系统发来 SIGSEGV。不是编译期能发现的事——NULL 是一个合法的指针值,只是指向的地址不可访问。编译器没有语义层面的"这个指针可能为空"的概念。

int* f() { int x = 5; return &x; } 也编译通过。x 在 f 的栈帧里,函数返回后栈帧被回收,返回的地址指向一块随时可能被覆盖的内存。这个地址仍然"合法"——它在一个可访问的内存段内,只是内容不受你控制。有时候打印出 5,有时候打印出随机数,有时候什么都没发生,取决于后续函数调用有没有踩到同一块栈空间。

free(p); *p = 1; 还是编译通过。free 之后那块堆内存被标记为可用,但 p 的值没变,仍然指向原地址。继续通过 p 读写,运气好时数据还没被覆盖,程序照常运行——这比直接崩溃更危险,因为你不知道错误已经埋下了。

Rust 的做法很简单:没有 NULL。没有悬垂引用。没有 use-after-free。

它是怎么做到的,一两句话讲不清楚。所有权、借用、生命周期——三个概念叠在一起才有那种效果。

第一层,用 Option<T> 消除空指针。C 里任何一个指针都可能为空,是否检查全靠程序员自觉。Rust 里,可能为空的引用类型是 Option<&T>,你必须显式处理 SomeNone 两种情况——编译器用模式匹配的穷尽性检查确保你不会漏掉。

fn print_first(words: &[String]) -> Option<&String> {
    words.first()  // 可能为空,返回 Option
}

let first = print_first(&items);
match first {
    Some(s) => println!("{}", s),
    None => println!("empty"),
}
// 不处理 first 直接解引用:编译不过

第二层,用借用检查器消除悬垂引用。编译器为每一个引用推导生命周期,确认它的存活范围不超出被引用数据的存活范围。fn f() -> &i32 { let x = 5; &x } 编译不过——不是运行时检测,是静态分析。编译器追踪到 &x 的引用源自局部变量 x,返回值的生命周期要求比 x 的存活范围更长,矛盾。

第三层,用所有权消除 use-after-free 和 double free。每个值在任一时刻只有一个 owner。owner 离开作用域,编译器自动插入析构代码(Drop trait)。没有显式的 free 调用,也就不存在"free 完忘了"或"free 了两次"的场景。free 之后的读取在 Rust 里等同于"值已经移动,原绑定失效"——同样是编译期拒绝,不是运行时 segfault。

let v = vec![1, 2, 3];
let v2 = v;           // v 的所有权移动给 v2
// println!("{:?}", v); // 编译不过:v 已经失效
println!("{:?}", v2);   // v2 可用

但作为使用者的感受很直接:编译器不给过的东西,你跑起来基本不会炸。指针不再是那种"小心点用,出事了别怪我没提醒你"的工具,而是你必须先向编译器证明它安全,才能用它。

写 C 的时候,我也觉得"注意检查返回值就行"。直到 debug 了三小时发现是个 double free——一个第三方库内部已经释放了,我们的代码又 free 了一次。

越界

2020 年那篇数组文章里的越界例子我还记得:

arr[3] = 20 把变量 i 改了。

不是 i 本意要改——是越界访问踩到了相邻内存。栈上变量按声明顺序分配,先声明的 i 在高地址,后声明的 arr[3] 恰好落在 i 的地址上。那篇文章花了大量篇幅解释这个布局,因为只有理解它,才能解释为什么改了 arr[3] 会牵连一个不相干的变量。

Rust 不做这件事。Rust 给你 panic。

内存考古是不可能的,Rust 直接在每次索引访问时插入边界检查。arr[100] 在 debug 模式下 panic,打印出明确的越界信息和调用栈。release 模式下同样是 panic——这与整数溢出的处理不同,数组越界在任何优化级别下都会触发检查,因为不存在一个合理的 wrapping 语义。

let arr = [1, 2, 3];
// arr[100];             // panic: index out of bounds
let x = arr.get(100);    // 返回 None,不 panic

性能上的代价是每次索引多一条分支指令。代价很小,因为 CPU 分支预测在顺序遍历时几乎不会预测失败。对性能极度敏感的代码可以通过 get_unchecked 绕过检查——但这要求你显式写 unsafe,并且在文档里证明索引不会越界。

let arr = [1, 2, 3];
// 调用者保证 index < arr.len()
unsafe { let x = arr.get_unchecked(2); }

(C 里越界是 UB。UB 的意思是:编译器不保证任何事。正确运行、崩溃、改掉隔壁变量、甚至让后续逻辑产生错误结果,在法律意义上都属于"程序行为"。)

释放之后

C 里最让人脊背发凉的场景:free 完之后再用。或者 free 两次。

不是不会写。是调用链长了之后,某段逻辑释放了指针,另一段逻辑持有了同一指针的拷贝,读到一半突然想起来——或者根本没想到——然后炸了。更麻烦的是跨模块的情况:你调用了一个返回裸指针的库函数,文档里写着"调用方负责释放",但那段文档藏在一个你不常翻的 wiki 页面里。

Rust 的答案是所有权,把"谁负责释放"写进了类型。

一块堆内存只有一个 owner(Box<T>Vec<T>String 等)。owner 离开作用域,编译器自动插入 drop 调用——不是 GC,没有运行时开销,是编译期确定的释放点。赋值默认是移动语义:let y = x; 之后 x 不再可用,避免了"两个变量指向同一块内存,各自决定什么时候 free"的局面。

需要共享访问时用引用。引用的规则是:同一时刻可以有多个不可变引用,或者恰好一个可变引用,两者不共存。这直接排除了两种经典 bug:数据竞争(多线程同时写)和迭代器失效(遍历途中修改集合)。

let mut v = vec![1, 2, 3];
let r1 = &v;       // 不可变引用
let r2 = &v;       // 允许:多个不可变引用共存
// let r3 = &mut v;  // 编译不过:不可变引用存在时不能可变引用
println!("{} {}", r1, r2);

引用的生命周期由编译器静态推导。每个引用都有一个被追踪的存活范围,编译器保证这个范围不超过被引用数据的存活范围。函数签名里的生命周期标注——fn longest<'a>(x: &'a str, y: &'a str) -> &'a str——本质上是在告诉编译器:返回值引用的存活范围与两个参数中较短的那个一致。

刚开始写 Rust 的时候,这些规则让人觉得编译器在找茬。每个借用冲突都是一道"你想做什么,我为什么不让你做"的谜题。后来发现,每一次被编译器打回来,都是 C 里一次潜在的 segfault 或者 data race。

编译期拆掉的 UB

以下四种 UB,C 里都是静默的,Rust 在编译期就给拦了。

整数溢出。

C 里有符号溢出是 UB。无符号溢出 wrapping。这里存在一个认知陷阱:你写 int a = INT_MAX + 1,编译器可以假设这件事永远不会发生,并基于这个假设做优化——包括删除你认为是"安全检查"的代码。一个经典的案例:if (a + 1 > a) 被编译器优化为恒真,因为编译器假定有符号加法不会溢出。

Rust:debug panic,release wrapping。写死,不让你猜。

行为在两个优化级别下都明确,不会因为编译器的"假设某个条件不可能发生"而让你的边界检查凭空消失。

let a: u8 = 255;
// a + 1 在 debug 下 panic,release 下 wrapping 为 0
let b = a.wrapping_add(1);  // 明确要求 wrapping:b = 0
let c = a.saturating_add(1); // 明确要求饱和:c = 255

未初始化变量。

C 里 int x; printf("%d", x); 编译通过。x 占据的栈空间里是上一轮函数调用留下的旧数据。打出来是什么取决于调用历史。

Rust 编译不过。变量必须先绑定到一个值才能使用。编译器不会让你读到一个不确定的状态。

数据竞争。

C 多线程写同一块内存:UB。可能正确,可能错,可能今天正常明天崩溃——取决于缓存一致性协议和时序的偶然组合。ThreadSanitizer 能帮上忙,但它只能检测你实际跑过的执行路径。

Rust:Send / Sync trait 加 borrow checker。用类型系统做这道门。

Send trait 标记一个类型可以在线程间转移所有权;Sync trait 标记一个类型可以在线程间共享引用。这两个 trait 是 auto trait——编译器自动推导,你不需要手动标注。当一个类型包含非线程安全的组件(比如 Rc<T> 不含原子计数),编译器自动判定它不实现 Send,进而在编译期拒绝跨线程传递。borrow checker 在线程场景下同样工作:两个线程不能同时持有同一个数据的可变引用。

use std::rc::Rc;
use std::sync::Arc;
use std::thread;

let rc = Rc::new(42);
// thread::spawn(move || { dbg!(rc); }); // 编译不过:Rc 没有实现 Send

let arc = Arc::new(42);
let a = Arc::clone(&arc);
thread::spawn(move || { dbg!(a); }).join().unwrap(); // Arc 可以

类型转换。

C 的 void* 想怎么转怎么转。转错了,后果自负。

一个常见的错误是把 int* 强转成 char* 然后逐字节操作,忘记了字节序和内存对齐,导致跨平台时数据解析出错。

Rust 里隐式转换的范围极窄——整数类型之间可以自动拓宽,引用可以自动解引用或重借用。其他转换必须显式:as 做基本数值转换,From/Into trait 做安全的值到值转换,TryFrom 处理可能失败的转换。mem::transmute 可以在任意类型之间重解释字节——但它是 unsafe 的,且文档会标明"你需要保证源类型和目标类型的内存布局兼容"。

let x: i32 = 42;
let y: i64 = x.into();        // From/Into:安全,不丢失信息
let z: u32 = x as u32;        // as:可能截断或改变符号
let w: u16 = x.try_into().unwrap(); // TryFrom:可能失败,返回 Result

不止内存

C 里容易忘记的不止内存。

FILE *fp = fopen("data.txt", "r"); ——如果中间的逻辑抛了异常或者提前返回,fclose(fp) 就跳过去了。malloc 之后忘了 freepthread_mutex_lock 之后忘了 pthread_mutex_unlock——编译器对这些遗漏全部保持沉默。

Rust 用 RAII 把释放和获取绑定在同一段作用域里。文件句柄 File 实现了 Drop trait,离开作用域时自动关闭。MutexGuard<T> 在离开作用域时自动释放锁——不是靠程序员记得写 unlock,是 guard 变量的析构函数替你做了。这不需要 GC,完全是编译期确定的代码生成。

{
    let f = File::open("data.txt").unwrap();
    // 使用 f...
} // f 离开作用域,文件自动关闭

错误处理。

C 里 if (ret != 0) 到处可见。漏一个检查,逻辑就错了。有研究统计过,Linux 内核中约有 15% 的安全漏洞与未检查的错误返回值有关。问题不是"程序员不知道应该检查",而是 C 的类型系统不区分"可能失败的操作"和"不可能失败的操作"——两者返回的是同一种类型。

Rust 的 Result<T, E>#[must_use] 标注的——如果你接收了一个 Result 但没有处理它,编译器会发出警告。处理方式可以是 match 显式分支,可以是用 ? 操作符向上传播错误(同时自动做类型转换),也可以 unwrap() 在失败时 panic。无论如何,不存在"忘记检查"这个选项。

fn read_config() -> Result<String, io::Error> {
    let content = std::fs::read_to_string("config.toml")?; // 出错则向上传播
    Ok(content)
}

match read_config() {
    Ok(cfg) => println!("{}", cfg),
    Err(e) => eprintln!("读取失败: {}", e),
}

迭代器的问题在 C++ 里更典型:std::vector 扩容,所有指向旧缓冲区的迭代器、引用、指针全部失效。继续使用产生 UB。Rust 的 borrow checker 在迭代过程中阻止可变引用——你可以在迭代器存活期间读取元素,但不能往 Vec 里 push,因为 push 需要可变引用,而迭代器持有不可变引用,两者不共存。

压进 unsafe

C 几乎所有操作都可能产生 UB。越界、空指针、有符号溢出、数据竞争、类型双关——每一项都在语言的"合法行为"之外。

Rust 的核心设计目标之一是把 UB 压缩到一个可控的区域:unsafe 代码块。在 safe Rust 中,你面对的异常行为只有逻辑错误和 panic。那些 C 里悄无声息破坏内存的程序缺陷,被编译器的静态分析拦在了 unsafe 的边界之外。

unsafe 不是"禁用安全检查"。是"程序员向编译器承诺,会手动遵守以下五项规则":解引用裸指针时保证其有效、调用 unsafe 函数时满足其前置条件、访问 union 字段时类型正确、不产生可变引用的别名、不破坏标准库类型的外部可见状态。编译器不再替你检查,责任回到你的手上。

let mut num = 5;
let r1 = &raw const num;   // 裸指针,不触发借用检查
let r2 = &raw mut num;
unsafe {
    *r2 = 10;              // 解引用裸指针必须在 unsafe 内
    println!("{}", *r1);   // 程序员保证 r1 指向有效数据
}

这跟 C 的全域无声模式不一样。unsafe 是一个显式的标记,代码审查时可以精准定位到这些块,检查程序员的手写安全承诺是否成立。safe Rust 的规模越大,需要人工审查的范围越小。

当年写 C 博客的时候,每篇文章结尾都会加一句"注意避免某类错误"。那是跟自己说的——编译器不帮你,你得自己记住。Rust 让人不习惯的地方是编译器太吵了,什么都管。但适应之后发现,那种"编译器帮你记住"的感觉,像有人替你翻了两代系统程序员的事故记录,然后说:这些不用再踩一遍了。

不是说学了 Rust 就看不起 C。C 的简洁和透明不可替代。但那种"编译通过,基本能跑对"的感觉——确实不太想回去了。

TouchingFish.top