【Rust自学】17.2. 使用trait对象来存储不同值的类型
喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
17.2.1. 需求
这篇文章以一个例子来介绍如何在Rust中使用trait对象来存储不同值的类型。
在第 8 章中,我们提到Vector
的一个限制是它们只能存储一种类型的元素。我们在 8.2. Vector + Enum的应用 中创建了一个解决方法,其中定义了一个SpreadsheetCell
枚举,它具有保存整数、浮点数和文本的变体。这意味着我们可以在每个单元格中存储不同类型的数据,并且仍然有一个代表一行单元格的向量。当我们的可互换项是我们在编译代码时知道的一组固定类型时,这是一个非常好的解决方案。
代码如下:
enum SpreadSheetCell {
Int(i32),
Float(f64),
Text(String),
}
fn main() {
let row = vec![
SpreadSheetCell::Int(5567),
SpreadSheetCell::Text("up up".to_string()),
SpreadSheetCell::Float(114.514),
];
}
然而,有时我们希望我们的库用户能够扩展在特定情况下有效的类型集合,以下是这个例子的需求:
创建一个GUI工具,它会遍历某个元素的列表,依次调用元素的draw
方法进行绘制(例如:Button
、TextField
等元素)。
这样的需求在面向对象语言里(比如Java或C#)可以定义一个Component
父类,里面定义了draw
方法。接下来定义Button
、TextField
等类,继承于Component
这个父类。
上一篇文章中说了Rust并没有提供继承功能,所以想使用Rust来构建GUI工具就得使用其他方法——为共有行为定义一个trait
17.2.2. 为共有行为定义一个trait
首先澄清一些定义:在Rust里我们避免将struct
或enum
称为对象,因为它们与impl
块是分开的。而trait对象有点类似于其他语言中的对象,因为它们某种程度上组合了数据与行为。
trait对象与传统对象也有不同之处,比如我们无法为trait对象添加数据。
trait对象被专门用于抽象某些共有行为,它没有其他语言中的对象那么通用。
这个GUI工具这么写:
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
- 首先声明了一个公开的trait叫
Draw
,里面定义了一个方法draw
,但没有写具体实现 - 然后声明了一个公开的结构体叫
Screen
,它里面有一个公开的字段叫components
。它的类型是Vector
,里面的元素是Box<dyn Draw>
。
Box<>
用于定义trait对象,表示Box
里的元素实现了Draw
trait - 通过
impl
块为Screen
写了run
方法,一运行就把所有元素画出来
同样是表示某个类型实现某个/某些trait,为什么不适用泛型呢?来看看泛型的写法:
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
这是因为泛型Vec<T>
只要T
一固定下来这个Vector
里就只能存储这个类型了。举个例子,假如第一个放进这个Vector
的元素是Button
类型,那么这个Vector
的其他元素就只能是Button
了(因为Vector
里的所有元素类型必须相同)。
而如果是Vec<Box<dyn Draw>>
,那么第一个放进去是Button
类型,后面还可以放TextField
类型,只要是实现了Draw
trait的类型都可以放进去。
接下来我们来写实现了Draw
trait的类型具体是什么样的:
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 绘制按钮
}
}
- 一个
Button
结构体可能有width
、height
和label
字段,所以我们这么定义 - 通过
impl
块为Button
实现了Draw
trait,里面的实际代码就忽略了
这只是lib.rs
的内容,接下来到mian.rs
写主程序:
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 绘制一个选择框
}
}
main.rs
里的结构体SelectBox
有三个字段,具有width
、height
和options
字段- 通过
impl
块为SelectBox
实现了Draw
trait,里面的实际代码就忽略了
接着看主函数:
use gui::{Button, Screen};
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();
}
- 主程序里有一个
Screen
结构体的实例,里面放了SelectBox
类型和Button
类型(得使用Box::new()
封装)。这个Vector
能放不同类型的元素正是归功于定义trait对象。 - 然后调用
Screen
上的方法run
渲染出来即可。实际上run
方法不管实际传进去是什么类型,只要这个类型实现了Draw
trait即可。
17.2.3. trait对象执行的是动态派发
将trait bound作用于泛型时,Rust编译器会执行单态化:编译器会为我们用来替换泛型参数类型的每一个具体类型生成对应函数和方法的非泛型实现。
这点在 10.2. 泛型 中有阐述:
举个例子:
fn main() {
let integer = Some(5);
let float = Some(5.0)
}
这里integer
是Option<i32>
,float
是Option<f64>
,在编译的时候编译器会把Option<T>
展开为Option_i32
和Option_f64
:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
也就是把Option<T>
这个泛型定义替换为了两个具体类型的定义。
单态后的main
函数也变成了这样:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main(){
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
通过单态化生成的代码会执行静态派发(static dispatch),在编译过程中确定调用的方法。
动态派发(dynamic dispatch) 无法爱编译过程中确定你调用的究竟是哪一种方法,编译器会产生额外的代码以便在运行时找出希望调用的方法。使用trait对象就会执行动态派发,代价是产生一些运行时的开销,并且阻止编译器内联方法代码,使得部分优化操作无法进行。
17.2.4. 使用trait对象必须保证对象安全
只能把满足对象安全(object-safe)的trait转化为trait对象。Rust使用了一系列规则来判定某个对象是否安全,只需要记住两条:
- 方法的返回类型不是
self
- 方法不包含任何的泛型类型参数
看个例子:
pub trait Clone{
fn clone(&self) -> self;
}
标准库里Clone
trait和clone
这个函数的签名如上所示,由于clone
方法的返回值是self
,所以Clone
trait就不符合对象安全。