学 Rust 学到崩溃?不是你的问题,是 Rust 本来就这样

0 阅读14分钟

本文是对Frustrated? It's not you, it's Rust的整理与翻译

内容结构概览

一、前言:有经验反而更难受
    - 越有经验越挫败感越强
    - 学 Rust 不像从法语换西班牙语,而是从零学会说一门新语言
    - 搜索引擎基本没用,最好的帮手是编译器本身

二、你比 Rust 更聪明
    - 动态语言的"隐式知识"在 Rust 里必须写明
    - 不加 trait bound 的泛型 fn add<T> 为什么不能编译
    - 不变量(invariant)和正确性(soundness)的概念
    - 编译期不变量 vs 运行时断言

三、Rust 不猜测,但会推导
    - 类型推导的范围:变量绑定、字面量、泛型参数
    - C 程序的 switch 漏 case → segfault 的完整示例
    - 相同逻辑的 Rust 版本:match 非穷尽报编译错误
    - 处理不完全匹配的三种方式:panic / 默认值 / Result
    - Into::into 泛型返回值的歧义问题:Rust 拒绝猜测
    - 整数字面量默认为 i32 的唯一例外

四、超越整数类型:trait 与动态派发
    - Wolf / Lizard 问题:为什么 acquire_pet<T> 不能工作
    - 结构相似性(都有 greet 方法)不等于合约一致
    - 为什么函数不能根据运行时条件返回不同的具体类型
    - dyn Any vs impl Any 的区别与局限
    - 栈上内存分配必须知道大小:Box<dyn T> 的出现理由
    - downcast_ref 降型查找的用法
    - 自定义 Greet trait + Box<dyn Greet>:正确的解法
    - 多 trait boundBox<dyn Greet + Clone> 为什么不行
    - trait GreetClone: Greet + Clone 为什么也不行

五、Unsized 类型与 trait 对象
    - "局部变量不能是 unsized" 这个规则的来源
    - Clone 为什么不能做 trait object:返回 Self 的根本问题
    - trait MyClone { fn my_clone(&self) -> Self } 的完整错误分析
    - Sized 作为 supertrait 的效果:依然不能做 trait object
    - dyn-clone 的底层技巧:unsafe fn clone_ptr + fat pointer 操作
    - MyCloneExt 的自动实现(blanket impl)
    - 最终可运行的代码
    - 结论:不需要自己发明这些技巧,用 dyn-clone crate

六、生命周期
    - 为什么生命周期是 Rust 最绕不过的概念
    - C 多线程共享状态的示例(pthreads)
    - Rust 的 unsafe 字面翻译 vs 正确的 Rust 写法
    - 数据竞争的真实输出(-980832767)
    - 借用检查器的目的:防止悬空引用和数据竞争
    - 拆分状态结构体:Rust 不认为状态是一个整体
    - "重新构思程序结构才能让它通过编译"是 Rust 思维方式的核心

七、结语:鼓励语与学习建议

前言:有经验反而更难受

学 Rust 是一段情绪旅程。作者说,他在学 Rust 的头几个月,比学任何其他东西都更挫败。

让人难受的是:之前有多少其他语言的经验,不重要。Java、C#、C、C++ 或者其他任何语言,都挡不住这份挫败感。事实上,经验越多,往往还越难受——因为习惯已经形成得更深,而且有一种预期是:到了这个阶段,我应该能更快搞定的。

也许在多年成功交付代码之后,你已经不再有那种刚开始学习时的好奇心、那种坦然接受"我迷失了"的状态。

学 Rust 会让你感觉像个新手——为什么这么难?这明明不应该这么难。我知道我想要什么,我只需要……让它发生。

作者说,他在所有初级文章里都会写这样的前言,因为这很重要:如果你在学 Rust,要预期会遇到路障。说"你很快就能上手"是彻头彻尾的谎话。

但有一个很好的理由解释为什么学起来这么难。从另一门语言切换到 Rust,不像从法语换西班牙语——你不是在学新词汇来表达同样的意思,只是拼写和发音不同。

你是在学新词汇,同时学着讨论一些你以前从来不需要讨论的话题。你在学一套全新的表达方式。

你会遇到用之前所有知识都无法构架的问题。写 Rust 需要遵守一套规则,这套规则你没有办法用其他语言类比。这还带来额外一层困难:你经常连"哪里出了问题"都描述不清楚,更别说找人帮忙了。

