📚 Rust 课程系列

  1. 课程概览
  2. 基础语法(一):变量、数据类型与字符串
  3. 切片(Slice):序列的借用视图
  4. 基础语法(二):运算符、表达式与控制流
  5. 函数与输入输出
  6. 所有权、借用与生命周期
  7. 结构体
  8. 枚举
  9. 模式匹配
  10. 类型系统:泛型、trait 与多态(本文)
  11. 集合与容器
  12. 错误处理与 Panic 恢复
  13. 模块、属性与宏
  14. 智能指针、迭代器与闭包
  15. 并发与异步编程
  16. Unsafe Rust 与常用 trait 详解
  17. 工具链、Cargo 与外部 crate
  18. 最佳实践、性能与调试

如果说所有权与生命周期是 Rust 内存安全的「纵向骨架」,那么类型系统就是「横向骨架」–它决定了哪些抽象可以表达、哪些错误能在编译期消灭。Rust 的类型系统把两套通常水火不容的能力缝合在一起:一套是 ML/Haskell 风格的参数化多态(泛型),另一套是 typeclass 风格的特设多态(trait)。前者保证「同一段代码对任意类型都成立」,后者保证「不同类型可以按各自的方式满足同一契约」。两者通过 trait bound 结合,再借由单态化在编译期展开成具体代码,从而实现了「高层抽象 + 零运行时开销」这一在多数语言里只能二选一的目标。

本章是全书的枢纽。前面几章(结构体枚举模式匹配)展示了类型如何「描述数据」,本章则回答类型如何「描述行为」并「安全复用」。主线有四条:泛型如何抽象「与类型无关」的代码、trait 如何抽象「类型相关的行为」、分发如何在静态与动态之间取舍、以及 Sized、型变、PhantomData 这些「管道层」如何让上述抽象在数学上自洽。标准库各 trait 的逐一签名与实现要点见《Rust 标准 Trait 详解》Deref/Drop/From 等常用 trait 的工程用法见《Unsafe Rust 与常用 trait 详解》,本文聚焦语言机制本身。

类型推断

Rust 的类型推断不是单向的「从右向左」,而是双向约束收集:编译器同时从变量的使用处和声明处收集约束,汇聚出唯一解;约束不足时要求显式注解,约束冲突时直接报错。这种推断让大部分代码无需写类型,又能在大类型签名上保持可读性。

1
2
3
4
5
let x = 5;            // i32(整数字面量默认类型)
let y = 3.14; // f64(浮点字面量默认类型)
let z = x + 1; // 由 x 推断为 i32
let v = Vec::new(); // 元素类型待定,尚无法推断
v.push("hi"); // 由 push 的参数推断 v: Vec<&str>

整数字面量默认 i32、浮点字面量默认 f64,这是回退规则(fallback)–仅当没有任何其他约束时才启用。一旦出现上下文约束,回退就让位:let n = 42u8; 直接定为 u8let m: u64 = 42; 由注解决定。

推断失效与涡轮鱼

推断在两类场景必然失效,必须由人补全类型信息:

1
2
3
4
5
6
7
8
// 1. 空容器:没有任何元素可推断
let v: Vec<i32> = Vec::new();

// 2. collect:迭代器元素可转多种目标类型
let nums: Vec<i32> = "1 2 3"
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect();

补全信息有两种写法:在变量上加注解,或用涡轮鱼(turbofish)::<T> 在表达式内部指定。涡轮鱼的好处是不必为临时值引入变量:

1
2
3
4
5
let v = Vec::<i32>::new();
let n = "42".parse::<i32>().unwrap();
let nums = "1 2 3"
.split_whitespace()
.collect::<Vec<_>>();

💡 提示collect::<Vec<_>>() 是最常用的涡轮鱼写法–_ 表示「元素类型交给编译器推断,我只指定容器」。它比 collect::<Vec<i32>>() 更省事,因为元素类型通常已由上游 map/filter 决定。

_ 占位与部分推断

下划线 _ 是「请推断这里」的占位符,可出现在类型注解、涡轮鱼、函数签名中(受限)。它与省略注解的区别在于:_ 显式声明「这里我故意不写」,能让编译器在「需要一点提示」时收敛:

1
2
3
let v: Vec<_> = (0..3).collect(); // 元素由迭代器推断为 i32
let id = 1u32;
let _: () = dbg!(id); // 丢弃返回值,明确类型为 ()

⚠️ 注意:函数签名的参数和返回值不会被推断–函数是供外部调用的边界,必须显式写清类型,否则签名会随调用点漂移。这是 Rust 刻意的工程取舍:公共契约要稳定、可文档化。

泛型

泛型是参数化多态:用类型参数 T 表示「任意类型」,写一份代码适配所有类型。Rust 泛型的关键承诺是零成本抽象–它通过单态化(monomorphization)在编译期为每个具体类型生成一份专用代码,运行时不存在任何「泛型分发」开销。

🔄 对比:Rust 泛型 ≈ C++ 模板(编译期展开),但 Rust 在定义处就用 trait bound 检查约束,而非等到实例化才报一堆深层错误;Java/C# 泛型走类型擦除,运行时只有一份 Object 代码,装箱开销不可避免;Go 泛型也单态化,但表达力受接口约束模型限制。

单态化:零成本的代价是体积

1
2
3
4
5
6
7
8
fn first<T>(list: &[T]) -> &T {
&list[0]
}

fn main() {
let a = first(&[1, 2, 3]); // 生成 first::<i32>
let b = first(&["x", "y"]); // 生成 first::<&&str>
}

