631 字
3 分钟
Trait 对象与单态化:两种多态的成本
Rust 同时提供了静态分发(泛型、impl Trait)和动态分发(dyn Trait)。两者的性能差异经常被简化为“静态快、动态慢”,但真正决定选择的是代码体积、编译时间和调用点的可预测性。
单态化做了什么
fn sum<T: Iterator<Item = u64>>(iter: T) -> u64 { iter.sum()}编译器为每个用到的 T 生成一份 sum 的机器码。具体类型在编译期已知,迭代器的 next 可以内联,边界检查可以消除,最终代码和手写循环差不多。
代价是:
- 每个具体类型一份代码,泛型层次深、类型多时二进制体积增长;
- 编译时间随实例化数量上升;
- 错误信息里的类型名会变得很长。
dyn Trait 做了什么
fn sum_dyn(iter: Box<dyn Iterator<Item = u64>>) -> u64 { iter.sum()}Box<dyn Iterator<Item = u64>> 是一个胖指针:数据指针加虚表指针。每次 next 都要查表并间接调用,编译器通常无法内联。好处是容器里可以放不同的迭代器实现,接口只依赖 trait。
dyn Trait 还有对象安全(object safety)的限制:不返回 Self、不含泛型方法、不在接收者上使用 where Self: Sized 的约束,才适合做成 trait 对象。
中间道路:枚举分发
当实现集合是封闭的,enum 往往比 dyn 更合适:
enum Backend { Tcp(TcpStream), Tls(TlsStream),}
impl Backend { async fn write(&mut self, buf: &[u8]) -> io::Result<usize> { match self { Backend::Tcp(s) => s.write(buf).await, Backend::Tls(s) => s.write(buf).await, } }}每个分支仍然可以内联,没有虚表;代价是每增加一种实现都要改 match。这正是“封闭集合用 enum、开放集合用 dyn”的经验法则。
怎么量
cargo bloat 能看到各函数在二进制里占多大;cargo llvm-lines 能看出单态化生成了多少 LLVM IR。两个工具配合,可以判断某处泛型是不是值得换成 dyn。
不过别只盯体积。一个每秒调用百万次的热路径,虚表间接调用的成本可能远大于几 KB 的代码;反过来,一个启动时执行一次的工厂函数,用 dyn 让编译时间减半更划算。
结论
静态分发是把类型信息用在做优化上,动态分发是把类型信息留到运行期。选哪种不是“性能 vs 灵活”的口号,而是看调用频率、实现集合是否封闭、以及二进制和编译时间是否真的构成问题。