Rust 的三種多型性

banq發表於2022-01-07

當您編寫的程式碼應該可以處理幾種不同型別的值,但事先不知道它們是什麼,不同語言處理方式不同:
  • 動態語言就可以讓您傳入任何內容。
  • 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中,這意味著進行堆分配,而該分配/解除分配本身可能會很昂貴。
原文點選標題

相關文章