编译器看到 first::<i32>first::<&&str> 两个调用点,会分别生成两份机器码。运行时调用 first 是直接跳转,无间接寻址、无虚表。代价是二进制体积增大:每个具体类型一份代码。对小型泛型函数这几乎无感,但对大型泛型算法被多种类型实例化时,体积可能显著膨胀。

💡 提示:缓解体积膨胀的常见手段:把泛型函数的核心逻辑抽成一个处理 &[u8] 或具体 trait 对象的非泛型函数,泛型外壳只做薄转发。VecHashMap 等标准库容器内部就大量采用这种「泛型外壳 + 非泛型内核」结构。

泛型函数与 trait bound

无约束的泛型只能做「所有类型都能做的事」–赋值、移动、借用,仅此而已。要调用方法、比较大小,必须用 trait bound 声明 T 具备的能力:

1
2
3
4
5
6
7
8
9
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for item in &list[1..] {
if item > max {
max = item;
}
}
max
}

T: PartialOrd 是 bound:承诺「调用者传入的 T 必须实现了 PartialOrd」,于是 item > max 才合法。bound 在定义处生效,意味着泛型函数的作者写代码时就拥有 T 的能力清单,编辑器补全、错误提示都基于这份清单。

泛型结构体、枚举与方法

泛型可用于结构体、枚举及其方法。类型参数写在名字后的 <T>,方法在 impl 块上也要声明:

1
2
3
4
5
6
7
8
9
10
11
struct Point<T> {
x: T,
y: T,
}

// impl<T> 表示「为所有 T 实现」
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

impl<T> 中的 <T> 是必须的–它声明「这是一个泛型实现,T 是泛型参数」。若写成 impl Point<T> 会报错,因为编译器会把 T 当成某个具体类型而非参数。

一个关键技巧:可以为特定泛型实例单独实现方法,称为特化实现(不是 C++ 那种正式的 specialization,而是按具体类型分别 impl):

1
2
3
4
5
6
// 仅为 Point<f64> 实现,其他 Point<T> 没有
impl Point<f64> {
fn dist(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

Point::<f64>.dist() 可用,Point::<i32> 则没有 dist。标准库的 impl Point<f64>impl<T> Point<T> 可以共存,编译器按「更具体者优先」选择。

xy 共用同一个 T,所以 Point { x: 1, y: 2.0 } 会报错(i32f64 不匹配)。要允许不同类型,用多个参数:

1
2
3
4
struct Pair<A, B> {
first: A,
second: B,
}

const 泛型

const 泛型把编译期常量值作为参数,最常见的是数组长度。它让函数能直接接受定长数组 [T; N] 而非退化成切片 &[T],保留长度信息:

1
2
3
4
5
6
7
8
9
10
11
12
struct Array<T, const N: usize> {
data: [T; N],
}

fn first<T, const N: usize>(a: &Array<T, N>) -> &T {
&a.data[0]
}

// N 在类型层面不同,互不兼容
let a: Array<i32, 4> = Array { data: [0; 4] };
let b: Array<i32, 8> = Array { data: [0; 8] };
// first(&a) 与 first(&b) 单态化出两份代码

const N: usize 表示 N 是一个编译期 usize 值。Array<i32, 4>Array<i32, 8>不同类型,不能互相赋值–这在类型层面防止了「把 4 长度数组传给期望 8 长度的函数」。

⚠️ 注意:当前 const 泛型参数仅支持整型、boolchar 等基本类型,且不能任意运算(如 N * 2 作参数受限制)。完整的 const 表达式泛型(如 [T; N][..M])仍在稳步推进中。日常最实用的场景是封装定长数组、实现矩阵/向量类型。

生命周期与泛型参数

泛型参数还能接受生命周期约束 T: 'a,含义是「T 中所有引用的存活都不短于 'a」。它解决一个问题:当含引用的泛型类型被放进更长的生命周期上下文时,编译器需要确认 T 内部的引用不会先于外部失效。

1
2
3
4
5
6
7
// 持有引用的泛型结构体
struct Ref<'a, T>(&'a T);

// 显式约束:T 的引用必须活得不短于 'a
fn f<'a, T: 'a>(x: &Ref<'a, T>) -> &'a T {
x.0
}

多数情况下 T: 'a 由编译器隐式推导Ref<'a, T> 的字段是 &'a T,编译器据此自动加上 T: 'a,无需手写。需要显式写 T: 'a 的场景,通常是泛型函数要把 T 借出比参数自身更久、或把含 T 的结构体存入更长生命周期的容器。

💡 提示T: 'a 不是「T 是引用」,而是「T 若含引用,那些引用至少活 'a」。对不含引用的类型(如 i32),T: 'a 对任意 'a 都平凡成立,因为它本就没有引用会失效。生命周期约束与所有权机制的深层关系见《所有权、借用与生命周期》

trait

trait 定义一组方法签名(可带默认实现),描述「实现此 trait 的类型能做什么」。除方法外,trait 还可声明关联类型关联常量关联函数(无 self 的函数);其中关联类型是 trait 表达「输出类型」的核心机制,后文单列一节。trait 对应其他语言的接口/typeclass,但有一个根本差异:必须显式 impl,不会仅因方法签名凑巧匹配就自动成立。

🔄 对比:Rust trait ≈ Haskell typeclass(显式实例)+ Go interface(方法集),但 Rust 走名义类型(nominal)路线–类型只有写了 impl Trait for T 才算实现,而 Go interface 是结构化类型(structural),签名匹配即满足。Rust 的选择避免了「无意中满足某接口」的意外耦合。

定义与实现

1
2
3
4
5
6
7
8
9
10
11
12
13
trait Summary {
fn summarize(&self) -> String;
}

struct Article {
content: String,
}

impl Summary for Article {
fn summarize(&self) -> String {
format!("Article: {}", self.content)
}
}

impl Summary for ArticleSummary 的要求与 Article 的实现绑定。实现体必须覆盖 trait 中所有无默认实现的方法,否则编译失败。

默认实现

trait 方法可提供默认实现,实现者可选用、也可覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
// 默认实现可以调用同 trait 的其他方法
fn notify(&self) {
println!("Breaking: {}", self.summarize());
}
}

// 空实现体:全部使用默认实现
impl Summary for Article {}

默认实现的威力在于可组合notify 的默认实现调用了 summarize,实现者只需覆盖 summarizenotify 自动获得正确行为。这是 trait 复用的核心机制。

超 trait

超 trait(supertrait)要求「实现 B 之前必须先实现 A」,语法是 trait B: A。它表达 trait 间的依赖:B 的方法需要 A 提供的能力作为前提:

1
2
3
4
5
6
7
8
use std::fmt;

// OutlinePrint 依赖 Display:它的默认实现调用 to_string
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
println!("* {} *", self.to_string());
}
}

