Rust 學習:詳細瞭解 Trait

特徵(Trait)是 Rust 非常重要的一個設計,甚至可以說是 Rust 語言在這些年衆多新生代編程語言中脫穎而出的重要原因之一。

由於 trait 在 Rust 中是一個非常重要且複雜的概念,我計劃分爲三部分來總結 trait 的特性:

  1. 從 CPP 使用者的角度來看 Rust 的 trait,作爲切入點。

  2. 詳細瞭解 Rust 中 trait 的常見使用方法;

  3. 深入 trait 的特性。

本篇是trait系列的第二篇,在這一部分,我會詳細的介紹關於trait的使用方法,爲了內容的完整性,部分內容可能會與上一篇有所重複。

1. 定義一個特徵

對於擁有一些共性的類型,比如相同的行爲,屬性等,我們就可以定義一個特徵,把一些方法集合在同一個特徵下,這些方法可以看作完成某種特定目的所需要的行爲的集合,總之,這些共性,在 Rust 中被稱作特徵。

1.1 特徵成員方法,默認實現以及靜態方法

下面是一個例子,我們定義了一系列圖形,例如點,線和圓形,顯然,圓形是可以計算面積的,考慮到將來也有許多其他圖形可以計算面積,因此我們定義了一個叫做 CanGetArea 的特徵,作爲所有可以實現面積計算的圖形的特徵,如下:

pub trait CanGetArea{
    fn GetArea(&self) -> f64;
}

最上面一個最基本的特徵的形式,其中的 self 參數需要注意,在 Trait 中 Self 和 self 均爲關鍵字(注意大小寫),Self 在特徵的定義中,指代使用特徵的類型,而 self 則是指實例出來的變量。而 GetArea 的完整簽名其實應該是:

 fn GetArea(self:&Self) -> f64;

在 trait 中定義的方法,第一個參數如果是 self,無論引用或者值傳遞,這個參數在 Rust 中稱爲 receiver,需要通過變量實例來調用,而沒有 receiver 的,則通過類型來調用,就像 CPP 中類的普通成員函數和靜態函數的區別,下面是個完整的例子。

pub trait CanGetArea{
    fn GetArea(self:&Self) -> f64;
    fn GetPI()->f64 //靜態方法,我們希望通過這個trait來全局維護我們參與計算的Pi的精度
    {
        3.1415926
    }
}


struct Point{
    XPoint:f64,
    YPoint:f64,
}


struct Line{
    PointStart:Point,
    PointEnd:Point,
    //顯然我們不會爲Line去實現獲取面積的特徵
}


struct Circle
{
    CeterPoint:Point,
    Radius:f64,
}


impl CanGetArea for Circle{
    //這裏爲Circle實現CanGetArea特徵中的函數
    fn GetArea(&self) -> f64 {
        Circle::GetPI()*self.Radius*self.Radius//注意這裏是通過Circle類型調用的GetPI方法。
    }
    //因爲GetPI()方法已在特徵的定義中給出了**默認實現**,在這裏我們可以不用再爲circle類型單獨實現該方法。
}


fn main() {
    let P = Point {XPoint:1.0,YPoint:2.0};
    let circle = Circle{CeterPoint:P,Radius:6.0};
    println!("Area is {}",circle.GetArea());//注意這裏是通過circle變量調用GetArea方法。
}

1.2 特徵的定義與實現,以及孤兒規則

有時候,我們會跨模塊,或者說跨包的去使用特徵,有時我們在爲類型 TypeA 實現 TraitA 時,TypeA 並不是我們自己定義的,比如我們可以爲 Rust 的基本類型去添加特徵。

pub trait CanPrint{
    fn print(&self);
}
impl  CanPrint for i32 {
    fn print(&self) {
        println!("value is {}",*self);
    }
}
fn main() {
    let i = 32;
    i.print();
}

同時因爲我們把 CanPrint 這個特徵定義爲公開的,那麼其他模塊就可以引用我們定義的 trait,賦予他們模塊裏定義的類型某項特徵,那麼我們能不能在我們自己的模塊裏,引用模塊 A 的某個特徵,然後爲模塊 B 的某個類型實現該特徵呢?答案是否定的,Rust 規定了對某個類型的某個特徵的實現,也就是上面代碼中的 impl 段的代碼,要麼與與特徵的聲明在同一作用域,要麼與類型的聲明在同一作用域,這稱作孤兒規則。因爲強行的爲兩個第三方的包裏聲明的類型和特徵來建立聯繫,很可能會因爲一些沒有暴露出來的內部代碼而造成問題,另一方面這也要求開發者,在開發一些工具庫時,要儘可能爲下游的使用者考慮清楚,尤其 Rust 標準庫中常見的 trait 要儘可能的實現完善。