通用搜索引擎在解决 Rust 问题上基本没用。你最好的帮手几乎就是 Rust 编译器本身和它的诊断信息。要么,就咬咬牙,接受自己要先回去读更多基础材料,再回来,等那个"啊哈!"的时刻自己出现。


一、你比 Rust 更聪明

作者的意思是认真的。

对于做了很多动态类型/弱类型工作的人来说,这尤其明显。

从 Python、Ruby 或 JavaScript 转来,你习惯写这样的函数:

// (不是有效的 Rust)
fn add(a, b) {
    a + b
}

有了这样的函数,你知道只能用可以被加的东西调用它——比如数字。你也知道不要用对象或者字典来调用,因为结果可能没有意义。

JavaScript 里:

console.log({} + {});
// 输出:[object Object][object Object]

Rust 没这么聪明。首先,它需要所有东西都有类型:

// (无法编译)
fn add(a: TypeA, b: TypeB) -> TypeResult {
    a + b
}

我们不能凭空变出类型来。可以选一个已有的类型,比如 i32

// 没问题!
fn add(a: i32, b: i32) -> i32 {
    a + b
}

但如果想让 add 对任何可以相加的两个东西都工作,就必须让函数变成泛型:

// (仍然无法编译)
fn add<T>(a: T, b: T) -> T {
    a + b
}

编译错误:

error[E0369]: cannot add `T` to `T`
help: consider restricting type parameter `T`
  |
5 | fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {

帮助信息说到点子上了——Rust 不允许相加两个东西,除非它确定它们能被相加。正确写法:

use std::ops::Add;

fn add<T>(a: T, b: T) -> T
where
    T: Add<Output = T>,
{
    a + b
}
$ cargo run --quiet
ten = 10

所以——你比 Rust 更聪明。Rust 只知道你告诉它的事情。而且你最好说清楚自己的意思!

但有一个好处:花时间向 Rust 仔细描述你的意图,能防止大量错误。防止的是整类错误。

不变量(Invariant)和正确性(Soundness)

add() 只接受可以相加的值 是一个不变量

如果你习惯了更动态/弱类型的语言,你一直在"维护不变量"——只是可能从来没有用过这个词。你也可以叫它"假设"——在整个 add 调用期间,我们假设 ab 可以相加。

维护不变量就是维护"正确性"(soundness)。违反不变量的代码叫"不正确的"(unsound)。

在 Rust 里,我们不是把不变量放在脑子里,而是直接写进代码,让编译器在编译期强制执行。


二、Rust 不猜测,但会推导

你也许会争辩:像这样的函数:

fn add<T>(a: T, b: T) -> T {
    a + b
}

……已经有了足够的信息让 Rust 自己推断 T 的约束。毕竟 Rust 确实能推导一些东西,比如变量的类型:

fn get_some_numbers() -> Vec<usize> {
    vec![1, 2, 3]
}

fn main() {
    let v = get_some_numbers();
    // Rust 知道 v 是 Vec<usize>
}

C 程序的 switch 漏 case 示例

这个 C 程序可以编译,但会在运行时崩溃:

char *humanize_number(size_t n) {
    switch (n) {
        case 0: return "zero";
        case 1: return "one";
        case 2: return "two";
        // 没有 default case
    }
}
$ gcc main.c -o main && ./main
0 = zero
1 = one
2 = two
[1]    segmentation fault

C 编译器其实知道有问题,用 -Wall 会告诉你:

warning: control reaches end of non-void function [-Wreturn-type]

类似的 Rust 程序根本不会通过编译:

fn humanize_number(n: usize) -> &'static str {
    match n {
        0 => "zero",
        1 => "one",
        2 => "two",
    }
}
error[E0004]: non-exhaustive patterns: `_` not covered

usize 的值可以从 0 到 180 亿亿,Rust 要求你处理每一种情况,有三种解决办法:

主动 panic:

_ => panic!("n is too large"),

返回默认值:

_ => "a big number",

用 Result 让调用者处理:

struct NumberTooBig;

fn humanize_number(n: usize) -> Result<&'static str, NumberTooBig> {
    match n {
        0 => Ok("zero"),
        1 => Ok("one"),
        2 => Ok("two"),
        _ => Err(NumberTooBig),
    }
}

如果函数返回了未定义值,最好的结果是立刻 segfault。更糟糕的是:如果你把结果存下来,或者传给期望有效字符串的函数,各种不变量都会被打破。后果可能是:给所有人 root 权限,泄露用户数据,或者更糟的事情。

