0x00 开篇
我们继续来介绍 trait object。本文来了解下它的内存布局。本篇文章的阅读时间大约 10 分钟。
0x01 内存布局
先上图再解释。
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 、size、alignment 这三个字段是每个 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 这个地址,找到这个地址刚好是 rectangle。Rectangle 是一个结构体,包含 width 和 height 字段,所以内存里的数据分别是 3 和 4。
vtable 里有什么
与 vtable 的结构对应了起来。我们具体看绿色的两个框,计算面积的 area 方法的机器码在 0x00007ff6540a1270地址,计算周长的 perimeter 方法的机器码在 0x00007ff6540a12b0 地址。这里的二进制就是具体方法的机器码了(如下图)。
通过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这种底层编程语言,当做到对数据类型的内存布局心中有数,编写任何程序都会游刃有余。