跳转到内容

8.2 dyn Trait 与 vtable:16 字节胖指针的物理真相

在上一节中,我们亲手撞上了编译期静态单态化的两道边界:

  1. 我们无法把实现了同一个 Summary 契约的 Article 和 Metric 直接按值装进同一个 Vec 数组;
  2. 我们无法在函数的 if / else 运行时分支里分别返回 Article 和 Metric。

现在,让我们顺着切片与胖指针和第 7 章建立的类型构造器与信息守恒原则,一步步把动态多态的机制推导出来。


第一步:从 Summary 契约到 dyn Summary(为什么它是 DST?)

Section titled “第一步:从 Summary 契约到 dyn Summary(为什么它是 DST?)”

我们在第 7 章 7.2 节明确过:Summary 本身只是一份编译期契约(Trait),不是类型(Type)。

如果我们要跨越具体类型的界限,把所有实现了 Summary 的值统一当成一种类型来处理,第一步需要先把「Summary 契约」转换成一个「类型」。

在 Rust 中,关键字 dyn(Dynamic 的缩写)扮演的正是这样一个类型构造器:当你把 Summary 契约喂给 dyn,就生成了一个抹去了具体类型名字的新类型实体——dyn Summary(代表「某个在编译期擦除了具体类型身份、但实现了 Summary 契约的底层值」)。