Rust 拒绝猜测

Into::into 可以根据期望的返回类型返回不同类型:

let a: u8 = 255;
let b: u16 = a.into();  // 转成 u16
let c: u32 = a.into();  // 转成 u32
let d: u64 = a.into();  // 转成 u64

但如果不指定目标类型:

let b = a.into();  // 应该转成什么?
error[E0282]: type annotations needed

u16u32u64u128i16i32i64i128 都满足条件。Rust 拒绝猜测

整数字面量是一个例外:vec![1, 2, 3] 里没有类型标注的整数默认是 i32,浮点数默认 f64。其他情况都需要明确说明。

类型推导是有限度的——当它开始看起来像猜测,编译器就会要求你给出更明确的类型标注。


三、超越整数类型:trait 与动态派发

考虑这个程序:

// (无法编译)

struct Wolf {}
impl Wolf {
    fn greet(&self) { println!("awoooo"); }
}

struct Lizard {}
impl Lizard {
    fn greet(&self) { println!("*chirp chirp*"); }
}

fn acquire_pet<T>(comfy: bool) -> T {
    if comfy { Wolf {} }
    else { Lizard {} }
}

fn main() {
    let pet = acquire_pet(true);
    pet.greet();
}

错误之一:

error[E0282]: type annotations needed

即便我们指定类型:

let pet: Wolf = acquire_pet(true);

还是会报:

error[E0308]: mismatched types
expected type parameter `T`, found struct `Wolf`

问题是什么?WolfLizard 都有 greet 方法,明明相似,为什么不行?

因为它们的结构相似性完全不相干。重要的只是各部分代码签订的合约。如果宠物的类型取决于用户输入(运行时数据),编译时根本无法确定该给 pet 分配多少栈空间——WolfLizard 大小可能完全不同。

dyn Any vs impl Any

fn acquire_pet(comfy: bool) -> dyn std::any::Any { ... }
// 编译失败:dyn T 是 unsized 类型,不能直接作为返回值

dyn T 是"trait 对象",包含一个指向具体值的指针和一个包含方法地址的 vtable。它是 unsized 的。

fn acquire_pet(comfy: bool) -> impl std::any::Any { ... }
// 还是失败:
// error[E0308]: `if` and `else` have incompatible types
// expected struct `Wolf`, found struct `Lizard`

impl Trait 承诺返回一个实现了该 trait 的具体类型。但编译器仍然需要确定是哪个具体类型,而 if/else 分支分别返回了两种不同类型,它做不到。

Box:正确的解法之一

fn acquire_pet(comfy: bool) -> Box<dyn std::any::Any> {
    if comfy { Box::new(Wolf {}) }
    else { Box::new(Lizard {}) }
}

Box<T> 只是一个指针,大小是已知的(64 位系统上 8 字节)。我们把 Wolf 或 Lizard 放在堆上,只返回指向它的指针。

dyn Any 只承诺有 type_id() 方法,不承诺有 greet()。我们可以:

// 用 type_id 查看类型
println!("We got a {:?}", pet.type_id());

// 或者用 downcast_ref 降型
if let Some(wolf) = pet.downcast_ref::<Wolf>() {
    wolf.greet();
} else if let Some(lizard) = pet.downcast_ref::<Lizard>() {
    lizard.greet();
}

但这样我们要求的比实际需要的少。我们需要的是承诺返回有 greet 方法的东西

自定义 Greet trait:正确的解法

trait Greet {
    fn greet(&self);
}

impl Greet for Wolf {
    fn greet(&self) { println!("awoooo"); }
}

impl Greet for Lizard {
    fn greet(&self) { println!("*chirp chirp*"); }
}

fn acquire_pet(comfy: bool) -> Box<dyn Greet> {
    if comfy { Box::new(Wolf {}) }
    else { Box::new(Lizard {}) }
}

fn main() {
    let comfy = ask_comfy_preference();
    let pet = acquire_pet(comfy);
    pet.greet();  // 正常运行!
}

多 trait bound 的陷阱

现在想要一个函数,克隆宠物三次再分别打招呼:

fn greet_clones<P>(pet: &P)
where
    P: Clone + Greet,
{
    for _ in 0..3 {
        let clone = pet.clone();
        clone.greet();
    }
}

对于已知具体类型的值,这没问题:

#[derive(Clone)]
struct Wolf {}

let wolf = Wolf {};
greet_clones(&wolf);  // 正常!

