博客 / 詳情

返回

解讀 Rust 中的高級 trait 與泛型

Hello world! 這篇文章將帶你快速回顧一下 Rust 的 trait 和泛型,並實現更高級的 trait 約束及類型簽名。

快速複習 Rust trait

編寫 Rust trait 就是這麼簡單:

pub trait MyTrait {
    fn some_method(&self) -> String;
}

只要某類型實現了 MyTrait,它就保證會實現 some_method() 函數。要實現一個 trait,只需實現必須的方法(結尾有分號的)。

struct MyStruct;

impl MyTrait for MyStruct {
    fn some_method(&self) -> String {
        "Hi from some_method!".to_string()
    }
}

也可以在你自己的類型上實現不屬於你的 trait,或者在不屬於你的類型上實現你的 trait——但不能兩者都不屬於你!原因在於 trait 的相干性(coherence)[1]。我們要確保 trait 的實現不會發生意外衝突:

// 為 MyStruct 實現不屬於我們的 Into<T> trait 
impl Into<String> for MyStruct {
    fn into(self) -> String {
        "Hello world!".to_string()
    }
}

// 為不屬於我們的類型實現 MyTrait
impl MyTrait for String {
    fn some_method(&self) -> String {
        self.to_owned()
    }
}

// 不能這樣!
impl Into<String> for &str {
   fn into(self) -> String {
       self.to_owned()
   }
}

通常的解決方法是採用 newtype 模式——創建一個單字段元組結構體,封裝要擴展的類型。

struct MyStr<'a>(&'a str);

// 注意,實現 From<T> 時也會實現 Into<T> - 因此可以同時使用 .into() 及 String::from()

impl<'a> From<MyStr<'a>> for String {
    fn from(string: MyStr<'a>) -> String {
        string.0.to_owned()
    }
}

fn main() {
    let my_str = MyStr("Hello world!");
    let my_string: String = my_str.into();

    println!("{my_string}");
}

如果多個 trait 具有相同的方法名,則需要手動聲明用哪個 trait 的實現來調用該類型:

pub trait MyTraitTwo {
    fn some_method(&self) -> i32;
}

impl MyTraitTwo for MyStruct {
    fn some_method(&self) -> i32 {
        42
    }
}

