强类型的诅咒,还是 Rust 类型系统的生存指南

本文是对 The curse of strong typing 的整理与翻译。

内容结构概览

  1. 故事开场:公司突然决定新项目都用 Rust,于是主角面对一堆编译错误。
  2. 强类型的第一击2 * 3.14159265 在 Rust 里不通过,因为整数和浮点数不是同一类型。
  3. 整数、浮点数和溢出 :Go 的整数会溢出,JavaScript 的 number 是 64 位浮点,Python 的整数能自动变大。
  4. Rust 数字字面量默认规则 :整数默认 i32,浮点默认 f64,也可以用后缀指定 u8/u64/f32 等。
  5. 类型推断 :Rust 会根据调用目标推断字面量类型,但不会随便把变量从 u32 变成 u64
  6. asintotry_into 的差异as 可以截断,into 只用于无损转换,try_into 用于可能失败的转换。
  7. parse 为什么需要类型标注 :字符串 "1234" 可以解析成很多类型,编译器不能凭空知道你要什么。
  8. 泛型入门fn show<T>(a: T) 能接收任何类型,但也因为太泛,无法对 T 做具体操作。
  9. trait boundT: Display 表示"这个类型必须能被格式化输出"。
  10. enum 作为 tagged union:一个 enum 可以安全表达"要么是 char,要么是 int"。
  11. impl Trait 的两种位置:参数位置表示泛型简写,返回位置表示某个隐藏但固定的具体类型。
  12. 为什么返回 impl Display 不能有两个分支类型:返回类型必须是同一个具体类型,不是"任意实现 Display 的类型"。
  13. 动态大小类型 DSTdyn Trait[T]str 本身大小未知,必须通过引用、Box、Arc 等指针间接使用。
  14. fat pointer 与 vtable&dyn DisplayBox<dyn Display> 通常是 data pointer + vtable pointer。
  15. monomorphization vs dynamic dispatch:泛型会为具体类型生成版本,trait object 则通过 vtable 动态分发。
  16. 读懂类型签名Vec<T>&[T]&mut [T] 表达了所有权、只读借用、可变借用的不同能力。
  17. 所有权与借用:Rust 阻止 move 后继续使用、阻止多个可变借用、阻止迭代期间修改底层数据。
  18. 闭包 :闭包是代码加捕获环境,Rust 用 Fn / FnMut / FnOnce 表达不同调用能力。
  19. async 的痛苦入口 :Future 不会自动执行,必须被 runtime poll;tokio::fs::write 不 await 就什么也不发生。
  20. Future::pollPinWaker :async 底层和自引用状态机有关,因此出现 Pin<&mut Self>
  21. async trait method 的历史限制 :当时 trait 里不能直接写 async fn,于是要借助 associated type、boxed future、GAT 或 async-trait
  22. hyper 的 Connect trait 案例 :为了同时支持 TCP 和 Unix socket connector,需要处理 Service<Uri>、associated type、BoxFutureSend + 'static、trait object 等一整套组合。
  23. 高阶生命周期约束 HRTBfor<'a> 表示"对任意生命周期都成立",用于避免把生命周期绑死。
  24. 最终态度 :Rust 类型系统确实痛苦,但很多时候它是在防止更严重的错误;真正的生存技巧是理解约束,也知道什么时候用 cloneArcBoxBox::pin 逃生。

这篇文章的标题叫 The curse of strong typing,直译是"强类型的诅咒"。

这个标题很像一个刚被 Rust 编译器教育过的人会说的话。你只是想乘个数,它告诉你整数和浮点数不能直接相乘;你只是想把 u64 塞进 u32,它告诉你可能放不下;你只是想返回"一个能 Display 的东西",它告诉你两个分支必须是同一个具体类型;你只是想写个 async trait method,它把 FuturePin、生命周期、associated type、GAT、boxed future 一整套概念都扔到你脸上。

如果只从情绪上看,Rust 确实像是在诅咒你。

但这篇文章真正讲的不是"Rust 很烦",而是:Rust 类型系统为什么这么烦?这些烦人的限制背后,到底是在保护什么?什么时候它真的能防止严重错误?什么时候它只是目前语言设计还没完全解决的边界?以及最重要的:当你被迫写 Rust,还没爱上它之前,怎么活下来?

