保證學會的 Rust 靜態分發與動態分發機制攻略手冊
徹底解析 Static Dispatch 與 Dynamic Dispatch 分發機制

Please don't OSINT me, I'd be shy. 🫣
分發(Dispatching)是什麼?
Rust 是一個支援「多型(Polymorphism)」的系統級程式語言。
多型簡單來說就是不同的型別(Type),可以具備相同方法(Method)與運算子(Operator),這樣呼叫者就不必為每個型別的每一個方法拉出一條使用路徑。
文謅謅的講這麼多,給你看 Python 示例就秒懂何謂多型了:
class Dog:
def speak(self):
return "汪汪!"
class Cat:
def speak(self):
return "喵喵!"
def make_sound(animal):
print(animal.speak()) # 👈 多型 (Polymorphism)
make_sound(Dog())
make_sound(Cat())
看到 make_sound() 怎麼呼叫 .speak() 了嗎?它並不在乎 animal 究竟是狗還是貓,它只是(樂觀地)假定 animal 必定有 speak() 方法,這就是多型。
具體來說,Python 會在 Runtime 去檢查 animal 是狗還是貓,然後再去尋找對應的 speak() 函式來執行,這個「尋找並決定執行哪個具體類型(Concrete Type)的方法」就叫做分發(Dispatching)。
而 Python 這種在運行期間邊跑邊檢查的方式,就叫做動態分發(Dynamic Dispatch)。與之相反的,在編譯期完成執行路徑檢索的方式,就叫做靜態分發(Static Dispatch)。
註:Python 是在 Runtime 進行 Bytecode 直譯與動態尋找,所以本質上只有動態分發。而 Rust 兩者都有。
Static Dispatch
看完了 Python 的動態分發範例,讓我們先看 Rust 版本寫成靜態分發會是長怎樣:
#!/usr/bin/env rust-script
trait Animal {
fn speak(&self) -> String;
// 🔼 Tips: 我們用了 `;` 作為結為
// 所以這個 fn 並沒有 Default Implementation 喔!
}
struct Dog;
impl Animal for Dog {
fn speak(&self) -> String {
"汪汪!".to_string()
}
}
struct Cat;
impl Animal for Cat {
fn speak(&self) -> String {
"喵喵!".to_string()
}
}
fn make_sound(animal: impl Animal) {
println!("{}", animal.speak());
}
fn main() {
// 實例化物件
let dog = Dog;
let cat = Cat;
// 呼叫方式對開發者來說,看起來一模一樣
make_sound(dog);
make_sound(cat);
}
執行它,看輸出:
❯ rust-script RunMe.rs
汪汪!
喵喵!
✋ 這邊希望你注意到
make_sound的參數型別指定是impl Animal。意味著
make_sound傳入dog與cat後,dog 與 cat 就會被所有權規則給消耗掉囉。💡 如果你不想讓所有權被消耗掉,就寫成
&impl Animal與make_sound(&dog),這樣也可以編譯成功。
&impl Animal仍然是靜態分發,只是變成傳遞 Reference 而已。
⭐️ 請養成肉眼肌肉記憶:以後看到 impl TraitName,不論這東西是作為函式參數 (var: impl TraitName),還是函式返回值 -> impl TraitName,這個語法一律代表靜態分發(Static Dispatch)。
當使用 Static Dispatch 時,編譯器會在編譯期間,把 make_sound 函式展開成(概念上的 Pseudocode):
fn make_sound___Dog(animal: Dog) {
println!("{}", animal.speak());
}
fn make_sound___Cat(animal: Cat) {
println!("{}", animal.speak());
}
fn main() {
let dog = Dog;
let cat = Cat;
// 被替換了
make_sound___Dog(dog);
make_sound___Cat(cat);
// 注意 Ownership 被吃掉了
}
🤠 這個概念,是不是似曾相識?
沒錯!如果之前你學過了泛型(Generic),教學文件或影片也會告訴你「泛型會在編譯期間展開程式碼,把所有可能的型別獨立寫出來」也就是單態化 Monomorphization。
Static Dispatch 是不是和 Monomorphization 有點像呢?是的,泛型本身也是一種靜態分發!
因此,靜態分發 impl TraitName 也可以有泛型寫法:
// 原本長這樣
fn make_sound(animal: impl Animal) {
println!("{}", animal.speak());
}
// 泛型寫法 (推薦)
// 宣告了一個泛型 T,限制它必須實作 Animal Trait (Trait Bound)
fn make_sound<T: Animal>(animal: T) {
println!("{}", animal.speak());
}
// 泛型 where 寫法 (比較多 Trait Bound 時使用)
fn make_sound<T>(animal: T)
where
T: Animal
{
println!("{}", animal.speak());
}
為什麼我會在註解說推薦第二種寫法呢?(複習:學泛型的重點之一)
在只有單一參數的情況下,impl Animal 適用 80% 的日常場景。
// 兩種都一樣意思,都可以編譯成功
fn make_double_sound(first: impl Animal, second: impl Animal)
fn make_double_sound<T: Animal>(first: T, second: T)
一但參數變多時,這兩者的編譯期限制就會出現顯著差異:
fn make_double_sound(first: impl Animal, second: impl Animal)
// 可以傳入 make_double_sound(dog, cat),編譯會過。
fn make_double_sound<T: Animal>(first: T, second: T)
// 👉 你只能傳入 (dog, dog) 或 (cat, cat)。
// 傳入 make_double_sound(dog, cat) 呢?編譯期報錯!
小結目前學到的 Static Dispatch 重點:
Dispatch 就是讓 CPU 知道接下來要執行的程式碼是哪個版本、程式碼放在哪個記憶體位址。
Static Dispatch 是在編譯期間完成的,會展開靜態程式碼。
因此編譯時間比較久。
編譯出來的 Binary 也比較大。
但 Runtime 執行速度比較快。
看到
impl TraitName關鍵字,立刻想到是 Static Dispatch。impl TraitName和&impl TraitName兩個都是 Static Dispatch。
Dynamic Dispatch
既然靜態分發(Static Dispatch)執行速度快、又能在編譯期抓錯,那我們為什麼還需要動態分發呢?
想像一個很常見的開發情境:我們想要建立一個貨幣市場(陣列),裡面允許交易 BTC 與 ETH。如果你嘗試用 Static Dispatch,編譯器不允許你這樣做:
// ❌ 編譯不會過
// 編譯器:Vec 裡面的元素大小與型別不一致!BTC 跟 ETH 是不同型別。
let trade_market = vec![BTC, ETH];
在 Rust 這個強型別程式語言中,Vec 要求所有元素是「同一個具體型別(Concrete Type)」,但 BTC 和 ETH 這兩個 struct 內部可能有不同數量、不同記憶體大小的欄位。
每個 struct 佔用的記憶體大小可能完全不一致,因此 Rust 不允許你塞到同一個陣列中。這種時候,我們必須告訴編譯器「別管它具體是 BTC 還是 ETH 了,把它當作『某個實作了 Crypto Trait 的結構體』來處理」。
這個東西,就叫做 Trait Object。語法上我們會使用 dyn TraitName 來宣告它。
trait Crypto {
fn decimal(&self)-> u8;
}
struct BTC;
impl Crypto for BTC { ... }
struct ETH;
impl Crypto for ETH { ... }
fn main() {
// 重點在下面這一行
let mut trade_market: Vec<Box<dyn Crypto>> = Vec::new();
trade_market.push(Box::new(BTC));
trade_market.push(Box::new(ETH));
for coin in trade_market {
println!("{}", coin.decimal());
}
}
這邊有兩大重點請你要記住:
請養成肌肉記憶:當你看到
dyn,不論它是被&dyn TraitName還是被Box<dyn TraitName>包起來,一律代表它正在使用動態分發(Dynamic Dispatch)!dyn TraitName絕對不可能單獨存在,它必須是以指標的形式存在,不論是&dyn TraitName、Box<dyn TraitName>、Rc<dyn TraitName>等等等,它必定是以指標形式存在。
❌ 這樣寫一定編譯不過:
fn show_decimal(coin: dyn Crypto) { ... }
// error[E0277]: the size for values of type `(dyn Crypto + 'static)` cannot be known at compilation time
那為什麼 dyn TraitName 必須是以指標形式存在?
在 Rust 中,所有被當作參數傳遞或是存放在變數內的資料,預設都必須在編譯期就能確定大小(也就是具備 Sized 特徵)。
但是 dyn TraitName 的語意是「任何實作了該 Trait 的未知型別」。它可能佔用 0 Byte,也有可能佔用了 100 Bytes,只有在 Runtime 時才知道具體佔用多少記憶體空間,這在 Rust 中被稱為 DST(Dynamic Sized Type 動態大小型別)。
編譯器不知道要在 Stack 上保留多少記憶體空間給 DST,因此我們絕對無法直接傳遞 dyn TraitName 作為函式參數或函式返回值,這就是為何我們必須使用指標把 dyn TraitName 包起來傳遞,因為指標的大小永遠是固定的(根據 CPU 架構決定指標大小)。
OK,那麼 &dyn TraitName 背後又是什麼魔法?既然是指標,為何不直接使用 &TraitName 呢?(P.S. 這是無效語法)
註:Rust 2018 以前,還真的可以寫成
&TraitName。但因為太容易和
&StructName搞混,因此後面改版用&dyn TraitName。
&dyn TraitName 叫做胖指標(Fat Pointer),是普通指標的兩倍大小(一樣是根據 CPU 架構決定)。胖指標包含了兩個普通指標:
Data Pointer(資料指標):指向 Stack 或 Heap 上真正的資料實體(指向
BTC或ETH)。Vtable Pointer(虛擬表指標):指向專屬於該具體型別(Concrete Type)的虛擬函式表(Virtual Method Table)。
先把 Vtable 放一邊,我稍後解釋。先看 &dyn TraitName 語法怎麼使用:
trait Crypto {
fn decimal(&self)-> u8;
}
struct BTC;
impl Crypto for BTC {
fn decimal(&self) {
8
}
}
struct ETH;
impl Crypto for ETH {
fn decimal(&self) {
18
}
}
// 別忘記參數要宣告成指標才能用
fn show_decimal(coin: &dyn Crypto) {
println!("{}", coin.decimal());
}
fn main() {
let btc = BTC;
let eth = ETH;
show_decimal(&btc);
show_decimal(ð);
}
當程式在 Runtime 執行到 coin.decimal() 時:
CPU 拿到
coin這個胖指標。先看 Vtable Pointer,找到這個物件的專屬函式查詢表(例如發現到它是
ETH的 vtable)。在 vtable 內,找到
decimal()這個函式實際所在的記憶體位址。
⚠️ 特別注意BTC.decimal()與ETH.decimal()不可能是同一個記憶體位址,他們各自獨立,沒有共用!最後拿著 Data Pointer 當作
&self參數傳入,跳過去執行那個函式。
這就是 Dynamic Dispatch 與 Static Dispatch 的最大差別:
Dynamic Dispatch 會在每一次在 Runtime 呼叫 Trait 方法時,都必須經過「查表 -> 取得函式位址 -> 跳轉並執行」步驟。因此:
➖ 損失了一點執行期間的效能
➕ 換來程式架構上的靈活性
請確保你靜下心,慢慢把本文上面全部看完並確實理解。
這段知識正是區分 Rust Junior 與 Senior 的關鍵!
額外小提醒(不用背):並不是所有 Trait 都能夠變成 Trait Object!
如果 Trait 有個方法會回傳
Self就不能變成 Trait Object。因為在 Dynamic Dispatching 時沒辦法知道
Self到底多大 😓。
Vtable 的內部結構
我們現在理解 Dynamic Dispatch、Fat-Pointer 怎麼協同運作了,接下來我們稍微暸解一下 Vtable 的底層原理。
簡單來說,vtable pointer 指向 vtable 的起始位址。這張 vtable 的第一個欄位也是個指標,指向 Concrete Data 的 Destructor(std::ptr::drop_in_place::<ConcreteData>),它會負責執行 Drop::drop 把 Concrete Data 結構體的所有欄位都釋放掉。
在清掉內部資料後,CPU 就會讀取 vtable 內的 size 和 align,把 Concrete Data 佔用的記憶體空間還給 Memory Allocator。
至於 vtable 本身?它是在編譯期就已經產生好,並存放在 .rodata 唯讀記憶體區段的靜態資料,所以它會一直存在直到程式結束,完全不需要也不會被釋放空間。
總結
在開發時,選擇 Static Dispatch 還是 Dynamic Dispatch 本質上就是在執行效能與架構靈活性之間做權衡。
推薦可以遵循以下原則做決定:
預設使用 Static Dispatch:大多數情況下盡可能使用 Generic 或
impl TraitName,讓錯誤可以在編譯期就被找出來,而且編譯完成後的執行也是最快的。需要異質集合時使用 Dynamic Dispatch:當你需要在某一集合塞入不同的型別時,就要使用
&dyn TraitName讓編譯器在 Runtime 時根據商業邏輯抽換掉具體的實作,就像上面trait Crypto的範例。
祝開發順利!