但一旦生成了 dyn Summary 这个类型实体,一个物理问题立刻摆在眼前:在编译期,dyn Summary 占多少个字节?

  • 如果它底层实际躺着的是 Article(含一个 String),它占 24 字节;
  • 如果它底层实际躺着的是 Metric(含 &'static str 与 u64),它占 24 字节(但内部字段布局完全不同);
  • 如果它底层实际躺着的是未来某个模块定义的 TinyBadge(u8),它只占 1 字节!

正因为 dyn Summary 故意在编译期擦除了底层数据的具体类型,编译器根本不可能在编译期算出 dyn Summary 到底占几个字节。 回想我们在第 4 章 4.3 节学过的概念:这种在编译期算不出固定字节尺寸的类型(即未实现 Sized),叫做动态大小类型(Dynamically Sized Type,简称 DST)。

就像第 4 章的裸切片类型 [i32] 因为擦掉了长度 ; N 而变成 DST、绝不能直接按值放在栈上(不能写 let x: [i32])一样,裸的 dyn Summary 也绝不能直接作为局部变量或参数按值放在栈上(不能写 let x: dyn Summary)。


第二步:套上第二层构造器——为什么 &dyn Summary 变成了 16 字节?

Section titled “第二步:套上第二层构造器——为什么 &dyn Summary 变成了 16 字节?”

既然裸的 dyn Summary 大小未知、不能直接上栈,怎么才能在变量、函数参数或 Vec 数组里使用它?

做法与第 4 章处理 [i32] 一模一样:再套上一层引用或指针构造器,比如用 & 包起来,合成出第二层类型——&dyn Summary。

现在,把指针放进栈帧后,最关键的信息守恒追问来了: 普通的引用 &Article 在 64 位机器上只占 8 字节(1 个机器字),里面只存着 post 在内存里的起始地址 data_ptr。 那么,当我们把 &Article 转换成擦除了具体类型的 &dyn Summary 时,如果栈上依然只存那 1 个 8 字节的数据地址 data_ptr,够用吗?

绝对不够! 因为我们在第 7 章 7.1 节反复确认过:Rust 的 Article 在内存里只是紧挨着的裸字节,没有任何像 Java 那样的隐藏对象头(Object Header)! 如果 &dyn Summary 只存一个指向裸数据的 8 字节地址 data_ptr,当程序在运行时执行 item.summarize() 时,CPU 顺着 data_ptr 只看到一堆毫无标记的裸字节,它去哪里找 Article::summarize 这个函数的机器码地址?!

既然编译期擦掉了具体类型,而运行时的数据本身又是纯粹的裸字节,那么「这个具体类型的方法地址在哪里」这项信息,就只能作为第 2 个机器字,直接装进栈上的引用变量里!

我们先用 4 行代码当场测量 &Article 与 &dyn Summary 在栈上的字节大小:

let post = Article { title: String::from("News") };
let thin_ref: &Article = &post;
let fat_ref: &dyn Summary = &post;
println!("&Article = {} bytes", size_of::<&Article>()); // 输出:8 bytes
println!("&dyn Summary = {} bytes", size_of::<&dyn Summary>()); // 输出:16 bytes
在 Rust Playground 中运行

从 8 字节 变成 16 字节——这就是我们在本指南中迎来的第二次胖指针会师!


第三步:一张图看穿 16 字节虚表胖指针 (data_ptr, vtable_ptr)

Section titled “第三步:一张图看穿 16 字节虚表胖指针 (data_ptr, vtable_ptr)”

多出来的那 8 个字节里装的到底是什么?為什麼是多加 1 个 8 字节(凑成 2 个机器字),而不是把契约里的所有方法地址全都直接塞进栈上的指针里?

试想:如果一个 Trait 契约里有 10 个方法,要是把 10 个函数指针全塞进栈上的引用变量里,每传一次引用就要拷贝 8 + 80 = 88 字节,太浪费栈空间了。 因此,编译器采用了一个极其紧凑的方案:对于每一种具体的实现(比如 impl Summary for Article),编译器在编译期会在可执行文件的只读静态数据段(Read-Only Data Segment)里生成一张固定格式的只读表,称为虚表(Virtual Method Table,简称 vtable)。

而在栈上的 &dyn Summary 胖指针(16 字节) 里,固定并列存放着两个分别指向不同内存区域的 8 字节地址:

具体类型 Article 留在 &Article 里时,栈上的 r1 只有 8 字节,只存一个 data_ptr;dyn Summary 擦掉具体类型后,栈上的 r2 变成 16 字节胖指针:data_ptr 仍指向栈上的 post,vtable_ptr 指向只读静态段里的虚表。
瘦指针 &Article 与胖指针 &dyn Summary 及虚表布局左上方是 r1,类型 &Article,由 &post 得到。编译期类型实体记下具体类型 Article,运行时占 0 字节;栈上只有一个 8 字节机器字 data_ptr,箭头指向栈上的 post。左下方是 r2,类型 &dyn Summary(即 &(dyn Summary)),由 &post 得到。因为 dyn Summary 擦掉了具体类型 Article,指针在栈上占两个 8 字节机器字:第一个字 data_ptr 指向同一个栈上数据 post,第二个字 vtable_ptr 指向只读静态段里的 <Article as Summary> vtable。右上方是栈上数据 post: Article(size = 24B),存放字段 title: "News"。右下方是只读静态段里的虚表,依次存放 drop_in_place、size: 24, align: 8,以及方法入口 summarize -> Article::summarize。瘦指针 · 8 字节r1: &Article = &post编译期类型实体&ArticleT = Article · 0 字节data_ptr · 8B栈上数据post: Articlesize = 24BArticle 实例title: "News"胖指针 · 16 字节r2: &dyn Summary = &post即 &(dyn Summary)dyn Summary 擦掉了 Article第二格存虚表地址data_ptr · 8Bvtable_ptr · 8B只读静态段 · 虚表<Article as Summary> vtabledrop_in_placesize: 24, align: 8summarize-> Article::summarize

对照上面这张内存布局图,看清 &dyn Summary 的全部物理真相:

  1. 栈上的第 1 个机器字 data_ptr(8 字节): 指向真实的数据实例(如栈上或堆上的 post: Article 裸数据)。
  2. 栈上的第 2 个机器字 vtable_ptr(8 字节): 指向只读静态段里的那张 <Article as Summary> vtable(虚表)。
  3. 只读静态段里的虚表(vtable)装了什么? 这张表在编译期就已经写死,全局只有一份(所有由 Article 转成的 &dyn Summary 都共享指向同一张表),里面按固定槽位依次记录着:
    • 释放函数指针 drop_in_place:当通过拥有所有权的 Box<dyn Summary> 释放该对象时,CPU 查这一格才知道该如何清理 Article 内部的 String 堆内存;
    • 具体类型的字节大小 size 与对齐 align:记录着 Article 占 24 字节、按 8 字节对齐;
    • 契约方法的机器码地址:如 summarize 槽位里,直接存着 <Article as Summary>::summarize 的函数机器码地址(如果是 Metric 转成的 &dyn Summary,它的 vtable_ptr 就会指向另一张 <Metric as Summary> vtable,那一格里存的就是 <Metric as Summary>::summarize 的地址)。

第四步:一举攻克 8.1 留下的两道边界

Section titled “第四步:一举攻克 8.1 留下的两道边界”

理解了 &dyn Summary 在栈上永远是整齐划一的 16 字节胖指针,上一节留下的那两道静态单态化边界就当场瓦解了。

1. 攻克边界一:用 Vec<&dyn Summary> 装入异构集合

Section titled “1. 攻克边界一:用 Vec<&dyn Summary> 装入异构集合”

既然不管是 &Article 还是 &Metric,一旦转成 &dyn Summary,它们在栈/堆上的类型就完全统一、且大小统统是 16 字节,我们就可以直接构建一个 Vec<&dyn Summary> 数组,把文章和监控指标装在一起循环渲染:

let post = Article { title: String::from("News") };
let cpu = Metric { name: "cpu", value: 42 };
// 每个元素都是 16 字节的 &dyn Summary 胖指针,类型与尺寸完全统一!
let dashboard: Vec<&dyn Summary> = vec![&post, &cpu];
for widget in dashboard {
println!("dashboard item -> {}", widget.summarize());
}
在 Rust Playground 中运行

2. 攻克边界二:用 Box<dyn Summary> 在运行时分支返回不同具体类型

Section titled “2. 攻克边界二:用 Box<dyn Summary> 在运行时分支返回不同具体类型”

再看第二个问题:在工厂函数 choose_item(is_article: bool) 中,我们要在函数内部现场创建 Article 或 Metric 并把所有权交出函数。 因为局部变量离开函数栈帧就会销毁,我们不能返回局部变量的借用 &dyn Summary;此时只需换用我们在第 8.1 节全局表里见过的堆独占智能指针 Box,将返回类型写为 Box<dyn Summary>(把 Article 或 Metric 的裸数据放在堆上,栈上同样返回 16 字节的 (data_ptr, vtable_ptr) 胖指针,并独自承担离开作用域时释放堆内存的责任;Box 的完整细节将在第 10 章展开):

fn choose_item(is_article: bool) -> Box<dyn Summary> {
if is_article {
Box::new(Article { title: String::from("Breaking") })
} else {
Box::new(Metric { name: "mem_mb", value: 512 })
}
}
在 Rust Playground 中运行

无论运行时 is_article 是 true 还是 false,两个分支返回给调用方的栈帧槽位永远是同一个类型、同样占 16 字节的 Box<dyn Summary> 胖指针,编译器欣然放行!

运行下面这份完整源码,核对 size_of 实测数字以及两道边界的攻克效果:

源文件:dyn-fat-pointer.rs
//! Measuring `&Article` (8-byte thin pointer) vs `&dyn Summary` (16-byte fat pointer),
//! and using `&dyn Summary` / `Box<dyn Summary>` to solve heterogeneous collections
//! and runtime-branch return types.
use std::mem::size_of;
trait Summary {
fn summarize(&self) -> String;
}
struct Article {
title: String,
}
struct Metric {
name: &'static str,
value: u64,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("Article: {}", self.title)
}
}
impl Summary for Metric {
fn summarize(&self) -> String {
format!("Metric: {}={}", self.name, self.value)
}
}
// Solving Boundary 2 from 8.1: returning different concrete types across branches!
fn choose_item(is_article: bool) -> Box<dyn Summary> {
if is_article {
Box::new(Article { title: String::from("Breaking") })
} else {
Box::new(Metric { name: "mem_mb", value: 512 })
}
}
fn main() {
let post = Article { title: String::from("News") };
let cpu = Metric { name: "cpu", value: 42 };
let thin_ref: &Article = &post;
let fat_ref: &dyn Summary = &post;
println!("size of &Article = {} bytes", size_of::<&Article>());
println!("size of &dyn Summary = {} bytes", size_of::<&dyn Summary>());
println!("thin = {}, fat = {}", thin_ref.summarize(), fat_ref.summarize());
// Solving Boundary 1 from 8.1: heterogeneous `Vec<&dyn Summary>`!
let dashboard: Vec<&dyn Summary> = vec![&post, &cpu];
for widget in dashboard {
println!("dashboard item -> {}", widget.summarize());
}
let dynamic_choice = choose_item(false);
println!("chosen at runtime -> {}", dynamic_choice.summarize());
}
在 Rust Playground 中运行