OutlinePrint: fmt::Display 意为「OutlinePrintDisplay 的超集,实现 OutlinePrint 的类型必须也实现 Display」。outline_print 的默认实现因此可以安全调用 self.to_string()(来自 Display)。

⚠️ 注意:超 trait 是对实现者的约束,不是对调用者的。impl OutlinePrint for T 时编译器会检查 T: Display;但若你只写 fn f<T: OutlinePrint>,编译器不会自动认为 T: Display 成立,调用 to_string 仍需显式加 T: OutlinePrint + Display。这是常见踩坑点。

trait bound 与 impl Trait

trait bound 是泛型与 trait 的粘合剂:它约束类型参数必须满足哪些 trait,从而让泛型代码能用上具体能力。bound 有三种写法,按复杂度递进。

内联 bound

最简单的形式,把 bound 直接写在类型参数后:

1
2
3
4
5
// T 必须同时实现 Clone 和 Debug
fn duplicate<T: Clone + std::fmt::Debug>(x: &T) -> T {
println!("{:?}", x);
x.clone()
}

+ 组合多个 bound,表示「同时满足」。bound 越多,函数能调用的方法越多,但能接受的类型越少–这是抽象与具体的固有张力。

where 子句

当 bound 多到挤占函数签名、或涉及关联类型约束时,用 where 子句把约束挪到签名下方,可读性显著提升:

1
2
3
4
5
6
7
8
9
10
11
fn parse_all<I, T>(iter: I) -> Vec<T>
where
I: IntoIterator<Item = T>,
T: std::str::FromStr,
<T as std::str::FromStr>::Err: std::fmt::Debug,
{
iter
.into_iter()
.map(|s| s.parse().unwrap())
.collect()
}

where 子句尤其擅长表达关联类型约束:第三行约束 T::Err 必须实现 Debug,这种约束在内联写法里几乎无法书写。<T as FromStr>::Err 是完全限定语法,显式指明 Err 来自哪个 trait 的实现。

💡 提示:经验法则–bound 少于两个且简单时用内联;一旦出现关联类型约束或超过两个 bound,一律改用 where。统一用 where 也无妨,可读性只增不减。

impl Trait:bound 的语法糖

impl Trait 在两个位置出现,语义略有不同:

1
2
3
4
5
6
7
8
9
10
11
// 参数位置:等价于 <T: IntoIterator<Item = i32>>
fn print(items: impl IntoIterator<Item = i32>) {
for i in items {
println!("{}", i);
}
}

// 返回位置:返回「某个实现了 Iterator 的具体类型」
fn counter() -> impl Iterator<Item = i32> {
(0..5).into_iter()
}

参数位置的 impl Trait 是 bound 的纯语法糖,与泛型版本完全等价(仍是静态分发、单态化)。

返回位置的 impl Trait存在类型(existential):「我返回某个实现了 Iterator 的类型,但具体是哪个不告诉你」。它让函数能返回无法写出名字的类型–最典型的是闭包:

1
2
3
4
// 闭包类型由编译器生成,无法手写其类型名
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}

⚠️ 注意:返回 impl Trait 的函数只能返回单一具体类型。下面写法非法–两个分支返回了不同的迭代器类型:

1
2
3
4
5
6
7
8
// 编译错误:返回类型不唯一
fn bad(cond: bool) -> impl Iterator<Item = i32> {
if cond {
(0..3).into_iter() // Range<i32>
} else {
vec![1, 2].into_iter() // IntoIter<i32>
}
}

需要在分支间返回不同类型时,必须用 trait 对象(Box<dyn Trait>,见下节)或把它们统一成一个枚举。这是静态分发「单类型」限制的直接体现。

dynimpl 的选择预告

impl Trait 是静态分发,dyn Trait 是动态分发。两者的取舍是本章后半段的主线,先给一张速查表,后文逐一展开:

维度impl Trait / 泛型dyn Trait
分发时机编译期单态化运行时 vtable
运行时开销一次间接调用
能否异构集合
能否返回多类型
二进制体积随实例数增长一份代码

关联类型

