阶段五 · 泛型与特征

特征 Trait(下):分发,关联类型与标准库 trait

一句话总结

当需要"一个集合里装多种实现了同一 Trait 的不同类型",就用 Trait 对象 dyn Trait动态分发(对应 Go interface 的运行时多态).
本篇讲清静态分发与动态分发的取舍,关联类型,并巡礼几个必须认识的标准库 trait.

两种分发:静态 vs 动态

上一篇的 fn notify<T: Summary>(item: &T)静态分发(static dispatch):编译期通过单态化为每个具体类型生成代码,调用哪个方法在编译时就钉死了.快,但要求编译期已知具体类型.

可有时类型要到运行时才确定.例如一个"图形列表",混着圆形,矩形:

trait Draw {
fn draw(&self) -> String;
}

struct Circle { r: f64 }
struct Square { side: f64 }

impl Draw for Circle { fn draw(&self) -> String { format!("圆 r={}", self.r) } }
impl Draw for Square { fn draw(&self) -> String { format!("方 s={}", self.side) } }

想把它们放进同一个 Vec.但 Vec<T> 要求所有元素是同一类型,CircleSquare 是不同类型,放不进去.这时需要 Trait 对象.

Trait 对象:dyn Trait

dyn Draw 表示"某个实现了 Draw 的类型,具体是谁运行时才知道".因为大小不固定,必须放在指针后面,常见形式是 Box<dyn Trait>:

let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { side: 2.0 }),
];

for shape in &shapes {
println!("{}", shape.draw()); // 运行时根据实际类型选择 draw 实现
}

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:根源在编译期不知道大小

先对比静态分发和动态分发的本质区别:

// 静态分发:编译器知道 T = Dog,知道返回值是 Dog
fn clone_static<T: Cloneable>(t: &T) -> T { t.clone_me() }

// 动态分发:运行时只知道"这是一个 Cloneable",不知道具体是谁
fn clone_dynamic(t: &dyn Cloneable) -> ??? { t.clone_me() } // 返回什么?不知道!

问题的核心不在虚表能不能找到函数指针--能找到.关键在于调用方不知道返回值有多大:

impl Cloneable for [u8; 8]   { fn clone_me(&self) -> Self { *self } }  // 返回 8 字节
impl Cloneable for [u8; 1024] { fn clone_me(&self) -> Self { *self } } // 返回 1024 字节

fn bad(t: &dyn Cloneable) {
let copy = t.clone_me(); // 栈上该预留多少空间? 编译器无法确定
}

绕过方式:让返回值大小固定

如果确实需要"多态地克隆自己",把返回值包进已知大小的指针:

trait Cloneable {
fn clone_me(&self) -> Box<dyn Cloneable>; // Box 永远是固定大小(双指针)
}

判断标准:如果一个方法的签名在"不知道具体类型"的情况下无法编译--比如返回值大小不确定,泛型参数个数不确定--那它就不是对象安全的.

对象安全的完整规则

编译器检查的具体条件:

  1. 方法不能返回 Self
  2. 方法不能有泛型参数(因为每个类型参数需要不同的单态化版本,vtable 里放不下)
  3. 关联常量/关联类型不能有 Self 约束

遇到 "trait cannot be made into an object" 报错,对照这三条排查即可.

关联类型:Trait 内部的类型占位符

关联类型(associated type)让 Trait 内部声明一个"待定类型",由实现者指定.最经典的例子是标准库的 Iterator(第 09 篇用了一路它的方法,这里看清它的定义):

trait Iterator {
type Item; // 关联类型:迭代产出什么
fn next(&mut self) -> Option<Self::Item>;
}

struct Counter { count: u32 }

impl Iterator for Counter {
type Item = u32; // 实现时指定 Item = u32
fn next(&mut self) -> Option<u32> {
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}

关联类型表达"这个 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 篇提过)
use std::fmt;

struct Celsius(f64);

impl fmt::Display for Celsius {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}°C", self.0)
}
}

println!("{}", Celsius(25.0)); // 25°C

最佳实践:优先实现标准 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;优先实现它们以接入生态.

动手练习

  1. 动态分发:定义 trait Animal { fn speak(&self) -> String; },实现 Dog,Cat,放进 Vec<Box<dyn Animal>> 遍历调用.
  2. 静态分发对比:把同样逻辑改用泛型函数 fn announce<T: Animal>(a: &T) 写一遍,体会静态与动态分发的差异.
  3. Display 实现:给一个结构体实现 std::fmt::Display,用 {} 打印并调用 .to_string().
  4. 自定义 Iterator:实现一个自定义 Iterator(如斐波那契),指定关联类型 Item = u64,用 take(10) 打印前十项.
  5. From 转换:为一个类型实现 From<&str>,然后用 .into() 触发转换,体会标准 trait 接入生态的便利.