Rust 中级教程 第19课——trait object (2)

794 阅读4分钟

0x00 开篇

我们继续来介绍 trait object。本文来了解下它的内存布局。本篇文章的阅读时间大约 10 分钟

0x01 内存布局

先上图再解释。

image.png

trait object 构成

前面提到过,trait object 是一个胖指针,那么它由两部分组成。包含某个值的地址 (data) 和一个指向与该值匹配的特型实现的指针 (vtable)。简单讲,trait object 包含 data 指针和 vtable 指针。vtable 是 virtual method table 的缩写。

vtable 是什么?

vtable 本质上是结构体指针的结构,指向具体实现中每种方法的一段机器代码。在 Rust 中,当使用多态性时,编译器会自动为每个带有虚函数的类型创建一个虚表。在运行时,这个虚表会被动态分配内存,并用于存储虚函数的地址。虚拟表只在编译时生成一次,由同类型的所有对象共享。

vtable 详细解释

上图中,右侧的表内的字段和数据结构无法直接访问。当你调用一个 trait object 的方法时,rust 会自动使用虚拟方法表,以确定调用哪个方法的实现。

vtable 本质上是一个结构体指针(如下)

struct VTable {
	destructor: fn(*mut ()),
    size: usize,
    alignment: usize,
    method_1: fn(*const ()),
    method_2: fn(*const ()),
    method_n: fn(*const ()),
}

每个 vtable 中的 destructor 字段都指向一个函数,该函数将清理 vtable 类型的任何资源。size 和 align 字段存储了被清除类型的大小,以及它的对齐要求。destructor 、sizealignment 这三个字段是每个 vtable 都共有的类型。method_1 到 method_n 就是 在trait 中定义的方法。比如 trait_object.method_1()这样的方法调用将从 vtable 中检索出正确的指针,然后对其进行动态调用。

0x02 通过IDE查看内存

上面说了那么多概念,那我们通过 IDE 直接查看下具体的内存布局是不是上面所说的那样呢?

示例代码我们还是以 Shape trait 为例:

/// 声明一个图形 shape trait
trait Shape {
    /// 计算面积
    fn area(&self) -> f64;

    /// 计算周长
    fn perimeter(&self) -> f64;
}

/// 圆形结构体
struct Circle {
    radius: f64,
}

impl Circle {
    fn new(radius: f64) -> Self {
        return Circle { radius };
    }
}

/// 为 Circle 实现 Shape
impl Shape for Circle {
    fn area(&self) -> f64 {
        return PI * self.radius * self.radius;
    }

    fn perimeter(&self) -> f64 {
        return 2.0 * PI * self.radius;
    }
}

fn main() {
    let circle = Circle::new(5.0);

    let shape: &dyn Shape = &circle;

    println!("shape => {}", shape.area());
}
&dyn shape 的构成

图片

可以看到 shape 有两部分组成,一个是 pointer 指向了 0x0000007fc490f7c8 这个地址,另一部分是 vtable。

pointer 指向的数据是什么?

图片

pointer 指向了 0x0000007fc490f7c8 这个地址,找到这个地址刚好是 rectangleRectangle 是一个结构体,包含 width 和 height 字段,所以内存里的数据分别是 3 和 4。

vtable 里有什么

图片

与 vtable 的结构对应了起来。我们具体看绿色的两个框,计算面积的 area 方法的机器码在 0x00007ff6540a1270地址,计算周长的 perimeter 方法的机器码在 0x00007ff6540a12b0 地址。这里的二进制就是具体方法的机器码了(如下图)。

image.png

通过LLDB查看机器码的汇编

不看到代码不死心?凭什么说这些二进制就是机器码?万一不是呢?接下来我们通过LLDB来验证。

  • 从 0x00007ff6540a1270 地址处开始的汇编代码(area)

    图片

  • 从 0x00007ff6540a12b0 地址处开始的汇编代码(perimeter)

    图片

即使我们对汇编一点儿都不懂,我们也能看出这里的对应的代码。

简单解释下计算乘法的汇编代码吧(电脑是Intel 64位的)。

(lldb) x/20i 0x00007ff6540a1270
    0x7ff6540a1270: 48 83 ec 38           subq   $0x38, %rsp				; 在堆上分配 0x38个字节的空间(申请内存) 
    0x7ff6540a1274: 48 89 4c 24 30        movq   %rcx, 0x30(%rsp)			; 将 寄存器 %rcx 的值移动到当前的堆栈指针位置%rsp 上偏移 0x30 个字节的内存位置
    0x7ff6540a1279: 8b 41 04              movl   0x4(%rcx), %eax 			; 将 寄存器 %rcx 的值上偏移 0x4 个字节的内存位置 移动到 寄存器 %eax 上
    0x7ff6540a127c: f7 21                 mull   (%rcx)						; 对 %eax 和内存位置 (%rcx) 的值进行乘法运算,并将结果存储回 %eax
    ; 上面的结果已经是做完乘法了。
  
    0x7ff6540a127e: 89 44 24 2c           movl   %eax, 0x2c(%rsp)			; 将寄存器 %eax 的值移动到当前的堆栈指针位置 %rsp 上偏移 0x2c 个字节的内存位置
    0x7ff6540a1282: 0f 90 c0              seto   %al						; 如果上一个指令的结果为正数,那么 %al 的值为 0;如果结果为负数,则 %al 的值为 1。如果乘法后的结果为负,那么这个寄存器的值就是 1
    0x7ff6540a1285: a8 01                 testb  $0x1, %al					; 寄存器 al 不等于 1 那么跳转到地址 0x7ff6540a1292
    0x7ff6540a1287: 75 09                 jne    0x7ff6540a1292            	; 抛出异常(忽略)
    0x7ff6540a1289: 8b 44 24 2c           movl   0x2c(%rsp), %eax			; 将存储在当前的堆栈指针位置 %rsp 上偏移 0x2c 个字节的内存位置的数据载入到寄存器 %eax 
    0x7ff6540a128d: 48 83 c4 38           addq   $0x38, %rsp				; 增加堆栈指针(释放内存)
    0x7ff6540a1291: c3                    retq   							; 返回数据

0x03 小结

本篇文章主要结合 IDE 来讲解 trait object 的内存布局。可能有些人会质疑我,我们学语言会用就行,为啥要了解内存布局呢?其实我个人认为学习一门编程语言,详细了解它的数据内存布局十分必要,尤其是像C/C++/Rust这种底层编程语言,当做到对数据类型的内存布局心中有数,编写任何程序都会游刃有余。