Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

17.2 使用trait对象来存储不同值的类型

17.2.1 需求

这篇文章以一个例子来介绍如何在 Rust 中使用 trait 对象来存储不同值的类型。

在第 8 章中,我们提到 Vec 的一个限制是它们只能存储一种类型的元素。我们在 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 方法进行绘制(例如:ButtonTextField 等元素)。

这样的需求在面向对象语言里(比如 Java 或 C#)可以定义一个 Component 父类,里面定义了 draw 方法。接下来定义 ButtonTextField 等类,继承于 Component 这个父类。

上一篇文章 17.1. Rust的面向对象的编程特性 中说了 Rust 并没有提供继承功能,所以想使用 Rust 来构建 GUI 工具就得使用其他方法——为共有行为定义一个 trait。

17.2.2 为共有行为定义一个 trait

首先澄清一些定义:在 Rust 里我们避免将 structenum 称为对象,因为它们与 impl 块是分开的。而 trait 对象有点类似于其他语言中的对象,因为它们某种程度上组合了数据与行为。

trait 对象与传统对象也有不同之处,比如我们无法为 trait 对象添加数据。

trait 对象被专门用于抽象某些共有行为,它没有其他语言中的对象那么通用。

这个 GUI 工具这么写:

#![allow(unused)]
fn main() {
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。它的类型是 Vec,里面的元素是 Box<dyn Draw>Box<> 用于定义 trait 对象,表示 Box 里的元素实现了 Draw trait。
  • 通过 impl 块为 Screen 写了 run 方法,一运行就把所有元素画出来。

同样是表示某个类型实现某个/某些 trait,为什么不使用泛型呢?来看看泛型的写法:

#![allow(unused)]
fn main() {
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 一固定下来,这个向量里就只能存储这个类型了。举个例子,假如第一个放进这个向量的元素是 Button 类型,那么这个向量的其他元素就只能是 Button 了(因为向量里的所有元素类型必须相同)。

而如果是 Vec<Box<dyn Draw>>,那么第一个放进去是 Button 类型,后面还可以放 TextField 类型,只要是实现了 Draw trait 的类型都可以放进去。

接下来我们来写实现了 Draw trait 的类型具体是什么样的:

#![allow(unused)]
fn main() {
pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 绘制按钮
    }
}
}
  • 一个 Button 结构体可能有 widthheightlabel 字段,所以我们这么定义。
  • 通过 impl 块为 Button 实现了 Draw trait,里面的实际代码就忽略了。

这只是 lib.rs 的内容,接下来到 main.rs 写主程序:

#![allow(unused)]
fn main() {
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // 绘制一个选择框
    }
}
}
  • main.rs 里的结构体 SelectBox 有三个字段:widthheightoptions
  • 通过 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() 封装)。这个向量能放不同类型的元素正是归功于定义 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);
}

这里 integerOption<i32>floatOption<f64>,在编译的时候编译器会把 Option<T> 展开为 Option_i32Option_f64

#![allow(unused)]
fn main() {
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 使用了一系列规则来判定某个 trait 是否安全,只需要记住两条:

  • 方法的返回类型不是 Self
  • 方法不包含任何的泛型类型参数

看个例子:

#![allow(unused)]
fn main() {
pub trait Clone {
    fn clone(&self) -> Self;
}
}

标准库里 Clone trait 和 clone 这个函数的签名如上所示,由于 clone 方法的返回值是 Self,所以 Clone trait 就不符合对象安全。