《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、分页数据等实战案例