文章用一段虚构对话展开:某个公司突然决定,从现在开始所有新东西都要用 Rust。主角并不反对进步,也不反对强类型,只是没想到自己会立刻被一大堆编译错误淹没。旁边有一只很懂 Rust 的熊,负责解释每一个看起来荒谬的错误。于是,一篇关于 Rust 类型系统的长文,就从"为什么 2 * 3.14159265 不工作"开始,一路讲到 async、trait object、hyper connector 和高阶生命周期约束。


一、第一刀:整数和浮点数不是一回事

开头的例子非常简单:

rust 复制代码
fn main() {
    println!("tau = {}", 2 * 3.14159265);
}

在 JavaScript 里,这当然可以跑:

js 复制代码
console.log(2 * 3.14159265);

但 Rust 不让它通过。因为 2 是整数,3.14159265 是浮点数。Rust 不会自动把整数变成浮点数。

这乍看很烦。人脑当然知道 2 * 3.14159265 想表达什么。为什么编译器不能"聪明一点"?

文章的回答是:因为真实机器上,整数和浮点数确实不是一回事。

整数有固定 bit 宽度。比如 64 位整数最多只能表示一定范围内的值。如果一直乘以 10,Go 里的 int 最终会溢出,结果从正常数字突然变成另一个看似随机的数字,甚至变成负数。

JavaScript 的 number 则是 64 位浮点数。它可以表示很大的数,也可以表示小数,但代价是精度。经典例子是:

js 复制代码
0.1 + 0.2

结果不是精确的 0.3,而是 0.30000000000000004

Python 又是另一种路线。Python 的整数可以自动变成任意精度大整数,所以小整数算起来很自然,大整数也不会像 Go 那样溢出。但这种便利也有成本:它需要更复杂的运行时表示和运算逻辑。

Rust 的路线接近 C/C++ 这类系统语言:整数和浮点数都分很多具体类型。比如:

text 复制代码
u8, u16, u32, u64, u128
i8, i16, i32, i64, i128
f32, f64

整数默认是 i32,浮点默认是 f64。你也可以写:

rust 复制代码
1_u8
1_u64
1_i128
1_f32
2_f64

所以原来的代码要改成:

rust 复制代码
println!("tau = {}", 2.0 * 3.14159265);

或者:

rust 复制代码
println!("tau = {}", 2_f64 * 3.14159265);

这不是 Rust 故意为难你,而是它不愿意在类型转换上偷偷替你做决定。因为很多自动转换看起来方便,最后会在边界上变成 bug。


二、类型推断不是没有类型

Rust 有类型推断。你不需要到处写类型标注。但这不代表 Rust 没有类型,也不代表类型可以随便变。

比如:

rust 复制代码
fn takes_u64(v: u64) {}

fn main() {
    takes_u64(230984423857928735);
}

这个可以编译。因为编译器从 takes_u64 的签名知道,这个数字字面量最终应该是 u64

但如果你先把它放进一个变量:

rust 复制代码
let val = 280_u32;
takes_u64(val);

这不通过。val 已经是 u32。Rust 不会因为目标函数需要 u64,就偷偷把它转换过去。

你可以显式转换:

rust 复制代码
takes_u64(val as u64);

或者:

rust 复制代码
takes_u64(val.into());

这里就引出 asinto 的区别。

as 很直接,也很危险。它可以做很多转换,包括会截断的转换。比如把一个很大的 u64 转成 u32

rust 复制代码
let a = 2930482035982309_u64;
let b = a as u32;

这不会报错。它会直接截断高位,只留下低 32 位。程序继续跑,但值已经不是你以为的值。

into() 更谨慎。u32 -> u64 是安全无损转换,所以可以 into()。但 u64 -> u32 不一定安全,因为不是所有 u64 都能塞进 u32,所以没有 From<u64> for u32,也就不能直接 into()

这种时候要用 try_into()

rust 复制代码
let small: u64 = 48_000;
let x: u32 = small.try_into().unwrap();

如果数值放不下,就会返回错误。你可以选择 unwrap() 让它 panic,也可以匹配 Result 正常处理。

这就是强类型真正想表达的东西:

text 复制代码
能保证无损的转换,用 From / Into。
可能失败的转换,用 TryFrom / TryInto。
可能截断的裸转换,用 as,但你要知道自己在做什么。

Rust 不阻止你做危险事,但它会让危险事看起来像危险事。


三、parse 为什么也要类型标注

再看字符串解析:

rust 复制代码
let val = "1234".parse();

很多人第一次写会觉得:这还不明显吗?"1234" 明显是数字。

