什么使得一个对象成为“trait object”?

92
最近 Rust 的变化使 "trait objects" 对我来说更加突出,但我只有模糊的理解,不知道是什么让某些东西成为 trait object。特别是一个变化是 即将到来的变化 允许 trait objects 将 trait 实现转发到内部类型。
给定一个 trait Foo,我很确定 Box<Foo> / Box<dyn Foo> 是 trait object。那么 &Foo / &dyn Foo 也是 trait object 吗?其他像 RcArc 这样的智能指针又怎么样呢?我该如何创建自己的类型来作为 trait object? 参考文献 只提到了一次 trait objects,但没有定义。
4个回答

120
当你有指向特质的指针时,你就拥有了特质对象。 BoxArcRc和引用&在本质上都是指针。就定义“特质对象”而言,它们的工作方式相同。
“特质对象”是Rust对动态分发的理解。 下面是一个例子,希望能够帮助说明什么是特质对象:
// define an example struct, make it printable
#[derive(Debug)]
struct Foo;

// an example trait
trait Bar {
    fn baz(&self);
}

// implement the trait for Foo
impl Bar for Foo {
    fn baz(&self) {
        println!("{:?}", self)
    }
}

// This is a generic function that takes any T that implements trait Bar.
// It must resolve to a specific concrete T at compile time.
// The compiler creates a different version of this function
// for each concrete type used to call it so &T here is NOT
// a trait object (as T will represent a known, sized type
// after compilation)
fn static_dispatch<T>(t: &T)
where
    T: Bar,
{
    t.baz(); // we can do this because t implements Bar
}

// This function takes a pointer to a something that implements trait Bar
// (it'll know what it is only at runtime). &dyn Bar is a trait object.
// There's only one version of this function at runtime, so this
// reduces the size of the compiled program if the function
// is called with several different types vs using static_dispatch.
// However performance is slightly lower, as the &dyn Bar that
// dynamic_dispatch receives is a pointer to the object +
// a vtable with all the Bar methods that the object implements.
// Calling baz() on t means having to look it up in this vtable.
fn dynamic_dispatch(t: &dyn Bar) {
    // ----------------^
    // this is the trait object! It would also work with Box<dyn Bar> or
    // Rc<dyn Bar> or Arc<dyn Bar>
    //
    t.baz(); // we can do this because t implements Bar
}

fn main() {
    let foo = Foo;
    static_dispatch(&foo);
    dynamic_dispatch(&foo);
}

为了进一步参考,Rust书中有一个良好的Trait Objects章节


1
谢谢,这似乎是一个全面的答案。那么创建自己的类型以像特质对象一样操作呢? - Shepmaster
12
@Shepmaster,类型不会像trait对象那样“表现出来”;实际上,指向trait的指针 才是trait对象,并且可以有不同类型的指针。Box<T>是一个拥有所有权的指针,Rc<T>是一个共享所有权的指针,Arc<T>是一个多线程共享所有权的指针,等等。原则上,每个指针类型都可以用于定义trait对象,但目前只有引用和 Box 类型适用于此。因此,现在无法创建可用于创建trait对象的自定义指针类型。 - Vladimir Matveev
2
@Shepmaster,不,那并不完全正确。Box<Trait>/可能的Rc<Trait>本身就是特质对象,并且它们不会被转换或提供&Trait - Vladimir Matveev
1
@Lii 我认为实际上没有什么区别。术语“trait object”可以应用于两者,并且通常不会引起混淆。我会说,从语义上讲,它确实更多地指向指针后面的值。但是,如果需要严格消除胖指针和其所指向的值之间的歧义,我通常称它们为“trait object pointer”和“trait object pointer所指向的值”。 - Vladimir Matveev
5
“Trait object”这个术语可以同时应用于两种情况,并且通常不会引起混淆。顺便提一下,我自己就对此感到相当困惑:似乎 trait objects 指的是指向数据和虚函数表的 fat pointers,但是这些 fat pointers 又必须是 unsized 的,这并没有什么意义。幸运的是,Rust参考文献目前已经对此进行了明确说明:unsized值 dyn Trait 本身就是一个trait对象,必须在某种指针的背后使用它(例如 & dyn TraitBox <dyn Trait>等)。 - dlukes
显示剩余6条评论

9

简短回答:你只能将对象安全特质转换为特质对象。