运行输出:

运行输出
size of &Article = 8 bytes
size of &dyn Summary = 16 bytes
thin = Article: News, fat = Article: News
dashboard item -> Article: News
dashboard item -> Metric: cpu=42
chosen at runtime -> Metric: mem_mb=512

思维桥梁:如果你熟悉其他语言的 interface,&dyn Trait 就是你的老朋友

Section titled “思维桥梁:如果你熟悉其他语言的 interface,&dyn Trait 就是你的老朋友”

回想第 7.2 节,当我们第一次指出「Trait 本身只是编译期契约,不是类型,不能写 fn notify(item: Summary)」时,习惯了 Java、Go 或 C# 中 interface 的读者心里难免会悬着一个疑问:「那我平时用 interface 做解耦的编程习惯,在 Rust 里到底对应什么?」

现在,这块拼图彻底严丝合缝了——你在其他语言里写的 interface 类型变量,在 Rust 里对应的从来就不是光秃秃的 Trait,而是刚刚合成出来的 &dyn Trait 与 Box<dyn Trait>!

为什么在 Go 或 Java 里直接写接口名(比如 item: Summary)就能当变量类型用?因为那些语言的编译器在幕后偷偷替你打包了指针与虚表:

  • 对标 Go 语言的 interface:Go 的接口变量在底层(iface 结构体)同样是一个占 16 字节的双指针结构——一个指针指向类型与方法表(tab),另一个指针指向真实数据(data)。这与 Rust 的 &dyn Trait / Box<dyn Trait>(data_ptr + vtable_ptr) 在物理内存布局上几乎一模一样!
  • 对标 Java / C# 的 interface:在 Java 中声明一个接口引用 Summary item,本质上也是一个指向堆对象的指针,运行时通过对象头的类型指针去查方法表。

