Rust 的三种多态性


当您编写的代码应该可以处理几种不同类型的值,但事先不知道它们是什么,不同语言处理方式不同:

  • 动态语言就可以让您传入任何内容。
  • Java/C# 会要求一个接口或一个超类。
  • Duck类型的语言,如 Go 或 TypeScript,需要一些结构类型:例如,具有一组特定属性的对象类型。

在 Rust 中,有三种主要的方法来处理这种情况,每种方法都有自己的优点和缺点。
 
假设我们需要表示形状,这是一个经典的多态问题:
Shape
 |-Rectangle
 |-Triangle
 |-Circle

我们希望以这样一种方式来表示这些,即它们每个都暴露它们的perimeter() 和area(),并且可以编写代码来处理这些属性,而无需关心它在给定时间看到的特定形状。
 
1. 枚举

// Data

enum Shape {
    Rectangle { width: f32, height: f32 },
    Triangle { side: f32 },
    Circle { radius: f32 },
}

impl Shape {

    pub fn perimeter(&self) -> f32 {
        match self {
            Shape::Rectangle { width, height } => width * 2.0 + height * 2.0,
            Shape::Triangle { side } => side * 3.0,
            Shape::Circle { radius } => radius * 2.0 * std::f32::consts::PI
        }
    }

    pub fn area(&self) -> f32 {
        match self {
            Shape::Rectangle { width, height } => width * height,
            Shape::Triangle { side } => side * 0.5 * 3.0_f32.sqrt() / 2.0 * side,
            Shape::Circle { radius } => radius * radius * std::f32::consts::PI
        }
    }
}

使用:
// Usage

fn print_area(shape: Shape) {
    println!(
"{}", shape.area());
}

fn print_perimeters(shapes: Vec<Shape>) {
    for shape in shapes.iter() {
        println!(
"{}", shape.perimeter());
    }
}

Rust 中的枚举是一种数据结构,可以采用几种不同的形状之一。这些不同的形状(“变体”)都将适合内存中的同一个插槽(其大小将适合其中最大的一个)。
这是在 Rust 中实现多态的最直接的方法,它具有一些关键优势:

  • 结构数据是内联的(不必遵循对其他内存位置的引用来找到它)。这里最重要的事情是它有助于缓存局部性:集合中的实体将在内存中“彼此相邻”,因此必须进行更少的旅行来检索它们。缓存局部性对于本文来说是一个太大的话题,但它在性能关键代码中很重要。
  • 即使数据是内联的,例如中的每个项目。一个集合可以采用与其邻居不同的变体。正如我们将看到的,这不是给定的。
  • 您可以更轻松地将它们用作原始数据;正如我们将看到的,其他方法只允许您通过方法调用处理混合值。对于某些用例来说,这可能是不必要的负担。

但是,它们也有一些缺点:
  • 如果不同变体的大小差异很大,可能会浪费一些内存。这通常并不重要,因为如果您正在存储例如。某些变体中的大型集合,无论如何它可能存在于堆中,而不是内联。但在某些情况下,这可能很重要。
  • 更重要的是:库中公开的枚举不能被该库的用户扩展。在定义枚举的地方,它是一成不变的:所有可能的变体都列在那个地方。对于某些用途,这可能会破坏交易。

 
2. 特性Trait
// Data

trait Shape {
    fn perimeter(&self) -> f32;
    fn area(&self) -> f32;
}

struct Rectangle { pub width: f32, pub height: f32 }
struct Triangle { pub side: f32 }
struct Circle { pub radius: f32 }

impl Shape for Rectangle {
    fn perimeter(&self) -> f32 {
        self.width * 2.0 + self.height * 2.0
    }
    fn area(&self) -> f32 {
        self.width * self.height
    }
}

impl Shape for Triangle {
    fn perimeter(&self) -> f32 {
        self.side * 3.0
    }
    fn area(&self) -> f32 {
        self.side * 0.5 * 3.0_f32.sqrt() / 2.0 * self.side
    }
}

impl Shape for Circle {
    fn perimeter(&self) -> f32 {
        self.radius * 2.0 * std::f32::consts::PI
    }
    fn area(&self) -> f32 {
        self.radius * self.radius * std::f32::consts::PI
    }
}

