Skip to main content

Command Palette

Search for a command to run...

保證學會的 Rust 靜態分發與動態分發機制攻略手冊

徹底解析 Static Dispatch 與 Dynamic Dispatch 分發機制

Updated
5 min read
保證學會的 Rust 靜態分發與動態分發機制攻略手冊
W

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 傳入 dogcat 後,dog 與 cat 就會被所有權規則給消耗掉囉。

💡 如果你不想讓所有權被消耗掉,就寫成 &impl Animalmake_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)」,但 BTCETH 這兩個 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());
  }
}

這邊有兩大重點請你要記住:

  1. 請養成肌肉記憶:當你看到 dyn,不論它是被 &dyn TraitName 還是被 Box<dyn TraitName> 包起來,一律代表它正在使用動態分發(Dynamic Dispatch)

  2. dyn TraitName 絕對不可能單獨存在,它必須是以指標的形式存在,不論是 &dyn TraitNameBox<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 架構決定)。胖指標包含了兩個普通指標:

  1. Data Pointer(資料指標):指向 Stack 或 Heap 上真正的資料實體(指向 BTCETH)。

  2. 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(&eth);
}

當程式在 Runtime 執行到 coin.decimal() 時:

  1. CPU 拿到 coin 這個胖指標。

  2. 先看 Vtable Pointer,找到這個物件的專屬函式查詢表(例如發現到它是 ETH 的 vtable)。

  3. 在 vtable 內,找到 decimal() 這個函式實際所在的記憶體位址。
    ⚠️ 特別注意 BTC.decimal()ETH.decimal() 不可能是同一個記憶體位址,他們各自獨立,沒有共用!

  4. 最後拿著 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 的底層原理。

Source: Understanding Rust's Trait Objects: Vtables, Dynamic Dispatch, and Memory Deallocation

簡單來說,vtable pointer 指向 vtable 的起始位址。這張 vtable 的第一個欄位也是個指標,指向 Concrete Data 的 Destructor(std::ptr::drop_in_place::<ConcreteData>),它會負責執行 Drop::drop 把 Concrete Data 結構體的所有欄位都釋放掉。

在清掉內部資料後,CPU 就會讀取 vtable 內的 sizealign,把 Concrete Data 佔用的記憶體空間還給 Memory Allocator。

至於 vtable 本身?它是在編譯期就已經產生好,並存放在 .rodata 唯讀記憶體區段的靜態資料,所以它會一直存在直到程式結束,完全不需要也不會被釋放空間。


總結

在開發時,選擇 Static Dispatch 還是 Dynamic Dispatch 本質上就是在執行效能與架構靈活性之間做權衡。

推薦可以遵循以下原則做決定:

  1. 預設使用 Static Dispatch:大多數情況下盡可能使用 Generic 或 impl TraitName,讓錯誤可以在編譯期就被找出來,而且編譯完成後的執行也是最快的。

  2. 需要異質集合時使用 Dynamic Dispatch:當你需要在某一集合塞入不同的型別時,就要使用 &dyn TraitName 讓編譯器在 Runtime 時根據商業邏輯抽換掉具體的實作,就像上面 trait Crypto 的範例。

祝開發順利!