关联类型把「trait 实现所对应的输出类型」与实现绑定,由实现方决定,而非调用方指定。它是 trait 表达「这个类型有一个相关联的类型」的机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
trait Container {
type Item; // 关联类型,无默认
fn get(&self, i: usize) -> Option<&Self::Item>;
fn first(&self) -> Option<&Self::Item> {
self.get(0) // 默认实现可用 Self::Item
}
}

struct VecContainer<T> {
items: Vec<T>,
}

impl<T> Container for VecContainer<T> {
type Item = T; // 实现方决定 Item 就是 T
fn get(&self, i: usize) -> Option<&Self::Item> {
self.items.get(i)
}
}

Self::Item 是关联类型的引用方式。实现 Container 时必须指定 type Item = ...,一旦指定,该类型对这个 trait 的实现就固定了–VecContainer<i32>Item 永远是 i32

关联类型 vs 泛型参数

这是 trait 设计中最高频的决策。同一个 trait,用关联类型还是泛型参数,决定了「谁能决定输出类型」与「能否多次实现」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 泛型参数版:调用方指定 U
trait From2<U> {
fn from2(v: U) -> Self;
}
// 同一类型可多次实现
impl From2<i32> for String { /* ... */ }
impl From2<bool> for String { /* ... */ }

// 关联类型版:实现方指定 Item
trait Container2 {
type Item;
}
// 同一类型只能实现一次
impl Container2 for String {
type Item = u8;
}
// impl Container2 for String { type Item = u16; } // 报错:重复实现

两者的核心区别:

维度泛型参数 Trait<U>关联类型 type Item
谁决定输出类型调用方实现方
同一类型可实现次数多次(每个 U 一次)仅一次
使用时写法Trait<T, U> 要带上参数Trait 即可,Item 已确定
典型例子From<T>Add<Rhs>Iterator::ItemDeref::Target
flowchart LR
  A["设计 trait"] --> B{"输出类型由谁决定?"}
  B -->|"调用方/可多次实现"| C["泛型参数"]
  B -->|"实现方/唯一确定"| D["关联类型"]
  C --> E["例: From<T>, Add<Rhs>"]
  D --> F["例: Iterator::Item, Deref::Target"]

💡 提示:判断口诀–「同一类型是否需要多种实现」。需要多种(如 String 能从 i32、从 bool 转换)→ 泛型参数;只需唯一一种(如 Vec 的迭代元素类型)→ 关联类型。标准库的 From/Into/Add 用泛型,Iterator/Deref/AsRef 的「目标」用关联类型,正合此律。

关联常量

关联常量把「与类型绑定的编译期常量」写进 trait,由实现方提供值:

1
2
3
4
5
6
7
8
9
10
11
trait Greet {
const NAME: &'static str;
fn greet(&self) {
println!("Hello, {}!", Self::NAME);
}
}

struct Earth;
impl Greet for Earth {
const NAME: &'static str = "Earth";
}

关联常量可以带默认值(const NAME: &str = "Anon";),实现方可覆盖。它常用于给类型打「配置标签」,如几何类型里的维度数、协议类型里的版本号。

静态分发与动态分发

泛型与 impl Trait静态分发:编译期单态化,每个具体类型一份代码,调用是直接跳转。dyn Trait动态分发:运行时通过虚表(vtable)间接调用,一份代码服务所有类型。这是 Rust 多态的核心权衡。

静态分发:单态化的得失

1
2
3
4
5
6
7
8
9
10
trait Draw {
fn draw(&self);
}

// 泛型 + bound:静态分发
fn draw_all<T: Draw>(items: &[T]) {
for it in items {
it.draw(); // 编译期已知 T,直接调用
}
}

draw_all::<Circle>draw_all::<Square> 生成两份机器码,各自直接调用 Circle::draw / Square::draw。运行时零开销,但二进制里有两份 draw_all。优势是性能(无间接调用、可内联),代价是体积编译时间

动态分发:vtable 机制

dyn Trait 是一个动态大小类型(DST),无法直接存放在栈上–它的体积运行时才知。因此 dyn Trait 总以胖指针(fat pointer)形式出现,同时携带数据地址与虚表地址:

1
2
3
fn draw_one(item: &dyn Draw) {
item.draw(); // 运行时查 vtable 间接调用
}

&dyn Draw 在内存中是两个机器字:一个指向数据,一个指向 vtable。vtable 是一张函数指针表,记录 Draw 的方法在该具体类型上的实现地址。调用 item.draw() 时,程序从 vtable 取出对应槽位的函数指针,再带着数据指针跳转。

flowchart TB
  subgraph FP["胖指针 &dyn Draw"]
    D["data ptr"]
    V["vtable ptr"]
  end
  subgraph HT["堆上的 Circle 实例"]
    D2["Circle 字段..."]
  end
  subgraph VT["Circle 的 Draw vtable"]
    S1["drop_in_place 指针"]
    S2["size / align 元数据"]
    S3["draw 函数指针"]
  end
  D --> D2
  V --> VT

Box<dyn Draw> 拥有堆上数据,其内部同样是「数据指针 + vtable 指针」的胖指针;Rc<dyn Draw>Arc<dyn Draw> 同理。vtable 中除了 trait 方法指针,还存放析构函数(drop_in_place)与类型大小/对齐–这些是「释放盒、布局内存」所需的信息,因为调用方已不知道具体类型。

异构集合:动态分发的核心价值

静态分发的集合只能存同构类型(Vec<Circle> 不能装 Square)。动态分发让一个集合容纳不同类型成为可能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Circle;
struct Square;

impl Draw for Circle {
fn draw(&self) {
println!("○");
}
}
impl Draw for Square {
fn draw(&self) {
println!("□");
}
}