Traits 是 Rust 中另一个重要的多态概念。它们可以被认为是来自其他语言的接口或协议:它们指定了一组结构必须实现的方法,然后它们可以为任意结构实现,并且这些结构可以用于预期特征的地方。
与枚举相比,它们的一个主要优势是可以为其他地方的新结构实现该特征——即使在不同的箱子中。您可以从 crate 导入 trait,为您自己的结构实现它,然后将该结构传递给需要该 trait 的 crate 中的代码。这对于某些类型的库来说可能是至关重要的。
还有一个巧妙的(如果是利基的)好处:您可以选择编写只接受特定变体的代码。使用枚举你不能这样做(我希望你能!)。
一个缺点,这在其他语言中不会很明显:​​无法通过特征找出您正在使用的变体并获取其其他属性。
与大多数具有类似概念的语言不同,Rust 为我们提供了一个有趣的选择,让我们可以在如何使用Trait方面做出选择。
 

  •  具有泛型的Trait

// Usage

fn print_area<S: Shape>(shape: S) {
    println!(
"{}", shape.area());
}

fn print_perimeters<S: Shape>(shapes: Vec<S>) {
// !
    for shape in shapes.iter() {
        println!(
"{}", shape.perimeter());
    }
}

Rust trait 可用于约束泛型函数(或泛型结构)中的类型参数。我们可以说“S必须是一个实现Shape的结构体”,这允许我们在相关代码中调用特征Trait的方法。
像枚举一样,这给了我们很好的局部性,因为数据的大小在编译时是已知的(Rust 为每个传递给它的具体类型标记了一个函数副本)。
但是,与枚举不同的是,这阻止了我们在同一通用代码中同时使用多个变体。例如:

fn main() {
    let rectangle = Rectangle { width: 1.0, height: 2.0 };
    let circle = Circle { radius: 1.0 };

    print_area(rectangle); // 
    print_area(circle);
// 

    print_perimeters(vec![ rectangle, circle ]);
// compiler error!
}

这不起作用,因为我们需要一个单一Vec的具体类型:我们可以有 Vec<Rectangle>或  Vec<Circle>,但不能同时拥有。
我们不能只拥有一个Vec<Shape>,因为Shape在内存中没有固定的大小。这只是一份合同。

  • 2b. 具有动态调度的特征trait

// Usage

fn print_area(shape: &dyn Shape) {
    println!(
"{}", shape.area());
}

fn print_perimeters(shapes: Vec<&dyn Shape>) {
    for shape in shapes.iter() {
        println!(
"{}", shape.perimeter());
    }
}

在 Rust 语法中,&Foo是对 Foo struct 的引用,而&dyn Bar是对实现某些trait 的Bar struct 的引用。trait 没有固定大小,但指针有,无论它指向什么。所以用我们的新定义重新审视上面的问题:

fn main() {
    let rectangle = Rectangle { width: 1.0, height: 2.0 };
    let circle = Circle { radius: 1.0 };

    print_area(&rectangle); // 
    print_area(&circle);
// 

    print_perimeters(vec![ &rectangle, &circle ]);
// 
}

我们可以在这里混合和匹配结构,因为它们的所有数据都在指针后面,并且指针具有已知的大小,集合可用于分配内存。
那么缺点是什么?主要是,我们失去了缓存局部性。因为所有结构的数据都在指针后面,所以计算机必须到处跳来追踪它。多次这样做,这可能会开始对性能产生重大影响。
值得注意的是:动态调度本身涉及在查找表中查找所需的方法。通常编译器会提前知道方法代码的确切内存位置,并且可以对该地址进行硬编码。但是对于动态调度,它无法提前知道它具有什么样的结构,因此当代码实际运行时,需要做一些额外的工作来弄清楚它并查找其方法所在的位置。
最后:在实践中,如果某个结构拥有一个仅其特征已知的值,则您可能必须将该值放入一个Box中,这意味着进行堆分配,而该分配/解除分配本身可能会很昂贵。
原文点击标题