trpl-zh-cn/src/ch17-02-trait-objects.md
2017-04-10 16:06:17 +08:00

18 KiB
Raw Blame History

为使用不同类型的值而设计的Trait对象

ch17-02-trait-objects.md
commit 872dc793f7017f815fb1e5389200fd208e12792d

在第8章我们谈到了vector的局限是vectors只能存储同种类型的元素。我们在Listing 8-1有一个例子其中定义了一个SpreadsheetCell 枚举类型可以存储整形、浮点型和text这样我们就可以在每个cell存储不同的数据类型了同时还有一个代表一行cell的vector。当我们的代码编译的时候如果交换地处理的各种东西是固定的类型是已知的那么这是可行的。

<!-- The code example I want to reference did not have a listing number; it's
the one with SpreadsheetCell. I will go back and add Listing 8-1 next time I
get Chapter 8 for editing. /Carol -->

有时我们想我们使用的类型集合是可扩展的可以被使用我们的库的程序员扩展。比如很多图形化接口工具有一个条目列表从这个列表迭代和调用draw方法在每个条目上。我们将要创建一个库crate包含称为rust_gui的CUI库的结构体。我们的GUI库可以包含一些给开发者使用的类型比如Button或者TextField。使用rust_gui的程序员会创建更多可以在屏幕绘图的类型:一个程序员可能会增加Image,另外一个可能会增加SelectBox。我们不会在本章节实现一个完善的GUI库但是我们会展示如何把各部分组合在一起。

当要写一个rust_gui库时,我们不知道其他程序员要创建什么类型,所以我们无法定义一个enum来包含所有的类型。我们知道的是rust_gui需要有能力跟踪所有这些不同类型的大量的值,需要有能力在每个值上调用draw方法。我们的GUI库不需要确切地知道当调用draw方法时会发生什么,只要值有可用的方法供我们调用就可以。

在有继承的语言里,我们可能会定义一个名为Component的类,该类上有一个draw方法。其他的类比如ButtonImageSelectBox会从Component继承并继承draw方法。它们会各自覆写draw方法来自定义行为,但是框架会把所有的类型当作是Component的实例,并在它们上调用draw

定义一个带有自定义行为的Trait

不过在Rust语言中我们可以定义一个名为Draw的trait其上有一个名为draw的方法。我们定义一个带有trait对象的vector绑定了一种指针的trait比如&引用或者一个Box<T>智能指针。

我们提到,我们不会调用结构体和枚举的对象,从而区分于其他语言的对象。在结构体的数据或者枚举的字段和impl块中的行为是分开的而其他语言则是数据和行为被组合到一个概念里。Trait对象更像其他语言的对象在这种场景下他们组合了由指针组成的数据到实体对象该对象带有在trait中定义的方法行为。但是trait对象是和其他语言是不同的因为我们不能向一个trait对象增加数据。trait对象不像其他语言那样有用它们的目的是允许从公有的行为上抽象。

trait定义了在给定场景下我们所需要的行为。在我们会使用一个实体类型或者一个通用类型的地方我们可以把trait当作trait对象使用。Rust的类型系统会保证我们为trait对象带入的任何值会实现trait的方法。我们不需要在编译阶段知道所有可能的类型我们可以把所有的实例统一对待。Listing 17-03展示了如何定义一个名为Draw的带有draw方法的trait。

Filename: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Listing 17-3:Draw trait的定义

因为我们已经在第10章讨论过如何定义trait你可能比较熟悉。下面是新的定义Listing 17-4有一个名为Screen的结构体,里面有一个名为components的vectorcomponents的类型是Box。Box<Draw>是一个trait对象它是一个任何Box内部的实现了Drawtrait的类型的替身。

Filename: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
pub struct Screen {
    pub components: Vec<Box<Draw>>,
}

Listing 17-4: 定义一个Screen结构体,带有一个含有实现了Drawtrait的components vector成员

Screen结构体上,我们将要定义一个run方法,该方法会在它的components上调用draw方法如Listing 17-5所示

Filename: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
# pub struct Screen {
#     pub components: Vec<Box<Draw>>,
# }
#
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Listing 17-5:在Screen上实现一个run方法,该方法在每个组件上调用draw方法

这是区别于定义一个使用带有trait绑定的通用类型参数的结构体。通用类型参数一次只能被一个实体类型替代而trait对象可以在运行时允许多种实体类型填充trait对象。比如我们已经定义了Screen结构体使用通用类型和一个trait绑定如Listing 17-6所示