fn main() {
// 异构集合:每个元素具体类型不同
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle),
Box::new(Square),
Box::new(Circle),
];
for s in &shapes {
s.draw(); // 运行时分发到各自实现
}
}

Box<dyn Draw> 把「类型信息」从编译期推迟到运行时,于是 Vec 里可以混装 CircleSquare。这是 GUI 框架、插件系统、解释器中「一组可替换实现」的惯用形态。

💡 提示:默认用泛型/impl Trait(静态分发)–它更快、更利于编译器优化。只有当确实需要异构集合返回多种类型、或想抑制二进制膨胀(某泛型被大量类型实例化)时,才转向 dyn Trait。两者的边界就是「是否需要在运行时持有多个不同具体类型」。

🔄 对比dyn Trait 的 vtable ≈ C++ 虚函数表,但 Rust 是显式选择(写 dyn 才动态),C++ 则按成员函数是否 virtual 决定;Go interface 值天然是动态分发(interface 值本身是胖指针),无需关键字。Rust 的设计让「分发策略」在类型签名上一目了然。

对象安全

对象安全是「trait 能否被做成 trait 对象」的约束。它源于一个根本矛盾:dyn Trait 在运行时已丢失具体类型信息,因此凡是要用到具体类型的能力,都不能出现在 trait 对象的方法里。

三条规则

一个 trait 要对象安全,其所有方法必须满足:

  1. 不能返回 SelfSelf 是具体类型,trait 对象不知道具体类型,无法构造它。
  2. 不能有泛型参数。vtable 是固定签名的函数指针表,无法为每个泛型实例化预留槽位。
  3. 第一个参数必须是接收器self/&self/&mut self/Box<Self> 等),不能是 fn foo(x: &T) 这种关联函数。

此外,trait 自身不能有 Sized 作为超 trait(因为 dyn Trait 本身不是 Sized)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 不对象安全:返回 Self
trait Clone2 {
fn clone2(&self) -> Self;
}
// Box<dyn Clone2> 编译失败

// 不对象安全:有泛型方法
trait Cache {
fn get<T>(&self, k: &str) -> Option<&T>;
}
// Box<dyn Cache> 编译失败

// 对象安全
trait Draw {
fn draw(&self); // 仅 &self,无 Self 返回,无泛型
}
// Box<dyn Draw> 合法

为什么是这些规则

每条规则都对应「trait 对象运行时缺失的信息」:

  • 返回 Self 要构造一个具体类型的值,但 vtable 里没有「具体类型是什么」的运行时反射(Rust 刻意不做运行时类型反射),无从构造。
  • 泛型方法要在调用点单态化,每个 T 生成一个调用入口;vtable 是定长表,无法装下「无穷多个 T 的入口」。
  • 关联函数(无 self)无法通过 dyn 调用,因为没有 self 就无从找到对应的 vtable–vtable 是「附在数据上的」。

规避手法

当 trait 有不对象安全的方法,但又想用 trait 对象时,常见三种处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 用 where 子句把不安全方法排除在 dyn 之外
trait Cache {
fn get(&self, k: &str) -> Option<&str>;
// 泛型方法用 where Self: Sized 排除出 dyn
fn get_typed<T>(&self, k: &str) -> Option<T>
where
Self: Sized;
}
// 现在 Box<dyn Cache> 合法,但只能调 get,不能调 get_typed

// 2. 把 Self 返回改成 Box<dyn Trait>
trait Factory {
fn spawn(&self) -> Box<dyn Factory>;
}

第三种是把该 trait 拆成「对象安全」与「不安全」两个 trait,需要动态分发时只对前者造 dyn

⚠️ 注意:对象安全是在使用点(写 dyn Trait 时)检查的,不是在 trait 定义处报错。trait 本身可以包含不对象安全的方法,编译器只在你想把它做成 dyn 时才拒绝。这也意味着设计公共 trait 时若预期会被动态分发,应主动避免 Self 返回与泛型方法。

💡 提示:标准库里 Clone(返回 Self)、SizedPartialOrd(部分方法)不对象安全;IteratorReadWriteDisplay 对象安全。Box<dyn Iterator<Item = i32>>Box<dyn std::io::Read> 是常见用法。

孤儿规则与 trait 工程实践

trait 系统的稳健性来自一条规则:孤儿规则(orphan rule)。它规定 impl Trait for Type 时,TraitType 至少有一个在当前 crate 定义。换言之,不能给外部类型实现外部 trait。

孤儿规则的意义

没有孤儿规则,两个 crate 可能各自给 Vec<T> 实现 MyTrait,链接到一起时实现冲突,无从裁决。孤儿规则把「实现权」分配给「定义了 trait 或类型的那个 crate」,从根上消除冲突:

1
2
// 假设 Display 与 Vec 都来自标准库(外部)
// impl std::fmt::Display for Vec<String> {} // 编译错误:孤儿规则

想给 Vec<String> 实现 Display,标准库的 DisplayVec 都不属于你,禁止。这正是 newtype 模式的用武之地(见下节)。

blanket 实现

当 trait 由你定义时,可以给「所有满足某 bound 的类型」一次性实现,称为 blanket impl

1
2
3
4
5
6
7
8
9
10
trait Print {
fn print(&self);
}

// 所有实现了 Display 的类型自动获得 Print
impl<T: std::fmt::Display> Print for T {
fn print(&self) {
println!("{}", self);
}
}