在日常 Rust 工程中,你只需按所有权需求把「接口变量」对号入座:

你在其他语言里的 interface 用法 Rust 中的等价写法 物理含义
函数参数临时借用:
void render(Summary item)
item: &dyn Summary 零堆分配,在栈上传入 16 字节借用胖指针,用完即退。
作为返回值 / 结构体字段 / 装入数组长期持有:
List<Summary> list
Box<dyn Summary>
Vec<Box<dyn Summary>>
在其他语言里靠垃圾回收器(GC)托管生命周期;在 Rust 里用 Box 明确独占堆上数据的所有权,离开作用域自动根据 vtable 里的析构函数释放内存。

也就是说,Rust 并没有拿走你熟悉的 interface,它只是拒绝在语法背后搞「隐式指针 + 隐式虚表」的暗箱操作——而是把 & / Box(指针与所有权)、dyn(运行期查表) 与 Trait(方法契约) 三个正交零件拆开摆在明面上:

  • 当你不需要运行时擦除类型时,只用 Trait 做泛型约束 <T: Summary>,享受零成本的编译期单态化;
  • 当你确实需要像 interface 那样在运行时动态装载不同类型时,就用 & / Box + dyn + Trait 显式拼装出 16 字节胖指针。

  1. 两层类型构造(&(dyn Trait)):关键字 dyn 将编译期契约 Summary 构造成动态大小类型 dyn Summary(因擦除了具体类型,底层数据字节数未知,属 DST);外层再套上 & 或 Box 构造出固定占 16 字节的胖指针类型 &dyn Summary 或 Box<dyn Summary>。
  2. 16 字节胖指针与 vtable 的物理结构:因为 Rust 的底层实例是不含对象信息头的裸数据,擦除编译期类型后,&dyn Summary 必须在栈上用两个 8 字节机器字分别存放数据地址 data_ptr 与只读静态段的 vtable_ptr,从而统一不同结构体的指针尺寸,攻克异构集合 Vec<&dyn Trait> 与运行时跨分支返回难题。