但 Rust 不知道你要什么数字。你要 u8u32i64f64Ipv4Addr?任何实现了 FromStr 的类型都可能是目标。

所以要写:

rust 复制代码
let val: u64 = "2930482035982309".parse().unwrap();

如果改成:

rust 复制代码
let val: u32 = "2930482035982309".parse().unwrap();

它会解析失败,因为这个数放不进 u32

这和 as 形成鲜明对比。parse::<u32>() 会检查范围,失败就报错;as u32 会直接截断。

文章借这个例子强调:类型系统不是只在编译期"烦你"。它也会影响运行时错误边界。一个转换到底是保证成功、可能失败、还是强制截断,应该通过 API 和类型表达出来。


四、泛型:能接收任何类型,不等于能做任何事

接下来进入泛型。

最简单的泛型函数:

rust 复制代码
fn show<T>(value: T) {
    todo!()
}

它能接收任何类型。但也正因为它能接收任何类型,函数内部其实几乎什么都不能做。你不知道 T 能不能打印,能不能相加,能不能比较,能不能 clone。

如果你想打印它,就要加 trait bound:

rust 复制代码
use std::fmt::Display;

fn show<T: Display>(value: T) {
    println!("{value}");
}

或者用更短的写法:

rust 复制代码
fn show(value: impl Display) {
    println!("{value}");
}

这表示:参数可以是任意具体类型,但这个类型必须实现 Display

有一个细节很重要:

rust 复制代码
fn show<T>(a: T, b: T) {}

这里两个参数必须是同一个类型。show(5, 7) 可以,show("a", "b") 也可以,但 show(42, "aha") 不行。

如果你要两个不同类型,就要两个类型参数:

rust 复制代码
fn show<A, B>(a: A, b: B) {}

泛型不是"动态类型"。它不是运行时随便接收任何东西再看情况处理,而是编译期为每个具体类型组合生成对应版本,或者至少在类型检查时确定所有约束。


五、enum:安全的"多种可能"

如果一个值真的可能是多种类型之一,Rust 常用 enum 表达。

比如一个值要么是数字,要么是字符:

rust 复制代码
enum Either {
    Number(i64),
    Character(char),
}

使用时通过 pattern matching:

rust 复制代码
match value {
    Either::Number(n) => println!("{n}"),
    Either::Character(c) => println!("{c}"),
}

这和 C 的 union 不一样。Rust enum 是 tagged union,也就是每个值内部带着"当前是哪种变体"的标签。你不能不检查变体就直接把一个 Character 当成 Number 读。

这就是类型系统在保护你:它让"当前是哪一种情况"成为值的一部分,而不是靠程序员自己记住。

接下来,如果想让 'C'.into()64.into() 都变成 Either,可以实现 From

rust 复制代码
impl From<char> for Either {
    fn from(c: char) -> Self {
        Either::Character(c)
    }
}

impl From<i64> for Either {
    fn from(n: i64) -> Self {
        Either::Number(n)
    }
}

于是:

rust 复制代码
show('C'.into());
show(64_i64.into());

就可以工作。

这也解释了前面 into() 的来源:你实现的是 From<T> for U,Rust 自动提供 Into<U> for T


六、impl Trait:参数位置和返回位置不是一回事

Rust 里 impl Trait 有两种常见位置。

参数位置:

rust 复制代码
fn show(value: impl Display) {
    println!("{value}");
}

它基本是泛型参数的简写:

rust 复制代码
fn show<T: Display>(value: T) {
    println!("{value}");
}

但返回位置:

rust 复制代码
fn get_value() -> impl Display {
    'C'
}

意思不一样。它表示:这个函数返回某个具体类型,这个具体类型实现了 Display,但调用者不知道具体是什么。它不是说"这个函数可以在不同分支返回任意实现 Display 的类型"。

所以这个不行:

rust 复制代码
fn get_char_or_int(flag: bool) -> impl Display {
    if flag {
        'C'
    } else {
        64
    }
}

因为一个函数的返回类型必须是同一个具体类型。这里一个分支是 char,另一个分支是整数。虽然二者都实现 Display,但它们不是同一个类型。

解决办法有几种。

第一,用 enum:

rust 复制代码
enum Either {
    Char(char),
    Int(i64),
}

然后给 Either 实现 Display

第二,用 trait object:

rust 复制代码
fn get_char_or_int(flag: bool) -> Box<dyn Display> {
    if flag {
        Box::new('C')
    } else {
        Box::new(64)
    }
}

