记一次rust动态分发分析

背景

开发时用到rust的特征对象来实现多态功能,遇到一个编译无法通过的问题

原始代码

trait MT {
    fn run(self: Box<Self>) -> Box<dyn MT> {
        let t: Box<dyn MT> = self;
        t
    }
}

struct C1();

impl MT for C1 {}

struct C2();

impl MT for C2 {}

struct C3();

impl MT for C3 {
    fn run(self: Box<Self>) -> Box<dyn MT> {
        Box::new(C1())
    }
}

编译报错:

3 |         let t: Box<dyn MT> = self;
  |                              ^^^^ doesn't have a size known at compile-time
  |
  = note: required for the cast from `Box<Self>` to `Box<dyn MT>`
help: consider further restricting `Self`
  |
2 |     fn run(self: Box<Self>) -> Box<dyn MT> where Self: Sized {
  |                                            +++++++++++++++++

代码修改

但如果我把trait里run方法的默认实现去掉(关键),并将实现下发到子类编译会通过。代码如下:

trait MT {
    fn run(self: Box<Self>) -> Box<dyn MT>;  // 不再提供默认实现
}

struct C1();

impl MT for C1 {
    fn run(self: Box<Self>) -> Box<dyn MT> {
        let t: Box<dyn MT> = self;
        t
    }
}

struct C2();

impl MT for C2 {
    fn run(self: Box<Self>) -> Box<dyn MT> {  // 将原先的默认实现分别在子类中独立实现
        let t: Box<dyn MT> = self;
        t
    }
}

struct C3();

impl MT for C3 {
    fn run(self: Box<Self>) -> Box<dyn MT> {  // 将原先的默认实现分别在子类中独立实现
        Box::new(C1())
    }
}

分析

两点说明:

  1. trait的默认实现只会编译一次,不会在每个子类中重新编译一次(猜测)。如果子类没有重载trait的默认实现方法,则会复用默认实现
  2. Box<Self>的编译时大小是已知的——指向堆上Self的指针大小(在64位机器上为8byte)。

问题1
为什么let t: Box<dyn MT> = self在trait的默认方法里无法通过编译,而如果是在具体的子类实现中可以通过编译?

分析:关键就在于trait的默认实现是不和任何具体子类绑定的,这样在trait的默认实现中Self具体类型是不确定的(而在具体子类实现中Self是确定的)。
在trait的默认实现中只知道栈上指向Self的指针,而无其他具体信息。那为什么无法像c++子类指针一样直接向上cast为父类指针呢?
我的理解是c++的class和rust的struct在vptr上的实现不同,因为c++的继承和rust的trait实现是两种不同的东西:

  • 在c++的继承体系中,以单继承为主(多重继承有特殊的实现),每个实现virtual函数的class指针均有一个vptr指向自己的vtable,而子类的内存布局结构通常是在父类结构基础上的扩展(试具体实现),这样一个子类往往很容易cast为父类的指针形式
  • 在rust中没有继承的概念,通常是以trait的方式来实现多态,同一个struct可以实现多个trait。这样的话,同一个struct可能包含多个vptr,在不知道struct的具体类型信息前是无法知道某个trait对应vptr所在的具体地址。

根据上边的分析,可知trait的默认实现里,self(注:持有所有权而非借用的self)是无法动态转变为dyn MT的。而子类因为知道self的具体类型,进而知道其内存布局,所以可以完成动态转变。

问题2
但为什么在trait的默认实现里可以调用其他vtable里的方发?这时候的vptr是如何得知的?

分析:我猜测在trait的默认实现方法中调用其它方法是通过偏移(offset)来实现的,因此也是不知道Self的实现该trait的vptr的。

结论:

在编译时,trait中的Box<Self>和struct里的Box<Self>的表现不完全一样:

  • 在trait中,Box<Self>包含的主要信息主要是指向Self的指针和各个方法间的偏离
  • 在struct中,Box<Self>包含指向Self的指针和Self的内存布局详情

进一步,Box<Self>的结论可以推广到&self、&mut self和self。这些类型的self在trait中均无法动态转换为响应的trait。例如:

trait MT {
    fn run(&self) {
        let t: &dyn MT = self;
    }
}

仍会报错。原因也是在trait里的self只有指针信息和方法偏移信息,而无其他信息可以获取到。

额外说明

在rust里,Box<T>和Box<dyn MT>的栈上大小是不一样的。

  • Box<T>只包含一个指向堆上对象的指针
  • Box<dyn MT>除了包含指向堆上对象的指针外,还包含一个指向MT trait的vptr
println!("size of Box<C1>: {}", std::mem::size_of::<Box<C1>>()); 
println!("size of Box<dyn MT>: {}", std::mem::size_of::<Box<dyn MT>>());

// 64位操作系统输出
// size of Box<C1>: 8
// size of Box<dyn MT>: 16

Box<dyn MT>的vptr可以通过下面方法拿到

#![feature(ptr_metadata)]

let mt: Box<dyn MT> = Box::new(C1());
println!("metadata: {:?}", std::ptr::metadata(&*mt));

或者直接根据ABI,自己来获取

use std::ptr::addr_of;

let mt: Box<dyn MT> = Box::new(C1());
let raw_ptr = addr_of!(mt) as *const();  // 指向具体数据的指针
let ptr_vptr = (raw_ptr as usize + 8) as *const();  // vptr存储位置。+8是因为在64位系统上
let vptr = *(ptr_vptr as *const *const ());
println!("vptr: {:p}", vptr);
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容