Filename: src/lib.rs

# 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();
        }
    }
}

Listing 17-6: 一种Screen结构体的替代实现,它的run方法使用通用类型和trait绑定

这个例子只能使我们有一个Screen实例,这个实例有一个组件列表,所有的组件类型是Button或者TextField。如果你有同种的集合那么可以优先使用通用和trait绑定这是因为为了使用具体的类型定义是在编译阶段是单一的。

而如果使用内部有Vec<Box<Draw>> trait对象的列表的Screen结构体,Screen实例可以同时包含Box<Button>Box<TextField>Vec。我们看它是怎么工作的,然后讨论运行时性能的实现。

来自我们或者库使用者的实现

现在,我们增加一些实现了Drawtrait的类型。我们会再次提供Button实际上实现一个GUI库超出了本书的范围所以draw方法的内部不会有任何有用的实现。为了想象一下实现可能的样子,Button结构体可能有 widthheightlabel`字段如Listing 17-7所示

Filename: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // Code to actually draw a button
    }
}

Listing 17-7: 实现了Draw trait的Button 结构体

Button上的 widthheightlabel会和其他组件不同,比如TextField可能有widthheight, labelplaceholder字段。每个我们可以在屏幕上绘制的类型会实现Drawtraitdraw方法中使用不同的代码,定义了如何绘制ButtonGUI代码的具体实现超出了本章节的范围。除了Draw traitButton可能也有另一个impl块,包含了当按钮被点击的时候的响应方法。这类方法不适用于TextField这样的类型。

有时,使用我们的库决定了实现一个包含widthheightoptions``SelectBox结构体。它们在SelectBox类型上实现了Drawtrait如 Listing 17-8所示

Filename: src/main.rs

extern crate rust_gui;
use rust_gui::Draw;

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

impl Draw for SelectBox {
    fn draw(&self) {
        // Code to actually draw a select box
    }
}

Listing 17-8: 另外一个crate中SelectBox结构体上使用rust_gui和实现了Draw trait

我们的库的使用者现在可以写他们的main函数来创建一个Screen实例,然后通过把自身放入Box<T>变成trait对象向screen增加SelectBoxButton。它们可以在每个Screen实例上调用run方法,这会调用每个组件的draw方法。 Listing 17-9展示了实现

Filename: src/main.rs

use rust_gui::{Screen, Button};

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();
}

Listing 17-9: 使用trait对象来存储实现了相同trait的不同类型

虽然我们不知道有些人可能有一天会增加SelectBox类型,但是我们的Screen 有能力操作SelectBox和绘制,因为SelectBox实现了Draw类型,这意味着它实现了draw方法。

只关心值响应的消息,而不关心值的具体类型,这类似于动态类型语言中的duck typing如果它像鸭子一样走路像鸭子一样叫那么它肯定是只鸭子在Listing 17-5的Screenrun方法的实现中,run不需要知道每个组件的具体类型。它也不检查是否一个组件是Button或者SelectBox的实例,只是调用组件的draw方法即可。通过指定Box<Draw>作为componentsvector中的值类型我们定义了Screen需要可以被调用其draw方法的值。

使用trait对象和支持duck typing的Rust类型系统的好处是我们永远不需要在运行时检查一个值是否实现了一个特殊方法或者担心因为调用了一个值没有实现方法而遇到错误。如果值没有实现trait对象需要的traitRust不会编译我们的代码。

比如Listing 17-10展示了当我们创建一个把String当做其成员的Screen时发生的情况:

Filename: src/main.rs

extern crate rust_gui;
use rust_gui::Draw;

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(String::from("Hi")),
        ],
    };

    screen.run();
}

Listing 17-10: 尝试使用一种没有实现trait对象的trait的类型

我们会遇到这个错误,因为String没有实现 Drawtrait

error[E0277]: the trait bound `std::string::String: Draw` is not satisfied
  -->
   |
 4 |             Box::new(String::from("Hi")),
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not
   implemented for `std::string::String`
   |
   = note: required for the cast to the object type `Draw`

这个报错让我们知道,或者我们传入了本来不想传给Screen的东西,我们应该传入一个不同的类型,或者是我们应该在String上实现Draw,这样,Screen才能调用它的draw方法。

Trait对象执行动态分发

回忆一下第10章我们讨论过当我们使用通用类型的trait绑定时编译器执行单类型的处理过程在我们需要使用通用类型参数的地方编译器为每个实体类型产生了非通用的函数实现和方法。由于非单类型而产生的代码是 static dispatch:当方法被调用,代码会执行在编译阶段就决定的方法,这样寻找那段代码是非常快速的。

当我们使用trait对象编译器不能执行单类型的因为我们不知道可能被代码调用的类型。而当方法被调用的时候Rust跟踪可能被使用的代码然后在运行时找出为了方法被调用时该使用哪些代码。这也是我们熟知的dynamic dispatch,当运行时的查找发生时是比较耗费资源的。动态分发也防止编译器选择内联函数的代码,这样防止了一些优化。虽然我们写代码时得到了额外的代码灵活性,不过,这是一个权衡考虑。

Trait 对象需要对象安全

Not all traits can be made into trait objects; only object safe traits can. A trait is object safe as long as both of the following are true:

  • The trait does not require Self to be Sized
  • All of the trait's methods are object safe.

Self is a keyword that is an alias for the type that we're implementing traits or methods on. Sized is a marker trait like the Send and Sync traits that we talked about in Chapter 16. Sized is automatically implemented on types that have a known size at compile time, such as i32 and references. Types that do not have a known size include slices ([T]) and trait objects.

Sized is an implicit trait bound on all generic type parameters by default. Most useful operations in Rust require a type to be Sized, so making Sized a default requirement on trait bounds means we don't have to write T: Sized with most every use of generics. If we want to be able to use a trait on slices, however, we need to opt out of the Sized trait bound, and we can do that by specifying T: ?Sized as a trait bound.

Traits have a default bound of Self: ?Sized, which means that they can be implemented on types that may or may not be Sized. If we create a trait Foo that opts out of the Self: ?Sized bound, that would look like the following:

trait Foo: Sized {
    fn some_method(&self);
}

The trait Sized is now a super trait of trait Foo, which means trait Foo requires types that implement Foo (that is, Self) to be Sized. We're going to talk about super traits in more detail in Chapter 19.

The reason a trait like Foo that requires Self to be Sized is not allowed to be a trait object is that it would be impossible to implement the trait Foo for the trait object Foo: trait objects aren't sized, but Foo requires Self to be Sized. A type can't be both sized and unsized at the same time!

For the second object safety requirement that says all of a trait's methods must be object safe, a method is object safe if either:

  • It requires Self to be Sized or
  • It meets all three of the following:
    • It must not have any generic type parameters
    • Its first argument must be of type Self or a type that dereferences to the Self type (that is, it must be a method rather than an associated function and have self, &self, or &mut self as the first argument)
    • It must not use Self anywhere else in the signature except for the first argument

Those rules are a bit formal, but think of it this way: if your method requires the concrete Self type somewhere in its signature, but an object forgets the exact type that it is, there's no way that the method can use the original concrete type that it's forgotten. Same with generic type parameters that are filled in with concrete type parameters when the trait is used: the concrete types become part of the type that implements the trait. When the type is erased by the use of a trait object, there's no way to know what types to fill in the generic type parameters with.

An example of a trait whose methods are not object safe is the standard library's Clone trait. The signature for the clone method in the Clone trait looks like this:

pub trait Clone {
    fn clone(&self) -> Self;
}

String implements the Clone trait, and when we call the clone method on an instance of String we get back an instance of String. Similarly, if we call clone on an instance of Vec, we get back an instance of Vec. The signature of clone needs to know what type will stand in for Self, since that's the return type.

If we try to implement Clone on a trait like the Draw trait from Listing 17-3, we wouldn't know whether Self would end up being a Button, a SelectBox, or some other type that will implement the Draw trait in the future.

The compiler will tell you if you're trying to do something that violates the rules of object safety in regards to trait objects. For example, if we had tried to implement the Screen struct in Listing 17-4 to hold types that implement the Clone trait instead of the Draw trait, like this:

pub struct Screen {
    pub components: Vec<Box<Clone>>,
}

We'll get this error:

error[E0038]: the trait `std::clone::Clone` cannot be made into an object
 -->
  |
2 |     pub components: Vec<Box<Clone>>,
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone` cannot be
  made into an object
  |
  = note: the trait cannot require that `Self : Sized`