可视化 Rust Vtable:动态 Trait 在内存中如何工作
我开始接触 Rust,这门语言既让人满足,又令人叹为观止。到目前为止,我主要通过学习 《Rust 程序设计语言》(The Book) 和 Mara Bos 的书 来入门,但我忍不住想自己动手拆解一下。我最初进行这些实验的目的是将 Rust 的多态处理方式与 C++ 进行对比。然而,正如我后来意识到的那样,试图通过另一种语言来理解一门新语言并寻找一一映射的关系,其实是个陷阱。这看起来似乎有帮助,但最终我们不能把 Rust 当作换了语法的 C++。如果是那样的话,它就毫无革命性可言了。
话虽如此,我认为深入探究并理解其背后的 原因 是有价值的。如果你和我一样,需要知道 内存中究竟发生了什么,才能真正理解这些概念,那么希望这篇文章能对你有所帮助 :)
顺便一提,封面图片是锈菌(rust fungus)的照片,Rust 这门语言的名称便源于此。图片来源:gailhampshire(来自英国 Malvern 的 Cradley),CC BY 2.0,via Wikimedia Commons。
所有代码和实验均可在 GitHub 上找到。
引言:问题的核心
我们想要实现的目标其实很简单。假设我们有一组形状:圆形、正方形、三角形,我们希望能在每个形状上调用 draw()。
C++ 方法 #1:虚函数
在 C++ 中,最先想到的解决方案是使用虚函数,它利用了运行时多态。虚函数表指针存在于对象内部,虚分发自动发生。
std::vector<Shape*> shapes = { new Circle(), new Square() };
for (auto* s : shapes)
s->draw();
Rust 中的等价实现是 dyn Trait,这也是我们最终想要理解的内容。但首先,让我们看看在 C++ 中解决这一问题的另一种方式。
C++ 方法 #2:CRTP
也可以采用 CRTP(奇异递归模板模式)的方式,这本质上是一种编译期多态。如果你对这方面感兴趣,Klaus Iglberger 的这篇精彩演讲是我最初接触该主题的入门内容,也是我后续查阅参考时常回味的经典。
template<typename Derived>
struct Shape {
void draw() {
static_cast<Derived*>(this)->draw();
}
};
这种方式没有虚函数表,所有调用都在编译期解析,但代价是代码可读性较差(确实拗口)。
Rust 提供了比 CRTP 更简洁直接的等价方案,即泛型单态化(monomorphization)。这也是我们首先深入探讨的方向,旨在帮助构建对 Rust 机制的心智模型。
静态分发
Photo by Jiawei Zhao on Unsplash静态分发(亦称泛型)可以实现与 CRTP 类似的效果:编译器会为每个调用类型生成一份独立的功能副本。运行时开销为零,但要求类型在编译期即可确定。
trait Draw {
fn draw(&self) -> &str;
}
struct Circle;
struct Square;
impl Draw for Circle {
fn draw(&self) -> &str {
"Drawing a circle"
}
}
impl Draw for Square {
fn draw(&self) -> &str {
"Drawing a square"
}
}
fn draw_shape<T: Draw>(shape: T) {
println!("{}", shape.draw());
}
fn main() {
let circle = Circle;
let square = Square;
draw_shape(circle);
draw_shape(square);
}
在底层,编译器会生成两个独立函数:draw_shape::<Circle> 和 draw_shape::<Square>。
这与 C++ 模板有何不同?
两者的核心差异在于设计理念。C++ 中的约束是隐式的——模板接受任何碰巧拥有 .draw() 方法的类型 T。而在 Rust 中,你需要显式地声明契约:“为 Square 实现 Draw trait。”
我的问题是:什么时候这还不够用?在回答这个问题之前,先让我岔开话题,聊点别的。
支线话题:Rust 的零大小类型
我之所以查看 Circle 和 Square 的大小,是想和稍后会讲到的宽指针做个对比,结果却发现了意外之事。在 C++ 中,标准规定每个对象至少占 1 字节,即使是空对象也一样,这是为了保证两个不同的对象总有不同的地址,也就是说 &obj1 必须不同于 &obj2。这一直是我当作自然法则铭记于心的事情,所以看到 Rust 返回 0 时我大为惊讶。探索 Rust 的过程中,正是这些小瞬间带给我莫大的乐趣——它推翻了我既有的心智模型,让我体会到另一套截然不同的设计哲学。
println!("{}", std::mem::size_of::<Circle>()); // 0 WHAT???
println!("{}", std::mem::size_of::<Square>()); // 0
由此我了解到,Rust 用另一种方式来保证地址唯一性。零大小类型(ZST)是不含任何字段的结构体,因此根本不需要分配内存。Rust 通过所有权而非地址来追踪身份:每个值在同一时刻有且仅有一个所有者,这由我们熟悉的老朋友——借用检查器(borrow-checker)在编译期强制执行。
在 C++ 中,我们可能会这样判断两个指针是否指向同一个对象:
if (&a == &b) { // same object }
而在 Rust 里,这个问题在编译期就由借用检查器解决了:
// the borrow checker already knows these are different bindings
// you don't need to compare addresses to tell them apart
let a = Circle;
let b = Circle;
编译器把 a 和 b 追踪为名字不同、所有者也不同的两个绑定。
了解到这一点后,我的新问题是:那如果对 ZST 取地址会怎样?试试看。
let a = Circle;
let b = Circle;
println!("{:p}", &a as *const Circle);
println!("{:p}", &b as *const Circle);
输出如下:
0x7ffdda99aece
0x7ffdda99aecf
奇怪……它们确实获得了相差 1 字节的独立栈地址(十六进制为 ce 和 cf)。这看起来像是编译器像 C++ 那样为每个变量分配了 1 字节空间,但这只是调试模式下的行为。编译器仅为 ZST(零大小类型)局部变量分配虚拟的栈槽,目的是让调试器能够通过引用跟踪和检查它们。
然而,如果我们在发布模式下尝试这样做,会得到不同的行为:
cargo run --release --bin 01_static_dispatch
地址确实合二为一:
0x7ffdf74afa6f
0x7ffdf74afa6f
我的结论是:编译器对 ZST 的地址不作任何保证,而变量标识符是由借用检查器通过所有权机制来追踪的,而非依赖内存地址。
动态分发
Photo by Aldrin Rachman Pradana on Unsplash为了继续我们探索的主线,让我们看看在上一个示例中动态分发是什么样子的:
fn draw_shape(shape: &dyn Draw) {
println!("{}", shape.draw());
}
fn main() {
let circle = Circle;
let square = Square;
draw_shape(&circle);
draw_shape(&square);
}
它看起来与静态分发版本几乎相同,唯一的区别是用 &dyn Draw 替代了 <T: Draw>。但在底层,发生的事情却有着本质的不同。让我们看看大小发生了什么变化:
println!("&Circle size: {}", std::mem::size_of::<&Circle>()); // 8
println!("&dyn Draw size: {}", std::mem::size_of::<&dyn Draw>()); // 16
&dyn Draw 的大小是普通指针的两倍。这被称为宽指针:它实际上是两个指针,一个指向数据,另一个指向 vtable。正是那个 vtable 在运行时告诉 Rust 应该调用哪个 draw() 方法。
我们可以检查一下这些指针长什么样:
fn inspect(shape: &dyn Draw) {
let (data_ptr, vtable_ptr) = unsafe {
std::mem::transmute::<&dyn Draw, (usize, usize)>(shape)
};
println!("data ptr: {:#x}", data_ptr);
println!("vtable ptr: {:#x}", vtable_ptr);
}
std::mem::transmute 会对源类型(&dyn Draw)和目标类型((usize, usize))之间的内存进行逐位拷贝。这需要 unsafe,因为编译器无法保证任意位模式都能构成目标类型的合法值。
我们看到,同一种类型的对象共享同一个 vtable,而数据指针则随实例不同而变化:
=== circle ===
data ptr: 0x7ffdcae73286
vtable ptr: 0x55f23cbe5338 <-- circle 的 vtable
=== circle2 ===
data ptr: 0x7ffdcae732ec
vtable ptr: 0x55f23cbe5338 <-- 与 circle 共享同一个 vtable!!
=== square ===
data ptr: 0x7ffdcae73287
vtable ptr: 0x55f23cbe5358
为什么需要动态分发
回到最初的问题:什么时候静态分发不够用?我们到底为什么需要动态分发?让我们来看一个静态分发无法解决的场景。如果我们想创建一个包含 Square 和 Circle 混合的 Vec<T>,单靠泛型就会撞墙。
// 编译不过!!
let shapes = vec![Circle, Square];
Vec<T> 要求每个元素必须是完全相同的类型和大小。而 Circle 和 Square 是完全无关的类型,它们的大小也可能不同。在 C++ 中,你可以依靠“基类”来解决这个问题:
std::vector<Shape*> shapes = { new Circle(), new Square() };
这里就需要用到动态分发,也就是 dyn Trait。Box<T> 是 Rust 中在堆上分配值并通过指针拥有它的标准方式。
let shapes : Vec<Box<dyn Draw>> = vec![ // Box -> [ 数据指针 | vtable 指针 ]
Box::new(Circle),
Box::new(Square),
];
Box<dyn Draw> 解决了大小不一的问题。一个 Box 的大小始终是固定的,因为它本质上就是一个宽指针。
Vec<Box<dyn Draw>> 在内存中的布局:
[ 16 字节 | 16 字节 ]
↓ ↓
[data|vtable] [data|vtable]
↓ ↓
Circle Square
与 C++ 理念的一个重要区别在于:C++ 中动态派发和静态派发是在类这一层级决定的。只要把某个方法标记为 virtual,这个类就永远走动态派发。之所以 vector<Shape*> 能正常工作,是因为 vtable 指针本身就是对象的一部分。
而 Rust 是在调用处做选择的。Circle 就只是 Circle,它对派发方式一无所知。你是走静态派发还是动态派发,取决于你怎么引用它:写 &Circle 就是静态,写 &dyn Draw 就是动态。

说实话,我得停下来消化一会儿才能理解这些。因为太习惯 C++ 的那套做法了,用 Rust 的理念来思考会让我大脑有点“拧巴”(不过是有益的那种)。我最近开始练空中绸缎(aerial silks),发现两者有奇妙的相似之处:当你头朝下倒挂在空中时,必须有意识地重新建立对整个身体运作方式的认知。Rust 对你的思维模型做的就是同样的事。
每个(类型, Trait)组合对应一个 Vtable
接下来看看给同一个类型组合多个 trait 会发生什么。我们想让 Duck 同时实现 Fly 和 Swim。
trait Fly {
fn fly(&self) -> &str;
}
trait Swim {
fn swim(&self) -> &str;
}
struct Duck;
impl Fly for Duck {
fn fly(&self) -> &str {
"Duck flies!"
}
}
impl Swim for Duck {
fn swim(&self) -> &str {
"Duck swims!"
}
}
它在内存中是什么样子呢?
```rust let fly_obj: &dyn Fly = &duck; let swim_obj: &dyn Swim = &duck; let (data_fly, vtable_fly) = unsafe { std::mem::transmute::<&dyn Fly, (usize, usize)>(fly_obj) }; let (data_swim, vtable_swim) = unsafe { std::mem::transmute::<&dyn Swim, (usize, usize)>(swim_obj) }; println!("fly_obj -> data: {:#x} vtable: {:#x}", data_fly, vtable_fly); println!("swim_obj -> data: {:#x} vtable: {:#x}", data_swim, vtable_swim); println!("size of duck: {}", std::mem::size_of_val(&duck)); ``` ```rust fly_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea48 swim_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea68 size of duck: 0 ```我们可以再次惊叹于 Duck 的大小为 0,因为它是一个 ZST(零大小类型)。同时我们看到 fly_obj 和 swim_obj 共享相同的数据指针,这是合理的,因为它们都是同一个底层 duck 对象的动态特质。然而,有趣的部分在于vtable 指针的不同。

摄影:Ross Sokolovski 拍摄于 Unsplash
这进一步印证了我们的核心理念:vtable 并不嵌入对象中(像 C++ 那样),它是外部静态数据,当你请求动态分发时才与对象配对。无论鸭子是游泳还是飞翔,或者实现了多少个特质,Duck 始终是 Duck。
对象安全性:为什么并非所有特质都能作为 dyn 使用
如果您像我一样,您可能认为这一切都很美妙。确实如此,但同样重要的是理解其局限性。这些局限性中,有一个是 C++ 中无需担心的问题,另一个则在 C++ 中也存在。为了揭开谜团,Rust 中并非所有特质都能用作 dyn Trait。一个特质必须遵循所谓的对象安全性规则才能作为特质对象使用:
- 方法不能返回
Self - 方法不能有泛型参数
方法不能返回 Self
Clone 是经典例子,因为它有 fn clone(&self) -> Self。
Self 本质上只是当前正在实现该 trait 的类型的占位符。
trait Clone {
fn clone(&self) -> Self;
}
因此,对于实现了 Clone trait 的 Circle,Self 解析为 Circle。
由于返回的是 Self,调用者需要知道具体类型才能确定返回值需要分配多少内存。而通过 vtable,调用者并不知道具体类型,所以编译器会拒绝这种写法。
C++ 完全没有这个问题,因为虚分发始终通过指针进行,返回类型也始终是指针。Rust 直接处理值,所以当按值返回 Self 时,你必须知道其大小。
方法不能有泛型参数
trait Serialize {
fn serialize<T>(&self, output: &mut T);
}
在这种情况下,编译器需要为每一个可能的 T 创建单独的 vtable 条目:
serialize::<File>
serialize::<String>
serialize::<Vec<u8>>
...
这在本质上需要无限多个条目。
因此,以下代码无法编译:
let s: Box<dyn Serialize> = ...; // 编译错误
那 C++ 呢?它也碰到了同样的墙,原因相同:你无法为模板虚拟方法做这件事:
class Serialize {
public:
template<typename T>
virtual void serialize(T& output); // 编译错误!!
};
我搜索了一下,技术上来说,借助足够多的 C++20 黑魔法,你可以手动构建 vtable 来绕过这个问题,但它只在单个源文件内有效,在生产代码中没人会这么写。这超出了本文的范围,但如果你想深入这个兔子洞,可以参考 Christian Daley 的文章。
总结
我们从一个问题开始:如何在 Rust 中调用一组形状的 draw() 方法,而不使用继承?
Rust 的单态化(静态分发)和 C++ 的 CRTP 都是编译期多态,没有运行时开销,代价是代码体积膨胀。可以说,单态化就是把 CRTP 这种“变通方案”变成了语言原生特性。
dyn Trait和 C++ 的虚函数都靠 vtable 实现动态分发。区别在于:C++ 把 vtable 指针放在对象内部,不管用不用多态,每个实例都要承担这份开销;而 Rust 只在显式使用&dyn Trait或Box<dyn Trait>时才会出现 vtable 指针。零大小类型(ZST):C++ 中每个对象至少占 1 字节,因为它在运行时通过内存地址来追踪对象身份。而 Rust 在编译期通过所有权追踪身份,所以才能有零大小类型。
看到这里的朋友,谢谢!这篇文章拖了很久才发出来。我 2026 年 3 月就开始写了,只差最后的润色,却一直没腾出时间发布。我反复怀疑这些内容到底有没有用,但转念一想,这种方法既然对我有帮助,也许对别人也有用。从那到现在,我的生活和 Rust 之路都发生了不少事,有好的也有坏的。我现在在西班牙,生活近况的文章很快会更新。Rust 方面,我正在钻研并发和无锁编程,目标是最终能为开源做贡献。这段历程我也打算在这里记录下来 :)