2. 使用特徵作爲函數參數類型

特徵不止可以用於定義特徵方法,還可以用於作爲函數的參數類型,也就是說,我們可以定義一個函數,所有具備某個特徵的類型的變量都可以作爲參數傳入,例如:

pub fn PrintShape(shape :&impl CanPrint)
{
    todo!()
}

在這樣的一個函數中,你可以把任何實現了 CanPrint 特徵的變量作爲參數傳入,同時可以在函數內調用該特徵的方法。

需要注意的時,&impl 的寫法只是 Rust 提供的一個語法糖,它的完整形態是這樣的:

pub fn PrintShapeEx<T:CanPrint>(shape :T)
{
    todo!()
}

<T:Trait> 這種形式在 Rust 中叫做特徵約束 (trait bound),顯然這種寫法更復雜一些,但是有時,例如我們希望函數傳入多個參數,類型一致,並且都實現了 CanPrint 這一特徵:

pub fn PrintShapeEx<T:CanPrint>(shape1 :T,shape2:T)
{
    todo!()
}

還是很有用的對吧。

同時特徵約束還可以同時使用多個 trait:

pub fn PrintShape(shape :&(impl CanPrint+ CanGetArea))
{
    todo!()
}


pub fn PrintShapeEx<T:CanPrint + CanGetArea>(shape1 :T,shape2:T)
{
    todo!()
}

再進一步,如果我們的特徵約束比較多的時候,還可以引入 where 關鍵字,使得代碼更易於閱讀,也更方便實現規模更復雜的約束:

fn AComplicatedFunc<T, U>(t: &T, u: &U) -> i32
    where T: CanPrint+ CanGetArea + Clone,
          U: Clone + Debug
{
    todo!()
}

此外,也可以利用 impl trait 的寫法,使泛型,參數和返回值的特徵定義都放在一起便於閱讀,例如下面這個來自 Rust Course 的例子:

use std::fmt::Display;


struct Pair<T> {
    x: T,
    y: T,
}


impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self {
            x,
            y,
        }
    }
}
//這裏只爲實現了Display和PartialOrd特徵的Pair<T>實現cmp_display方法
impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

3. 使用特徵作爲函數返回值

在某些場景下,我們使用同一個函數,然後視情況會返回某些類型中的一種,比較典型的是在 UI 框架中,例如我們爲了便於管理,使用一個單例的 Dialog Generator 來生成諸如主窗體,子窗體乃至於用於 Warning,error 的對話框,這種情況下需要我們使用同一個方法,根據參數不同返回不同類型的返回值,那麼因爲這些窗體一定都有一個共同的特徵,比如叫 Drawable,那麼我們就可以有這樣一個方法:

fn WindowDialogGen(/*..相應參數..*/) -> impl Drawable {
    todo!()
}

需要注意的是,如果我們的函數實現寫成下面這樣,是無法編譯通過的:

fn WindowDialogGen(/*..相應參數..*/) -> impl Drawable {
    if conditonA
    {
        aDialog
    }
    else 
    { 
        aWarning
    }
}

因爲 Rust 不允許函數返回不同類型的返回值,要實現這樣的方法,我們會在後面講到特徵對象.

4. 派生特徵

Rust 提供了一些稱作特徵派生語法的功能,我們可以使用 derive 關鍵字標記我們定義的類型,,被標記的對象會自動生成一些默認的特徵代碼,以實現相應的功能,同時也允許我們在有特殊需要的時候手動的去實現。

