Trait 对象与动态分发

本页目录

dyn Trait 隐藏具体实现类型,让调用方通过约定的接口操作对象。它适合统一处理不同实现或允许下游扩展;即使实现类型在编译时已知,也可以为了控制接口和代码规模主动选择它。

对象不必在堆上

&dyn Measure借用,不要求堆分配具体值可能在栈、堆或其他有效存储对应实现的 vtable提供可动态调用的方法信息trait object:数据与行为信息共同确定调用示意不规定 vtable 字段顺序或槽位;其内部布局不是稳定 Rust ABI。
trait Measure {
    fn amount(&self) -> usize;
}
impl Measure for usize {
    fn amount(&self) -> usize { *self }
}
impl Measure for String {
    fn amount(&self) -> usize { self.len() }
}
fn total(items: &[&dyn Measure]) -> usize {
    items.iter().map(|item| item.amount()).sum()
}

fn main() {
    let number = 7usize;
    let text = String::from("abc");
    let items: [&dyn Measure; 2] = [&number, &text];
    assert_eq!(total(&items), 10);
}

这里 number 和 String 对象是局部值,&dyn Measure 只借用它们。Box 则额外提供拥有关系;动态分发与堆分配是两个选择。String 自己的字节缓冲是否在堆上,又是 String 的存储问题。

trait object 属于动态大小类型,通常放在引用或智能指针后。对象指针携带数据地址与对应的 vtable 信息;动态方法调用通过相应函数入口完成。Trait object

不要手写“第 3 个槽一定是 fmt”并转型调用。vtable 的内部字段、空析构入口等实现细节不是稳定 ABI,也不是 Rust 插件跨编译器版本兼容的保证。

数据地址与方法入口一起配对

用同一个 amount 调用观察分发即可,不必把整个 vtable 的内部布局当成稳定接口。这里 usize 的实现返回数值本身,String 的实现返回字节长度;改变对象时,数据与实现入口一起变化,调用方仍只依赖 Measure。

正在呈现知识画面
数据地址与方法入口一起配对

对象引用保留数据和分发信息;&dyn Trait 不要求先把对象放进 Box。

性能代价需要放回调用上下文

动态调用通常需要间接分发,也可能影响跨调用优化;编译器在足够了解具体实现时仍可能去虚拟化。泛型通常更利于针对 T 优化,但实例增多可能增加代码体积。

enum 的 match 同样存在选择分支,其数据布局可能以最大变体为基础。不能仅用“有没有 vtable”判断整段程序谁更快:访问局部性、对象大小、分配数量和实际计算量都可能更重要。

把动态边界放在哪里

假设解析器处理一万个字节。如果接口是 fn parse(&self, input: &[u8]),一次动态调用后,具体实现内部仍可使用静态类型完成循环;若接口只暴露 fn next_byte(&mut self),调用方则可能在每个字节上都经过动态边界。两种接口都使用 dyn,但潜在调用次数不同。

这不意味着应该把所有方法合并成巨型函数。应先确定自然工作单元,例如一条消息、一批记录或一次渲染,再观察热路径。比较时还需控制对象存储:Vec<Box<dyn Trait>> 可能包含逐项分配和指针追踪,而连续的 enum 数组可能让大变体撑大每个元素;这些成本不能全部归因于分发方式。

dyn compatibility:哪些方法能通过擦除类型调用

这一概念以前称为 object safety。trait 自身不能要求 Self: Sized,父 trait 也要满足相应条件;关联常量和带泛型的关联类型等会限制 dyn 使用。可动态调用的方法不能有类型参数,不能在普通参数或返回值中依赖未知 Self,也不能直接返回不透明 impl Trait 或声明为 async fn。生命周期参数本身不等同于被禁止的类型参数。规则

不适合动态调用的方法,可以通过 where Self: Sized 明确排除,而不一定让整个 trait 都不可用于 dyn:

trait Label {
    fn label(&self) -> &str;
    fn duplicate(&self) -> Self where Self: Sized;
}
impl Label for String {
    fn label(&self) -> &str { self }
    fn duplicate(&self) -> Self { self.clone() }
}

fn main() {
    let value = String::from("hello");
    let object: &dyn Label = &value;
    assert_eq!(object.label(), "hello");
    assert_eq!(value.duplicate(), "hello");
    // object.duplicate() 不可调用:此方法只对 Sized 实现类型开放。
}

普通关联类型可以显式指定,例如 dyn Iterator<Item = u8>。Clone 本身不能直接变成 dyn Clone;Any 的 downcast 只解决类型识别,并不自动提供对象克隆能力。需要克隆对象时应另外设计返回 Box 的方法或使用合适的抽象。

固定关联输出,让调用方知道返回什么

擦除实现类型不等于擦除全部类型关系。下面的两种迭代器具有不同的内部状态,但都产生 u8;调用方知道每次 next 返回 Option<u8>,因此可以统一处理。

fn total(input: &mut dyn Iterator<Item = u8>) -> u64 {
    let mut result = 0u64;
    for value in input {
        result += u64::from(value);
    }
    result
}
fn main() {
    let mut range = 1u8..4;
    let mut values = vec![4u8, 5].into_iter();
    assert_eq!(total(&mut range), 6);
    assert_eq!(total(&mut values), 9);
    assert_eq!(range.next(), None);
}

只写 dyn Iterator 会缺少 Item 的指定;写成 dyn Iterator<Item = u8> 后,也不能往同一个位置放入产生 String 的迭代器。这里统一的是迭代协议和输出类型,内部实现仍各不相同。例子的总和仍受 u64 范围限制,与使用泛型时一样需要按业务处理溢出。

