特征对象
此笔记记录于Rust Course,大多数为其中的摘要,少数为笔者自己的理解
在 Rust 中,特征对象(Trait Objects)是一种使用特征(Traits)进行动态分发的机制。特征对象允许你在运行时处理不同类型的值,只要它们实现了相同的特征。这是一种动态多态性,因为你可以使用特征对象来处理多种类型,而不需要在编译时知道具体的类型。
特征对象通常通过使用dyn关键字和特征名称来创建,例如dyn TraitName。对于特征对象,你通常会使用指针(如&或Box)来引用它,因为特征对象的大小在编译时是未知的。
例如,你可能有一个Draw特征,定义了一个draw方法。你可以创建一个特征对象&dyn Draw,然后可以使用此特征对象引用任何实现了Draw特征的类型。
trait Draw {
fn draw(&self);
}
fn draw_it(x: &dyn Draw) {
x.draw();
}在这个例子中,draw_it函数可以接受任何类型的参数,只要该类型实现了Draw特征。这为你提供了很大的灵活性,因为你可以在运行时决定要处理的具体类型。
请注意,使用特征对象可能会有一些运行时开销,因为 Rust 必须在运行时查找并调用正确的方法。如果性能是关键因素,那么可能需要考虑使用静态分发(例如,使用泛型代替特征对象)。
特征对象更像是一个类型,标识相同接口的不同实现的一个类型
定义
在介绍特征对象之前,先来为之前的 UI 组件定义一个特征:
pub trait Draw {
fn draw(&self);
}只要组件实现了 Draw 特征,就可以调用 draw 方法来进行渲染。假设有一个 Button 和 SelectBox 组件实现了 Draw 特征:
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 绘制按钮的代码
}
}
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 绘制 SelectBox 的代码
}
}此时,还需要一个动态数组来存储这些 UI 对象:
pub struct Screen {
pub components: Vec<?>,
}那这个?我们该填入什么样的类型的。这就需要特征对象了
特征对象指向实现了 Draw 特征的类型的实例,也就是指向了 Button 或者 SelectBox 的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。
Box<T>在后面章节会详细讲解,大家现在把它当成一个引用即可,只不过它包裹的值会被强制分配在堆上。
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}而如果用泛型来表示:
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}- 这种写法限制了
Screen实例的Vec<T>中的每个元素必须是Button类型或者全是SelectBox类型。 - 如果只需要同质(相同类型)集合,更倾向于采用泛型+特征约束这种写法,因其实现更清晰,且性能更好
- 特征对象,需要在运行时从
vtable动态查找需要调用的方法。
现在来运行渲染下咱们精心设计的 UI 组件列表:
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No")
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}- 在动态类型语言中,有一个很重要的概念:鸭子类型(duck typing),简单来说,就是只关心值长啥样,而不关心它实际是什么。当一个东西走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子,就算它实际上是一个奥特曼,也不重要,我们就当它是鸭子。
- 在上例中,
Screen在run的时候,我们并不需要知道各个组件的具体类型是什么。它也不检查组件到底是Button还是SelectBox的实例,只要它实现了Draw特征,就能通过Box::new包装成Box<dyn Draw>特征对象,然后被渲染在屏幕上。 - 使用特征对象和 Rust 类型系统来进行类似鸭子类型操作的优势是,无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征, 那么 Rust 根本就不会编译这些代码
注意
dyn不能单独作为特征对象的定义,而&dyn和Box<dyn>在编译期都是已知大小,所以可以用作特征对象的定义。
特征对象的动态分发
- 泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。
- 与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字
dyn正是在强调这一“动态”的特点。
当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。
下面这张图很好的解释了静态分发 Box<T> 和动态分发 Box<dyn Trait> 的区别:

- 特征对象大小不固定:这是因为,对于特征
Draw,类型Button可以实现特征Draw,类型SelectBox也可以实现特征Draw,因此特征没有固定大小 - 几乎总是使用特征对象的引用方式,如
&dyn Draw、Box<dyn Draw>- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
ptr和vptr),因此占用两个指针大小 - 一个指针
ptr指向实现了特征Draw的具体类型的实例,也就是当作特征Draw来用的类型的实例,比如类型Button的实例、类型SelectBox的实例 - 另一个指针
vptr指向一个虚表vtable,vtable中保存了类型Button或类型SelectBox的实例对于可以调用的实现于特征Draw的方法。当调用方法时,直接从vtable中找到方法并调用。之所以要使用一个vtable来保存各实例的方法,是因为实现了特征Draw的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征Draw来使用时(此时,它们全都看作是特征Draw类型的实例),有必要区分这些实例各自有哪些方法可调用
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
可以理解为指针指向一个对象,指针的大小是固定的,所以可以用来放在同类型数组下,而对象是不固定的,每个对象的大小都可能不一样。然后这个对应关系就是作为一张映射表存储。
Self 与 self
在 Rust 中,有两个self,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
trait Draw {
fn draw(&self) -> Self;
}
#[derive(Clone)]
struct Button;
impl Draw for Button {
fn draw(&self) -> Self {
return self.clone()
}
}
fn main() {
let button = Button;
let newb = button.draw();
}上述代码中,self指代的就是当前的实例对象,也就是 button.draw() 中的 button 实例,Self 则指代的是 Button 类型。
当理解了 self 与 Self 的区别后,我们再来看看何为对象安全。
特征对象的限制
不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:
- 方法的返回类型不能是
Self。因为Self的类型已经不知道了,类型被特征对象代替了 - 方法没有任何泛型参数
对象安全对于特征对象是必须的,因为一旦有了特征对象,就不再需要知道实现该特征的具体类型是什么了。如果特征方法返回了具体的
Self类型,但是特征对象忘记了其真正的类型,那这个Self就非常尴尬,因为没人知道它是谁了。但是对于泛型类型参数来说,当使用特征时其会放入具体的类型参数:此具体类型变成了实现该特征的类型的一部分。而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么。
标准库中的 Clone 特征就不符合对象安全的要求:
pub trait Clone {
fn clone(&self) -> Self;
}如下使用该特征的特征对象就会编译器报错:
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}