对象安全特质:不会解析为具体实现类型的特质。在实践中,有两个规则来决定一个特质是否是对象安全的。

  1. 返回类型不是 Self。
  2. 不存在泛型类型参数。

满足这两个规则的任何特质都可以用作特质对象。

对象安全特质的示例可用作特质对象

trait Draw {
    fn draw(&self);
}

无法用作特质对象的特质示例:
trait Draw {
    fn draw(&self) -> Self;
}

详细说明请参见:https://doc.rust-lang.org/book/second-edition/ch17-02-trait-objects.html

更普遍地说,任何不在对象级别(也称为使用Self)的东西都会使特质不具备对象安全性。例如,如果您的特质具有常量成员或没有self作为第一个参数的函数。 - Boiethios

7

特质对象是Rust实现的动态分派。动态分派允许在运行时选择多态操作(特质方法)的一个特定实现。动态分派允许非常灵活的架构,因为我们可以在运行时交换函数实现。然而,动态分派会带来一些小的运行时成本。

持有特质对象的变量/参数是胖指针,由以下组件组成:

  • 指向内存中对象的指针
  • 指向该对象vtable的指针,vtable是一个具有指向实际方法实现的指针的表格。

示例

struct Point {
    x: i64,
    y: i64,
    z: i64,
}

trait Print {
    fn print(&self);
}

// dyn Print is actually a type and we can implement methods on it
impl dyn Print + 'static {
    fn print_traitobject(&self) {
        println!("from trait object");
    }
}

impl Print for Point {
    fn print(&self) {
        println!("x: {}, y: {}, z: {}", self.x, self.y, self.z);
    }
}

// static dispatch (compile time): compiler must know specific versions
// at compile time generates a version for each type

// compiler will use monomorphization to create different versions of the function
// for each type. However, because they can be inlined, it generally has a faster runtime
// compared to dynamic dispatch
fn static_dispatch<T: Print>(point: &T) {
    point.print();
}

// dynamic dispatch (run time): compiler doesn't need to know specific versions
// at compile time because it will use a pointer to the data and the vtable.
// The vtable contains pointers to all the different different function implementations.
// Because it has to do lookups at runtime it is generally slower compared to static dispatch

// point_trait_obj is a trait object
fn dynamic_dispatch(point_trait_obj: &(dyn Print + 'static)) {
    point_trait_obj.print();
    point_trait_obj.print_traitobject();
}

fn main() {
    let point = Point { x: 1, y: 2, z: 3 };

    // On the next line the compiler knows that the generic type T is Point
    static_dispatch(&point);

    // This function takes any obj which implements Print trait
    // We could, at runtime, change the specfic type as long as it implements the Print trait
    dynamic_dispatch(&point);
}

为什么有些人会使用动态调度,如果静态调度似乎可以做到动态调度所能做的一切,并且没有运行时开销呢? - nbro
@nbro 请查看这个这个 - at54321

-1

这个问题已经有了关于trait对象是什么的好答案。让我在这里举一个使用trait对象的例子以及为什么要使用它们。我将基于Rust Book中给出的例子。

假设我们需要一个GUI库来创建GUI表单。该GUI表单将由可视组件组成,例如按钮、标签、复选框等。让我们问问自己,谁应该知道如何绘制给定的组件?库还是组件本身?如果库带有您可能需要的所有组件的固定集合,那么它可以在内部使用枚举,其中每个枚举变量表示单个组件类型,并且库本身可以处理所有绘图(因为它了解其组件以及它们应该如何绘制)。但是,如果库允许您还使用第三方组件或自己编写的组件,则会更好。

在像Java、C#、C++等面向对象编程语言中,通常通过具有组件层次结构的方式来实现。所有组件都继承一个基类(我们称之为Component)。该Component类将具有一个draw()方法(甚至可以定义为abstract,以强制所有子类实现该方法)。

然而,Rust 没有继承。Rust 枚举非常强大,因为每个枚举变体可以具有不同类型和数量的关联数据,并且它们通常用于您在典型的 OOP 语言中使用继承的情况下。在 Rust 中使用枚举和泛型的一个重要优点是,在编译时可以知道所有内容,这意味着您不需要牺牲性能(无需像 vtables 这样的东西)。但在某些情况下,例如我们的示例中,枚举提供的灵活性不足。库需要跟踪不同类型的组件,并且需要一种调用甚至不知道其存在的组件方法的方法。这通常被称为动态分派,正如其他人所解释的那样,特质对象是 Rust 执行动态分派的方式。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接