8.2 dyn Trait 与 vtable:16 字节胖指针的物理真相
在上一节中,我们亲手撞上了编译期静态单态化的两道边界:
- 我们无法把实现了同一个
Summary契约的Article和Metric直接按值装进同一个Vec数组; - 我们无法在函数的
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 bytesprintln!("&dyn Summary = {} bytes", size_of::<&dyn Summary>()); // 输出:16 bytes从 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 字节地址:
对照上面这张内存布局图,看清 &dyn Summary 的全部物理真相:
- 栈上的第 1 个机器字
data_ptr(8 字节): 指向真实的数据实例(如栈上或堆上的post: Article裸数据)。 - 栈上的第 2 个机器字
vtable_ptr(8 字节): 指向只读静态段里的那张<Article as Summary> vtable(虚表)。 - 只读静态段里的虚表(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());}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 }) }}无论运行时 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());}运行输出:
运行输出size of &Article = 8 bytessize of &dyn Summary = 16 bytesthin = Article: News, fat = Article: Newsdashboard item -> Article: Newsdashboard item -> Metric: cpu=42chosen 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 字节胖指针。
- 两层类型构造(
&(dyn Trait)):关键字dyn将编译期契约Summary构造成动态大小类型dyn Summary(因擦除了具体类型,底层数据字节数未知,属 DST);外层再套上&或Box构造出固定占 16 字节的胖指针类型&dyn Summary或Box<dyn Summary>。 - 16 字节胖指针与
vtable的物理结构:因为 Rust 的底层实例是不含对象信息头的裸数据,擦除编译期类型后,&dyn Summary必须在栈上用两个 8 字节机器字分别存放数据地址data_ptr与只读静态段的vtable_ptr,从而统一不同结构体的指针尺寸,攻克异构集合Vec<&dyn Trait>与运行时跨分支返回难题。
在认清了 &dyn Summary 的 16 字节内存布局后,接下来我们要追问的是函数调用的执行过程:
- 当 CPU 调用泛型函数
notify<T: Summary>(item: &T)与调用notify(item: &dyn Summary)时,底层的调用寻址过程(静态分发 vs 动态分发)究竟有什么区别?为什么 Rust 不把所有多态都默认做成&dyn Trait? - 是不是随便拿一个 Trait(比如第 7 章见过的
Clone或带泛型方法的 Trait),都能在前面加上&dyn变成胖指针?
下一节,我们就专门拆解静态分发(Static Dispatch)与动态分发(Dynamic Dispatch)的运行时调用机制与成本对照,并从 vtable 的物理限制推导出 dyn 兼容性边界。
动手核对与小练习
Section titled “动手核对与小练习”- 两次胖指针大会师对比:在 64 位平台上,切片引用
&[i32]和&dyn Summary在栈上都占 16 字节。请分别说出它们的第 1 个 8 字节和第 2 个 8 字节里各自存的是什么?为什么普通引用&Article只需要 8 字节? - 虚表共享判断:假设我们在程序里创建了 1000 个不同的
Article实例,并把它们的引用全部转成&dyn Summary放进一个长度为 1000 的Vec<&dyn Summary>中:- 这个
Vec在堆上的缓冲区一共占多少字节(即 1000 个&dyn Summary胖指针的总字节数)? - 为了支持这 1000 个
&dyn Summary元素,可执行文件的只读静态段里一共生成了几张<Article as Summary>的虚表(vtable)?
- 这个
点击核对参考答案
- 两次胖指针对比:
&[i32](16 字节):第 1 个 8 字节存连续元素的起始地址ptr,第 2 个 8 字节存编译期被擦除的元素个数len: usize;&dyn Summary(16 字节):第 1 个 8 字节存具体结构体实例的数据地址data_ptr,第 2 个 8 字节存指向只读静态段虚表的地址vtable_ptr;&Article(8 字节):因为具体类型Article(及其大小、方法地址)完整保存在编译期类型实体里,运行时无需任何附加元信息,只需 8 字节存放数据地址。
- 虚表共享判断:
Vec<&dyn Summary>堆缓冲区占1000 × 16 = 16000字节:其中每个元素的第 1 格data_ptr分别指向各自那 1000 个不同的Article数据地址;- 但只读静态段里只生成了唯一 1 张
<Article as Summary> vtable!这 1000 个胖指针的第 2 格vtable_ptr里,存的全是指向这同一张只读虚表的相同地址。