blanket impl 强大但需谨慎:它会把实现「蔓延」到所有满足 bound 的类型,包括未来用户自定义的类型,可能挤占用户自行实现的余地。标准库的 impl<T: IntoIterator> ... 之类广泛使用 blanket impl,但公共库定义 trait 时若非确有必要,避免随意 blanket。

sealed trait 模式

有时你想让 trait「只能被本 crate 的类型实现」,外部类型无法实现,但外部可以「使用」它。这通过 sealed trait 模式实现:把一个私有 marker trait 作为超 trait,外部无法实现私有 trait,于是也就无法实现你的 trait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mod sealed {
pub trait Sealed {}
}

// 公开 trait,但超 trait 是私有的
pub trait Stable: sealed::Sealed {
fn stable_method(&self);
}

// 只在本 crate 内为受控类型实现
struct MyType;
impl sealed::Sealed for MyType {}
impl Stable for MyType {
fn stable_method(&self) {}
}

外部类型想 impl Stable 必须先 impl sealed::Sealed,但 Sealed 是私有的,外部无法实现,于是 Stable 对外「关闭了实现、开放了使用」。标准库的 UnpinTrustedLen 等都用此模式封装 unsafe 或不稳定契约。

newtype 模式

newtype 是 Rust 类型工程的基石模式:用一个单字段元组结构体把已有类型包一层,得到编译期全新的类型,而运行时零开销–底层内存布局与原类型完全一致,区别只存在于类型检查阶段。

零开销的语义区分

最直接的收益是让编译器区分「底层相同、语义不同」的值。两个函数都接收 u32,一个表示用户 ID、一个表示会话 ID,传反位置时编译器毫无办法:

1
2
3
4
5
6
7
8
9
10
11
struct UserId(u32);
struct SessionId(u32);

fn authenticate(user: UserId) {
// ...
}

let uid = UserId(123);
let sid = SessionId(456);
authenticate(uid);
// authenticate(sid); // 编译错误:类型不匹配

UserIdSessionId 底层都是 u32,运行时无任何额外开销,但类型系统把它们视为两个互不兼容的类型。这种「用类型挡住参数误用」的思路,是把运行时 bug 提前到编译期的最廉价手段。

🔄 对比:newtype ≈ Haskell 的 newtype,零开销语义完全一致;C++ 没有等价物(强 typedef 提案多年未通过),只能用继承或宏笨拙模拟;TypeScript 的 branded type(type UserId = string & { __brand: ... })是运行时无、编译期有的近似物,但 TS 的结构化类型系统让隔离不如 Rust 彻底。

绕过孤儿规则

newtype 是孤儿规则最常用的「绕法」。想给 Vec<String> 实现 Display 直接报错,但 Wrapper(Vec<String>) 是本 crate 的新类型,给它实现 Display 合法:

1
2
3
4
5
6
7
8
9
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}

Deref 透传原类型能力

newtype 默认继承原类型的方法(它是新类型)。若想保留原类型的多数方法又只覆盖少数,可手动实现 Deref

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::ops::Deref;

struct UserId(u32);

impl Deref for UserId {
type Target = u32;
fn deref(&self) -> &u32 {
&self.0
}
}

let uid = UserId(5);
let n: u32 = *uid + 1; // 透过 Deref 取到 u32

⚠️ 注意Deref 应仅用于「智能指针」语义(BoxRcStringstr),不要把它当「继承」用。滥用 Deref 会让方法解析变得隐晦、并在类型转换链上产生意外。newtype 若只是想复用几个方法,显式写转发函数比 Deref 更清晰。

newtype 的进阶应用(类型状态机、带校验的构造器)见《最佳实践、性能与调试》

类型别名

type 关键字创建类型别名–给已存在类型起个新名字,但不创建新类型,与 newtype 有本质区别:

1
2
3
4
5
6
7
8
type Kilometers = i32; // Kilometers 与 i32 完全等价

fn travel(d: Kilometers) {
// d 就是 i32,无任何隔离
}

let d: i32 = 10;
travel(d); // 合法:别名不隔离

Kilometersi32 可互换,编译器不区分。需要类型安全时 newtype 才是正解,type 仅用于可读性

1
2
3
// 减少重复书写,常见于泛型别名
type Result<T> = std::result::Result<T, String>;
type IntMap<V> = std::collections::HashMap<u32, V>;

💡 提示:判断用 type 还是 newtype 的标准–若你希望两个类型互相赋值报错,用 newtype;若只是嫌全名太长想缩短,用 type。标准库 io::Result<T>Box<dyn Error> 的频繁出现,都靠 type 别名简化。

Never 类型 !

! 是「永不返回」的发散类型(never type)。它的核心特性是可强制转换为任意类型,因此 panic!returnbreakloop {} 等永不正常求值的表达式能与任意其他值类型统一:

1
2
3
4
5
6
7
8
9
10
let x: i32 = match value {
Some(v) => v,
None => return, // return 的类型是 !,可转为 i32
};

let y = if cond {
1
} else {
panic!("oops")
}; // panic! 类型是 !,y: i32

若无 ! 的「转为任意类型」能力,上例 match 的两个分支类型无法统一(i32return),编译器会拒绝。! 让「控制流跳转」在类型层面表现为「任何类型都行」,分支类型一致得以保持。

发散函数显式标注返回 !,表示它不会正常返回:

1
2
3
fn forever() -> ! {
loop {}
}

💡 提示! 未来将稳定为完整类型,可用于表示「不可能发生的状态」。历史上常用 enum Void {}(无变体枚举)模拟,! 稳定后可直接用 !,且 ! 能自动满足任意 trait bound(因为它不可能有值,任何方法都不会被实际调用)。