这就进入动态分发了。


七、动态大小类型:为什么 dyn Display 不能单独存在

dyn Display 是 trait object,但它本身是动态大小类型。原因是:实现 Display 的类型太多了。chari64String、各种自定义类型都可能实现 Display,它们大小不同。编译器不能在编译期知道一个裸 dyn Display 到底多大。

所以你不能直接拥有一个裸的 dyn Display。你必须通过某种指针使用它:

rust 复制代码
&dyn Display
Box<dyn Display>
Arc<dyn Display>
Rc<dyn Display>

这些指针本身大小是确定的。对于 trait object,通常是 fat pointer:一个指向数据,一个指向 vtable。

vtable 可以理解成"这个具体类型针对某个 trait 的函数表"。比如 Display 要求 fmt 方法,那么 vtable 里就有对应具体类型的 fmt 函数指针。

这解释了两种多态:

text 复制代码
泛型:编译期 monomorphization,为具体类型生成版本。
trait object:运行时 dynamic dispatch,通过 vtable 调用。

泛型通常更容易优化,但会生成多个版本。trait object 会多一层间接调用,但可以把不同具体类型放进同一种容器里,或者从同一个函数返回。


八、str[T] 也是动态大小类型

动态大小类型不只有 dyn Trait

str 本身也是动态大小类型。一个字符串切片有多长,编译期不知道。所以我们平时用的是:

rust 复制代码
&str
String
Box<str>

其中 &str 也是 fat pointer:指针 + 长度。

[T] 也是动态大小类型。一个切片有几个元素,编译期不知道。所以我们用:

rust 复制代码
&[T]
Vec<T>
Box<[T]>
Arc<[T]>

数组 [T; N] 大小已知,因为 N 是类型的一部分。比如 [u8; 5][u8; 10] 是不同类型。切片 [T] 大小未知,只能通过引用或智能指针使用。

这也是 Rust 类型系统让人一开始痛苦的地方:String&strVec<u8>&[u8]Box<[u8]> 看起来都像"字符串"或"字节数组",但它们表达的所有权、大小、可变性、分配方式都不同。

这种区分是负担,也是力量。


九、读类型签名:它告诉你函数能做什么

文章接着强调:Rust 类型签名不是装饰,而是文档,而且是编译器会检查的文档。

比如:

rust 复制代码
fn double(a: Vec<i32>) -> Vec<i32>

这个函数拿走了 Vec 的所有权。调用后,原来的变量不能再使用。

如果只是读取:

rust 复制代码
fn double(a: &[i32]) -> Vec<i32>

更好。它只借用一个只读 slice。调用方可以传数组、Vec、Box slice,只要能借出 &[i32] 就行。函数也不能修改输入。

如果要原地修改:

rust 复制代码
fn double(a: &mut [i32])

这表示函数需要独占可变借用。调用期间,不能有其他可变借用,也不能同时读写同一块数据。

文章用 JavaScript 的数组例子说明了这点。在 JS 里,一个函数可能看起来返回新数组,实际却原地修改了输入。除非你读实现,否则只看函数签名很难知道它有没有修改参数。

Rust 把这些信息放进签名:

text 复制代码
Vec<T>       拿走所有权
&[T]         只读借用
&mut [T]     可变借用

这不是语法洁癖。它直接影响并发和正确性。

比如多个线程同时只读同一个 slice,可以。多个线程同时拿 &mut,不行。迭代期间修改底层数组,也不行。编译器会拦住你。


十、生命周期:不是为了折磨人,是为了描述借用关系

生命周期是 Rust 最容易让人崩溃的部分之一。

文章没有把生命周期讲成形式化理论,而是从"借用能活多久"解释。比如迭代一个数组时,迭代器内部持有对数组的借用。只要迭代器还活着,你就不能修改数组:

rust 复制代码
let mut a = [1, 2, 3, 4, 5];
let mut iter = a.iter();

a[2] = 42; // 不允许
dbg!(iter.next());

因为 iter 还可能继续读数组。你同时修改数组,会破坏借用规则。

生命周期本质是在表达:某个引用必须在它指向的数据有效期间使用,而且不能和不兼容的借用重叠。

当你习惯动态语言时,这看起来像编译器多管闲事。但从并发和内存安全角度看,它是在提前阻止一大类非常难查的问题。


十一、闭包:代码加上捕获环境

闭包看起来像匿名函数:

rust 复制代码
a.iter().map(|x| x * 2).collect()

但闭包不只是函数。它可以捕获环境:

rust 复制代码
let factor = 10;
a.iter().map(|x| x * factor).collect()

这里 factor 被闭包捕获了。也就是说,闭包是"代码 + 环境"。

Rust 用几个 trait 表达闭包的调用能力:

text 复制代码
Fn      可以通过共享引用调用,不消耗、不修改捕获环境
FnMut   需要可变访问捕获环境
FnOnce  调用时可能消耗捕获环境,只能调用一次

比如一个闭包只是读 factor,它可以是 Fn。如果它修改捕获的计数器,就需要 FnMut。如果它 move 走捕获的值,就可能只能是 FnOnce

你也可以把闭包装进 trait object:

rust 复制代码
Vec<Box<dyn Fn()>>

这样可以把多个不同闭包放进同一个集合里,因为闭包本身每个都有不同匿名类型,必须通过 trait object 做类型擦除。

这和前面的 Box<dyn Display> 是同一类思想:不同具体类型,共享同一个 trait 接口。


十二、async:Future 不会自己运行

文章进入 async 后,语气突然变得痛苦。

先看同步写文件:

rust 复制代码
std::fs::write("/tmp/hi", "hi!\n");

这会真的写文件。

但如果写成:

rust 复制代码
tokio::fs::write("/tmp/bye", "bye!\n");

而没有 .await,它什么都不会做。因为 tokio::fs::write 返回的是一个 Future。Future 是惰性的,不会自己运行。

要让它执行,需要 async runtime poll 它:

rust 复制代码
#[tokio::main]
async fn main() {
    tokio::fs::write("/tmp/bye", "bye!\n").await.unwrap();
}

Future 的核心 trait 大概是:

rust 复制代码
trait Future {
    type Output;

    fn poll(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output>;
}

这里又出现几个吓人的东西:

text 复制代码
Poll::Ready
Poll::Pending
Context
Waker
Pin<&mut Self>

Future 被 poll 时,如果可以继续推进,就继续;如果暂时不能完成,比如 I/O 没准备好,就返回 Pending,并通过 Waker 告诉 runtime:等条件满足时再来 poll 我。

Pin<&mut Self> 则和 self-referential future 有关。async block 会被编译成状态机。状态机内部可能保存跨 .await 的局部变量和引用。如果这个状态机被随便移动,内部引用可能失效。Pin 的目的就是保证某些 Future 被 poll 后不再移动。

文章说,日常使用 async 不一定需要深刻理解 RawWakerContext 和 pinning。很多时候,知道几个逃生技巧就够了:

text 复制代码
遇到 Unpin 问题,可以先 Box::pin。
遇到生命周期太麻烦,可以先 clone。
不能 clone,可以 Arc。
需要共享可变,可以 Arc<Mutex<T>>。

这不一定是最优解,但能让你活下来。


十三、async trait method:为什么当时这么难

文章写作时,Rust 还不能在 trait 里直接写:

rust 复制代码
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
}

编译器会拒绝。原因很复杂,但可以从返回类型理解。

async fn 本质上会返回一个 Future。问题是,这个 Future 的类型由函数体生成,而且经常会借用 self 和参数。放到 trait 里后,每个实现者都可能返回不同的 Future 类型,还要表达这些 Future 和借用之间的生命周期关系。

一种写法是用 associated type:

rust 复制代码
trait AsyncRead {
    type Future: Future<Output = io::Result<usize>>;

    fn read(&mut self, buf: &mut [u8]) -> Self::Future;
}

但马上会出问题:这个 Future 借用了 selfbuf,它不是 'static。如果简单写成 Box<dyn Future<...>>,默认会要求 'static,于是生命周期爆炸。

更准确的写法需要 generic associated type:

rust 复制代码
trait AsyncRead {
    type Future<'a>: Future<Output = io::Result<usize>>
    where
        Self: 'a;

    fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> Self::Future<'a>;
}

这在当时还需要 nightly feature。文章把这段写得很崩溃,因为这确实是 Rust async 早期最痛的交界处之一。

后来 Rust 稳定了 async fn in trait,很多日常场景更舒服了。但底层问题仍然存在:async trait method 背后仍然要表达"这个方法返回一个可能借用 self 的 Future"。


十四、hyper 的 Connect trait:所有概念聚到一起

文章后半段用 hyper 的 Connect 相关代码把前面学过的东西串起来。

