理解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。
這很容易理解,因為一種類型可能實現了很多種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:
現在,將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。
上面實例的輸出結果: