JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

特徵、特徵物件,以及靜態 vs 動態分派

特徵(trait)是 Rust 對「某個型別承諾它會做的一組事情」的稱呼——它對介面這個概念的回答。深的部分在於呼叫處發生了什麼:有時編譯器把確切的函式直接烤進去、不花任何代價;有時它附上一張隱藏的指標表,在執行期才查出該呼叫哪個函式。知道你寫的是哪一種、又為什麼,就是這整盤棋的關鍵。

特徵是一份關於行為的承諾

在上一篇你看著借用檢查器推理誰擁有什麼、擁有多久。特徵回答的是另一個問題:一個型別能做什麼? 特徵(trait)是一束具名的方法簽章——一份承諾,保證任何實作它的型別都提供那些方法。如果你寫過 C,你最熟悉、最接近的東西是一個塞滿函式指標、由你親手填寫的結構,或一個宣告了某個 `.c` 檔必須定義之函式的標頭檔。特徵就是把那個觀念變成一等公民、並交給編譯器檢查的版本。你宣告 `trait Draw { fn draw(&self); }`,然後為每個具體型別寫 `impl Draw for Circle { fn draw(&self) { ... } }`。現在 `Circle` 就是一個 `Draw`——不是靠繼承,而是靠它信守了那份承諾。

特徵之所以在 Rust 裡如此要緊,是因為當你想要彈性時,你幾乎從不指名一個具體型別——你指名一個特徵。一個函式說它接受任何實作了 `Draw` 的 `T`,它就對 `Circle`、`Square`、以及某人明年才寫出來的型別都管用,完全不必改。那句「任何實作了 `Draw` 的 `T`」就是一個特徵界限(trait bound),它是真實 Rust 程式碼裡最常見的單一形狀。你會看到它寫成 `fn render<T: Draw>(shape: &T)`,讀作:render 對每個型別 `T` 都管用,只要 `T` 信守 `Draw` 這份承諾。

靜態分派:編譯器把呼叫烤進去

當你寫下 `fn render<T: Draw>(shape: &T)`,在編譯期會悄悄發生一件相當激進的事。編譯器不會產生一個在執行期才搞清楚該呼叫哪個 `draw` 的 `render`。它反而會為你實際呼叫所用的每個具體型別,各壓印出一份獨立、特化的副本:一份給 `Circle` 的 `render`,其中 `Circle::draw` 直接接好線;一份給 `Square` 的 `render`,其中 `Square::draw` 直接接好線。這種壓印就是 單型化(monomorphization)——「把它變成單一型別」——而下一篇正是專講它。這裡造成的效果就是靜態分派:要呼叫哪個確切函式在編譯期就已決定,並直接寫進機器碼裡。

回報就是速度,而且不是含糊的那種。一個靜態分派的呼叫會編譯成一個普通的直接呼叫指令——和 C 為一個單純函式呼叫所產生的東西一模一樣。因為目標已知,最佳化器可以更進一步把函式本體行內展開(inline),徹底抹去這個呼叫,然後跨過邊界進行最佳化,彷彿你親手寫了一個融在一起的函式。這就是 Rust「零成本抽象」承諾的基礎:特徵給了你只寫一次 `render` 的彈性,而編譯之後,那份彈性在二進位檔裡不留一絲痕跡——沒有間接、沒有查表、執行期沒有任何要付的代價。

是有代價的,但它落在編譯期和二進位檔大小上,而不在執行期。如果你用十種不同型別呼叫 `render`,你的執行檔裡就會有十份 `render` 副本。這就是單型化所做的交易:它用更大的二進位檔和更慢的編譯,換取盡可能最快的呼叫。對大多數程式碼而言,這正是你想要的交易,這也是為什麼「帶特徵界限的泛型」是你會第一個伸手去拿的預設工具。

特徵物件:一個型別,一張隱藏的函式表

靜態分派需要在編譯期知道每一個具體型別。但有時你是真的辦不到:你想要一個 `Vec` 把一個圓、一個方、一個文字框全混在一起裝著,而你想遍歷它、對每一個呼叫 `draw`。一個 `Vec<T>` 會被單型化成單一元素型別——它沒辦法裝混合的東西。逃生口就是特徵物件(trait object),寫成 `&dyn Draw` 或 `Box<dyn Draw>`。關鍵字 `dyn` 是一面刻意的旗子,宣告:別把這個特化掉;在執行期才分派。 於是 `Vec<Box<dyn Draw>>` 就能愉快地裝下任何混合的形狀,因為每個元素都有相同的靜態型別——「某個 `Draw`,稍後再決定」。

為了讓這行得通,特徵物件不是一個單純的指標——它是一個胖指標(fat pointer),兩個機器字並排在一起。第一個字指向資料(真正坐在堆積或堆疊上的那個 `Circle`);第二個字指向一張 虛擬函式表(vtable),那是一張小小的靜態函式指標表,特徵裡每個方法一格,填的是那個具體型別的實作。對一個 `&dyn Draw` 呼叫 `obj.draw()` 的意思是:載入 vtable 指標、索引到 `draw` 那一格、載入那裡的函式指標、再透過它呼叫。這種執行期查表就是 動態分派,而那張 vtable 正是 C 程式設計師為了多型而親手打造的「函式指標結構」——只不過編譯器替你打造並接好線,每次都正確無誤。