目标是写一个 SuperConnector:它既能连接普通 TCP HTTP 地址,也能连接 Unix socket 地址,比如 Docker daemon 的 /var/run/docker.sock

这就需要实现 hyper 需要的 connector trait。大致结构是:

text 复制代码
SuperConnector
  - tcp: HttpConnector
  - unix: UnixConnector

根据 URI scheme 判断走哪个 connector。

但返回类型很麻烦。TCP connector 返回一种连接类型,Unix connector 返回另一种连接类型。它们都实现了:

text 复制代码
AsyncRead
AsyncWrite
Connection

我们希望把它们统一成一个返回类型。

直觉上可能会写:

rust 复制代码
Pin<Box<dyn AsyncRead + AsyncWrite + Connection>>

但 Rust 不允许多个非 auto trait 直接组成一个 trait object。SendSync 这类 auto trait 可以叠加,生命周期 bound 也可以叠加,但多个带方法的普通 trait 不能这样直接拼。

解决方法是定义一个新 trait:

rust 复制代码
trait SuperConnection: AsyncRead + AsyncWrite + Connection {}

impl<T> SuperConnection for T
where
    T: AsyncRead + AsyncWrite + Connection
{}

然后使用:

rust 复制代码
Pin<Box<dyn SuperConnection + Send + 'static>>

Future 类型也要处理。hyper 的 Service trait 通常有 associated type:

rust 复制代码
type Future = ...

为了把不同分支的 future 统一起来,可以用 BoxFuture<'static, Result<...>>。再用 map_okmap_errboxed() 这类 combinator,把两个分支都转成同一个 future 类型。

最终才能同时支持:

text 复制代码
unix:///var/run/docker.sock
http://example.org

这个例子非常适合展示 Rust 类型系统的"累积复杂度"。你会同时遇到:

text 复制代码
associated type
trait bound
dynamic dispatch
Pin<Box<...>>
Send + 'static
BoxFuture
Service<Uri>
AsyncRead + AsyncWrite + Connection
错误类型擦除

单独每个概念都能解释,但组合起来会让人觉得自己在读一本书。

文章也承认:这几乎是他见过的最糟糕 Rust 类型组合之一。普通人不一定天天遇到。但了解一次,就知道以后类似错误大概从哪里来。


十五、高阶生命周期约束:for<'a> 到底在说什么

最后一节是 higher-ranked trait bounds,也就是 HRTB。

看一个 trait:

rust 复制代码
trait Transform<'a> {
    fn apply(&self, slice: &'a mut [u8]);
}

如果想写一个函数,接收任意 transform 并应用到 slice:

rust 复制代码
fn apply_transform<T>(slice: &mut [u8], transform: T)
where
    T: Transform,
{
    transform.apply(slice);
}

它不行,因为 Transform 需要生命周期参数。

你可能尝试加 'a

rust 复制代码
fn apply_transform<'a, T>(slice: &'a mut [u8], transform: T)
where
    T: Transform<'a>,
{
    transform.apply(slice);
}

然后会遇到各种生命周期错误。问题在于,你把太多东西绑到同一个 'a 上了:slice 的借用、transform 自己、调用 apply 时对 transform 的借用,都被迫活得一样久。

真正想表达的是:T 不是只对某一个具体生命周期实现 Transform<'a>,而是对任意生命周期都实现。

语法是:

rust 复制代码
fn apply_transform<T>(slice: &mut [u8], transform: T)
where
    T: for<'a> Transform<'a>,
{
    transform.apply(slice);
}

for<'a> 表示"对所有 'a 都成立"。

文章接着又用泛型参数版本展示同样问题:

rust 复制代码
trait Transform<T> {
    fn apply(&self, target: T);
}

如果写:

rust 复制代码
T: Transform<&'a mut [u8]>

那么调用一次可以,调用三次就会出现多个可变借用冲突。因为每次借用都被迫活到同一个 'a。用 HRTB:

rust 复制代码
T: for<'a> Transform<&'a mut [u8]>

就表示每次调用都可以用自己的短生命周期。

HRTB 一开始看起来很吓人,但它解决的问题很具体:

text 复制代码
不要把某个借用生命周期固定死。
我要的是:对任意足够短的生命周期都能工作。

十六、这篇文章到底想让人记住什么

这篇文章表面上讲了很多 Rust 类型系统细节,但真正的主题是"不要被类型错误吓跑"。