理解方法限制时,可以检查调用方是否能够确定调用签名:返回 Self 会依赖已隐藏的具体类型;方法类型参数又允许调用者选择许多不同实例。where Self: Sized 将这类方法留给具体类型调用,表示对象接口有意只开放 trait 的一部分。它不会使被排除的方法突然能够通过对象调用。

对象生命周期与线程约束

Box 在常见类型位置默认采用 'static 对象生命周期约束,不能因此装入任意短借用的实现;可按接口写 Box<dyn Trait + 'a>。这不意味着 Box 必须存活到程序结束,而是其中不能藏着不满足约束的借用。

跨线程时还要显式考虑 dyn Trait + Send 或 + Sync。具体类型原本实现这些 auto trait,不代表擦除后的接口自动保留所有能力;这与外层使用 Box 还是 Arc 分别有关。

拥有容器与拥有全部数据是两件事

Box<dyn Text + 'a>释放时销毁被拥有的对象Borrowed<'a>堆中保存一个 &strString 的字节缓冲由外部 source 拥有拥有借用先结束 object 的使用,再释放 sourceBox 拥有包装对象,包装对象仍可能借用外部数据给 dyn 写生命周期约束,不会复制字符串或延长 source 的寿命示意区分两条关系;对象指针的 vtable 信息省略,地址与布局不按比例绘制。
trait Text {
    fn text(&self) -> &str;
}
struct Borrowed<'a>(&'a str);
impl Text for Borrowed<'_> {
    fn text(&self) -> &str { self.0 }
}
fn make<'a>(source: &'a str) -> Box<dyn Text + 'a> {
    Box::new(Borrowed(source))
}
fn main() {
    let source = String::from("borrowed text");
    {
        let object = make(&source);
        assert_eq!(object.text(), "borrowed text");
    }
    assert_eq!(source, "borrowed text");
}

Box 负责销毁 Borrowed,Borrowed 的字段却只是对 source 的借用。若让 object 逃出 source 的有效范围,借用检查会拒绝;把返回类型改成没有合适生命周期约束的 Box<dyn Text>,也不能凭空把这个借用提升为 'static。另一种合法设计是让包装对象保存 String 并取得其所有权,但它改变了数据的拥有关系。

类型形式需要分开看的约束
&'a (dyn Text + 'b)外层引用可使用多久,以及隐藏实现中的借用须满足什么约束
&'a dyn Text通常由外层引用推导对象生命周期边界
Box<dyn Text + 'a>Box 拥有对象,但对象可包含满足 'a 的借用
Box<dyn Text>无其他推导约束的类型位置通常默认 'static;局部表达式可以参与推导

不要将所有省略都简化成“dyn 默认 'static”。包含它的类型、trait 自身约束、表达式上下文都会参与默认规则;需要借用接口时,明确写出边界通常更易读。默认对象生命周期

Arc 只负责共享拥有,不替内部对象提供线程安全

Arc<dyn Text + Send + Sync> 可以表达可在线程间共享的接口,但实际装入的类型也必须满足对应约束。Arc 的引用计数是线程安全的,不意味着 RefCell<T> 等内部可变状态因此可以跨线程共享;数据访问仍应由合适的同步机制保护。Arc 线程安全

擦除时应保留之后需要的约束。若只得到 Box<dyn Text>,不能仅凭“最初装入的是 String”就让编译器把它当作 Box<dyn Text + Send>。此外,Send、Sync 与 'static 回答不同问题:可在线程间移动、可共享引用、是否包含受限借用,不能相互替代。

向父 trait 收缩接口

Rust 1.86 起稳定支持 trait upcasting:可以把子 trait 对象转换为其父 trait 对象。这样通用函数只依赖基本接口,而扩展功能保留给需要它的调用方。Rust 1.86 发布说明

trait Named {
    fn name(&self) -> &str;
}
trait Detailed: Named {
    fn revision(&self) -> u32;
}
struct Document;
impl Named for Document {
    fn name(&self) -> &str { "guide" }
}
impl Detailed for Document {
    fn revision(&self) -> u32 { 3 }
}
fn display(value: &dyn Named) -> String {
    format!("name={}", value.name())
}
fn main() {
    let document = Document;
    let detailed: &dyn Detailed = &document;
    let named: &dyn Named = detailed;
    assert_eq!(detailed.revision(), 3);
    assert_eq!(display(named), "name=guide");
}

这一步收缩可见接口,并没有恢复 Document 类型,也不授权反向转换。dyn Read + Write 这种直接拼接两个非 auto trait 的写法不成立;可以声明一个同时继承两者的基础 trait,再为实现者提供适当实现。对象类型可在一个基础 trait 之外附加 Send、Sync 等 auto trait。对象类型约束

若确实需要识别具体类型,可以设计 Any 接口,但 Any 要求 'static,不适合任意携带短借用的对象。频繁 downcast 还可能说明业务操作没有进入公共 trait;应先检查能否直接表达所需行为。Any

enum、泛型和 dyn 的选择

方案谁定义实现集合常见优势要考虑的成本
enum + match类型定义者列出变体穷尽检查、集中描述状态增加变体会影响匹配与布局
泛型 T: Trait调用方选择具体 T类型信息充分、易专门优化实例数量与接口类型复杂度
dyn Trait满足接口的实现者异构对象与可扩展接口动态分发、生命周期与对象兼容性

集合是否开放、是否需要拥有对象、能否接受动态调用,是比“类型在编译时知不知道”更有用的决策问题。相关机制见 单态化。