跳转到内容

Rust trait 对象(dyn)

在 trait 那一页,你写过接收“任何实现了 Speak 的类型”的函数。在泛型那一页,你看到 Rust 会为每种类型单独生成一份泛型函数。如果在编译时就知道类型,这两种做法都很好用。可要是你想要一个能装下多种不同类型的列表呢?

这就是 trait 对象(trait object)的用武之地。想象一位音乐老师,教室里坐满了学生:有人弹钢琴,有人拉小提琴,有人打鼓。老师不需要知道每个学生拿的是什么乐器,只要说一声“开始演奏!”,每个人就用自己的方式演奏起来。trait 对象让你的代码也能这样做:“我不知道它的确切类型,但我知道它会做这件事。”

假设你有两种图形,想把它们放进同一个 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 对象。

你并不总是需要 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。

“分发”(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。不用太在意速度上的差别,大多数程序里你根本察觉不到。

有时候函数需要在运行时决定返回哪种类型。你可能会试试 -> 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 的正方形
▶ 动手试一试

这是“工厂”函数、插件系统和配置驱动代码的常见写法。只要确切的类型取决于程序运行时才能看到的数据,都可以这样做。

来看一个更接近真实应用的例子。你的程序要通知用户,但每个用户选择的渠道不同:邮件、短信,或者两者都要。每种渠道是一个单独的类型,它们共享一个 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(Email { address: String::from("[email protected]") }),
Box::new(Sms { phone: String::from("+1-555-0100") }),
];
notify_all(&channels, "你的订单已发货!");
}

输出:

发邮件给 [email protected]:你的订单已发货!
发短信给 +1-555-0100:你的订单已发货!
▶ 动手试一试

好处在于:如果以后要加一个 Push 推送通知,你只需写一个新结构体和一个 impl Notifier for Push,notify_all 函数一行都不用改。

不是每个 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 留给真正需要混放不同类型的场合。

定义一个 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());
}
}

输出:

小狗说:汪汪
奶牛说:哞哞
鸭子说:嘎嘎
▶ 动手试一试

定义一个 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