但如果用 acquire_pet 返回的 trait 对象:

let pet = acquire_pet(ask_comfy_preference());
greet_clones(pet.as_ref());
// error[E0277]: the trait bound `dyn Greet: std::clone::Clone` is not satisfied

因为 acquire_pet 只承诺返回实现了 Greet 的东西,没有承诺实现了 Clone!即便它实际上只返回 WolfLizard(两者都实现了 Clone)。

可以告诉 Rust 更多意图——但方式是有限制的:

// 不行:
fn acquire_pet(comfy: bool) -> Box<dyn Greet + Clone> { ... }
// error[E0225]: only auto traits can be used as additional traits in a trait object

// 也不行:
trait GreetClone: Greet + Clone {}
fn acquire_pet(comfy: bool) -> Box<dyn GreetClone> { ... }
// error[E0038]: the trait `GreetClone` cannot be made into an object
//   ...because it requires `Self: Sized`

这就引出了下一节要解释的根本原因。


四、Unsized 类型与 trait 对象

为什么 Clone 不能做 trait object

尝试自己写一个克隆 trait:

trait MyClone {
    fn my_clone(&self) -> Self;
}

my_clone 接收 &self(指针,大小已知),但返回 Self。如果 Wolf 实现了 MyClonewolf.my_clone() 返回一个 Wolf。具体类型的大小是知道的。

但如果类型是被隐藏的——你只知道 box 里面实现了 MyClone

impl MyClone for Wolf {
    fn my_clone(&self) -> Self { Self {} }
}

fn main() {
    let pet = Box::new(Wolf {}) as Box<dyn MyClone>;
    let pet2 = pet.my_clone();  // 应该返回什么?大小是多少?
}

编译器的错误:

error[E0038]: the trait `MyClone` cannot be made into an object
...because method `my_clone` references the `Self` type in its return type

error[E0277]: the size for values of type `dyn MyClone` cannot be known at compilation time
...all local variables must have a statically known size

本质问题:my_clone 返回 Self,而当类型被隐藏成 dyn MyClone 时,这个 Self 是什么大小,编译期不知道。

MyCloneSized 约束:

trait MyClone: Sized {
    fn my_clone(&self) -> Self;
}

依然无法做 trait object——Sized 要求明确确认"我知道大小",而 dyn T 天然是 unsized 的,矛盾。

关键是:my_clone 依然可以调用,只是不能通过 trait object 调用:

// 这些都没问题:
let pet = Wolf {};
let pet2 = pet.my_clone();

let pet = Box::new(Wolf {});
let pet2 = pet.my_clone();

// 只有这个不行:
let pet: Box<dyn MyClone> = Box::new(Wolf {});
let pet2 = pet.my_clone();  // 错误!

dyn-clone 的底层技巧

想要绕过这个限制,需要动用一些高级技巧。完整解法来自 dyn-clone crate:

第一步:MyClone 改成不返回 Self,而是返回裸指针:

trait MyClone {
    unsafe fn clone_ptr(&self) -> *mut ();
}

impl MyClone for Wolf {
    unsafe fn clone_ptr(&self) -> *mut () {
        Box::into_raw(Box::new(self.clone())) as _
    }
}

impl MyClone for Lizard {
    unsafe fn clone_ptr(&self) -> *mut () {
        Box::into_raw(Box::new(self.clone())) as _
    }
}

第二步: 写一个扩展 trait,自动为所有实现了 MyClone 的类型(包括 unsized 类型)提供 clone_box 方法:

trait MyCloneExt {
    fn clone_box(&self) -> Box<Self>;
}

impl<T> MyCloneExt for T
where
    T: MyClone + ?Sized,
{
    fn clone_box(&self) -> Box<Self> {
        let mut fat_ptr = self as *const Self;
        unsafe {
            let data_ptr = &mut fat_ptr as *mut *const T as *mut *mut ();
            assert_eq!(*data_ptr as *const (), self as *const T as *const ());
            *data_ptr = <T as MyClone>::clone_ptr(self);
        }
        unsafe { Box::from_raw(fat_ptr as *mut Self) }
    }
}

第三步: 现在 MyClone 不再引用 Self,可以安全地做 trait object 了:

trait GreetClone: Greet + MyClone {}

fn acquire_pet(comfy: bool) -> Box<dyn GreetClone> {
    if comfy { Box::new(Wolf {}) }
    else { Box::new(Lizard {}) }
}