在认清了 &dyn Summary 的 16 字节内存布局后,接下来我们要追问的是函数调用的执行过程:

  1. 当 CPU 调用泛型函数 notify<T: Summary>(item: &T) 与调用 notify(item: &dyn Summary) 时,底层的调用寻址过程(静态分发 vs 动态分发)究竟有什么区别?为什么 Rust 不把所有多态都默认做成 &dyn Trait?
  2. 是不是随便拿一个 Trait(比如第 7 章见过的 Clone 或带泛型方法的 Trait),都能在前面加上 &dyn 变成胖指针?

下一节,我们就专门拆解静态分发(Static Dispatch)与动态分发(Dynamic Dispatch)的运行时调用机制与成本对照,并从 vtable 的物理限制推导出 dyn 兼容性边界。

继续:静态分发 vs 动态分发与 dyn 兼容性 →

  1. 两次胖指针大会师对比:在 64 位平台上,切片引用 &[i32] 和 &dyn Summary 在栈上都占 16 字节。请分别说出它们的第 1 个 8 字节和第 2 个 8 字节里各自存的是什么?为什么普通引用 &Article 只需要 8 字节?
  2. 虚表共享判断:假设我们在程序里创建了 1000 个不同的 Article 实例,并把它们的引用全部转成 &dyn Summary 放进一个长度为 1000 的 Vec<&dyn Summary> 中:
    • 这个 Vec 在堆上的缓冲区一共占多少字节(即 1000 个 &dyn Summary 胖指针的总字节数)?
    • 为了支持这 1000 个 &dyn Summary 元素,可执行文件的只读静态段里一共生成了几张 <Article as Summary> 的虚表(vtable)?
点击核对参考答案
  1. 两次胖指针对比:
    • &[i32](16 字节):第 1 个 8 字节存连续元素的起始地址 ptr,第 2 个 8 字节存编译期被擦除的元素个数 len: usize;
    • &dyn Summary(16 字节):第 1 个 8 字节存具体结构体实例的数据地址 data_ptr,第 2 个 8 字节存指向只读静态段虚表的地址 vtable_ptr;
    • &Article(8 字节):因为具体类型 Article(及其大小、方法地址)完整保存在编译期类型实体里,运行时无需任何附加元信息,只需 8 字节存放数据地址。
  2. 虚表共享判断:
    • Vec<&dyn Summary> 堆缓冲区占 1000 × 16 = 16000 字节:其中每个元素的第 1 格 data_ptr 分别指向各自那 1000 个不同的 Article 数据地址;
    • 但只读静态段里只生成了唯一 1 张 <Article as Summary> vtable!这 1000 个胖指针的第 2 格 vtable_ptr 里,存的全是指向这同一张只读虚表的相同地址。