Hacker News 中文摘要

RSS订阅

可视化 Rust 虚函数表:解析 dyn Trait 内存布局 -- Visualizing Rust's Vtables: How dyn Trait Works In Memory

索菲亚·贝伦(Sofia Belen)正在探索 Rust 语言,既感到满足又觉得烧脑。此前她一直通过 Rust 官方书籍和 Mara Bos 的原子相关书籍学习,但产生了亲自拆解研究的念头。起初她想对比 Rust 和 C++ 的多态实现方式,后来才意识到用另一门语言来理解新语言并尝试一一对应是个陷阱。

正文

我正在学习 Rust,既满足又觉得烧脑。之前一直在看《Rust 程序设计语言》和 Mara Bos 的原子操作相关书籍,现在想自己动手拆解一些细节。最初我是想对比 Rust 和 C++ 的多态实现,但后来发现,试图用一门语言去 1:1 对照理解另一门语言其实是个陷阱。看起来有帮助,但归根结底,我们不能把 Rust 当成换了语法的 C++,否则它也就没什么革命性可言了。

不过,我觉得亲手去探究底层、理解“为什么”还是很有价值的。如果你也和我一样,非要搞清楚内存里到底发生了什么,才能真正理解一个概念,那希望这篇内容对你有用。

所有代码和实验都可以在 GitHub 上找到。

引言:问题的核心

我们的目标很简单。假设有一堆形状:圆形、方形、三角形,我们想对每一个都调用 draw() 方法。

C++ 方法一:虚函数

在 C++ 中最先想到的就是用虚函数,这属于运行时多态。虚表指针(vtable pointer)存在于对象内部,虚函数派发是自动发生的。

cpp std::vector<Shape*> shapes = { new Circle(), new Square() }; for (auto* s : shapes) s->draw();

Rust 中的等价物是 dyn Trait,这也是我们最终想要理解的。但在此之前,先看看 C++ 里另一种解法。

C++ 方法二:CRTP

也可以用 CRTP(奇异递归模板模式),这本质上是编译时多态。如果你感兴趣,Klaus Iglberger 的那个精彩演讲是我入门这个话题的教材,也是我反复查阅的参考资料。

cpp template<typename Derived> struct Shape { void draw() { static_cast<Derived*>(this)->draw(); } };

这里没有虚表,一切都在编译时解决,代价是牺牲了可读性(确实挺绕口的)。

Rust 对 CRTP 有一个更直观、更简单的等价实现,即单态化(monomorphization)。我们首先深入这个方法,开始构建对 Rust 多态机制的思维模型。

静态派发

静态派发,也就是泛型,实现的效果和 CRTP 类似:编译器会为每一种调用该函数的类型生成一份独立的副本。它的运行时开销为零,但类型必须在编译时就确定。

```rust 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(shape: T) { println!("{}", shape.draw()); }

fn main() { let circle = Circle; let square = Square; drawshape(circle); drawshape(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 时的小瞬间给我带来了很多乐趣,因为它解构了我的思维模型,让我欣赏到不同的设计哲学。

rust println!("{}", std::mem::size_of::<Circle>()); // 0 什么??? println!("{}", std::mem::size_of::<Square>()); // 0

我由此发现 Rust 处理“唯一地址保证”的方式不同。零大小类型(ZST)是不包含任何字段的结构体,因此不需要分配任何内存。Rust 通过所有权来追踪身份,而不是地址。每个值在同一时间只有一个所有者,这由我们那位老朋友——借用检查器在编译时强制执行。

在 C++ 中,我们可能会这样做来检查两个指针是否指向同一个对象:

cpp if (&a == &b) { // 同一个对象 }

在 Rust 中,这个问题在编译时就已经由借用检查器回答了:

rust // 借用检查器已经知道这是不同的绑定 // 你不需要比较地址来区分它们 let a = Circle; let b = Circle;

编译器将 a 和 b 追踪为具有不同所有者的不同名称。

了解这些后,我的问题是,如果我们取一个 ZST 的地址会发生什么?我们来试试。

```rust 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 的地址不做任何保证,身份是由借用检查器通过所有权来追踪的,而不是内存地址。

动态派发

回到主线,我们来看看上一个例子的动态派发版本:

```rust fn draw_shape(shape: &dyn Draw) { println!("{}", shape.draw()); }

fn main() { let circle = Circle; let square = Square; drawshape(&circle); drawshape(&square); } ```

它看起来和静态派发版本几乎一模一样,唯一的区别是把 <T: Draw> 换成了 &dyn Draw。但在底层,发生的事情却截然不同。我们来看看大小的变化:

