Rust Trait Object详解:dyn Trait与动态分发

0 阅读14分钟

《Rust编程实战》系列第46篇
上一篇文章中,我们学习了Rust的Trait,知道Trait可以定义不同类型共享的行为。例如:

trait Summary {
    fn summary(&self) -> String;
}

然后让不同类型分别实现:

struct User {
    name: String,
}
struct Product {
    name: String,
}
impl Summary for User {
    fn summary(&self) -> String {
        format!("用户:{}", self.name)
    }
}
impl Summary for Product {
    fn summary(&self) -> String {
        format!("商品:{}", self.name)
    }
}

如果函数使用泛型:

fn show<T: Summary>(value: &T) {
    println!("{}", value.summary());
}

编译器在编译阶段能够知道T到底是什么具体类型,这种方式称为静态分发。但真实项目中还有另一类需求:

程序运行时才知道实际使用哪个具体类型。
例如一个通知系统可能同时保存:

Email
Sms
WeChat

它们都是完全不同的Struct,但都实现Notification。我们希望把它们放进同一个集合,然后统一调用:

sender.send();

这时就需要Rust中的:

Trait Object

以及非常常见的:

dyn Trait

本文将介绍:

  • 什么是Trait Object

  • dyn Trait

  • &dyn Trait

  • Box<dyn Trait>

  • 不同类型为什么不能直接放入同一个Vec

  • 动态分发

  • Trait Object内存结构

  • vtable虚函数表

  • Object Safety

  • Box<dyn Trait>所有权

  • Arc<dyn Trait>共享

  • Trait Object与泛型区别

  • 通知、支付和插件系统实战

什么是Trait Object

Trait Object可以简单理解为:

一个具体类型暂时被隐藏,只通过某个Trait提供的行为访问它。
例如定义:

trait Animal {
    fn speak(&self);
}

两个不同类型:

struct Dog;
struct Cat;

分别实现:

impl Animal for Dog {
    fn speak(&self) {
        println!("Dog: Wang");
    }
}
impl Animal for Cat {
    fn speak(&self) {
        println!("Cat: Miao");
    }
}

如果使用:

&dyn Animal

调用者只知道:

这个对象实现了Animal

至于实际是:

Dog
Cat

可以在运行时决定。

dyn Trait是什么

Rust使用:

dyn TraitName

表示Trait Object类型。
例如:

dyn Animal
dyn Summary
dyn Payment
dyn Notification

这里的:

dyn

可以理解为:

dynamic
动态

说明方法调用可能需要在运行时根据真实类型决定调用哪个实现。
注意,通常不能直接写:

let animal: dyn Animal;

因为dyn Animal本身是动态大小类型,编译阶段不知道具体对象有多大。
因此通常需要放在某种指针后面,例如:

&dyn Animal
Box<dyn Animal>
Arc<dyn Animal>

&dyn Trait

最简单的Trait Object形式是:

&dyn Trait

例如:

fn make_sound(animal: &dyn Animal) {
    animal.speak();
}

调用:

fn main() {
    let dog = Dog;
    let cat = Cat;
    make_sound(&dog);
    make_sound(&cat);
}

make_sound()并不知道接收到的是Dog还是Cat。
它只知道参数实现了:

Animal

因此可以调用:

animal.speak();

这就是动态多态。

Trait Object与泛型的区别

泛型版本:

fn make_sound<T: Animal>(
    animal: &T,
) {
    animal.speak();
}

Trait Object版本:

fn make_sound(
    animal: &dyn Animal,
) {
    animal.speak();
}

两者都可以接受Dog和Cat,但实现机制不同。
泛型:

编译阶段确定具体类型
↓
静态分发

Trait Object:

运行阶段确定具体实现
↓
动态分发

泛型通常性能更高,而Trait Object更加灵活。

为什么不同类型不能直接放入Vec

假设:

let dog = Dog;
let cat = Cat;

很多初学者可能想:

let animals = vec![
    dog,
    cat,
];

但这通常无法通过编译。
原因是:

Vec<T>

要求所有元素拥有相同类型。
Dog和Cat虽然都实现Animal,但它们仍然是两个不同的具体类型:

Dog != Cat

Rust不会因为它们实现了同一个Trait,就自动把它们当成相同类型。

使用Box保存不同类型

可以通过:

Box<dyn Animal>

把不同具体类型统一成Trait Object。
例如:

fn main() {
    let animals: Vec<Box<dyn Animal>> = vec![
        Box::new(Dog),
        Box::new(Cat),
    ];
    for animal in animals {
        animal.speak();
    }
}

这里Vec元素类型统一为:

Box<dyn Animal>

虽然Box内部实际可能分别保存:

Dog
Cat
Dog
Cat

但调用者统一通过Animal接口访问。

为什么需要Box

问题在于不同类型的大小可能不同。
例如:

struct Dog {
    name: String,
}
struct Cat {
    age: u32,
}

Dog和Cat占用的内存大小不同。
而Vec要求每个元素大小一致。
使用:

Box<T>

以后,Vec中保存的其实是指针。
指针大小固定,因此:

Vec<Box<dyn Animal>>

可以统一保存不同大小的具体对象。
实际对象存放在堆上。

Box与所有权

看下面代码:

let dog = Dog;
let animal: Box<dyn Animal> =
    Box::new(dog);

这里Dog所有权Move到Box中。
之后原来的:

dog

不能继续使用。
Box现在负责拥有Dog。
当Box离开作用域后,内部Dog也会自动释放。
所以:

Box<dyn Trait>

不仅提供动态分发,也表示:

Trait Object拥有具体对象。

&dyn Trait与Box区别

&dyn Trait

fn show(value: &dyn Summary)

只是借用一个已经存在的对象。
对象所有权仍然在外部。
Box<dyn Trait>

fn save(value: Box<dyn Summary>)

通常会取得对象所有权。
可以简单理解:

&dyn Trait
临时借用动态对象
Box<dyn Trait>
拥有动态对象

动态分发是什么

假设:

animal.speak();

如果animal是:

&dyn Animal

编译器在编译make_sound()时并不知道它到底是:

Dog
还是
Cat

因此不能直接确定具体调用:

Dog::speak

还是:

Cat::speak

程序会在运行时根据对象携带的信息找到正确方法。
这种机制称为:

Dynamic Dispatch
动态分发

Trait Object的内存结构

一个Trait Object引用通常可以简单理解为包含两个指针:

数据指针
+
vtable指针

例如:

&dyn Animal

概念上类似:

┌─────────────────┐
│ data pointer    │──> Dog对象
├─────────────────┤
│ vtable pointer  │──> Dog的Animal实现信息
└─────────────────┘

因此Trait Object常被称为:

Fat Pointer
胖指针

普通引用:

&Dog

主要只需要知道数据在哪里。
而:

&dyn Animal

还需要知道应该调用哪一组Trait方法。

什么是vtable

vtable通常称为:

Virtual Method Table
虚函数表

可以简单理解成一张方法地址表。
例如Dog实现Animal:

impl Animal for Dog {
    fn speak(&self) {
        println!("Dog");
    }
}

Cat实现Animal:

impl Animal for Cat {
    fn speak(&self) {
        println!("Cat");
    }
}

Dog对应的vtable会指向:

Dog::speak

Cat对应的vtable会指向:

Cat::speak

当执行:

animal.speak()

时,就可以通过vtable找到正确的方法。
初学阶段不需要深入底层实现,只需要知道:

Trait Object通过运行时方法表实现动态分发。

静态分发与动态分发

静态分发通常使用:

fn show<T: Summary>(value: &T)

或者:

fn show(value: &impl Summary)

特点:

  • 编译时知道具体类型

  • 编译器可能生成多个具体版本

  • 容易内联优化

  • 通常性能更高
    动态分发:

fn show(value: &dyn Summary)

特点:

  • 运行时确定具体方法

  • 可以统一处理不同具体类型

  • 存在一次间接调用

  • 更适合插件式架构
    所以并不是Trait Object一定比泛型更高级,而是解决不同问题。

什么时候适合使用Trait Object

最典型场景是:

运行时需要保存多个不同类型

例如:

  • GUI组件

  • 插件系统

  • 支付方式

  • 日志后端

  • 消息通知

  • 数据存储驱动

  • 不同任务处理器
    例如:

Vec<Box<dyn Plugin>>

非常适合插件系统。

Trait Object方法

例如:

trait Notification {
    fn send(&self, message: &str);
}

Email:

