理解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 ABox<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,
    },
]
}