Sized?Sized

Sized 是最重要的标记 trait:表示类型在编译期已知大小。所有泛型参数默认隐式绑定 T: Sized,因为绝大多数代码需要知道类型大小才能在栈上分配、计算偏移。

动态大小类型(DST)

并非所有类型大小都编译期可知。[T]切片)、str(字符串切片)、dyn Trait 都是动态大小类型(DST)–大小要到运行时、看了具体值才知道。DST 不能直接存放在栈上,必须以胖指针形式出现:

  • &[T] = 数据指针 + 长度
  • &str = 数据指针 + 长度
  • &dyn Trait = 数据指针 + vtable 指针

胖指针本身大小固定(两个机器字),于是「指向 DST 的指针」是 Sized 的,被指向的 DST 不是。

?Sized:放宽约束

?Sized 表示「类型可能不是 Sized」,即「允许 DST」。它用来写能接受 DST 的通用函数:

1
2
fn foo<T>(x: &T) {}          // T 默认 Sized
fn bar<T: ?Sized>(x: &T) {} // T 可以是 DST

bar 能接受 &str&[u8]&dyn Draw 这些指向 DST 的引用,而 foo 不能。?Sized 是「在约束上做减法」–移除默认的 Sized 要求,是少数几个 ? 约束之一。

💡 提示:标准库的 Box<T>Rc<T>Arc<T>&T&mut T 都对 T 用了 ?Sized,所以它们能持有 str[T]dyn Trait。日常写泛型几乎不需手动加 ?Sized,除非你在写「接受任意 DST 引用」的底层通用函数,如 fn to_debug<T: ?Sized: Debug>(x: &T)

递归类型与 Box

枚举或结构体若直接包含自身,大小无法在编译期确定–会无限嵌套下去。用 Box 把递归字段放到堆上,给出固定大小的指针即可打破:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 错误:大小无限递归
// enum List {
// Cons(i32, List),
// Nil,
// }

// 正确:用 Box 提供固定大小的间接层
enum List {
Cons(i32, Box<List>),
Nil,
}

use List::{Cons, Nil};

let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));

Box<List> 大小恒为指针大小(一个机器字),于是 List 的大小 = 变体判别字 + i32 + 指针,可计算。链表、树、AST 等递归数据结构都靠这种「指针断开」实现。

💡 提示Box 在此的作用是提供固定大小的间接层,而非「堆分配」本身(尽管它确实分配在堆上)。任何固定大小的间接类型(RcArc、裸指针+PhantomData)都能打破递归,Box 因独占所有权与零开销而最常用。智能指针的完整对比见《智能指针、迭代器与闭包》

型变(Variance)

型变描述「子类型关系如何沿泛型/引用传播」。它是借用检查器安全保证的数学基础,平时由编译器自动处理,但在写 unsafe 代码或设计含生命周期的 API 时必须理解。

Rust 的子类型:只有生命周期

多数语言的子类型是「类型间」的(DogAnimal 的子类型)。Rust 几乎没有类型间子类型(除了 !),但生命周期存在子类型关系:活得更长的生命周期是活得更短的生命周期的子类型。即 'static 是任意 'a 的子类型('static: 'a),因为 'static 引用能安全替代 'a 引用。

三种型变

把一个「容器类型」F<T> 沿 T 的子类型关系传播时,有三种结果:

型变含义Rust 示例
协变子类型关系与 T 同向&'a T'aT 协变
逆变子类型关系与 T 反向fn(T) -> R 对参数 T 逆变
不变子类型关系不传播&mut TCell<T>T 不变
1
2
let s: &'static str = "hello"; // 活得最长
let r: &'a str = s; // 协变:'static -> 'a 允许

&'a T 协变意味着「需要 &'a str 的地方,给 &'static str 也行」–更长的总是安全的。这是「活得更久可以替代活得更短」的直接体现。

为什么 &mut T 必须不变

不变性的经典论证:若 &mut T 协变,就能把短生命周期引用写进「原本承诺长生命周期」的可变引用,制造悬空:

1
2
3
4
5
6
7
fn evil<'a>(long: &mut &'static str, short: &'a str) {
// 假设 &mut T 协变,则 &mut &'static str
// 可当作 &mut &'a str 使用
*long = short; // 写入短命引用
// 此后 long 指向的数据已超出生命周期
// 但外部仍以为它是 &'static -> 悬空!
}

不变性堵死了这条路径:&mut &'static str&mut &'a str 被视为不兼容类型,*long = short 编译失败。同理,Cell<T>UnsafeCell<T>RefCell<T> 内部可变性也要求 T 不变–否则通过 Cell 写入短命值会破坏长命引用。

🔬 进阶:函数指针 fn(T) -> RT 逆变、对 R 协变,源于函数调用的「替换方向」–一个「接受更宽输入、返回更窄输出」的函数可安全替代「接受更窄、返回更宽」的。&'a T 协变、&'a mut T'a 协变但对 T 不变,区分点正是「只读引用可放宽、可写引用不可放宽」。型变规则由编译器依据类型的逻辑结构自动推导,PhantomData 可手动覆盖。

PhantomData

PhantomData<T> 是零大小的标记类型,告诉编译器「假装我拥有/使用了 T」,从而影响型变、drop 检查与生命周期约束,却不实际存储 T。它解决一个常见困境:类型参数 T 未出现在任何字段中,但需要让结构体「逻辑上」与 T 相关。

未使用类型参数的问题

1
2
3
4
struct TaggedVec<T> {
data: Vec<u8>,
// T 没出现在字段里 -> 编译器警告
}

