强类型的诅咒,还是 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。先活下来,再慢慢理解。

相关推荐
飘尘1 小时前
前端转全栈(Java 后端)必须要知道的:开发中的锁机制与分布式并发控制
前端·后端·全栈
苍何1 小时前
清华团队做了个具身智能大脑,有点东西!
后端
用户8356290780511 小时前
Python 操作 PDF 附件:添加、查看与管理指南
后端·python
神奇小汤圆3 小时前
接口响应慢到崩溃?CompletableFuture 并行编排让效率提升 3 倍
后端
程序员cxuan4 小时前
GPT-5.6 还不发布?不过大家可以先看看 Codex 的白皮书。
人工智能·后端·程序员
妙码生花4 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(八):设计管理员模型、热重载配置
前端·后端·go
ServBay4 小时前
拒绝当二等公民,Windows 开发者如何无痛开启 Claude Code 本地全栈运维?
后端·ai编程·mcp
用户34232323763174 小时前
从数据源到仪表盘——全链路端到端实战整合
后端