类型系统:泛型、trait 与多态
📚 Rust 课程系列
Rust 的类型系统是它内存安全与零成本抽象的根基。本章从类型推断、泛型、trait 出发,逐步讲到 trait bound、关联类型、静态/动态分发的取舍,再到 newtype、型变、PhantomData 等进阶主题,帮你建立对类型系统全局的认识。标准库各 trait 的逐一详解见《Unsafe Rust 与常用 trait 详解》,完整方法签名可参考《Rust 标准 Trait 详解》。
类型推断
Rust 编译器能自动推断大多数类型,推断是「双向」的:从使用处和声明处同时收集约束,汇聚出唯一类型;信息不足时要求显式注解。
1 | |
当推断无能为力时(空 Vec、collect 目标类型等),用「涡轮鱼」::<T> 或变量注解补全:
1 | |
💡 提示:
collect::<Vec<_>>()中的_让编译器推断元素类型,只需指定容器类型——这是最常用的写法。
泛型
泛型通过单态化(monomorphization)在编译期为每个具体类型生成一份代码,是零运行时成本的抽象。
🔄 对比:Rust 泛型 ≈ C++ 模板(编译期展开),但 Rust 会在定义处检查 trait bound,而非实例化时才报错;Go 泛型也用单态化,但表达能力受接口约束限制。
1 | |
泛型可用于函数、结构体、枚举和方法:
1 | |
const 泛型
const 泛型允许把编译期常量作为参数,常用于固定大小数组:
1 | |
💡 提示:const 泛型让函数直接接受
[T; N]而非&[T],保留了数组长度信息。目前仅支持整型、bool、char等基本类型作为 const 参数。
trait
trait 定义共享行为,类似其他语言的接口:
🔄 对比:Rust trait ≈ Go interface(结构化类型)+ Haskell typeclass,但 Rust 要求显式
impl(不能仅因方法签名匹配就自动满足),这避免了无意中实现 trait 的隐患。
1 | |
- 默认实现:trait 方法可提供默认实现,实现者可覆盖也可直接用:
1 | |
- 超 trait(supertrait):要求实现某 trait 前必须先实现另一个,用
trait B: A语法:
1 | |
⚠️ 注意:超 trait 的约束在
impl时由编译器检查——若类型没实现Display,就无法impl OutlinePrint。
trait bound 与 impl Trait
trait bound 约束泛型使其具备某些能力。约束复杂时用 where 子句提高可读性:
1 | |
impl Trait 是 bound 的语法糖,让函数签名更简洁:
1 | |
⚠️ 注意:
impl Trait作返回值时,函数只能返回单一具体类型——不能在不同分支返回不同类型。闭包类型无法写出名字时impl Trait尤其有用。需要多态返回请用Box<dyn Trait>(见下文)。
关联类型
关联类型把「输出类型」与 trait 实现绑定,由实现方决定,而非调用方指定:
1 | |
关联类型 vs 泛型参数的关键区别:
| 维度 | 泛型参数 | 关联类型 |
|---|---|---|
| 谁决定 | 调用方 | 实现方 |
| 同一类型可实现次数 | 多次(如 impl From<u8> + impl From<u16>) | 仅一次(如 Iterator 的 type Item) |
| 调用时写法 | Trait<T=U> | Trait(类型已确定) |
💡 提示:如果每次使用 trait 都需要指定额外类型参数,用关联类型更简洁;如果同一类型需要多种实现,用泛型参数。
关联常量同理:
1 | |
静态分发与动态分发:impl Trait vs dyn Trait
静态分发(泛型 / impl Trait) | 动态分发(dyn Trait) | |
|---|---|---|
| 时机 | 编译期单态化 | 运行时 vtable 间接调用 |
| 开销 | 零运行时开销,但二进制变大 | 一次间接调用开销 |
| 集合 | 只能存同构类型 | 可存异构类型 |
1 | |
dyn Trait 是动态大小类型(DST),必须以胖指针形式出现——同时记录数据地址和 vtable 地址:
1 | |
💡 提示:默认用泛型/
impl Trait(静态分发);只有当需要在集合中存放不同类型、或想减少二进制体积时才用dyn Trait。
🔄 对比:
dyn Trait的 vtable 机制 ≈ C++ 虚函数表,但 Rust 的 trait 对象是显式选择(dyn),而非默认行为;Go 的 interface 值天然是动态分发。
对象安全
并非所有 trait 都能作为 dyn Trait 使用。trait 必须对象安全(object safe)才能构造 trait 对象,要求:
- 所有方法不能返回
Self(运行时不知道具体类型,无法构造)。 - 所有方法不能有泛型参数(vtable 是固定签名的,无法为每个泛型实例化单独留槽)。
- 第一个参数必须是
self/&self/&mut self/Box<Self>等接收器形式,且不能有额外的泛型约束。
例如 Clone(返回 Self)、Sized(要求编译期已知大小)都不对象安全;Iterator 本身对象安全,可用 Box<dyn Iterator<Item = i32>>。
⚠️ 注意:对象安全是编译器在
dyn Trait使用点检查的,不是 trait 定义处的硬约束——trait 本身可以包含不对象安全的方法,只是不能通过dyn调用它们。
newtype 模式
newtype 用元组结构体包裹底层类型,零开销地获得一个全新类型,常用于:
- 区分语义相同的底层类型(避免把
UserId和SessionId混用)。 - 为外部类型实现外部 trait(孤儿规则的常见绕法)。
- 限制可见性、附加校验。
1 | |
🔄 对比:newtype ≈ Haskell 的
newtype,零开销语义相同;C++ 没有等价物(强 typedef 提案未通过),只能用继承或宏模拟。
更完整的 newtype 与类型状态机案例见《最佳实践、性能与调试》。
类型别名
使用 type 关键字创建类型别名,提高代码可读性。别名不创建新类型(与 newtype 不同),不会带来类型安全:
1 | |
⚠️ 注意:
Kilometers和i32可互换——编译器不区分。需要类型安全请用 newtype。
Never 类型 !
! 是「永不返回」的发散类型。它可以强制转换为任意类型,因此 panic!、return、break、loop {} 等表达式能与其他值类型统一:
1 | |
💡 提示:
!的「可转为任意类型」特性让match/if各分支类型统一变得自然——不需要额外包装。未来!将稳定为完整类型,可用于「不可能发生的状态」(如enum Void {}的替代)。
Sized 与 ?Sized
Sized 是标记 trait,表示编译期已知大小。所有泛型参数默认隐式绑定 T: Sized。?Sized 表示「可能不是 Sized」,允许泛型接受动态大小类型(DST)如 [T]、str、dyn Trait:
1 | |
💡 提示:
Box<T>、Rc<T>、&T、&mut T内部都用了T: ?Sized,所以它们能持有 DST。你日常写泛型时几乎不需要手动加?Sized,除非要写接受dyn Trait或str的通用函数。
递归类型与 Box
枚举或结构体若直接包含自身,大小无法在编译期确定(无限嵌套)。用 Box 把递归字段放到堆上,给出固定大小的指针即可打破:
1 | |
💡 提示:
Box在此的作用是提供固定大小的间接层(指针大小恒定),而非「堆分配」本身。Box的更多用法见《智能指针、迭代器与闭包》。
型变(Variance)(进阶)
型变描述「子类型关系如何沿着泛型/引用传播」。Rust 中生命周期存在子类型关系:'static 是任意 'a 的子类型(活得更长的引用可以替代活得较短的)。
| 型变 | 含义 | Rust 示例 |
|---|---|---|
| 协变 | 子类型关系与 T 同向 | &'a T 对 'a 和 T 协变——&'static str 可赋给 &'a str |
| 逆变 | 子类型关系与 T 反向 | fn(T) -> R 对参数 T 逆变 |
| 不变 | 子类型关系不传播 | &mut T 对 T 不变——防止通过可变引用写入短生命周期值后让长生命周期引用悬空 |
1 | |
💡 提示:型变通常由编译器自动处理,但在使用
unsafe、设计含生命周期的 API 时理解它至关重要。Cell<T>、UnsafeCell<T>内部可变性也是型变系统的关键(Cell<T>对T不变)。
🔬 进阶:为什么
&mut T必须不变?假设&mut &'static str能协变为&mut &'a str,你就可以把&'a str写入其中,但外部仍以为它是&'static str——生命周期被「拉长」,悬空引用由此产生。不变性是借用检查器安全保证的数学基础。
PhantomData(进阶)
PhantomData<T> 是零大小的标记类型,告诉编译器「假装我拥有/使用了 T」,从而影响型变、drop 检查与生命周期约束,而不实际存储 T:
1 | |
💡 提示:标准库中
Box<T>用PhantomData<T>表达对T的所有权语义(正确参与 drop 检查),Rc<T>用PhantomData<*const T>表达「非拥有但参与型变」。PhantomData的实战应用(类型状态机)见《最佳实践、性能与调试》。
🔬 进阶:
PhantomData<T>让结构体对T协变,PhantomData<*const T>让它不变(因为裸指针没有所有权语义,编译器保守选择不变)。选择哪种取决于你的类型是否应该「接受子类型替换」。
类型转换(简述)
Rust 不支持隐式类型转换,转换都通过 trait 显式完成,避免意外的精度丢失或语义变化:
| trait | 用途 | 失败时 |
|---|---|---|
From/Into | 不会失败的转换 | — |
TryFrom/TryInto | 可能失败的转换 | 返回 Result |
AsRef/AsMut | 廉价的引用转换 &T -> &U | — |
as | 基本数值类型强制转换 | 可能截断(静默) |
1 | |
⚠️ 注意:
as转换可能静默截断(如300_i32 as u8得到 44),建议优先用try_into()让截断显式化。各转换 trait 的完整定义与实现要点见《Rust 标准 Trait 详解》,From在错误传播中的关键作用见《错误处理与 Panic 恢复》。
延伸阅读:Rust 标准 Trait 详解(标记/派生/操作/迭代/IO/错误 trait 的逐一讲解与代码)。


