iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 26

(Day26) Rust 進階智慧指標:Pin、特徵物件與動態派發的界線

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 進階智慧指標:Pin、特徵物件與動態派發的界線

Rust 的所有權、模組化、錯誤處理,都是為了寫出簡單、可預測、高效的程式碼。

今天我們要談的是另一條路。
一條充滿了效能陷阱和複雜性的岔路。這條路上有兩個著名的工具:dyn Trait (動態派發) 和 Pin

在我們理解之前,必須先學會最重要的原則:盡所能地避免使用它們。

預設選項:靜態派發 (也就是「正常」的程式碼)

在用泛型 fn foo<T: Trait>(...) 的時候,編譯器在編譯的時候就知道 T 具體是什麼型別。
它會為每個型別產生一份專門的程式碼,沒有任何執行期的額外開銷。這叫靜態派發。

這是 Rust 預設選項,你的首選,唯一應該感到舒服的選擇。
它的速度最快,最佳化最好。也是 Rust "零成本抽象" 的真正含義。

動態派發 (dyn Trait)

有時候,確實無法在編譯期知道所有型別。
最典型的例子就是一個外掛系統,你希望使用者能自己寫程式碼載入進來。

這時候,就需要動態派發。

trait Plugin {
    fn name(&self) -> &'static str;
    fn execute(&self);
}
// ... 實作 ...

// 我們不知道 Vec 裡面到底是什麼鬼東西,只知道它們都遵守 Plugin 的合約
fn run_all_plugins(plugins: &[Box<dyn Plugin>]) {
    for p in plugins {
        p.execute(); // <--- 這裡就是動態派發
    }
}

這東西的真面目:一個 vtable

別被 dyn Trait 這個時髦的名字騙了。
這就是個虛擬函式表 (vtable)。

建立一個 Box<dyn Plugin> 時,得到的不是一個普通的指標,而是一個「胖指標」,裡面包含:

  1. 一個指向你的資料 (比如 UpperCasePlugin 的實例) 的指標。

  2. 一個指向 vtable 的指標。vtable 是一個函式指標列表,對應 Plugin trait 裡的所有方法。

當你呼叫 p.execute() 時,實際發生的事情是:

  1. 讀取胖指標,找到 vtable 的位址。

  2. 在 vtable 裡找到 execute 方法對應的函式指標。

  3. 透過那個函式指標呼叫真正的函式。

付出的代價

  1. 效能稅:每一次呼叫都有至少一次額外的指標間接定址。這會阻止 CPU 的指令預取和分支預測。更重要的是,編譯器無法內聯這個函式呼叫,這會徹底摧毀許多最佳化機會。

  2. 空間稅:每個特徵物件都多了一個指標的大小。

  3. 靈活性稅:你只能呼叫 Trait 裡面定義的方法。所有原始型別的資訊都丟失了。而且,不是所有的 Trait 都能變成特徵物件(有所謂的「物件安全」規則)。

在你伸手去拿 dyn Trait 之前,先問自己

我能不能用一個 enum 來解決問題?

如果你的型別是有限的、可預知的幾種,用 enum 永遠是更好、更快的選擇。

// 更好、更快的選擇
enum Shape {
    Circle(CircleData),
    Rectangle(RectangleData),
}

fn draw_shape(shape: &Shape) {
    match shape { // <--- 靜態派發,快得要命
        Shape::Circle(c) => c.draw(),
        Shape::Rectangle(r) => r.draw(),
    }
}

只有在你真的無法窮舉所有型別(比如外掛)時,才去考慮 dyn Trait

Pin

Pin 是一件為你的資料量身打造的、極其複雜的緊身衣,用來防止你自己搬起石頭砸自己的腳。

問題來源於「自參考結構體」,也就是一個結構體裡面包含了指向自己其它欄位的指標。

struct SelfReferential {
    data: String,
    pointer_to_data: *const String,
}

在 C 語言裡,如果你用 memcpy 移動了這個結構體,pointer_to_data 就會變成一個懸空指標,指向一塊已經無效的記憶體。

C 解決這個問題的方法是:在文件裡寫一行註解「警告:不要移動這個結構體」,然後希望所有人都會遵守。

Rust 認為這還不夠。
它要用型別系統來保證你不會成為那個不看文件的笨蛋。
於是,Pin 誕生了。

Pin 是幹嘛的?

Pin 包裝一個指標(比如 Box&mut),並做出一個承諾:從現在開始,這個指標指向的記憶體不會被移動或被 drop,直到 Pin 的生命週期結束。

這套系統的背後是 Unpin trait、PhantomPinned 標記、unsafe 程式碼和一套複雜的規則。

你應該在什麼時候使用 Pin

幾乎永遠不要。

99.9% 的 Rust 工程師,整個職業生涯都不需要手動去創造一個需要 Pin 的 API。

每天都在使用 Pin,只是你們不知道而已。
當你寫下 .await 的時候:

async fn my_function() {
    let data = some_operation().await; // <--- Pin 就藏在這裡
    use_data(data).await;
}

編譯器會把這個函式變成一個巨大的、自參考的狀態機 struct
然後它用 Pin 把這個狀態機釘死在記憶體裡,確保每次 poll 之間,狀態機內部的參考不會失效。

Pinasync/await 能夠安全運作的基礎。
它是給非同步執行庫的作者、作業系統的核心駭客、以及編譯器的開發者使用的工具。

對於應用程式開發者來說,Pin 就是一個實作細節。

我們不需要懂它,更不應該去碰它。
如果發現在應用程式的程式碼裡,想手動建立一個 Pin,那設計 100% 出了嚴重問題。

結論

  1. 永遠預設使用靜態派發。 這是最快、最簡單、最穩健的路。

  2. 在你無法窮舉所有型別時,才考慮 dyn Trait 把它當成一種需要付出效能代價的權衡。在使用前,先用 enum 試試。

  3. Pin 當成編譯器的內部工具。 除非在寫一個像 tokio 這樣的底層函式庫,否則就當它不存在。

一個新手程式設計師看到這些「進階」功能時會很興奮,覺得自己學會了屠龍之技。
一個有經驗的工程師看到這些,只會感到頭痛,因為他知道每一個都代表著一個充滿妥協和複雜性的場景。

好的工程,是把複雜的問題用簡單的工具解決掉。
dyn TraitPin 本身,就是極其複雜的工具。

Rust 的目標是讓你的程式碼盡可能地「笨」。而不是用這些看起來很聰明的工具,去證明自己有多聰明。


上一篇
(Day25) Rust 模組與可見性:縮小 API 表面,封裝不變式
下一篇
(Day27) Rust unsafe 的最小暴露面:把風險關在最小區域
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言