&dyn Draw   is two words:

  +-----------+        +----------------------------+
  | data ptr  | -----> | the Circle's bytes         |
  +-----------+        +----------------------------+
  | vtable ptr| --+
  +-----------+   |    vtable for Circle as Draw:
                  +--> +----------------------------+
                       | drop_in_place(Circle)  ptr |
                       | size = 8,  align = 8       |
                       | draw -> Circle::draw   ptr |  <- method slot
                       +----------------------------+

  obj.draw()  =  call  (*(obj.vtable + draw_slot))(obj.data)
特徵物件是個胖指標:資料加上一張 vtable。呼叫時索引 vtable,在執行期找出正確的 draw。把它和在 C 裡用親手打造的函式指標結構查找函式相比一下。

誠實地在兩者之間做選擇

那你該寫哪一個?誠實的答案是:沒有哪一個普世更好;它們交易的是不同的東西,而那交易是真實的。靜態分派每次呼叫更快(直接呼叫,常被行內展開),但會膨脹二進位檔和編譯時間,而且沒辦法把混合的型別裝進同一個集合。動態分派讓二進位檔保持精簡、也讓你自由混合型別,但每次呼叫都要為那層間接付費——一次指標載入再透過它呼叫——而且最佳化器通常無法跨越它做行內展開,所以你也失去了行內展開本會解鎖的那些最佳化。不要相信任何告訴你 vtable 呼叫「基本上免費」的人,反過來也別信「它很慢」的說法:它就是多一次間接呼叫,常常看不見影子,偶爾才是熱點。要緊時就量。

  1. 預設先伸手拿靜態分派(`fn f<T: Draw>`):它是最快的呼叫,並讓跨越邊界的最佳化保持完整。
  2. 當你必須儲存一個異質集合時——一個藏在同一特徵後、真正由不同型別組成的 `Vec`——就改用特徵物件(`Box<dyn Draw>`)。
  3. 當單型化會爆炸時也偏向用 `dyn`:一個泛型若被許多型別實例化,會嚴重撐大二進位檔,而一份共享的 `dyn` 副本更精簡。
  4. 記住那道門檻:一個特徵必須是物件安全(object-safe)的,才能當作 `dyn` 使用。粗略地說,它的方法必須能透過指標呼叫——不能有泛型方法,而方法要以參照接收 `self`,而非以 `Self` 的值。

讓特徵變得強大的那些零件

一個特徵能攜帶的不只是方法。一個 關聯型別(associated type) 讓特徵能為它所搭配的某個型別命名,卻把選擇權留給實作者。經典案例是 `Iterator`:這個特徵宣告 `type Item;` 和 `fn next(&mut self) -> Option<Self::Item>;`,而當你 `impl Iterator for Counter` 時,你把 `type Item = u32` 釘死。呼叫方寫 `Self::Item`,得到的就是實作者所選的東西,不必有額外的型別參數弄亂每一道簽章。這就是為什麼在 Rust 裡迭代感覺如此自然,卻又能編譯成一個沒有任何裝箱的緊湊迴圈。

還有兩個零件決定了特徵如何在整個程式中組合。一個涵蓋實作(blanket implementation)是一個一次為所有滿足某界限的型別所寫的 `impl`——`impl<T: Display> ToString for T` 一筆就給了每個可顯示的型別一個 `to_string()`,不必逐型別動工。而孤兒規則(orphan rule)是讓這一切保持清醒的護欄:你只有在自己擁有那個特徵擁有那個型別時,才可以為某個型別實作某個特徵。少了它,兩個彼此無關的箱(crate)可能各自為同一個外來型別實作同一個外來特徵,編譯器就沒有任何有原則的方法去挑。這條規則可能讓人覺得綁手綁腳,但正是它讓編譯器能保證恰好只有一個實作可供分派——而這正是讓靜態與動態分派都毫不含糊的同一份保證。

最後一條線索拉回所有權,因為一旦你看見它,它就無所不在。你到處傳遞的閉包——`|x| x + 1` 之類——是一些其型別實作了 `Fn`、`FnMut` 或 `FnOnce` 特徵的值,所以閉包不過是喬裝過的特徵,而同樣的靜態 vs 動態抉擇也適用:`impl Fn(i32) -> i32` 會被單型化並行內展開,而 `Box<dyn Fn(i32) -> i32>` 是一個帶 vtable 的胖指標。連清理也遵循這個模式:你在清理資源時遇過的 `Drop` 特徵,對一個 `dyn` 值是透過 vtable 分派的,這就是為什麼一個 `Box<dyn Draw>` 在離開範圍時仍會執行正確的解構子。特徵不是 Rust 眾多功能裡的其中一項——它們是連結組織,而分派這個問題貫穿其中的一切。