一句话总结
当需要"一个集合里装多种实现了同一 Trait 的不同类型",就用 Trait 对象 dyn Trait 和动态分发(对应 Go interface 的运行时多态).
本篇讲清静态分发与动态分发的取舍,关联类型,并巡礼几个必须认识的标准库 trait.
两种分发:静态 vs 动态
上一篇的 fn notify<T: Summary>(item: &T) 是静态分发(static dispatch):编译期通过单态化为每个具体类型生成代码,调用哪个方法在编译时就钉死了.快,但要求编译期已知具体类型.
可有时类型要到运行时才确定.例如一个"图形列表",混着圆形,矩形:
|
想把它们放进同一个 Vec.但 Vec<T> 要求所有元素是同一类型,Circle 和 Square 是不同类型,放不进去.这时需要 Trait 对象.
Trait 对象:dyn Trait
dyn Draw 表示"某个实现了 Draw 的类型,具体是谁运行时才知道".因为大小不固定,必须放在指针后面,常见形式是 Box<dyn Trait>:
|
Vec<Box<dyn Draw>> 几乎就是 Go 的 []Draw(一个 interface 切片).
Go 的 interface 变量底层是"类型指针 + 数据指针"的胖指针;Rust 的 Trait 对象底层是"数据指针 + 虚表(vtable)指针",原理一致.
动态分发的工作原理与取舍
Trait 对象通过虚表(vtable) 实现运行时方法查找:每个 Box<dyn Draw> 携带一个指向虚表的指针,虚表里存着该具体类型各方法的地址.调用 shape.draw() 时,运行时查表,跳转.
静态分发 <T: Trait> |
动态分发 dyn Trait |
|
|---|---|---|
| 决定时机 | 编译期 | 运行时(查虚表) |
| 性能 | 零开销,可内联 | 一次指针跳转,不可内联 |
| 编译产物 | 每类型一份,体积大 | 共用一份,体积小 |
| 适用场景 | 编译期已知具体类型 | 异构集合,运行时才定类型 |
最佳实践:默认静态分发
除非确实需要"异构集合"或"运行时决定类型",否则优先用泛型 + 静态分发,把性能和内联机会留给编译器.
需要把不同类型装进同一容器时,才请出 dyn Trait.Go 没有这个选择--interface 一律动态分发;Rust 给了按场景权衡的自由.
对象安全:并非所有 Trait 都能做对象
一个细节坑:只有满足对象安全(object safe) 的 Trait 才能写成 dyn Trait.直觉上最常踩的两条规则:
- 方法不能返回
Self(即具体实现类型) - 方法不能有泛型参数
为什么不能返回 Self:根源在编译期不知道大小
先对比静态分发和动态分发的本质区别:
|
问题的核心不在虚表能不能找到函数指针--能找到.关键在于调用方不知道返回值有多大:
|
绕过方式:让返回值大小固定
如果确实需要"多态地克隆自己",把返回值包进已知大小的指针:
|
判断标准:如果一个方法的签名在"不知道具体类型"的情况下无法编译--比如返回值大小不确定,泛型参数个数不确定--那它就不是对象安全的.
对象安全的完整规则
编译器检查的具体条件:
- 方法不能返回
Self - 方法不能有泛型参数(因为每个类型参数需要不同的单态化版本,vtable 里放不下)
- 关联常量/关联类型不能有
Self约束
遇到 "trait cannot be made into an object" 报错,对照这三条排查即可.
关联类型:Trait 内部的类型占位符
关联类型(associated type)让 Trait 内部声明一个"待定类型",由实现者指定.最经典的例子是标准库的 Iterator(第 09 篇用了一路它的方法,这里看清它的定义):
|
关联类型表达"这个 Trait 对某类型而言,只有唯一一组配套类型".
一个 Counter 迭代只可能产出 u32,不会既产出 u32 又产出 String.用关联类型把这种"唯一性"写进类型系统,比泛型参数更贴切,也让调用方少写类型标注.
为什么这对开发者重要
第 09 篇的 map,filter,collect 全部建立在 Iterator Trait 和它的关联类型 Item 之上.
一旦为自己的类型实现了 Iterator,它就自动获得那几十个适配器方法--这就是为什么自定义迭代器只需写一个 next.
必须认识的标准库 Trait
Rust 标准库用 trait 定义了大量"通用能力".这些 trait 会反复遇到,提前混个脸熟:
| Trait | 含义 | 实现后能做什么 |
|---|---|---|
Display |
面向用户的格式化 | 用 {} 打印,.to_string() |
Debug |
面向调试的格式化 | 用 {:?} 打印(通常 derive) |
From / Into |
类型转换 | T::from(x) / x.into(),错误处理的 ? 靠它 |
Default |
默认值 | T::default() |
Iterator |
可迭代 | 获得全套迭代器适配器 |
Drop |
自定义析构 | 离开作用域时执行清理(第 03 篇提过) |
|
最佳实践:优先实现标准 trait,而非自造方法
想让类型"可打印",实现 Display,而不是自造一个 fn print_me();想支持类型转换,实现 From,而不是自造 fn from_xxx().
实现标准 trait 能让类型无缝接入整个生态--From 让 ? 自动转换错误(第 14 篇),Display 让它能用于任何接受可显示类型的地方.这是 Rust 代码"地道"的重要标志.
快速回顾
- 静态分发(
<T: Trait>):编译期定方法,零开销,需编译期已知类型. - 动态分发(
dyn Trait):运行时查虚表,支持异构集合,对应 Go interface;默认优先静态分发. - Trait 对象:大小不定,需置于
Box<dyn Trait>,&dyn Trait等指针后. - 对象安全:返回
Self或带泛型方法的 Trait 不能做对象. - 关联类型:Trait 内唯一配套类型(如
Iterator::Item),实现它即获全套适配器. - 标准库 trait:
Display/Debug/From/Default/Iterator/Drop;优先实现它们以接入生态.
动手练习
- 动态分发:定义
trait Animal { fn speak(&self) -> String; },实现Dog,Cat,放进Vec<Box<dyn Animal>>遍历调用. - 静态分发对比:把同样逻辑改用泛型函数
fn announce<T: Animal>(a: &T)写一遍,体会静态与动态分发的差异. - Display 实现:给一个结构体实现
std::fmt::Display,用{}打印并调用.to_string(). - 自定义 Iterator:实现一个自定义
Iterator(如斐波那契),指定关联类型Item = u64,用take(10)打印前十项. - From 转换:为一个类型实现
From<&str>,然后用.into()触发转换,体会标准 trait 接入生态的便利.