← 文章 / 编程开发
Hacker News 12小时前 · 2026-09-06 07:29:14 · 4 阅读

可视化 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 的零大小类型

我之所以查看 CircleSquare 的大小,是想和稍后会讲到的宽指针做个对比,结果却发现了意外之事。在 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;

编译器把 ab 追踪为名字不同、所有者也不同的两个绑定。

了解到这一点后,我的新问题是:那如果对 ZST 取地址会怎样?试试看。

let a = Circle;
let b = Circle;

println!("{:p}", &a as *const Circle);
println!("{:p}", &b as *const Circle);

输出如下:

0x7ffdda99aece
0x7ffdda99aecf

奇怪……它们确实获得了相差 1 字节的独立栈地址(十六进制为 cecf)。这看起来像是编译器像 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

为什么需要动态分发

回到最初的问题:什么时候静态分发不够用?我们到底为什么需要动态分发?让我们来看一个静态分发无法解决的场景。如果我们想创建一个包含 SquareCircle 混合的 Vec<T>,单靠泛型就会撞墙。

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

Vec<T> 要求每个元素必须是完全相同的类型大小。而 CircleSquare 是完全无关的类型,它们的大小也可能不同。在 C++ 中,你可以依靠“基类”来解决这个问题:

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

这里就需要用到动态分发,也就是 dyn TraitBox<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 就是动态。

摄影:Anderson Portella

说实话,我得停下来消化一会儿才能理解这些。因为太习惯 C++ 的那套做法了,用 Rust 的理念来思考会让我大脑有点“拧巴”(不过是有益的那种)。我最近开始练空中绸缎(aerial silks),发现两者有奇妙的相似之处:当你头朝下倒挂在空中时,必须有意识地重新建立对整个身体运作方式的认知。Rust 对你的思维模型做的就是同样的事。

每个(类型, Trait)组合对应一个 Vtable

接下来看看给同一个类型组合多个 trait 会发生什么。我们想让 Duck 同时实现 FlySwim

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_objswim_obj 共享相同的数据指针,这是合理的,因为它们都是同一个底层 duck 对象的动态特质。然而,有趣的部分在于vtable 指针的不同。

Brown and white duck on gray concrete floor
一只鸭子就是一只鸭子。
摄影: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 的 CircleSelf 解析为 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 TraitBox<dyn Trait> 时才会出现 vtable 指针。

  • 零大小类型(ZST):C++ 中每个对象至少占 1 字节,因为它在运行时通过内存地址来追踪对象身份。而 Rust 在编译期通过所有权追踪身份,所以才能有零大小类型。

看到这里的朋友,谢谢!这篇文章拖了很久才发出来。我 2026 年 3 月就开始写了,只差最后的润色,却一直没腾出时间发布。我反复怀疑这些内容到底有没有用,但转念一想,这种方法既然对我有帮助,也许对别人也有用。从那到现在,我的生活和 Rust 之路都发生了不少事,有好的也有坏的。我现在在西班牙,生活近况的文章很快会更新。Rust 方面,我正在钻研并发和无锁编程,目标是最终能为开源做贡献。这段历程我也打算在这里记录下来 :)

原始来源: Hacker News

评论 (0)