T 未被任何字段使用时,编译器会报「未使用类型参数」错误,因为这种类型对 T 的型变、drop 检查无从推导。PhantomData 是标准的修补手段:

1
2
3
4
5
6
use std::marker::PhantomData;

struct TaggedVec<T> {
data: Vec<u8>,
_marker: PhantomData<T>,
}

_marker 零大小,不占内存,但让 TaggedVec<T> 在类型系统里「拥有 T 的语义」。

PhantomData 的多种语义

PhantomData<X> 的类型参数 X 决定它传达的语义,远不止「标记一个 T」:

写法传达语义型变drop 检查
PhantomData<T>拥有 T协变参与(可能 drop T
PhantomData<*const T>非拥有裸指针不变不参与
PhantomData<&'a T>借用 'a'a 协变不参与
PhantomData<fn(T) -> T>函数式使用逆变+协变不参与

标准库内部广泛使用 PhantomData 精确表达所有权语义:

  • Box<T>PhantomData<T> 表达「拥有 T、参与 drop 检查、对 T 协变」。
  • Rc<T>PhantomData<*const T> 表达「非拥有但参与型变,且对 T 不变」(裸指针没有所有权语义,编译器保守选择不变,防止通过 Rc 制造型变漏洞)。
  • std::iter::Map 等用 PhantomData<fn() -> T> 表达「产出 T 但不拥有它」。

🔬 进阶:drop 检查(drop check)决定「当一个类型被 drop 时,它持有的引用是否仍需有效」。PhantomData<T> 让结构体参与对 T 的 drop 检查,意味着结构体可能在析构时访问 T 的引用,于是 T 的生命周期必须覆盖结构体。选择 PhantomData<*const T> 则退出 drop 检查,放宽生命周期约束–这是 RcArc 等非拥有智能指针能持有任意生命周期的关键。PhantomData 的实战应用(类型状态机)见《最佳实践、性能与调试》

类型转换

Rust 不支持隐式类型转换–任何转换都通过 trait 显式完成,避免 C/C++ 那种静默截断、符号丢失、精度损失。这是「显式优于隐式」在类型层面的贯彻。

转换 trait 总览

trait用途失败时
From/Into不会失败的转换不失败
TryFrom/TryInto可能失败的转换返回 Result
AsRef/AsMut廉价引用转换 &T&U不失败
FromStr/ToString字符串 ↔ 类型FromStr 返回 Result
as基本数值强制转换可能截断(静默)
1
2
3
4
let n: i32 = "42".parse().unwrap(); // FromStr
let s: String = n.to_string(); // ToString
let bytes: &[u8] = "hi".as_bytes(); // AsRef
let f = 5_i32 as f64; // 数值 as 转换

From/IntoTryFrom/TryInto

From 表示「不会失败的转换」,Into 是其反向(实现了 From 自动获得 Into)。惯例是实现 From,让调用方用 .into()

1
2
let s: String = String::from("hi");
let s2: String = "hi".into(); // 同上,Into 自动实现

可能失败的转换用 TryFrom/TryInto,返回 Result,把「截断/越界」显式化:

1
2
let big: i32 = 300;
let small: u8 = big.try_into().unwrap(); // Err: 越界

as 转换:便利与陷阱

as 用于基本数值类型间的强制转换,可能静默截断

1
2
let n = 300_i32 as u8; // 44,截断且无警告
let f = 1.5_f64 as i32; // 1,向零截断

⚠️ 注意as 转换不检查越界,截断静默发生。涉及「可能丢数据」的转换(宽→窄、浮→整)应优先 try_into(),让失败显式可见。as 适合「确知范围安全」的快速转换,如 usize as u32 在已知不超过 u32::MAX 的索引场景。

Deref 强制转换

除了显式转换 trait,Rust 还有**Deref 强制转换**(deref coercion):当某类型 T: Deref<Target=U> 时,&T 可自动转为 &U。这让 &String 能传给期望 &str 的函数、&Vec<T> 传给 &[T]&Box<T> 传给 &T

1
2
3
4
5
6
fn greet(name: &str) {
println!("Hi, {}", name);
}

let s = String::from("Ada");
greet(&s); // &String 自动 Deref 为 &str

Deref 强制转换是有限的、由类型系统保证安全的隐式转换,与「隐式数值转换」不同–它只发生在「明确实现了 Deref」的类型上,且只沿 Deref 链进行。Deref 的实现要点与陷阱见《Unsafe Rust 与常用 trait 详解》

💡 提示:转换 trait 的选择口诀–确定不失败用 From/Into;可能失败用 TryFrom/TryInto;只是「换个视角看同一份数据」用 AsRef;字符串解析用 FromStrFrom 在错误传播中的关键作用(? 运算符依赖 From 转换错误类型)见《错误处理与 Panic 恢复》


类型系统是 Rust 的「中央基础设施」:泛型与 trait 提供表达力,单态化与 vtable 决定执行模型,Sized/型变/PhantomData 守住安全底线,newtype 与转换 trait 桥接具体工程。后续章节几乎都在这一框架内展开–集合是泛型与 trait 的直接应用,错误处理依赖 From?智能指针Deref/DropBox<dyn Trait> 的舞台,并发则建立在 Send/Sync 这两个标记 trait 之上。掌握本章,便掌握了读 Rust 标准库与第三方库源码的钥匙。

延伸阅读:Rust 标准 Trait 详解(标记/派生/操作/迭代/IO/错误 trait 的逐一签名与实现要点)。