rust println!("&Circle size: {}", std::mem::size_of::<&Circle>()); // 8 println!("&dyn Draw size: {}", std::mem::size_of::<&dyn Draw>()); // 16

&dyn Draw 的大小是普通指针的两倍。这被称为胖指针:它实际上包含两个指针,一个指向数据,另一个指向一个虚表(vtable)。那个虚表就是告诉 Rust 在运行时该调用哪个 draw() 方法的关键。

我们可以检查这两个指针长什么样:

rust 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,因为编译器无法保证任意的位模式能构成目标类型的有效值。

我们看到,相同类型的对象共享同一个虚表,而每个实例的数据指针是不同的:

``` === circle === data ptr: 0x7ffdcae73286 vtable ptr: 0x55f23cbe5338 <-- circle 的虚表

=== circle2 === data ptr: 0x7ffdcae732ec vtable ptr: 0x55f23cbe5338 <-- 和 circle 一样的虚表!!

=== square === data ptr: 0x7ffdcae73287 vtable ptr: 0x55f23cbe5358 ```

为什么需要动态派发

回到我们的问题:什么时候静态派发不够用?为什么我们需要动态派发?让我们设想一个场景。如果我们想创建一个包含 Square 和 Circle 混合体的 Vec<T>,只用泛型是行不通的。

rust // 编译不通过!! let shapes = vec![Circle, Square];

Vec<T> 要求每个元素必须是完全相同的类型和大小。Circle 和 Square 是完全不同的类型,大小也可能不同。Rust 没有像 C++ 那样的“基类”,在 C++ 里你会这样写:

cpp std::vector<Shape*> shapes = { new Circle(), new Square() };

这就是我们需要动态派发,也就是 dyn Trait 的地方。Box<T> 是 Rust 在堆上分配值并通过指针拥有它的方式。

rust let shapes : Vec<Box<dyn Draw>> = vec![ Box::new(Circle), Box::new(Square), ];

Box<dyn Draw> 解决了大小问题。一个 Box 的大小总是固定的,因为它只是一个胖指针。

内存中的 Vec<Box<dyn Draw>>: [ 16 字节 | 16 字节 ] ↓ ↓ [数据|虚表] [数据|虚表] ↓ ↓ Circle Square

这与 C++ 哲学的一个关键区别在于:在 C++ 中,动态派发和静态派发的选择是在类级别做出的。如果你把一个方法标记为 virtual,那个类就永远使用动态派发。对于 vector<Shape*>,它能工作是因为虚表指针是对象的一部分。

而在 Rust 中,选择是在调用点做出的。Circle 就是 Circle,它不知道什么是派发。你决定是用静态还是动态派发,取决于你如何引用它:用 &Circle 就是静态,用 &dyn Draw 就是动态。

老实说,我不得不停下来消化一下这一切,因为我太习惯 C++ 的做法了,Rust 的哲学让我的大脑(以一种好的方式)感到扭曲。我最近开始练习空中丝带舞,这两者有一种奇特的相似感:当你倒挂在空中时,你必须刻意重新连接对身体运作方式的整个感知。Rust 对你的思维模型也做着同样的事情。

每对(类型,Trait)共用一个虚表

让我们看看当同一个类型组合不同的 trait 时会发生什么。我们想让 Duck 既能 Fly 又能 Swim。

```rust 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 flyobj: &dyn Fly = &duck; let swimobj: &dyn Swim = &duck;

let (datafly, vtablefly) = unsafe { std::mem::transmute::<&dyn Fly, (usize, usize)>(flyobj) }; let (dataswim, vtableswim) = unsafe { std::mem::transmute::<&dyn Swim, (usize, usize)>(swimobj) };

println!("flyobj -> data: {:#x} vtable: {:#x}", datafly, vtablefly); println!("swimobj -> data: {:#x} vtable: {:#x}", dataswim, vtableswim); println!("size of duck: {}", std::mem::sizeofval(&duck)); ```

fly_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea48 swim_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea68 size of duck: 0

我们再次惊叹于 Duck 的大小是 0,因为它是 ZST。我们还看到 fly_obj 和 swim_obj 共享相同的数据指针,这很合理,因为它们是同一个底层鸭子对象的不同动态 trait 引用。但这里有趣的部分在于虚表指针是不同的。

一只鸭子就是一只鸭子。

这巩固了我们的核心观点:虚表并不嵌入在对象内部(不像 C++),它是外部的静态数据,只有在你要求动态派发时才会与对象配对。无论鸭子是游泳还是飞翔,或者实现了多少个 trait,Duck 始终是 Duck。

对象安全性:为什么不是所有 Trait 都能用 Dyn

