理解Trait Object和vtable
Trait的另一個作用是Trait Object。
理解Trait Object也簡單:當Car、Boat、Bus實現了Trait Drivable後,在需要Drivable類型的地方,都可以使用實現了Drivable的任意類型,如Car、Boat、Bus。從場景需求來說,需要Drivable的地方,其要求的是具有可駕駛功能,而實現了Drivable的Car、Bus等類型都具有可駕駛功能。
所以,只要能保護唐僧去西天取經,是選孫悟空還是選六耳獼猴,這是無關緊要的,重要的是要求具有保護唐僧的能力。
這和鴨子模型(Duck Typing)有點類似,只要叫起來像鴨子,它就可以當成鴨子來使用。也就是說,真正需要的不是鴨子,而是鴨子的叫聲。
再看Rust的Trait Object。按照上面的說法,當B、C、D類型實現了Trait A後,就可以將類型B、C、D當作Trait A來使用。這在概念上來說似乎是正確的,但根據Rust語言的特性,Rust沒有直接實現這樣的用法。原因之一是,Rust中不能直接將Trait當作數據類型來使用。
例如,Audio類型實現了Trait Playable,在創建Audio實例對象時不能將數據類型指定為Trait Playable。
#![allow(unused)] fn main() { // Trait Playable不能作為數據類型 let x: Playable = Audio{ name: "telephone.mp3".to_string(), duration: 3.42, }; }
這很容易理解,因為一種類型可能實現了很多種Trait,將其實現的其中一種Trait作為數據類型,顯然無法代表該類型。
Rust真正支持的用法是:雖然Trait自身不能當作數據類型來使用,但Trait Object可以當作數據類型來使用。因此,可以將實現了Trait A的類型B、C、D當作Trait A的Trait Object來使用。也就是說,Trait Object是Rust支持的一種數據類型,它可以有自己的實例數據,就像Struct類型有自己的實例對象一樣。
可以將Trait Object和Slice做對比,它們在不少方面有相似之處。
-
對於類型T,寫法
[T]
表示類型T的Slice類型,由於Slice的大小不固定,因此幾乎總是使用Slice的引用方式&[T]
,Slice保存在棧中,包含兩份數據:Slice所指向數據的起始指針和Slice的長度。 -
對於Trait A,寫法
dyn A
表示Trait A的Trait Object類型,由於Trait Object的大小不固定,因此幾乎總是使用Trait Object的引用方式&dyn A
,Trait Object保存在棧中,包含兩份數據:Trait Object所指向數據的指針和指向一個虛表vtable的指針。
上面所描述的Trait Object,還有幾點需要解釋:
- Trait Object大小不固定:這是因為,對於Trait A,類型B可以實現Trait A,類型C也可以實現Trait A,因此Trait Object沒有固定大小
- 幾乎總是使用Trait Object的引用方式:
- 雖然Trait Object沒有固定大小,但它的引用類型的大小是固定的,它由兩個指針組成,因此佔用兩個指針大小,即兩個機器字長
- 一個指針指向實現了Trait A的具體類型的實例,也就是當作Trait A來用的類型的實例,比如B類型的實例、C類型的實例等
- 另一個指針指向一個虛表vtable,vtable中保存了B或C類型的實例對於可以調用的實現於A的方法。當調用方法時,直接從vtable中找到方法並調用。之所以要使用一個vtable來保存各實例的方法,是因為實現了Trait A的類型有多種,這些類型擁有的方法各不相同,當將這些類型的實例都當作Trait A來使用時(此時,它們全都看作是Trait A類型的實例),有必要區分這些實例各自有哪些方法可調用
- Trait Object的引用方式有多種。例如對於Trait A,其Trait Object類型的引用可以是
&dyn A
、Box<dyn A>
、Rc<dyn A>
等
簡而言之,當類型B實現了Trait A時,類型B的實例對象b可以當作A的Trait Object類型來使用,b中保存了作為Trait Object對象的數據指針(指向B類型的實例數據)和行為指針(指向vtable)。
一定要注意,此時的b被當作A的Trait Object的實例數據,而不再是B的實例對象,而且,b的vtable只包含了實現自Trait A的那些方法,因此b只能調用實現於Trait A的方法,而不能調用類型B本身實現的方法和B實現於其他Trait的方法。也就是說,當作哪個Trait Object來用,它的vtable中就包含哪個Trait的方法。
其實,可以對比著來理解Trait Object,比如v是包含i32類型數據的Vec,v的類型是Vec而不是i32,但v中保存了i32類型的實例數據,另外v也只能調用Vec部分的方法,而不能調用i32相關的方法。
例如:
trait A{ fn a(&self){println!("from A");} } trait X{ fn x(&self){println!("from X");} } // 類型B同時實現trait A和trait X // 類型B還定義自己的方法b struct B{} impl B {fn b(&self){println!("from B");}} impl A for B{} impl X for B{} fn main(){ // bb是A的Trait Object實例, // bb保存了指向類型B實例數據的指針和指向vtable的指針 let bb: &dyn A = &B{}; bb.a(); // 正確,bb可調用實現自Trait A的方法a() bb.x(); // 錯誤,bb不可調用實現自Trait X的方法x() bb.b(); // 錯誤,bb不可調用自身實現的方法b() }
使用Trait Object類型
瞭解Trait Object之後,使用它就不再難了,它也只是一種數據類型罷了。
例如,前文的Audio類型和Video類型都實現Trait Playable:
#![allow(unused)] fn main() { // 為了排版,調整了代碼格式 trait Playable { fn play(&self); fn pause(&self) {println!("pause");} fn get_duration(&self) -> f32; } // Audio類型,實現Trait Playable struct Audio {name: String, duration: f32} impl Playable for Audio { fn play(&self) {println!("listening audio: {}", self.name);} fn get_duration(&self) -> f32 {self.duration} } // Video類型,實現Trait Playable struct Video {name: String, duration: f32} impl Playable for Video { fn play(&self) {println!("watching video: {}", self.name);} fn pause(&self) {println!("video paused");} fn get_duration(&self) -> f32 {self.duration} } }
現在,將Audio的實例或Video的實例當作Playable的Trait Object來使用:
fn main() { let x: &dyn Playable = &Audio{ name: "telephone.mp3".to_string(), duration: 3.42, }; x.play(); let y: &dyn Playable = &Video{ name: "Yui Hatano.mp4".to_string(), duration: 59.59, }; y.play(); }
此時,x的數據類型是Playable的Trait Object類型的引用,它在棧中保存了一個指向Audio實例數據的指針,還保存了一個指向包含了它可調用方法的vtable的指針。同理,y也一樣。
再比如,有一個Playable的Trait Object類型的數組,在這個數組中可以存放所有實現了Playable的實例對象數據:
use std::fmt::Debug; fn main() { let a:&dyn Playable = &Audio{ name: "telephone.mp3".to_string(), duration: 3.42, }; let b: &dyn Playable = &Video { name: "Yui Hatano.mp4".to_string(), duration: 59.59, }; let arr: [&dyn Playable;2] = [a, b]; println!("{:#?}", arr); } trait Playable: Debug {} #[derive(Debug)] struct Audio {} impl Playable for Audio {} #[derive(Debug)] struct Video {...} impl Playable for Video {...}
注意,上面為了使用println!
的調試輸出格式{:#?}
,要讓Playable實現名為std::fmt::Debug
的Trait,因為Playable自身也是一個Trait,所以使用Trait繼承的方式來繼承Debug。繼承Debug後,要求實現Playable Trait的類型也都要實現Debug Trait,因此在Audio和Video之前使用#[derive(Debug)]
來實現Debug Trait。
上面實例的輸出結果:
#![allow(unused)] fn main() { [ Audio { name: "telephone.mp3", duration: 3.42, }, Video { name: "Yui Hatano.mp4", duration: 59.59, }, ] }