struct Email {
    address: String,
}
impl Notification for Email {
    fn send(&self, message: &str) {
        println!(
            "邮件发送到{}:{}",
            self.address,
            message
        );
    }
}

Sms:

struct Sms {
    phone: String,
}
impl Notification for Sms {
    fn send(&self, message: &str) {
        println!(
            "短信发送到{}:{}",
            self.phone,
            message
        );
    }
}

统一保存:

let senders:
    Vec<Box<dyn Notification>> = vec![
        Box::new(Email {
            address: String::from(
                "test@example.com"
            ),
        }),
        Box::new(Sms {
            phone: String::from(
                "13800000000"
            ),
        }),
    ];

发送:

for sender in &senders {
    sender.send("系统通知");
}

Trait Object可以使用&mut

如果Trait方法需要修改对象:

trait Counter {
    fn increment(&mut self);
    fn value(&self) -> u32;
}

实现:

struct NumberCounter {
    value: u32,
}
impl Counter for NumberCounter {
    fn increment(&mut self) {
        self.value += 1;
    }
    fn value(&self) -> u32 {
        self.value
    }
}

使用:

let mut counter:
    Box<dyn Counter> =
    Box::new(NumberCounter {
        value: 0,
    });
counter.increment();
println!("{}", counter.value());

说明Trait Object同样支持:

&mut dyn Trait
Box<dyn Trait>

返回Box

上一篇中提到:

fn create() -> impl Summary

通常必须返回同一个具体类型。
如果不同分支需要返回不同类型,可以使用:

fn create(
    user: bool,
) -> Box<dyn Summary> {
    if user {
        Box::new(User {
            name: String::from("Tom"),
        })
    } else {
        Box::new(Product {
            name: String::from("Rust Book"),
        })
    }
}

虽然一个分支返回User,一个分支返回Product,但最终统一转换成:

Box<dyn Summary>

所以函数返回类型一致。

Trait Object与生命周期

Trait Object同样会涉及生命周期。
例如:

fn show(value: &dyn Summary) {
    println!("{}", value.summary());
}

这里只是临时借用对象,生命周期通常由Rust自动推断。
如果Trait Object内部持有引用,则可能需要显式生命周期:

struct Message<'a> {
    text: &'a str,
}

然后:

Box<dyn Summary + 'a>

表示Trait Object内部可能包含生命周期为'a的引用。
普通业务对象使用拥有型字段如String时,生命周期通常更加简单。

Box<dyn Trait + Send>

多线程程序中经常看到:

Box<dyn Task + Send>

意思是:

这个Trait Object不仅实现Task,还必须能够安全地在线程之间移动。
如果还需要线程安全共享,可能看到:

Arc<dyn Task + Send + Sync>

这里:

Send
允许跨线程转移所有权
Sync
允许多个线程安全共享引用

这些也是Trait。

Arc

如果同一个Trait Object需要被多个所有者共享,可以使用:

Arc<dyn Trait>

例如:

use std::sync::Arc;
trait Logger {
    fn log(&self, message: &str);
}
struct ConsoleLogger;
impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("{}", message);
    }
}
fn main() {
    let logger: Arc<dyn Logger> =
        Arc::new(ConsoleLogger);
    let first = Arc::clone(&logger);
    let second = Arc::clone(&logger);
    first.log("first");
    second.log("second");
}

Arc本身不会复制底层Logger,只会增加引用计数。
这种方式在:

  • Web服务器

  • 异步任务

  • 多线程服务

  • 共享日志组件
    中非常常见。

Trait Object并不是所有Trait都能直接使用

并不是任何Trait都可以直接写:

dyn Trait

一个Trait必须满足Trait Object能够动态调用的相关规则。
传统上这类规则经常被称为:

Object Safety
对象安全

现代Rust文档也会使用:

dyn compatibility

来描述Trait是否能够作为dyn Trait使用。
初学阶段可以重点注意几个典型限制。

返回Self的方法可能影响Trait Object

例如:

trait CloneLike {
    fn clone_value(&self) -> Self;
}

这里:

Self

到底是Dog还是Cat,Trait Object本身并不知道具体大小。
因此这种方法通常不能直接通过:

dyn CloneLike

动态调用。
如果某个方法只允许具体类型使用,可以添加:

where
    Self: Sized

例如:

trait Example {
    fn create() -> Self
    where
        Self: Sized;
}

这样Trait仍可能用于Trait Object,但这个方法不会通过Trait Object调用。

泛型方法会影响Trait Object

例如:

trait Processor {
    fn process<T>(&self, value: T);
}

这个方法本身又有泛型参数T
运行时vtable无法简单地为无限可能的T准备统一方法入口,因此这种Trait通常不能直接作为普通动态接口使用。
实际设计动态接口时,更常见的是把参数本身也设计成明确类型或Trait Object。

Trait Object接口设计

适合作为Trait Object的Trait通常比较简单:

trait Plugin {
    fn name(&self) -> &str;
    fn run(&self);
}

这种Trait:

  • 方法数量清晰

  • 参数类型明确

  • 使用&self&mut self

  • 不大量返回Self
    非常适合:

Box<dyn Plugin>

实战:支付系统

定义统一支付接口:

trait Payment {
    fn name(&self) -> &str;
    fn pay(
        &self,
        amount: f64,
    ) -> Result<(), String>;
}

银行卡:

struct BankCard {
    number: String,
}
impl Payment for BankCard {
    fn name(&self) -> &str {
        "银行卡"
    }
    fn pay(
        &self,
        amount: f64,
    ) -> Result<(), String> {
        println!(
            "{}支付:{},卡号:{}",
            self.name(),
            amount,
            self.number
        );
        Ok(())
    }
}

余额:

struct Balance {
    user_id: u64,
}
impl Payment for Balance {
    fn name(&self) -> &str {
        "余额"
    }
    fn pay(
        &self,
        amount: f64,
    ) -> Result<(), String> {
        println!(
            "用户{}余额支付:{}",
            self.user_id,
            amount
        );
        Ok(())
    }
}

统一集合:

let payments:
    Vec<Box<dyn Payment>> = vec![
        Box::new(BankCard {
            number: String::from(
                "62220001"
            ),
        }),
        Box::new(Balance {
            user_id: 1001,
        }),
    ];

执行:

for payment in payments {
    payment.pay(100.0).unwrap();
}

支付方式可以动态扩展,而调用代码不需要为每种类型分别编写逻辑。

实战:插件系统

插件架构是Trait Object的典型场景。

trait Plugin {
    fn name(&self) -> &str;
    fn execute(&self);
}

插件一:

struct SeoPlugin;
impl Plugin for SeoPlugin {
    fn name(&self) -> &str {
        "SEO"
    }
    fn execute(&self) {
        println!("执行SEO插件");
    }
}

插件二:

struct CachePlugin;
impl Plugin for CachePlugin {
    fn name(&self) -> &str {
        "Cache"
    }
    fn execute(&self) {
        println!("执行缓存插件");
    }
}

插件管理器:

struct PluginManager {
    plugins: Vec<Box<dyn Plugin>>,
}
impl PluginManager {
    fn new() -> Self {
        Self {
            plugins: Vec::new(),
        }
    }
    fn add(
        &mut self,
        plugin: Box<dyn Plugin>,
    ) {
        self.plugins.push(plugin);
    }
    fn run(&self) {
        for plugin in &self.plugins {
            println!(
                "运行插件:{}",
                plugin.name()
            );
            plugin.execute();
        }
    }
}

使用:

let mut manager =
    PluginManager::new();
manager.add(Box::new(SeoPlugin));
manager.add(Box::new(CachePlugin));
manager.run();

以后增加新插件,只需要:

定义Struct
↓
实现Plugin
↓
加入PluginManager

主流程无需大幅修改。

Trait Object与Enum怎么选择

假设程序只有固定几种类型:

Email
Sms
WeChat

也可以使用:

enum Sender {
    Email(Email),
    Sms(Sms),
    WeChat(WeChat),
}

Enum优点:

  • 类型集合固定

  • match可以完整检查

  • 通常没有动态分发
    Trait Object优点:

  • 更容易扩展新类型

  • 调用者不必修改match

  • 适合插件化结构
    可以简单判断:

类型集合固定
→ Enum
类型未来可能不断扩展
→ Trait Object

Trait Object与泛型怎么选择

泛型:

fn run<T: Plugin>(plugin: &T)

适合:

  • 编译时知道具体类型

  • 追求性能

  • 希望静态分发
    Trait Object:

fn run(plugin: &dyn Plugin)

适合:

  • 运行时决定具体类型

  • 一个集合保存不同类型

  • 插件式架构

  • 减少泛型传播
    没有哪一种永远更好,应根据场景选择。

Trait Object性能成本

动态分发通常需要通过vtable进行一次间接方法调用,因此相比静态分发可能少一些优化机会。
另外:

Box<dyn Trait>

通常意味着对象位于堆上,需要一次堆内存分配。
但在很多业务程序中,这点成本往往远小于:

  • 数据库查询

  • HTTP请求

  • 文件IO

  • 网络通信
    因此不要因为动态分发存在少量开销就完全避免Trait Object。
    应该优先考虑API和架构是否清晰。

常见错误

直接把Trait当普通类型

错误:

fn show(value: Summary)

应该根据场景使用:

fn show(value: &dyn Summary)

或者:

fn show(value: &impl Summary)

Vec直接保存不同Struct

错误:

vec![    Dog,    Cat,]

如果类型不同,可以使用:

Vec<Box<dyn Animal>>

返回impl Trait时返回不同类型

错误:

fn create(flag: bool) -> impl Animal {
    if flag {
        Dog
    } else {
        Cat
    }
}

可以考虑:

fn create(
    flag: bool,
) -> Box<dyn Animal>

所有场景都使用Trait Object

如果类型在编译阶段已经确定,泛型通常更简单且更容易优化。

为简单固定状态设计Trait Object

只有几个固定业务状态时,Enum往往更加直观。

Trait设计得无法动态使用

大量泛型方法、返回Self等设计可能让Trait无法作为普通dyn Trait使用。设计动态接口时应尽量保持方法签名清晰。

Trait Object最佳实践

动态集合使用Box

例如:

Vec<Box<dyn Plugin>>

是最典型的用法。

只临时调用使用&dyn Trait

例如:

fn run(plugin: &dyn Plugin)

不需要取得所有权。

多线程共享使用Arc

例如:

Arc<dyn Service + Send + Sync>

在服务端程序中非常常见。

Trait保持职责单一

推荐:

trait Read {}
trait Write {}

不要设计一个包含几十个无关方法的巨大Trait。

编译阶段类型已知优先泛型

使用:

T: Trait

可以获得静态分发。

运行时多态再使用dyn Trait

不要为了“面向对象风格”而无条件使用Trait Object。

Enum和Trait Object合理配合

业务固定状态使用Enum,可扩展能力使用Trait Object,往往能得到更加清晰的架构。

本章小结

Trait Object是Rust实现运行时多态的重要工具。
本文学习了:

  • dyn Trait表示Trait Object

  • Trait Object隐藏具体实现类型

  • &dyn Trait用于临时借用动态对象

  • Box<dyn Trait>用于拥有动态对象

  • Vec<Box<dyn Trait>>可以保存不同具体类型

  • Trait Object通过动态分发调用方法

  • Trait Object引用通常包含数据指针和vtable指针

  • vtable负责找到具体类型的方法实现

  • 泛型通常属于静态分发

  • Trait Object属于动态分发

  • 并非所有Trait都适合作为Trait Object

  • 返回Self和泛型方法可能影响动态调用

  • Arc<dyn Trait + Send + Sync>常用于并发共享

  • Trait Object适合插件、支付、通知等可扩展系统

  • Enum适合固定类型集合,Trait Object适合开放扩展
    可以记住:

Trait定义共同能力,Trait Object把不同具体类型统一成同一个动态接口。
编译时知道类型时优先泛型;运行时需要保存和处理多种不同类型时考虑dyn Trait
Box<dyn Trait>是Rust插件化、策略模式和动态组件系统中非常常见的基础结构。

下一篇预告

下一篇我们将学习Rust类型系统中另一项核心能力:

Rust 泛型 Generics详解:编写可复用且类型安全的代码

内容包括:

  • 什么是泛型

  • 泛型函数

  • 泛型Struct

  • 泛型Enum

  • 泛型impl

  • 多个泛型参数

  • Trait Bound

  • where约束

  • 泛型与生命周期

  • 泛型与Trait

  • 泛型与Trait Object区别

  • 单态化Monomorphization

  • 泛型性能原理

  • Repository、Response、分页数据等实战案例