如果你和我一样,可能会觉得这些都很棒。确实如此,但理解其局限性也很重要。其中一个限制在 C++ 里我们不需要担心,另一个则在 C++ 中也存在。揭晓谜底:Rust 中并不是每个 trait 都能用作 dyn Trait。一个 trait 必须满足所谓的对象安全规则才能被用作 trait 对象:

  • 方法不能返回 Self
  • 方法不能有泛型参数

方法不能返回 Self

Clone 是经典的例子,因为它有 fn clone(&self) -> Self。

Self 本质上只是一个占位符,代表当前正在实现该 trait 的类型。

rust trait Clone { fn clone(&self) -> Self; }

所以,对于实现了 Clone trait 的 Circle 来说,Self 解析为 Circle。

因为它返回 Self,这意味着调用者需要知道具体的类型,才能知道要为返回值分配多少内存。通过虚表,调用者不知道具体类型,所以编译器会拒绝它。

C++ 完全没这个问题,因为虚函数派发总是通过指针进行,返回类型也总是指针。Rust 直接操作值,所以当你按值返回 Self 时,你需要知道它的大小。

方法不能有泛型参数

rust trait Serialize { fn serialize<T>(&self, output: &mut T); }

在这种情况下,编译器需要为每一个可能的 T 准备一个独立的虚表条目:

serialize::<File> serialize::<String> serialize::<Vec<u8>> ...

这本质上是无限的。

因此,这行代码无法编译:

rust let s: Box<dyn Serialize> = ...; // 编译错误

那 C++ 呢?它本质上撞上了同一堵墙,因为你不能拥有模板虚函数,原因相同:

cpp class Serialize { public: template<typename T> virtual void serialize(T& output); // 编译错误!! };

我查了一下,理论上通过足够复杂的 C++20 黑魔法,你可以手动构建虚表来绕过这个限制,但这只能在单个源文件内工作,不是你会在生产代码中做的事情。这超出了本文的范围,但如果你感兴趣,我留了一个 Christian Daley 文章的链接供你深究。

回顾

我们从一个简单的问题开始:在没有继承的 Rust 中,如何在一堆形状上调用 draw()?

  • Rust 的单态化(静态派发)和 C++ 的 CRTP 都是编译时多态。它们没有运行时开销,缺点是代码体积会变大。本质上,单态化是 Rust 原生支持的语言特性,而 CRTP 更像是 C++ 的一种变通方案。
  • dyn Trait 和 C++ 的虚函数都使用虚表来实现动态派发。区别在于,在 C++ 中,虚表指针存在于对象内部,无论你是否使用多态,每个实例都要承担这个开销。而在 Rust 中,只有当你显式使用 &dyn Trait 或 Box<dyn Trait> 时,虚表指针才会出现。
  • 零大小类型(ZST):在 C++ 中,每个对象必须至少占用 1 字节,因为它在运行时通过内存地址追踪身份。而在 Rust 中,身份是在编译时通过所有权追踪的,这就是为什么它可以拥有零大小类型。

评论

评论区看上去不像争论,更像是顺着文章往下追问。唯一的分歧点出现在文首:作者用 C++ 检查“两个指针是否指向同一个对象”来引入问题,但回答到 Rust 时只说借用检查器编译期就知道答案;有高赞读者觉得这个回答并没有解释程序员为什么不再需要关心。

编译器知道答案,并不等于程序员就不需要区分

  • (高赞)文章以 C++ 中检查两个指针是否指向同一对象作为动机,但进入 Rust 后只用“借用检查器在编译期回答该问题”带过。评论者认为这个跳跃没有讲清:如果程序员确实想区分两个引用指向同一实例、还是指向两个值相等但地址不同的零大小对象,该从哪里获得这类信息?把所有权层面的安全检查混同于“消除所有语义疑问”,反而把原本的动机绕开了。 > 借用检查器在编译期知道答案,但这为什么意味着程序员永远不用检查?如果程序员想区分这两种情形,又怎么从借用检查器那里拿到这个信息?(@ketzu)

有价值的补充

  • 有读者被文章勾起了更底层的好奇心:零大小对象的行为可以逆向分析,vtable 本身的内存结构也应该可以继续拆解,并推测它大致就是一组指向各方法实现的函数指针。(@returningfory2)
  • 术语补充:“Object Safety” 已经是过时叫法,Rust 官方现在称之为 “dyn compatibility”,因为重点不是“安全”,而是 trait 是否允许被用作 dyn Trait。评论者还提到文章结尾其实对命名改进有所交代——作者可能在读到新术语之前很久就开始写这篇文章了。(@tialaramex)