Rust trait 对象(dyn)
在 trait 那一页,你写过接收“任何实现了 Speak 的类型”的函数。在泛型那一页,你看到 Rust 会为每种类型单独生成一份泛型函数。如果在编译时就知道类型,这两种做法都很好用。可要是你想要一个能装下多种不同类型的列表呢?
这就是 trait 对象(trait object)的用武之地。想象一位音乐老师,教室里坐满了学生:有人弹钢琴,有人拉小提琴,有人打鼓。老师不需要知道每个学生拿的是什么乐器,只要说一声“开始演奏!”,每个人就用自己的方式演奏起来。trait 对象让你的代码也能这样做:“我不知道它的确切类型,但我知道它会做这件事。”
问题:一个 Vec,不同的类型
Section titled “问题:一个 Vec,不同的类型”假设你有两种图形,想把它们放进同一个 Vec:
struct Circle { radius: f64 }struct Square { side: f64 }
fn main() { let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];}这段代码无法编译:
error[E0308]: mismatched types --> src/main.rs:5:47 |5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }]; | ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`编译器说:类型不匹配,这里期望的是 Circle,却得到了 Square。一个 Vec 只能装一种类型。每个元素在内存里的大小都必须相同,Rust 才能把它们一个挨一个地排好。Circle 和 Square 是不同的类型,所以没法共用一个普通的 Vec。
Box<dyn Trait>:装着不同图形的列表
Section titled “Box<dyn Trait>:装着不同图形的列表”解决办法分两步。第一,给两种图形一个共同的 trait。第二,把每个图形放进 Box,并把类型写成 Box<dyn Shape>:
trait Shape { fn name(&self) -> String; fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn name(&self) -> String { String::from("圆形") } fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn name(&self) -> String { String::from("正方形") } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 1.0 }), ];
for shape in &shapes { println!("{}的面积是 {:.2}", shape.name(), shape.area()); }}输出:
圆形的面积是 12.57正方形的面积是 9.00圆形的面积是 3.14我们把 Box<dyn Shape> 拆开来一块块看:
dyn Shape的意思是“某个实现了Shape的类型,具体是哪个,等程序运行时才知道”。dyn是 dynamic(动态)的缩写。Box把值放到堆上,自己保存一个指向它的指针(你在智能指针那一页见过Box)。不管里面装的是什么,每个Box的大小都一样。所以现在Vec就满意了。
盒子里的值(再加上一个隐藏的指针,用来说明“这个是 Circle”)就是我们所说的 trait 对象。
&dyn Trait:借用,而不是装箱
Section titled “&dyn Trait:借用,而不是装箱”你并不总是需要 Box。如果值已经存放在别的地方,用引用也可以。&dyn Shape 的意思是“指向某个实现了 Shape 的类型的借用引用”:
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side }}
fn print_area(shape: &dyn Shape) { println!("面积:{:.1}", shape.area());}
fn main() { let circle = Circle { radius: 1.0 }; let square = Square { side: 4.0 };
print_area(&circle); print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square]; println!("一共有 {} 个图形", both.len());}输出:
面积:3.1面积:16.0一共有 2 个图形一条简单的规则:列表要拥有它的值时,用 Box<dyn Trait>;只需要暂时借用它们时,用 &dyn Trait。
静态分发与动态分发
Section titled “静态分发与动态分发”“分发”(dispatch)其实就是“决定调用哪个方法”。Rust 有两种做法。
静态分发发生在使用泛型和 impl Trait 的时候。编译器知道确切的类型,所以会为每种类型生成一份专门的函数(就像你在泛型那一页看到的那样)。调用会直接跳到正确的方法,速度非常快。
动态分发发生在使用 dyn Trait 的时候。编译器不知道确切的类型,所以每个 trait 对象都带着一张隐藏的小表(叫做 vtable,虚函数表),上面写着“对于这个值,area 在这里”。运行时,Rust 先在这张表里查到方法,再去调用它。这次查找会带来一点点额外开销。
// 静态分发:Rust 为每种类型生成一份这个函数fn static_area(shape: &impl Shape) -> f64 { shape.area()}
// 动态分发:只有一个函数,方法在运行时查找fn dynamic_area(shape: &dyn Shape) -> f64 { shape.area()}对同一个图形,两个函数给出的答案完全一样。唯一的区别是 Rust 怎样找到正确的 area 方法。那该选哪一个呢?
这些情况用泛型 / impl Trait…… |
这些情况用 dyn Trait…… |
|---|---|
| 每次调用只处理一种已知类型 | 需要在一个集合里混放不同类型 |
| 想要尽可能快的代码 | 类型要到运行时才决定(比如取决于用户输入) |
| 不介意程序稍微大一点 | 想要更小的代码和更快的编译速度 |
给新手的一个好默认做法:先用泛型或 impl Trait。遇到“一个列表里要放不同类型”的问题时,再请出 dyn。不用太在意速度上的差别,大多数程序里你根本察觉不到。
从函数返回 Box<dyn Trait>
Section titled “从函数返回 Box<dyn Trait>”有时候函数需要在运行时决定返回哪种类型。你可能会试试 -> impl Shape,但它只在函数总是返回同一种类型时才行得通。如果不同分支返回不同类型,就改为返回 Box<dyn Shape>:
trait Shape { fn describe(&self) -> String;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn describe(&self) -> String { format!("半径为 {} 的圆形", self.radius) }}
impl Shape for Square { fn describe(&self) -> String { format!("边长为 {} 的正方形", self.side) }}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> { if kind == "circle" { Box::new(Circle { radius: size }) } else { Box::new(Square { side: size }) }}
fn main() { let a = make_shape("circle", 1.5); let b = make_shape("square", 2.0); println!("我做了一个{}", a.describe()); println!("我做了一个{}", b.describe());}输出:
我做了一个半径为 1.5 的圆形我做了一个边长为 2 的正方形这是“工厂”函数、插件系统和配置驱动代码的常见写法。只要确切的类型取决于程序运行时才能看到的数据,都可以这样做。
实用示例:发送通知
Section titled “实用示例:发送通知”来看一个更接近真实应用的例子。你的程序要通知用户,但每个用户选择的渠道不同:邮件、短信,或者两者都要。每种渠道是一个单独的类型,它们共享一个 Notifier trait:
trait Notifier { fn send(&self, message: &str) -> String;}
struct Email { address: String }struct Sms { phone: String }
impl Notifier for Email { fn send(&self, message: &str) -> String { format!("发邮件给 {}:{}", self.address, message) }}
impl Notifier for Sms { fn send(&self, message: &str) -> String { format!("发短信给 {}:{}", self.phone, message) }}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) { for channel in channels { println!("{}", channel.send(message)); }}
fn main() { let channels: Vec<Box<dyn Notifier>> = vec![ Box::new(Sms { phone: String::from("+1-555-0100") }), ];
notify_all(&channels, "你的订单已发货!");}输出:
发邮件给 [email protected]:你的订单已发货!发短信给 +1-555-0100:你的订单已发货!好处在于:如果以后要加一个 Push 推送通知,你只需写一个新结构体和一个 impl Notifier for Push,notify_all 函数一行都不用改。
限制:dyn 兼容性
Section titled “限制:dyn 兼容性”不是每个 trait 都能变成 trait 对象。trait 必须是 dyn 兼容的(dyn compatible;较早的书和博客把它叫做对象安全,object safety)。完整的规则很长,但下面两条涵盖了大多数实际情况:
- 方法不能返回
Self。(在dyn背后,Rust 不知道Self是什么,所以也不知道返回值有多大。) - 方法不能有自己的泛型类型参数,比如
fn process<T>(&self, item: T)。
违反第一条规则会怎样?看这里:
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self;}// ……后面:let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];error[E0038]: the trait `Shape` is not dyn compatible |14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })]; | ^^^^^ `Shape` is not dyn compatible | 1 | trait Shape { | ----- this trait is not dyn compatible... 2 | fn area(&self) -> f64; 3 | fn duplicate(&self) -> Self; | ^^^^ ...because method `duplicate` references the `Self` type in its return type = help: consider moving `duplicate` to another trait这条错误信息既诚实又有用:它准确地指出了是哪个方法出了问题(duplicate 的返回类型里用到了 Self)。你可以把这个方法移到另一个 trait 里,或者给这个方法加上 where Self: Sized。这等于告诉 Rust:“这个方法只存在于真实、已知的类型上,trait 对象上没有它。”
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self where Self: Sized;}这也是为什么有些标准 trait(比如 Clone)不能写成 dyn Clone:clone() 返回的就是 Self。
忘了写 Box:很容易顺手写成 Vec<dyn Shape>:
let shapes: Vec<dyn Shape> = Vec::new();error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time --> src/main.rs:3:17 |3 | let shapes: Vec<dyn Shape> = Vec::new(); | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `dyn Shape`编译器说:dyn Shape 的大小在编译时无法确定。一个 dyn Shape 可能是任意大小,可能是小小的 Square,也可能是一个有很多字段的大结构体。Rust 需要固定的大小,所以 trait 对象必须始终放在指针后面:Box<dyn Shape>、&dyn Shape 或 Rc<dyn Shape>。
到处都用 dyn:如果一个函数每次只会拿到一种类型,impl Trait 或泛型更简单,也更快(高级 trait 那一页对 impl Trait 有更多介绍)。把 dyn 留给真正需要混放不同类型的场合。
1. 装进一个 Vec 的动物园
Section titled “1. 装进一个 Vec 的动物园”定义一个 trait Animal,包含 fn name(&self) -> String 和 fn sound(&self) -> String。为 Dog、Cow 和 Duck 实现它,每种各放一个到 Vec<Box<dyn Animal>> 里,然后遍历它,打印每种动物的叫声。提示:结构体可以是空的(struct Dog;),每个都用 Box::new(...) 放进去。
查看答案
trait Animal { fn name(&self) -> String; fn sound(&self) -> String;}
struct Dog;struct Cow;struct Duck;
impl Animal for Dog { fn name(&self) -> String { String::from("小狗") } fn sound(&self) -> String { String::from("汪汪") }}
impl Animal for Cow { fn name(&self) -> String { String::from("奶牛") } fn sound(&self) -> String { String::from("哞哞") }}
impl Animal for Duck { fn name(&self) -> String { String::from("鸭子") } fn sound(&self) -> String { String::from("嘎嘎") }}
fn main() { let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo { println!("{}说:{}", animal.name(), animal.sound()); }}输出:
小狗说:汪汪奶牛说:哞哞鸭子说:嘎嘎2. 在运行时挑选问候者
Section titled “2. 在运行时挑选问候者”定义一个 trait Greeter,包含 fn hello(&self, name: &str) -> String,再定义两个类型:English 和 Spanish。写一个 fn make_greeter(lang: &str) -> Box<dyn Greeter>,遇到 "es" 返回 Spanish,其他情况一律返回 English。对 ["en", "es", "fr"] 里的每个语言代码,都向 "Mia" 打个招呼。提示:if 的两个分支必须返回同一种类型,所以每个都要用 Box::new(...) 包起来。
查看答案
trait Greeter { fn hello(&self, name: &str) -> String;}
struct English;struct Spanish;
impl Greeter for English { fn hello(&self, name: &str) -> String { format!("Hello, {name}!") }}
impl Greeter for Spanish { fn hello(&self, name: &str) -> String { format!("¡Hola, {name}!") }}
fn make_greeter(lang: &str) -> Box<dyn Greeter> { if lang == "es" { Box::new(Spanish) } else { Box::new(English) }}
fn main() { for lang in ["en", "es", "fr"] { let greeter = make_greeter(lang); println!("{lang}: {}", greeter.hello("Mia")); }}输出:
en: Hello, Mia!es: ¡Hola, Mia!fr: Hello, Mia!这里的问候语保留了英文和西班牙文,因为这个练习本身就是在不同语言之间切换。
- 一个
Vec只能装一种类型。想混放不同类型,就用Box<dyn Trait>这样的 trait 对象。 Box<dyn Trait>拥有值;&dyn Trait借用值。trait 对象总是放在指针后面。- 泛型和
impl Trait使用静态分发(很快,在编译时决定)。dyn Trait使用动态分发(运行时查一次 vtable)。 - 函数要在运行时从不同类型中挑一个返回时,就返回
Box<dyn Trait>。 - 只有 dyn 兼容的 trait 才能做成 trait 对象:不能有返回
Self的方法,也不能有泛型方法(除非它们带有where Self: Sized)。
小测验
1. 为什么不能把 Circle 和 Square 放进同一个普通的 Vec?
2. Box<dyn Shape> 里的 dyn 告诉 Rust 什么?
3. 下面哪一个使用的是静态分发?
4. 为什么 Vec<dyn Shape>(不带 Box)会报错?
5. 哪个 trait 方法会让 trait 不再 dyn 兼容?
得分: 0 / 5
- 使用 trait object 来抽象出共享行为 —《Rust 程序设计语言》:官方教程的这一章用
Box<dyn Draw>构建了一个 GUI 风格的组件列表。 - 使用 dyn 返回 trait —《通过例子学 Rust》:一个返回
Box<dyn Trait>的函数,简短、可运行。 - Trait objects — The Rust Reference(英文):
dyn Trait类型的精确规则,包括生命周期以及Send这类额外的标记 trait。 - Dyn compatibility — The Rust Reference(英文):一个 trait 要能和
dyn一起使用,必须遵守的全部规则。 - The
dynkeyword — 标准库文档(英文):简要说明dyn、vtable,以及与泛型相比的取舍。