强类型确实会让你难受。尤其是 Rust,它不只强类型,还把所有权、借用、生命周期、trait、泛型、async 状态机都塞进类型系统边界里。很多时候,编译器错误不是一个简单的"这里写错了",而是一整套设计约束在提醒你:你想表达的东西不够精确,或者你要求的能力太多,或者你把生命周期绑得太死,或者你想把多个不同具体类型伪装成一个类型。

但这些类型形状有时能防止严重错误:

text 复制代码
整数和浮点不要偷偷混用。
可能截断的转换不要假装安全。
可能失败的 parse 必须处理 Result。
enum 必须匹配变体后才能取值。
move 后不能继续用。
只读借用可以共享,可变借用必须独占。
迭代时不能修改底层数据。
Future 不 await 就不会执行。
非 Unpin 的 Future 不能随便移动。

这就是 Rust 的交易:它挡住很多危险,但你要付出更多类型表达成本。


十七、真正的生存技巧

文章最后给出一种很实际的态度:你不一定要一次性理解所有高级概念。

遇到生命周期痛苦,可以先 clone。

不能 clone,可以 Arc<T>

需要共享可变状态,可以 Arc<Mutex<T>>

需要把多个不同类型放进同一个集合,或者从同一个函数返回,可以 Box<dyn Trait>

遇到 Unpin 问题,可以 Box::pin()

遇到复杂 future,可以先用 BoxFuture

这些不是最极致性能的答案,也不是最优雅的答案。但它们是逃生出口。先让代码表达清楚,再逐步优化,不要一开始就试图写出最抽象、最零成本、最生命周期完美的版本。

文章也提醒:很多 Rust 编译器诊断非常好,尤其是基础类型错误。但越往 async、GAT、HRTB、trait object、associated type 深处走,诊断会迅速变得难读。不是你一个人这样,大家都会痛。

这也是为什么理解一点底层设计会有帮助。不是为了炫技,而是为了在遇到看似任性的限制时,知道它不是凭空出现的。


十八、总结

这篇文章从一个荒诞场景开始:公司突然决定所有新项目都写 Rust,主角被一堆编译错误淹没。第一处错误是 2 * 3.14159265,Rust 不允许整数和浮点数直接相乘。文章借此解释 Rust 为什么区分整数、浮点数和 bit 宽度;Go 的整数会溢出,JavaScript 的 number 是浮点并有精度问题,Python 的整数可以自动变大,而 Rust 选择让这些差异显式化。

接着,文章讲类型推断和转换。Rust 可以根据函数参数推断数字字面量类型,但不会自动把已有 u32 变量转换成 u64as 可以强制转换,但可能截断;into() 只用于保证无损的 From/Into 转换;try_into() 用于可能失败的 TryFrom/TryInto 转换。字符串 parse() 也必须知道目标类型,因为同一个字符串可以解析成很多种东西。这里的核心是:转换的风险应该出现在 API 形状里,而不是被语言悄悄吞掉。

之后,文章进入泛型、enum 和 trait。泛型函数 fn show<T>(x: T) 能接收任意类型,但也因为太泛,不能对 T 做什么。加上 T: Display 后,函数才能打印它。enum 则用于表达"多种可能之一",比如 Either::CharEither::Int,并通过 pattern matching 安全访问内部值。实现 From<char>From<i64> 可以让 .into() 把不同输入转换成同一个 enum。

然后是 impl Trait、动态大小类型和 trait object。参数位置的 impl Display 是泛型简写;返回位置的 impl Display 表示某个隐藏但固定的具体类型,所以不能在两个分支分别返回 chari64。如果确实要返回多种具体类型,可以用 enum,也可以用 Box<dyn Display>dyn Display 本身是动态大小类型,必须通过引用、Box、Arc 等指针使用。trait object 通常是 data pointer 加 vtable pointer,vtable 保存具体类型对 trait 方法的实现。泛型靠 monomorphization,trait object 靠 dynamic dispatch。

文章还解释了 str[T]、slice、数组、Vec<T>Box<[T]>。数组 [T; N] 大小已知,slice [T] 大小未知,&[T] 是指针加长度。Vec<T> 拥有一段可增长内存,通常有指针、长度、容量。&[T] 只是只读借用,不要求来源一定是 Vec,也可以来自数组、Box slice、Arc slice 或另一个 slice。

