本文是对 The curse of strong typing 的整理与翻译。
内容结构概览
- 故事开场:公司突然决定新项目都用 Rust,于是主角面对一堆编译错误。
- 强类型的第一击 :
2 * 3.14159265在 Rust 里不通过,因为整数和浮点数不是同一类型。 - 整数、浮点数和溢出 :Go 的整数会溢出,JavaScript 的
number是 64 位浮点,Python 的整数能自动变大。 - Rust 数字字面量默认规则 :整数默认
i32,浮点默认f64,也可以用后缀指定u8/u64/f32等。 - 类型推断 :Rust 会根据调用目标推断字面量类型,但不会随便把变量从
u32变成u64。 as、into、try_into的差异 :as可以截断,into只用于无损转换,try_into用于可能失败的转换。parse为什么需要类型标注 :字符串"1234"可以解析成很多类型,编译器不能凭空知道你要什么。- 泛型入门 :
fn show<T>(a: T)能接收任何类型,但也因为太泛,无法对T做具体操作。 - trait bound :
T: Display表示"这个类型必须能被格式化输出"。 - enum 作为 tagged union:一个 enum 可以安全表达"要么是 char,要么是 int"。
impl Trait的两种位置:参数位置表示泛型简写,返回位置表示某个隐藏但固定的具体类型。- 为什么返回
impl Display不能有两个分支类型:返回类型必须是同一个具体类型,不是"任意实现 Display 的类型"。 - 动态大小类型 DST :
dyn Trait、[T]、str本身大小未知,必须通过引用、Box、Arc 等指针间接使用。 - fat pointer 与 vtable :
&dyn Display、Box<dyn Display>通常是 data pointer + vtable pointer。 - monomorphization vs dynamic dispatch:泛型会为具体类型生成版本,trait object 则通过 vtable 动态分发。
- 读懂类型签名 :
Vec<T>、&[T]、&mut [T]表达了所有权、只读借用、可变借用的不同能力。 - 所有权与借用:Rust 阻止 move 后继续使用、阻止多个可变借用、阻止迭代期间修改底层数据。
- 闭包 :闭包是代码加捕获环境,Rust 用
Fn/FnMut/FnOnce表达不同调用能力。 - async 的痛苦入口 :Future 不会自动执行,必须被 runtime poll;
tokio::fs::write不 await 就什么也不发生。 Future::poll、Pin、Waker:async 底层和自引用状态机有关,因此出现Pin<&mut Self>。- async trait method 的历史限制 :当时 trait 里不能直接写 async fn,于是要借助 associated type、boxed future、GAT 或
async-trait。 - hyper 的
Connecttrait 案例 :为了同时支持 TCP 和 Unix socket connector,需要处理Service<Uri>、associated type、BoxFuture、Send + 'static、trait object 等一整套组合。 - 高阶生命周期约束 HRTB :
for<'a>表示"对任意生命周期都成立",用于避免把生命周期绑死。 - 最终态度 :Rust 类型系统确实痛苦,但很多时候它是在防止更严重的错误;真正的生存技巧是理解约束,也知道什么时候用
clone、Arc、Box、Box::pin逃生。
这篇文章的标题叫 The curse of strong typing,直译是"强类型的诅咒"。
这个标题很像一个刚被 Rust 编译器教育过的人会说的话。你只是想乘个数,它告诉你整数和浮点数不能直接相乘;你只是想把 u64 塞进 u32,它告诉你可能放不下;你只是想返回"一个能 Display 的东西",它告诉你两个分支必须是同一个具体类型;你只是想写个 async trait method,它把 Future、Pin、生命周期、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());
这里就引出 as 和 into 的区别。
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 不知道你要什么数字。你要 u8?u32?i64?f64?Ipv4Addr?任何实现了 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 的类型太多了。char、i64、String、各种自定义类型都可能实现 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、&str、Vec<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 不一定需要深刻理解 RawWaker、Context 和 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 借用了 self 和 buf,它不是 '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。Send、Sync 这类 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_ok、map_err、boxed() 这类 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 变量转换成 u64。as 可以强制转换,但可能截断;into() 只用于保证无损的 From/Into 转换;try_into() 用于可能失败的 TryFrom/TryInto 转换。字符串 parse() 也必须知道目标类型,因为同一个字符串可以解析成很多种东西。这里的核心是:转换的风险应该出现在 API 形状里,而不是被语言悄悄吞掉。
之后,文章进入泛型、enum 和 trait。泛型函数 fn show<T>(x: T) 能接收任意类型,但也因为太泛,不能对 T 做什么。加上 T: Display 后,函数才能打印它。enum 则用于表达"多种可能之一",比如 Either::Char 或 Either::Int,并通过 pattern matching 安全访问内部值。实现 From<char>、From<i64> 可以让 .into() 把不同输入转换成同一个 enum。
然后是 impl Trait、动态大小类型和 trait object。参数位置的 impl Display 是泛型简写;返回位置的 impl Display 表示某个隐藏但固定的具体类型,所以不能在两个分支分别返回 char 和 i64。如果确实要返回多种具体类型,可以用 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 用 Fn、FnMut、FnOnce 表达不同调用能力。闭包也可以作为 trait object 被装进 Box<dyn Fn()>,从而把多个不同闭包放进同一个集合。
async 部分是整篇最痛的地方。Future 不会自己执行,不 .await 的 tokio::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>,返回的连接必须同时实现 AsyncRead、AsyncWrite 和 Connection。因为 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。先活下来,再慢慢理解。