深入 Rust

特徵物件(dyn Trait)與其虛擬方法表

假設你想要一個串列,能裝下許多不同的形狀——一個圓、一個正方形、一個三角形——並對每一個呼叫 .area(),即使它們是不同型別。泛型沒辦法直接幫忙:一個 Vec<T> 裝的是一個固定的 T。你需要的是一個「具體型別被遺忘、只記得它是『某個實作了 Shape 特徵的東西』」的值,這樣一個集合就能把它們混在一起,仍能呼叫特徵的方法。那就是特徵物件,寫作 dyn Trait(例如 Box<dyn Shape> 或 &dyn Shape):一個指向某個值的指標,加上一個在執行期找到該值之特徵方法的方法。

特徵物件是一個「胖指標」——並排的兩個機器字。第一個字是指向值本身的指標(比方說那個圓的位元組);第二個字是指向該值之虛擬方法表(vtable)的指標。vtable 是一個小小的、由編譯器產生、每個(型別、特徵)一份的函式指標表:特徵宣告的每個方法各一個槽,每個槽存著「那個型別」之實作的位址,外加該值的大小與它的 drop 程式碼位址。所以當你對一個 dyn Shape 呼叫 shape.area() 時,機器在編譯期並不知道該跑哪個 area;它改從胖指標所攜帶的 vtable 載入 area 槽並透過它呼叫——一次間接呼叫。不同的形狀攜帶不同的 vtable,所以同一個呼叫點會依指標實際指向什麼,而分派到 circle::area 或 square::area。這正是 C++ 虛擬函式的運作方式,只不過在 Rust 裡 vtable 指標是與資料指標並排同行,而非嵌在物件裡。

為何重要:特徵物件替你換來執行期多型與異質集合,代價是每次方法呼叫都有一次間接、且跨越它無法行內展開。這裡有些誠實的限制。一個特徵必須「物件安全」(現在稱為「dyn 相容」)才能當作 dyn Trait 使用——大致上,它的方法不能是泛型的、也不能以值回傳 Self,因為一個 vtable 槽是單一固定的函式指標,無法依呼叫者特化。而一個 dyn Trait 值沒有靜態已知的大小,所以你幾乎總是把它放在一個指標後面持有(Box、&、Rc)。它與泛型的對比——泛型會為每個型別單型化成各自的快速程式碼——正是接下來要談的靜態對比動態分派的取捨。

trait Shape { fn area(&self) -> f64; } struct Circle { r: f64 } struct Square { s: f64 } impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.r * self.r } } impl Shape for Square { fn area(&self) -> f64 { self.s * self.s } } // 異質串列:每個元素都是一個胖指標(資料 + vtable)。 let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Square { s: 2.0 }), ]; for s in &shapes { println!("{:.2}", s.area()); } // 透過 vtable 分派

dyn Shape 是個胖指標:一個字指向值、一個字指向它的 vtable;每次呼叫都透過那張表查出對的 area。

並非每個特徵都能當特徵物件。一個特徵只在它的方法非泛型、且不以值回傳 Self 時才是 dyn 相容(物件安全)的,因為一個 vtable 槽是一個固定的函式指標。而 dyn Trait 是不定大小的,所以它總是住在像 Box 或 & 的指標後面。

又称
dyn Traittrait objectvtablevirtual method tablefat pointer特徵物件虛擬方法表胖指標