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(); // <--- 這裡就是動態派發
}
}
別被 dyn Trait
這個時髦的名字騙了。
這就是個虛擬函式表 (vtable)。
建立一個 Box<dyn Plugin>
時,得到的不是一個普通的指標,而是一個「胖指標」,裡面包含:
一個指向你的資料 (比如 UpperCasePlugin
的實例) 的指標。
一個指向 vtable 的指標。vtable 是一個函式指標列表,對應 Plugin
trait 裡的所有方法。
當你呼叫 p.execute()
時,實際發生的事情是:
讀取胖指標,找到 vtable 的位址。
在 vtable 裡找到 execute
方法對應的函式指標。
透過那個函式指標呼叫真正的函式。
效能稅:每一次呼叫都有至少一次額外的指標間接定址。這會阻止 CPU 的指令預取和分支預測。更重要的是,編譯器無法內聯這個函式呼叫,這會徹底摧毀許多最佳化機會。
空間稅:每個特徵物件都多了一個指標的大小。
靈活性稅:你只能呼叫 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
之間,狀態機內部的參考不會失效。
Pin
是 async/await
能夠安全運作的基礎。
它是給非同步執行庫的作者、作業系統的核心駭客、以及編譯器的開發者使用的工具。
對於應用程式開發者來說,Pin
就是一個實作細節。
我們不需要懂它,更不應該去碰它。
如果發現在應用程式的程式碼裡,想手動建立一個 Pin
,那設計 100% 出了嚴重問題。
永遠預設使用靜態派發。 這是最快、最簡單、最穩健的路。
在你無法窮舉所有型別時,才考慮 dyn Trait
。 把它當成一種需要付出效能代價的權衡。在使用前,先用 enum
試試。
把 Pin
當成編譯器的內部工具。 除非在寫一個像 tokio
這樣的底層函式庫,否則就當它不存在。
一個新手程式設計師看到這些「進階」功能時會很興奮,覺得自己學會了屠龍之技。
一個有經驗的工程師看到這些,只會感到頭痛,因為他知道每一個都代表著一個充滿妥協和複雜性的場景。
好的工程,是把複雜的問題用簡單的工具解決掉。
而 dyn Trait
和 Pin
本身,就是極其複雜的工具。
Rust 的目標是讓你的程式碼盡可能地「笨」。而不是用這些看起來很聰明的工具,去證明自己有多聰明。