Rust for cpp dev - 使用 Trait 对象实现多态

假设我们希望设计一个 GUI 库,对于每一个组件,我们希望能调用 draw() 方法来显示。

对于传统的有“继承”特性的语言,可以让所有组件都继承自一个 Component 类,这个类有一个纯虚函数 draw(),然后每个组件实现各自的 draw() 方法。

然而 Rust 没有继承的概念,如果用泛型来实现:

pub struct StaticScreen<T: Draw> {
    pub components: Vec<T>,
}

impl<T: Draw> StaticScreen<T> {
    pub fn show(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这样的话,StaticScreen 类型只能存放一种类型的组件。例如Button。不能满足需求。

我们需要一个新的方式来实现,即 Trait 对象。

定义 trait 对象

我们需要定义一个 Draw trait,然后定义一个 vector,其中的对象都是实现了 Draw trait 的。每个 trait 对象指向两个东西:

  • 一个实现了 Draw trait 的类型的实例
  • (类似于 cpp 中的虚表)一个在 runtime 查找 trait 方法的表

trait 对象不能拥有数据成员,它的作用仅是允许抽象出公共的行为。 其定义方法是:

Box<dyn T>>

例如,对于这里的需求,可以如下定义:

pub struct DynamicScreen {
    pub components: Vec<Box<dyn Draw>>,
}

impl DynamicScreen {
    pub fn show(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这样,components 就是一个由实现了 Draw trait 的智能指针构成的 vector。可以如下使用:

use gui::{DynamicScreen, Button, SelectBox};

fn main() {
    let screen = DynamicScreen {
        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.show();
}

可以看出,screen 中保存了两个类的实例,分别是 ButtonSelectBox。他们的共同特点是都实现了 Draw trait。运行时会调用各自的 draw() 函数。

// component definition

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        println!("Draw a button");
    }
}

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

impl Draw for SelectBox {
    fn draw(&self) {
        println!("Draw a select box");
    }
}

结果是:

Draw a select box
Draw a button

Rust 中,我们并不关心 ButtonSelectBox 之间有什么关系,只要他们都实现了 draw() 方法,就可以调用。这个思路和 golang 相似,都是基于 duck typing 的概念——如果它走路像样子,叫声也像鸭子,那它就是鸭子。

这样做的优势是我们不需要在运行时去检查是否实现了一个方法(cpp 中的 dynamic_cast),如果没有实现 Draw trait,它不会通过编译。

trait 对象的开销

trait 对象实际上起到了 cpp 中的虚函数的作用,因此也有类似的额外开销。

在上面的例子中,编译器无法在编译时知道要调用的对象的类型,因为这些对象是在一个动态数组中的,实际使用时,可能会根据用户输入变化。所以,它只能在运行时通过 trait 对象内部的指针来查找需要调用的方法。

推荐阅读更多精彩内容