fn greet_clones<P>(pet: &P)
where
    P: MyClone + Greet + ?Sized,
{
    for _ in 0..3 {
        let clone = pet.clone_box();
        clone.greet();
    }
}

fn main() {
    let pet = acquire_pet(ask_comfy_preference());
    greet_clones(pet.as_ref());
}
$ cargo run --quiet
Do you like comfy pets? (yes or no)
yes
awoooo
awoooo
awoooo

作者强调:你不需要自己想出这些技巧。他之所以展示这个特别繁琐的例子,是要证明:真的,这不是你的问题,是 Rust 就这样。如果需要这类功能,直接用 dyn-clone crate,不需要自己造轮子。

Rust 需要确信不变量不会被违反,需要说服它你的代码是正确的(sound)。有时候这很棘手,棘手到需要专门的 crate 来处理。特别是数据结构,在 Rust 里格外难以实现——这是习惯了在其他语言里随手自己写容器的有经验开发者最先碰壁的地方。


五、生命周期

生命周期是贯穿整个 Rust 语言的核心概念,也是让人最头疼的部分。每个人对生命周期的理解都需要自己的时间——没有一种解释能对所有人奏效。作者说他可以用整个职业生涯来发明各种解释,还是会有人不理解。

这次,他从"你比 Rust 更聪明"这个角度来解释。

C 多线程共享状态

一个 C 程序,用 pthreads 让两个线程共同操作同一个全局 struct:

// 一个全局的 State struct
typedef struct State { int a; int b; } State;
State state = { .a = 3, .b = 3 };

void *thread_a(void *arg) {
    while (state.a > 0) {
        state.a--;
        printf("a = %d\n", state.a);
    }
    return NULL;
}

void *thread_b(void *arg) {
    while (state.b > 0) {
        state.b--;
        printf("b = %d\n", state.b);
    }
    return NULL;
}
$ gcc -Wall main.c -o main -lpthread && ./main
a = 3
b = 3
b = 2
a = 2
b = 1
a = 1

没问题。

用 unsafe Rust 做字面翻译,使用裸指针——也能编译和运行。但这是 unsafe 的,一旦访问模式改变,真正的数据竞争就会出现,输出可能是:

a = -9808
b = 32767

完全随机,完全损坏。

Rust 借用检查器的目的

Rust 的借用检查器阻止这类问题在安全代码里发生。它追踪每一个引用的生命周期,确保:

  • 没有悬空引用(dangling reference):引用不能活得比它指向的数据更长
  • 没有数据竞争(data race):不能同时有一个可变引用和任何其他引用

生命周期不是你加进去的限制,而是你向编译器描述引用实际上有多长寿命。当引用关系变得复杂,必须显式写出。

重新思考状态结构

Rust 里有一个重要的思维转变:不要把"状态"想成一个整体。在很多 OOP 语言里,你会有一个大 class(或大 struct)把所有东西都放进去,然后到处传递。

在 Rust 里,正是因为合法程序的集合被 Rust 的规则(生命周期、marker trait 等)严格限制,往往需要重新构思程序的结构才能让它通过编译。这是"用 Rust 思考"的本质部分。

在很多情况下,把应用程序状态拆分成几个独立的 struct 非常有帮助。粒度越细,越容易表达程序实际需要的生命周期约束,借用检查器才能通过。


结语:鼓励的话

作者用这样一段话收尾:

即使你拼命学 Rust,认真对待,很可能还是要花一段时间(和几次尝试)才能"懂了"。有没有什么正常的时间表?没有。这会依赖你有多少低层编程经验,你花多少时间,以及你愿意接触多少底层概念。

当你觉得沮丧时,记住:学 Rust 是困难的。它要求你描述其他语言从来不要求你描述的事情。它是一门极少数能让非常有经验的程序员感觉自己是新手的语言——这正是因为它在要求你思考你以前从来不需要思考的事情。

但最终,你会上手的。而那段时间,确实是值得的。


写在最后

这篇文章的核心洞察,用一句话概括:

在其他语言里,你用脑子记住的那些规则("这个函数只能传数字"、"这个指针可能是 null,调用前要检查"),在 Rust 里要写进代码里。

这不是多余的啰嗦,而是把"我在脑子里维护的不变量"变成"编译器帮我强制执行的不变量"。代价是更高的学习曲线和更多的显式标注;收益是一整类运行时错误从源头消失。

学 Rust 学到崩溃,真的不是你的问题。


参考链接