fn main() {
    let my_struct = MyStruct;
    println("{}", MyTraitTwo::some_method(&my_struct);
}

有時可能希望為用户提供一個默認實現,否則會很繁瑣。此時可以通過在 trait 中定義方法來實現。

trait MyTrait {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}

trait 也可以依賴其他 trait!以 std::error::Error 為例:

trait Error: Debug + Display {
    // .. 如果需要,在這裏重新實現所提供的方法
}

此處,我們明確地告訴編譯器,在實現 Error 之前,類型必須同時實現 DebugDisplay

標記 trait 簡介

標記 trait 顧名思義是一種“標記”,編譯器通過它可以瞭解到:當某個類型實現了標記 trait 時,表示該類型做出了特定的承諾。標記 trait 沒有方法或特定屬性,但通常被編譯器用於確保其具有某些行為。

使用標記 trait 的原因有:

  • 編譯器需要確保是否可以做某事
  • 它是實現層面的細節,可以手動實現

有兩個標記 trait(結合其他一些較少使用的標記 trait)對我們而言相當重要:SendSync。手動實現 SendSync 是 unsafe 的——通常是因為你需要手動確保它們被安全地實現。Unpin 也是此類 trait 的一個例子。關於為什麼手動實現這些 trait 會不安全,請查閲官方文檔。

除此之外,(一般來説)標記 trait 也是 auto trait。而如果某結構體的所有字段都實現了 auto trait,則結構體本身也會實現 auto trait。例如:

  • 假設結構體中的所有字段類型都是 Send,編譯器就會自動將結構體標記為 Send,用户無需提供任何信息。
  • 假設除一個字段外,結構體的所有字段都實現了 Clone,只有這一個字段沒有,那麼結構體就不能再推導出 Clone。可以通過 ArcRc 封裝相關類型來解決該問題,但這取決於使用場景,在某些場景下並不可行,不可行時需要考慮其他解決方案。

Rust 中的標記 trait 為何重要?

Rust 中的標記 trait 構成了其生態系統的核心,並允許我們提供在其他語言中可能無法實現的保證。例如,Java 的標記接口與 Rust 的標記 trait 類似。然而,Rust 中的標記 trait 可不止是用於像 CloneableSerializable 這樣的行為;比方説,它們還能確保類型可以在線程間發送。這是 Rust 生態系統中一個微妙但影響深遠的區別。例如有了 Send 類型,就能確保跨線程發送類型始終是安全的,使得併發問題變得更易於處理。標記 trait 還可能影響其他方面:

  • Copy trait 要求通過按位複製來複制內容(儘管還需要 Clone)。嘗試按位複製一個指針只會返回其地址!這也是 Rust 中的字符串無法被 copy 而必須被 clone 的原因:字符串是智能指針。
  • Pin trait 允許將某個值“釘”在內存中的固定位置。
  • Sized trait 允許在編譯時將類型定義為具有固定大小——只不過大多數類型已經自動實現。

還有一些標記 trait,如 ?Sized, !Send!Sync,較之 Sized, SendSync ,它們是“非 trait 約束”,起到完全相反的作用:

  • ?Sized 允許類型是未定大小的(或者稱為動態大小的)[2]
  • !Send 告訴編譯器某對象絕對不能發送到其他線程
  • !Sync 告訴編譯器某對象的引用絕對不能在線程之間共享

標記 trait 還可以改善 crates 庫。例如,鑑於應用或類庫的需要,假設你有個類型實現了 Pin (Future 就是個典型案例)。很棒,因為現在你可以安全地使用該類型,但要將 Pin 類型與不關心 pin 的東西一起使用就變得困難得多。此時你可以為不需要 pin 的類型實現 Unpin,從而大大改善開發體驗。

對象 trait 及動態分派

除了上述所有內容外,trait 還可以使用動態分派。動態分派本質上是在運行期選擇多態函數的具體實現的過程。雖然 Rust 出於性能考慮傾向於使用靜態分派,但通過 trait 對象使用動態分派也確有好處。

使用 trait 對象的最常見模式是 Box<dyn MyTrait>,這裏需要將 trait 對象包裝在 Box 中以使其實現 Sized。由於我們將多態過程移到了運行期,編譯器將無法得知該類型的大小。通過將類型包裝在指針中(或“裝箱”它)可以將其放在堆上而不是棧上。

// 存放 trait 對象的結構體
struct MyStruct {
     my_field: Box<dyn MyTrait>
}

// 正常工作!
fn my_function(my_item: Box<dyn MyTrait>) {
     // .. 此處有一些代碼
}

// 這樣不行!
fn my_function(my_item: dyn MyTrait) {
     // .. 此處有一些代碼
}

// 使用 Sized 約束的 trait 示例
trait MySizedTrait: Sized {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}

// 非法結構體,由於使用了 Sized 約束而無法編譯
struct MyStruct {
    my_field: Box<dyn MySizedTrait>
}

對象類型將在運行期計算得出,這與使用編譯期的泛型不同。

動態分派的主要優勢在於,函數不需要知道具體的類型;只要類型實現了某個 trait,就可以被當作 trait 對象使用(只需是安全的 trait 對象)。類似於其他語言中鴨子類型的概念,其中對象的可用函數和屬性決定了其類型。通常從用户的角度來看,編譯器並不關心底層的具體類型是什麼——只要它實現了特定 trait 即可。然而,在某些情況下,這點確實又很重要——針對這種情況,Rust 提供了確定其具體類型的方法,儘管使用起來有些棘手。依據你的用法,動態分派也可以做到避免代碼膨脹,這可能是其好的一面。

從庫用户的角度來看,錯誤也更容易理解。從庫開發者的角度來看,這不是大問題,但若需要使用個重度依賴泛型的庫,則可能會遇到非常令人困惑的錯誤!Axum 和 Diesel 這兩個庫有時就會犯此錯誤,它們倒是有相應的解決方案(Axum 通過 #[debug_handler] 宏而 Diesel 依靠文檔)。由於將分派過程轉移到了運行期,所以還節省了些編譯時間。

缺點在於,你需要確保對象 trait 的安全性。滿足對象安全性所需要的條件包括:

  • 類型不需要 Self: Sized
  • 類型必須在函數參數中使用某種形式的 "self"(無論是 &selfselfmut self 等...)
  • 類型不能返回 Self

更多內容參見這裏[3]。

注意,如果某 trait 不需要 Self: Sized,但它有個方法需要,那你將無法在 dyn 對象上調用那個方法。

這是由於將分派移至運行期後,編譯器無法推測類型大小——對象 trait 在編譯期並沒有固定的大小。這也是為什麼需要像之前提到的那樣,將動態分派的對象裝箱並放在堆上。鑑於該原因,應用程序也將會受到性能影響——當然了,影響程度取決於你使用了多少動態分派的對象以及各對象的大小!

為了進一步闡述這些要點,我想到了兩個 HTML 模板庫:

  • Askama,它使用宏和泛型進行編譯期檢查
  • Tera,它使用動態分派來在運行期獲取 filter 和 tester

這兩個庫雖然在大部分使用場景下可以互換使用,但它們有着不同的權衡。Askama 編譯時間較長,任何錯誤都會在編譯期顯示,而 Tera 只有在運行期才會拋出編譯錯誤,並因動態分派而損失性能。靜態站點生成器 Zola 使用了 Tera,因為某些設計條件無法通過 Askama 來滿足。從這裏[4]你可以看出,Tera 框架使用了 Arc<dyn T>

結合 trait 和 泛型

開始

trait 和泛型可以很好地協同工作,易於使用。你可以輕鬆地編寫一個實現泛型的結構體,像這樣:

struct MyStruct<T> {
    my_field: T
}

不過,為了能將結構體與其他 crate 中的類型一起使用,我們需要確保結構體實現了某些行為。這正是要添加 trait 約束的地方:類型必須滿足條件才能編譯。你可能會遇到的一個常見的 trait 邊界是 Send + Sync + Clone

struct MyStruct<T: Send + Sync + Clone> {
    my_field: T
}

現在,可以為 T 類型使用任何我們想要的值,只要該類型實現了 SendSyncClone 這三個 trait!

作為一個更復雜的例子,你可能偶爾需要為自己的類型重新實現使用了泛型的 trait,以 Axum 的 FromRequest trait 為例(下面的代碼片段是對原始 trait 的簡化,以便於説明):

use axum::extract::State;
use axum::response::IntoResponse;

trait FromRequest<S>
   where S: State
    {
    type Rejection: IntoResponse;

    fn from_request(r: Request, _state: S) -> Result<Self, Self::Rejection>;
}

這裏還可以通過使用 where 子句來添加 trait 約束。該 trait 只是告訴我們 S 實現了 State。然而,State 還要求內部對象實現 Clone。通過使用複雜的 trait 約束,我們可以創建出大量使用 trait 的框架系統,以實現一些人可能稱之為“trait 魔法”的功能。舉個例子,看一下這個 trait 約束:

use std::future::Future;

struct MyStruct<T, B> where
   B: Future<Output = String>,
   T: Fn() -> B
   {
    my_field: T
}

#[tokio::main]
async fn main() {
    let my_struct = MyStruct { my_field: hello_world };
    let my_future = (my_struct.my_field)();
    println!("{:?}", my_future.await);
}

async fn hello_world() -> String {
    "Hello world!".to_string()
}

上述單字段結構體存儲了一個返回 impl Future<Output = String> 的函數閉包,我們將 hello_world 存儲其中,然後在主函數中調用它。我們給該字段加上括號以便能夠調用它,然後等待 future 完成。請注意,我們沒在字段末尾加上括號。這是因為在末尾添加 () 實際上會調用這個函數!可以看到我們是在聲明結構體後調用函數,然後等待它。

在庫中的使用

像這樣將 trait 和泛型結合起來是非常強大的。HTTP 框架就是有效利用這點的一個範例。例如,Actix Web 有個名為 Handler<Args> 的 trait,它接受一些參數,調用自身,然後有個名為 call 的函數,該函數產生一個 Future:

pub trait Handler<Args>: Clone + 'static {
     type Output;
     type Future: Future<Output = Self::Output>;

     fn call(&self, args: Args) -> Self::Future;
}

這樣就可以將此 trait 擴展為 handler 函數。我們可以告訴 Web 服務,這裏有一個函數,它有一個內部函數、一些參數,並實現了Responder(Actix Web 的 HTTP 響應 trait):

pub fn to<F, Args>(handler: F) -> Route where
    F: Handler<Args>,
    Args: FromRequest + 'static,
    F::Output: Responder + 'static {
         // .. the actual function  code here
}

注意,Axum 等其他框架也採用了同樣的方法為開發人員提供極其人性化的體驗。

總結

感謝閲讀!儘管 trait 和泛型可能是個難以理解的主題,但希望這篇關於如何使用 Rust trait 和泛型的指南能夠為你帶來些許啓示!

譯註:
[1] 即“孤兒原則”,可參考譯者的一篇文章
[2] 可參考譯者譯的另一篇文章
[3] 地址為:https://doc.rust-lang.org/reference/items/traits.html#object-...
[4] 地址為:https://github.com/Keats/tera/blob/3b2e96f624bd898cc96e964cd63194d58701ca4a/src/tera.rs#L61

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.