下面簡要的列舉下可以自動派生的特徵 (來自派生 - 通過例子學 Rust 中文版 :

5. 特徵對象

回到第三節最後提到的代碼,我們加以完善,得到一段可以編譯的代碼:

pub trait Drawable {
    fn printShape(&self);
}
struct Button 
{}
struct Label
{}


impl Drawable for Button
{
    fn printShape(&self) {
        println!("this is a button")
    }
}
impl Drawable for Label
{
    fn printShape(&self) {
        println!("this is a label")
    }
}
fn getAControl(switch :bool) -> impl Drawable
{
    if switch
    {
        Label
        {}
    }
    else
    {
        Button
        {}
    }
}
fn main() {
    getAControl(true);
}

但是顯然,編譯是會報錯的,因爲我們在 if...else... 的分支中分別返回了不同類型的變量:

error[E0308]: `if` and `else` have incompatible types
  --> src\main.rs:30:9
   |
23 | /       if switch
24 | |       {
25 | |           Label
   | |  _________-
26 | | |         {}
   | | |__________- expected because of this
...  |
30 | | /         Button
31 | | |         {}
   | | |__________^ expected struct `Label`, found struct `Button`
32 | |       }
   | |_______- `if` and `else` have incompatible types
   |
help: you could change the return type to be a boxed trait object
   |
21 | fn getAControl(switch :bool) -> Box<dyn Drawable>
   |                                 ~~~~~~~         +
help: if you change the return type to expect trait objects, box the returned expressions
   |
25 ~         Box::new(Label
26 ~         {})
27 |     }
28 |     else
29 |     {
30 ~         Box::new(Button
31 ~         {})
   |

同時編譯器非常貼心的給出了提示,告訴我們可以通過使用 Box 來封裝我們的返回值,那麼,什麼是 Box 呢,那麼就要說到特徵對象了。

5.1 什麼是特徵對象

根據上一節的代碼,我們可以想象下,如果我們需要一個 stack 或者隊列,來保存我們的諸如 Button,Label,乃至於 ComboBox 等等不同的 UI 控件,以便在界面上渲染和顯示,顯然無論是 Stack 還是隊列,我們都需要指定一個類型來定義它,例如 Vec, 那麼我們能不能指定一個特徵,讓它能夠存儲這個實現了這個特徵的所有類型的變量呢?答案就是特徵對象。

如果有其他語言的經驗,尤其是 C/C++ 的程序員們,我們很容易想到,既然隊列裏沒辦法保存多種類型的變量,那麼我們只要保存這些變量的引用或者說指針不就好了,就如我們上一文說到特徵的動態分發的特性時提到的一樣,我們可以通過引用,或者上一篇文章最後提到的 Box 的方式,來引用或者包裝我們的不同類型的變量,本質上就像 C++ 的虛函數一樣,在編譯器我們只是將他們的指針管理起來,直到運行時調用的時候纔會真正的使用到這個指針指向的變量並調用其成員變量或方法。

代碼例子如下:

pub trait Drawable {
    fn printShape(&self);
}
struct Button 
{}
struct Label
{}


impl Drawable for Button
{
    fn printShape(&self) {
        println!("this is a button")
    }
}
impl Drawable for Label
{
    fn printShape(&self) {
        println!("this is a label")
    }
}


pub struct Screen {
    pub components: Vec<Box<dyn Drawable>>,//無論我們新增加多少UI Component的類型,只要它實現了Drawable特徵,那麼就可以添加到這個Vec中。
}
//我們也可以使用泛型的方式來實現,但是這樣寫的話泛型T就限定了我們的隊列中只能保存同一種類型的元素,顯然不是我們想要的
//pub struct Screen<T: Draw> {  
//    pub components: Vec<T>,
//}
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.printShape();
        }
    }


    pub fn addComp(&mut self, item : Box<dyn Drawable> )
    {
        self.components.push(item);
    }
}


fn main() {
    let mut screen = Screen{
        components: vec![
            Box::new(Button {})],
    };
    screen.addComp(
        Box::new(Label{})
    );
    screen.run();
}

總結一下,當我們的類型 Button 實現了 Drawable 特徵時,就可以作爲 Drawable 特徵的特徵對象來使用,同時特別需要注意的是,作爲 Drawable 特徵的特徵對象使用時,我們只能調用這個特徵對象實現自該特徵的方法,不能使用 Button 類型自身或者其他特徵的方法。正如上一篇文章中提到的關於動態分發的內容,在編譯期間,就確定了這個特徵對象的虛函數表,所以我們在作爲特徵對象使用時,也只能調用虛函數表所指向的這些成員函數。

5.2 特徵對象的限制

並非所有的特徵都能夠使用特徵對象,只有符合 Rust 的對象安全的特徵纔可以,Rust 對於對象安全的定義如下:

這兩點的原因都是一樣的,因爲對於特徵對象,編譯時會擦除實現該特徵的具體對象類型,例如對於上面的代碼,編譯器對於 Component 中的 Vec,只知道保存了 Drawable 的特徵對象,並不關心該對象到底時 Button 還是 Label 還是瑪卡巴卡,所以當方法返回 Self 時,無從知曉該返回值到底是何類型。對於泛型參數也是如此,因爲同樣的原因,我們無法直到放入的參數究竟類型如何,因此當有這兩種方法時,Rust 稱之爲不符合對象安全的特徵。因此這類特徵是無法使用特徵對象的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/V4-8sOTWbn02IY3c6d_lFA