接下来是所有权、借用和类型签名。Vec<T> 作为参数表示拿走所有权,&[T] 表示只读借用,&mut [T] 表示独占可变借用。Rust 会阻止 move 后继续使用,阻止多个同时的可变借用,阻止迭代器还在使用时修改底层数组。函数签名因此不只是语法,它告诉你函数到底能不能修改输入、会不会拿走输入、能不能并发共享输入。

闭包部分解释了闭包是"代码加捕获环境"。闭包可以捕获外部变量,因此 Rust 用 FnFnMutFnOnce 表达不同调用能力。闭包也可以作为 trait object 被装进 Box<dyn Fn()>,从而把多个不同闭包放进同一个集合。

async 部分是整篇最痛的地方。Future 不会自己执行,不 .awaittokio::fs::write 什么也不会做。Future 由 runtime poll,核心方法是 poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>Poll::Pending 表示暂时不能完成,Waker 负责之后唤醒,Pin 则与 async 状态机和自引用结构有关。文章强调,日常不一定要完全理解 RawWaker 和 pinning,但知道 Box::pin() 是重要逃生口,可以减少很多"为什么这里不是 Unpin"的痛苦。

然后文章讲 async trait method。写作时 Rust 还不能在 trait 里直接写 async fn。要表达"trait 方法返回一个借用 self 和参数的 Future",需要 associated type;但普通 associated type 不足以表达这个 Future 对生命周期的依赖,于是需要 GAT,或者退而求其次使用 boxed future。这里出现 Box<dyn Future + 'a>Pin<Box<...>>Self: 'a 等一整套概念。

hyper 的 Connect 案例把这些概念组合起来:实现一个同时支持普通 TCP 和 Unix socket 的 SuperConnector。它要实现 Service<Uri>,返回的连接必须同时实现 AsyncReadAsyncWriteConnection。因为 trait object 不能直接写多个非 auto trait,需要定义一个新的 supertrait SuperConnection。因为两个分支返回的 future 类型不同,需要用 BoxFuture 统一。因为 hyper 要求 Send + 'static,future 和连接对象也要加对应 bound。最终代码能同时请求 Docker Unix socket 和普通 HTTP URL,但一路上会遇到 Rust 类型系统里一整套高级边界。

最后是高阶生命周期约束 HRTB。for<'a> 表示"对任意生命周期 'a 都成立"。它用于避免把某个借用生命周期固定得过长。比如一个 transform 可以对任何短生命周期的 &mut [u8] 工作,而不是只对某个特定 'a 工作。文章用 Transform<'a>Transform<&'a mut [u8]> 两个例子说明,如果生命周期绑死,调用三次就会出现重复可变借用;用 for<'a> 后,每次调用可以拥有自己的短生命周期。

整篇文章最后的态度很温和:不要被 Rust 吓坏。类型系统有时会阻止严重错误,有时只是因为语言目前的表达能力还不够顺手。Rust 不是完美语言,async 生态尤其会让人痛苦。但大多数时候,你不必一次性变聪明。生命周期卡住了可以 clone,不能 clone 可以 Arc,需要共享可变可以 Arc<Mutex>,异构类型可以 Box,复杂 Future 可以 BoxFuture,Unpin 问题可以 Box::pin。先活下来,再慢慢理解。

相关推荐
程序员cxuan1 小时前
速度太快了!本地可以跑 DeepSeek-V4-Flash 了
人工智能·后端·程序员
阿祖zu2 小时前
芝士就是力量!开源私有化部署与 GitHub 双向同步的个人知识笔记 App
前端·后端·ios
不才不才不不才3 小时前
Spring 源码系列(16): doDispatch 全流程——一次请求的主干链路
java·后端·spring
IT_陈寒3 小时前
SpringBoot自动配置失效?这个隐藏配置坑了我一整晚
前端·人工智能·后端
搜狐技术产品小编20233 小时前
告别“黑盒”与误判:如何用“多智能体对抗辩论”重构内容安全审核系统
后端·多agent
DantyWei3 小时前
controller注册及调用时机
后端
edwarddamon4 小时前
Spring Cloud 配置热更新与 Bean 代理机制梳理
java·后端
码事漫谈4 小时前
我用 Seed Evolving 做了个 AI 小说写作工具
后端
Raas1004 小时前
MAIGateway,魔芋企业级AI网关的FinAPI成本竞争力设计
java·后端·网关·api网关·魔芋ai·finapi·mai gateway
计算机魔术师5 小时前
传统基础架构 - 微服务
大数据·后端·微服务·架构·开发工具·编程语言