Rust编译器原理-第8章 Trait Object 与虚表:运行时多态的内存布局

《Rust 编译器原理》完整目录

第8章 Trait Object 与虚表:运行时多态的内存布局

"在 C++ 中,你需要猜测虚函数调用的代价;在 Rust 中,每一次 dyn Trait 调用的内存成本是完全透明的------两次指针加载,一次间接跳转,不多不少。"

Rust 是一门以零成本抽象著称的语言,泛型和 trait 的组合让大部分多态在编译期通过单态化(monomorphization)解决------每种具体类型生成一份专用代码,没有任何运行时开销。但现实世界的程序不可能完全避免运行时多态:当你需要一个异构集合(比如存储不同类型的绘图元素)、当你需要在运行时决定使用哪个实现(比如插件系统)、当你需要减少泛型膨胀带来的二进制体积------这时候,dyn Trait 就登场了。

dyn Trait 是 Rust 的运行时多态机制。它通过胖指针 (fat pointer)和虚表 (vtable)实现,与 C++ 的虚函数表在精神上一脉相承,但在实现细节上有显著不同。本章将从内存布局的第一个字节讲起,一直讲到 Rust 编译器源码中虚表是如何生成的,力求让你对 dyn Trait 的每一个比特都了然于胸。

:::tip 本章要点

  • &dyn Trait 是一个胖指针,包含两个机器字:数据指针 + 虚表指针
  • 虚表的固定头部包含三个元素:drop_in_place 函数指针、类型大小 size、类型对齐 align
  • 虚表的方法槽位按 trait 中方法的声明顺序 排列,不可分发的方法对应 Vacant 空槽
  • 对象安全(现更名为 Dyn Compatibility)规则限制了哪些 trait 可以成为 trait object
  • Trait upcasting 通过在虚表中嵌入超级 trait 虚表指针TraitVPtr)实现
  • dyn Trait + Send + Sync 中 marker trait 不增加虚表槽位
  • 静态分发与动态分发的性能差距在现代 CPU 上通常是 2-5 倍,但真实场景受缓存影响更大 :::

8.1 为什么需要运行时多态

在深入胖指针和虚表之前,我们先理解为什么 Rust 的泛型系统不能解决所有多态问题。

8.1.1 泛型的局限

考虑一个简单的绘图系统:

rust 复制代码
trait Shape {
    fn area(&self) -> f64;
    fn draw(&self);
}

struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
struct Triangle { base: f64, height: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
    fn draw(&self) { println!("Drawing circle with radius {}", self.radius); }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 { self.width * self.height }
    fn draw(&self) { println!("Drawing {}x{} rectangle", self.width, self.height); }
}

impl Shape for Triangle {
    fn area(&self) -> f64 { 0.5 * self.base * self.height }
    fn draw(&self) { println!("Drawing triangle"); }
}

如果你用泛型写一个"绘制所有形状"的函数:

rust 复制代码
fn draw_all<S: Shape>(shapes: &[S]) {
    for shape in shapes {
        shape.draw();
    }
}

这个函数只能接受同一种 形状的切片------&[Circle]&[Rectangle],但不能混合。这是因为 [S] 要求所有元素具有相同的类型和大小,这是 Rust 内存布局的硬性要求。

当你需要一个异构集合时:

rust 复制代码
// 这无法用泛型实现
let shapes: Vec<???> = vec![
    Circle { radius: 5.0 },
    Rectangle { width: 3.0, height: 4.0 },
    Triangle { base: 6.0, height: 3.0 },
];

此时 dyn Trait 就是解决方案:

rust 复制代码
let shapes: Vec<Box<dyn Shape>> = vec![
    Box::new(Circle { radius: 5.0 }),
    Box::new(Rectangle { width: 3.0, height: 4.0 }),
    Box::new(Triangle { base: 6.0, height: 3.0 }),
];

for shape in &shapes {
    shape.draw();   // 运行时动态分发
}

8.1.2 类型擦除

dyn Shape 的本质是类型擦除 (type erasure)。当你将一个 Circle 放入 Box<dyn Shape> 时,编译器"忘记"了它是 Circle------它只记得这个值实现了 Shape trait,以及通过哪些函数指针来调用对应的方法。这个"记住方法入口"的数据结构,就是虚表(vtable)。

类型擦除带来的好处:

  • 异构集合:不同大小、不同类型的值可以放在同一个容器中
  • 减少代码膨胀:不需要为每个具体类型生成一份函数副本
  • 运行时决策:可以根据配置、用户输入等动态选择实现
  • 稳定的 ABIdyn Trait 的布局不依赖具体类型,适合跨 FFI 边界

类型擦除带来的代价:

  • 间接调用开销:每次方法调用需要两次指针解引用
  • 无法内联:编译器通常无法优化掉间接调用
  • 更大的指针:胖指针占两个机器字,而非一个

理解了动机之后,让我们深入到内存的最底层。

8.2 胖指针的内存布局

8.2.1 薄指针与胖指针

在 Rust 中,指针分两类:

  • 薄指针 (thin pointer):只包含一个地址,占一个机器字(64 位平台上 8 字节)。例如 &i32&String*const u8
  • 胖指针(fat pointer / wide pointer):包含一个地址加额外的元数据,占两个机器字(16 字节)。

胖指针有三种形态:

类型 数据指针 元数据 例子
切片引用 指向第一个元素 元素个数 usize &[u8]&str
Trait object 指向具体值 虚表指针 *const VTable &dyn ShapeBox<dyn Shape>
尾部 DST 结构体 指向结构体 与 DST 字段对应的元数据 &Wrapper<dyn Shape>

这一点在 Rust 标准库的 core::ptr::metadata 模块中有明确定义。来看标准库源码(library/core/src/ptr/metadata.rs):

rust 复制代码
// library/core/src/ptr/metadata.rs

/// Provides the pointer metadata type of any pointed-to type.
///
/// Raw pointer types and reference types in Rust can be thought of
/// as made of two parts: a data pointer that contains the memory
/// address of the value, and some metadata.
///
/// For statically-sized types (that implement the `Sized` traits)
/// as well as for `extern` types,
/// pointers are said to be "thin": metadata is zero-sized and
/// its type is `()`.
///
/// Pointers to dynamically-sized types are said to be "wide" or "fat",
/// they have non-zero-sized metadata:
///
/// * For structs whose last field is a DST, metadata is the metadata
///   for the last field
/// * For the `str` type, metadata is the length in bytes as `usize`
/// * For slice types like `[T]`, metadata is the length in items as `usize`
/// * For trait objects like `dyn SomeTrait`, metadata is
///   `DynMetadata<Self>` (e.g. `DynMetadata<dyn SomeTrait>`)
pub trait Pointee: PointeeSized {
    type Metadata: fmt::Debug + Copy + Send + Sync + Ord + Hash + Unpin + Freeze;
}

对于 trait object,元数据类型是 DynMetadata<dyn Trait>

rust 复制代码
// library/core/src/ptr/metadata.rs

/// The metadata for a `Dyn = dyn SomeTrait` trait object type.
///
/// It is a pointer to a vtable (virtual call table)
/// that represents all the necessary information
/// to manipulate the concrete type stored inside a trait object.
/// The vtable notably contains:
///
/// * type size
/// * type alignment
/// * a pointer to the type's `drop_in_place` impl (may be a no-op for
///   plain-old-data)
/// * pointers to all the methods for the type's implementation of the trait
///
/// Note that the first three are special because they're necessary to
/// allocate, drop, and deallocate any trait object.
pub struct DynMetadata<Dyn: PointeeSized> {
    _vtable_ptr: NonNull<VTable>,
    _phantom: crate::marker::PhantomData<Dyn>,
}

注意这段文档的精确措辞------虚表包含四类信息:类型大小、类型对齐、析构函数指针、方法指针。前三个是"公共头部",用于管理内存;方法指针则实现动态分发。

8.2.2 验证胖指针大小

我们可以用编译期断言来验证胖指针的大小:

rust 复制代码
use std::mem;

trait Animal {
    fn speak(&self) -> &str;
    fn legs(&self) -> u32;
}

struct Dog {
    name: String,
    weight: f64,
}

impl Animal for Dog {
    fn speak(&self) -> &str { "Woof!" }
    fn legs(&self) -> u32 { 4 }
}

fn main() {
    // 薄指针:1 个机器字
    assert_eq!(mem::size_of::<&Dog>(), 8);         // 64 位平台
    assert_eq!(mem::size_of::<&i32>(), 8);
    assert_eq!(mem::size_of::<Box<Dog>>(), 8);

    // 胖指针:2 个机器字
    assert_eq!(mem::size_of::<&dyn Animal>(), 16);
    assert_eq!(mem::size_of::<Box<dyn Animal>>(), 16);
    assert_eq!(mem::size_of::<*const dyn Animal>(), 16);

    // 对比:切片引用也是胖指针
    assert_eq!(mem::size_of::<&[u8]>(), 16);
    assert_eq!(mem::size_of::<&str>(), 16);

    // 多加 marker trait 不改变大小
    assert_eq!(mem::size_of::<&(dyn Animal + Send)>(), 16);
    assert_eq!(mem::size_of::<&(dyn Animal + Send + Sync)>(), 16);
}

所有 trait object 指针的大小都是 16 字节------无论 trait 有多少方法、无论附加多少 marker trait。这是因为胖指针只存储一个虚表指针,而方法数量只影响虚表本身的大小。

8.2.3 胖指针的内存图

graph LR subgraph "胖指针 &dyn Animal(16 字节)" direction LR D["字节 0-7
data_ptr
→ Dog 实例"] V["字节 8-15
vtable_ptr
→ Dog::Animal vtable"] end subgraph "Dog 实例(堆或栈上)" F1["name: String
(24 字节)"] F2["weight: f64
(8 字节)"] end subgraph "虚表(只读数据段,静态分配)" V0["slot 0: drop_in_place<Dog>"] V1["slot 1: size = 32"] V2["slot 2: align = 8"] V3["slot 3: Dog::speak"] V4["slot 4: Dog::legs"] end D --> F1 V --> V0 style D fill:#3b82f6,color:#fff,stroke:none style V fill:#8b5cf6,color:#fff,stroke:none style V0 fill:#f59e0b,color:#000,stroke:none style V1 fill:#f59e0b,color:#000,stroke:none style V2 fill:#f59e0b,color:#000,stroke:none style V3 fill:#10b981,color:#fff,stroke:none style V4 fill:#10b981,color:#fff,stroke:none

上面的图展示了 &dyn Animal 指向一个 Dog 实例时的完整内存布局。Dog 包含一个 String(24 字节:指针 + 长度 + 容量)和一个 f64(8 字节),总共 32 字节,对齐到 8 字节。这些信息被编码在虚表的 sizealign 槽位中。

8.2.4 用 unsafe 代码窥探胖指针

以下代码展示了如何在运行时拆解一个胖指针,查看其内部结构:

rust 复制代码
use std::mem;
use std::raw::TraitObject; // nightly only,或者我们手动定义

// 在 stable Rust 上,我们可以用 transmute 手动拆解
#[repr(C)]
struct FatPointer {
    data: *const (),
    vtable: *const usize,
}

trait Animal {
    fn speak(&self) -> &str;
    fn legs(&self) -> u32;
}

struct Cat;
impl Animal for Cat {
    fn speak(&self) -> &str { "Meow!" }
    fn legs(&self) -> u32 { 4 }
}

fn main() {
    let cat = Cat;
    let animal: &dyn Animal = &cat;

    // 将胖指针 transmute 为两个原始指针
    let fat: FatPointer = unsafe { mem::transmute(animal) };

    println!("数据指针: {:p}", fat.data);
    println!("虚表指针: {:p}", fat.vtable);

    // 读取虚表头部
    unsafe {
        let vtable = fat.vtable;
        let drop_fn = *vtable;               // slot 0: drop_in_place
        let size    = *vtable.add(1);         // slot 1: size
        let align   = *vtable.add(2);         // slot 2: align

        println!("drop_in_place 函数地址: 0x{:x}", drop_fn);
        println!("类型大小: {} 字节", size);    // Cat 是 ZST,输出 0
        println!("类型对齐: {} 字节", align);   // 输出 1

        // 方法指针
        let speak_fn: fn(&Cat) -> &str =
            mem::transmute(*vtable.add(3));    // slot 3: speak
        let legs_fn: fn(&Cat) -> u32 =
            mem::transmute(*vtable.add(4));    // slot 4: legs

        println!("speak 返回: {}", speak_fn(&cat));
        println!("legs 返回: {}", legs_fn(&cat));
    }
}

运行这段代码(需要 unsafe),你会看到:

  • Cat 是零大小类型(ZST),size=0, align=1
  • drop_in_place 对于没有实现 Drop 且没有需要析构字段的类型,指向一个空操作(或者为 null 指针)
  • 方法指针确实按声明顺序排列:slot 3 是 speak,slot 4 是 legs

8.3 虚表的内存结构

8.3.1 编译器中的虚表定义

Rust 编译器在 compiler/rustc_middle/src/ty/vtable.rs 中定义了虚表条目的枚举类型:

rust 复制代码
// compiler/rustc_middle/src/ty/vtable.rs

#[derive(Clone, Copy, PartialEq, HashStable)]
pub enum VtblEntry<'tcx> {
    /// destructor of this type (used in vtable header)
    MetadataDropInPlace,
    /// layout size of this type (used in vtable header)
    MetadataSize,
    /// layout align of this type (used in vtable header)
    MetadataAlign,
    /// non-dispatchable associated function that is excluded from trait object
    Vacant,
    /// dispatchable associated function
    Method(Instance<'tcx>),
    /// pointer to a separate supertrait vtable, can be used by
    /// trait upcasting coercion
    TraitVPtr(TraitRef<'tcx>),
}

这个枚举揭示了虚表中可能出现的六种条目:

变体 含义 在虚表中占的空间
MetadataDropInPlace 析构函数指针 1 个指针大小
MetadataSize 类型的 size_of::<T>() 1 个指针大小(存整数值)
MetadataAlign 类型的 align_of::<T>() 1 个指针大小(存整数值)
Vacant 不可分发的方法占位 跳过,不写入
Method(Instance) 具体方法的函数指针 1 个指针大小
TraitVPtr(TraitRef) 超级 trait 的虚表指针 1 个指针大小

特别注意 Vacant 条目------当一个 trait 方法有 where Self: Sized 约束时,它在 trait object 上下文中不可调用。编译器在生成虚表时,对这些方法用 Vacant 标记,并在写入虚表内存时直接跳过,不占用任何空间。

公共头部的常量定义也很清晰:

rust 复制代码
// compiler/rustc_middle/src/ty/vtable.rs

impl<'tcx> TyCtxt<'tcx> {
    pub const COMMON_VTABLE_ENTRIES: &'tcx [VtblEntry<'tcx>] = &[
        VtblEntry::MetadataDropInPlace,
        VtblEntry::MetadataSize,
        VtblEntry::MetadataAlign,
    ];
}

pub const COMMON_VTABLE_ENTRIES_DROPINPLACE: usize = 0;
pub const COMMON_VTABLE_ENTRIES_SIZE: usize = 1;
pub const COMMON_VTABLE_ENTRIES_ALIGN: usize = 2;

这三个常量定义了头部三个槽位的索引。每个 (类型, trait) 组合的虚表都以这三个条目开头,后面跟随方法指针。

8.3.2 虚表的内存分配

虚表是一个静态的、不可变的 内存块,在编译期生成,存储在可执行文件的只读数据段(.rodata)中。来看编译器如何分配虚表内存(compiler/rustc_middle/src/ty/vtable.rs):

rust 复制代码
// compiler/rustc_middle/src/ty/vtable.rs - vtable_allocation_provider

pub(super) fn vtable_allocation_provider<'tcx>(
    tcx: TyCtxt<'tcx>,
    key: (Ty<'tcx>, Option<ty::ExistentialTraitRef<'tcx>>),
) -> AllocId {
    let (ty, poly_trait_ref) = key;

    let vtable_entries = if let Some(poly_trait_ref) = poly_trait_ref {
        let trait_ref = poly_trait_ref.with_self_ty(tcx, ty);
        let trait_ref = tcx.erase_and_anonymize_regions(trait_ref);
        tcx.vtable_entries(trait_ref)
    } else {
        TyCtxt::COMMON_VTABLE_ENTRIES
    };

    let layout = tcx.layout_of(
        ty::TypingEnv::fully_monomorphized().as_query_input(ty)
    ).unwrap();
    assert!(layout.is_sized(), "can't create a vtable for an unsized type");
    let size = layout.size.bytes();
    let align = layout.align.bytes();

    let ptr_size = tcx.data_layout.pointer_size();
    let ptr_align = tcx.data_layout.pointer_align().abi;

    // 虚表总大小 = 指针大小 × 条目数
    let vtable_size = ptr_size * u64::try_from(vtable_entries.len()).unwrap();
    let mut vtable = Allocation::new(
        vtable_size, ptr_align, AllocInit::Uninit, ()
    );

    for (idx, entry) in vtable_entries.iter().enumerate() {
        let idx: u64 = u64::try_from(idx).unwrap();
        let scalar = match *entry {
            VtblEntry::MetadataDropInPlace => {
                if ty.needs_drop(tcx, ty::TypingEnv::fully_monomorphized()) {
                    let instance = ty::Instance::resolve_drop_in_place(tcx, ty);
                    let fn_alloc_id = tcx.reserve_and_set_fn_alloc(
                        instance, CTFE_ALLOC_SALT
                    );
                    Scalar::from_pointer(Pointer::from(fn_alloc_id), &tcx)
                } else {
                    // 不需要 drop 的类型,写入 null 指针
                    Scalar::from_maybe_pointer(Pointer::null(), &tcx)
                }
            }
            VtblEntry::MetadataSize => Scalar::from_uint(size, ptr_size),
            VtblEntry::MetadataAlign => Scalar::from_uint(align, ptr_size),
            VtblEntry::Vacant => continue, // 跳过!不写入任何内容
            VtblEntry::Method(instance) => {
                let fn_alloc_id = tcx.reserve_and_set_fn_alloc(
                    instance, CTFE_ALLOC_SALT
                );
                Scalar::from_pointer(Pointer::from(fn_alloc_id), &tcx)
            }
            VtblEntry::TraitVPtr(trait_ref) => {
                // 递归获取超级 trait 的虚表
                let super_trait_ref =
                    ty::ExistentialTraitRef::erase_self_ty(tcx, trait_ref);
                let supertrait_alloc_id =
                    tcx.vtable_allocation((ty, Some(super_trait_ref)));
                Scalar::from_pointer(
                    Pointer::from(supertrait_alloc_id), &tcx
                )
            }
        };
        vtable
            .write_scalar(&tcx, alloc_range(ptr_size * idx, ptr_size), scalar)
            .expect("failed to build vtable representation");
    }

    vtable.mutability = Mutability::Not; // 虚表是不可变的
    tcx.reserve_and_set_memory_alloc(tcx.mk_const_alloc(vtable))
}

这段代码有几个关键细节:

  1. Vacant 条目被 continue 跳过 ------不写入任何内容,但实际上 Vacant 条目不会出现在 vtable_entries 返回的结果中用于占位(编译器在计算条目时对于 impossible_predicates 的方法会放入 Vacant,但虚表内存中 Vacant 的位置由于 continue 不会被写入,保持 Uninit 状态)。
  2. 不需要 drop 的类型使用 null 指针------避免了无意义的析构函数调用。
  3. TraitVPtr 递归调用 tcx.vtable_allocation------超级 trait 的虚表通过同一个 query 系统生成,天然支持缓存。
  4. 最终标记为不可变 ------vtable.mutability = Mutability::Not,确保虚表在运行时不会被修改。

8.3.3 虚表内存的精确字节示例

让我们画出一个完整的虚表内存布局。假设在 64 位 Linux 平台上,有以下类型和 trait:

rust 复制代码
trait Drawable {
    fn draw(&self);
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle {
    x: f64,      // 8 字节
    y: f64,      // 8 字节
    radius: f64, // 8 字节
}
// size = 24, align = 8

虚表内存布局如下(地址为示意):

php 复制代码
Circle 的 Drawable 虚表 (位于 .rodata 段)
┌─────────────────────────────────────────────────────────┐
│ 偏移量  │ 内容                          │ 值(示意)     │
├─────────┼───────────────────────────────┼────────────────┤
│ +0x00   │ drop_in_place<Circle>         │ 0x00005555_00401000 │
│ +0x08   │ size_of::<Circle>()           │ 24 (0x18)      │
│ +0x10   │ align_of::<Circle>()          │ 8  (0x08)      │
│ +0x18   │ <Circle as Drawable>::draw    │ 0x00005555_00402000 │
│ +0x20   │ <Circle as Drawable>::area    │ 0x00005555_00402100 │
│ +0x28   │ <Circle as Drawable>::name    │ 0x00005555_00402200 │
└─────────────────────────────────────────────────────────┘

整个虚表占 6 个指针 = 48 字节。这 48 字节在程序的整个生命周期中只存在一份------无论你创建了多少个 Box<dyn Drawable> 指向 Circle,它们的虚表指针都指向同一个地址。

graph TD subgraph "虚表内存布局(48 字节)" direction TB H0["+0x00: drop_in_place<Circle>
析构函数(或 null)"] H1["+0x08: size = 24
类型大小"] H2["+0x10: align = 8
类型对齐"] M0["+0x18: Circle::draw
方法 #0"] M1["+0x20: Circle::area
方法 #1"] M2["+0x28: Circle::name
方法 #2"] H0 --> H1 --> H2 --> M0 --> M1 --> M2 end style H0 fill:#f59e0b,color:#000,stroke:none style H1 fill:#f59e0b,color:#000,stroke:none style H2 fill:#f59e0b,color:#000,stroke:none style M0 fill:#10b981,color:#fff,stroke:none style M1 fill:#10b981,color:#fff,stroke:none style M2 fill:#10b981,color:#fff,stroke:none

8.3.4 为什么虚表需要 size 和 align

你可能会问:析构函数显然需要,但 sizealign 为什么要存在虚表里?答案是 Box<dyn Trait> 的析构流程。

当你 drop 一个 Box<dyn Trait> 时:

rust 复制代码
// 伪代码:Box<dyn Trait> 的 Drop 实现
impl<T: ?Sized> Drop for Box<T> {
    fn drop(&mut self) {
        unsafe {
            // 第一步:通过 vtable 调用类型的析构函数
            let drop_fn = self.vtable[0]; // drop_in_place
            drop_fn(self.data_ptr);

            // 第二步:释放堆内存
            // 需要 size 和 align 来正确 dealloc
            let size = self.vtable[1];
            let align = self.vtable[2];
            let layout = Layout::from_size_align_unchecked(size, align);
            dealloc(self.data_ptr as *mut u8, layout);
        }
    }
}

如果虚表中没有 sizealignBox 就无法知道要释放多少内存。这是 Rust 虚表与 C++ 虚表的一个重要区别------C++ 的 delete 通过 operator delete 和编译器生成的析构函数配合工作,不需要在虚表中存储大小信息;而 Rust 的 Box 使用的是通用的分配器接口,需要明确的 Layout

标准库的 DynMetadata 也提供了访问这些信息的方法:

rust 复制代码
// library/core/src/ptr/metadata.rs

impl<Dyn: PointeeSized> DynMetadata<Dyn> {
    /// Returns the size of the type associated with this vtable.
    pub fn size_of(self) -> usize {
        unsafe { crate::intrinsics::vtable_size(self.vtable_ptr() as *const ()) }
    }

    /// Returns the alignment of the type associated with this vtable.
    pub fn align_of(self) -> usize {
        unsafe { crate::intrinsics::vtable_align(self.vtable_ptr() as *const ()) }
    }

    /// Returns the size and alignment together as a `Layout`
    pub fn layout(self) -> crate::alloc::Layout {
        unsafe {
            crate::alloc::Layout::from_size_align_unchecked(
                self.size_of(), self.align_of()
            )
        }
    }
}

8.4 动态分发的精确流程

8.4.1 方法调用的机器码

当你写 animal.speak() 时(其中 animal: &dyn Animal),编译器生成的代码等价于以下伪代码:

rust 复制代码
// 源代码
let animal: &dyn Animal = &dog;
let result = animal.speak();

// 编译器生成的等价逻辑(伪代码)
let (data_ptr, vtable_ptr) = transmute::<&dyn Animal, (*const (), *const usize)>(animal);
let speak_fn_ptr: fn(*const ()) -> &str = *(vtable_ptr.add(3)) as fn(*const ()) -> &str;
let result = speak_fn_ptr(data_ptr);

在 x86-64 汇编层面,这大致对应:

asm 复制代码
; animal 在栈上占 16 字节
; [rsp]     = data_ptr
; [rsp + 8] = vtable_ptr

mov  rdi, [rsp]      ; rdi = data_ptr (第一个参数 = self)
mov  rax, [rsp + 8]  ; rax = vtable_ptr
call [rax + 24]      ; 调用 vtable[3] = speak 方法
                      ; 24 = 3 * 8 (64位平台上每个槽位8字节)
sequenceDiagram participant 调用方 as 调用方代码 participant FP as 胖指针 participant VT as 虚表 participant M as 方法实现 调用方->>FP: 读取 data_ptr 和 vtable_ptr Note over 调用方,FP: 第一次内存加载
(16 字节,通常在缓存中) 调用方->>VT: 通过 vtable_ptr + offset
读取方法函数指针 Note over 调用方,VT: 第二次内存加载
(8 字节,可能 cache miss) 调用方->>M: 通过函数指针间接调用
传入 data_ptr 作为 self Note over 调用方,M: 间接跳转
(CPU 分支预测可能失败) M-->>调用方: 返回结果

8.4.2 动态分发的开销分析

每次 dyn Trait 方法调用的开销可以分解为:

  1. 两次指针解引用:读取胖指针中的虚表地址,再从虚表中读取函数指针。在缓存命中的情况下,每次约 1-4 个 CPU 周期。
  2. 间接跳转 :CPU 的分支预测器面对间接调用(call [reg])时,预测准确率取决于调用模式。如果同一个调用点总是调用同一个方法(单态场景),预测准确率可以很高;如果调用点交替调用不同类型的方法(多态场景),预测失败率会显著上升。
  3. 无法内联:这是最大的隐性开销。静态分发时,编译器可以将小方法内联到调用点,进而进行常量折叠、死代码消除等优化。动态分发时,由于编译器不知道具体会调用哪个函数,这些优化机会全部丧失。

8.4.3 与 C++ 虚表的对比

Rust 和 C++ 的虚表机制有几个关键区别:

特性 Rust C++
虚表指针位置 在胖指针中(外部) 在对象内部(通常是第一个字段)
虚表内容 drop + size + align + 方法指针 RTTI + 方法指针
每个对象的开销 0 字节(虚表指针在引用中) 8 字节(嵌入的 vptr)
多重继承 通过 TraitVPtr 多个嵌入的 vptr
RTTI 无(虚表中有 size/align 但无类型名) type_info

Rust 将虚表指针放在引用而非对象中的设计意味着:同一个对象可以通过不同的 trait object 引用被看作不同的"接口",而对象本身不需要任何额外空间。这使得即使是零大小类型(ZST)也可以成为 trait object。

8.5 对象安全(Dyn Compatibility)

8.5.1 规则总览

不是所有 trait 都能变成 dyn Trait。编译器要求 trait 满足一组称为"对象安全"(object safety)------在最新的 Rust 术语中更名为"Dyn Compatibility"------的规则。这些规则存在的根本原因是:虚表的大小和内容必须在编译期完全确定

在 Rust 编译器源码中(compiler/rustc_trait_selection/src/traits/dyn_compatibility.rs),这些规则的检查起点是:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/dyn_compatibility.rs

//! "Dyn-compatibility" refers to the ability for a trait to be converted
//! to a trait object. In general, traits may only be converted to a trait
//! object if certain criteria are met.
//!
//! Formerly known as "object safety".

fn dyn_compatibility_violations(
    tcx: TyCtxt<'_>,
    trait_def_id: DefId,
) -> &'_ [DynCompatibilityViolation] {
    debug_assert!(tcx.generics_of(trait_def_id).has_self);
    tcx.arena.alloc_from_iter(
        elaborate::supertrait_def_ids(tcx, trait_def_id)
            .flat_map(|def_id| dyn_compatibility_violations_for_trait(tcx, def_id)),
    )
}

注意这段代码遍历了 trait 的所有超级 trait(包括自身),对每一个都检查兼容性违规。这意味着即使你的 trait 本身是对象安全的,如果它的超级 trait 不是,它也不能变成 trait object。

8.5.2 七条核心规则

flowchart TD START["trait T 是否可以
成为 dyn T?"] --> R1{"T 或其超级 trait
要求 Self: Sized?"} R1 -->|是| FAIL["不兼容"] R1 -->|否| R2{"T 的超级 trait 谓词
引用了 Self 类型?
(非 self 参数位置)"} R2 -->|是| FAIL R2 -->|否| R3{"T 有泛型关联类型
(GAT)?"} R3 -->|是| FAIL R3 -->|否| R4{"T 有关联常量?
(且未启用
min_generic_const_args)"} R4 -->|是| FAIL R4 -->|否| R5{"所有方法
逐一检查"} R5 --> M1{"方法是否有
Self: Sized 约束?"} M1 -->|是| SKIP["该方法被跳过
不进入虚表"] M1 -->|否| M2{"方法是否有
泛型类型参数?"} M2 -->|是| FAIL M2 -->|否| M3{"方法参数或返回值
引用了 Self 类型?"} M3 -->|是| FAIL M3 -->|否| M4{"方法的 receiver
可以分发?"} M4 -->|否| FAIL M4 -->|是| OK["兼容"] style FAIL fill:#ef4444,color:#fff,stroke:none style OK fill:#10b981,color:#fff,stroke:none style SKIP fill:#f59e0b,color:#000,stroke:none

下面逐一解释每条规则及其在编译器中的实现。

规则 1:trait 不能要求 Self: Sized
rust 复制代码
// 不兼容:trait 自身要求 Self: Sized
trait StaticOnly: Sized {
    fn method(&self);
}
// 错误:dyn StaticOnly 本身是 !Sized 的,无法满足 Sized 约束

编译器检查(dyn_compatibility.rs):

rust 复制代码
fn dyn_compatibility_violations_for_trait(tcx, trait_def_id) -> Vec<...> {
    // ...
    if trait_has_sized_self(tcx, trait_def_id) {
        let spans = get_sized_bounds(tcx, trait_def_id);
        violations.push(DynCompatibilityViolation::SizedSelf(spans));
    }
    // ...
}

fn trait_has_sized_self(tcx: TyCtxt<'_>, trait_def_id: DefId) -> bool {
    tcx.generics_require_sized_self(trait_def_id)
}

为什么dyn Trait 本身是一个动态大小类型(DST),它的大小在编译期未知。如果 trait 要求 Self: Sized,那 dyn Trait 作为 Self 时无法满足这个约束。

规则 2:方法不能有泛型类型参数
rust 复制代码
// 不兼容:方法有泛型参数
trait Processor {
    fn process<T: Debug>(&self, item: T);
}

编译器检查:

rust 复制代码
fn virtual_call_violations_for_method(tcx, trait_def_id, method) -> Vec<...> {
    // ...
    let own_counts = tcx.generics_of(method.def_id).own_counts();
    if own_counts.types > 0 || own_counts.consts > 0 {
        errors.push(MethodViolation::Generic);
    }
    // ...
}

为什么 :每个不同的类型参数 T 需要一个不同的函数实例。虚表的槽位数量必须在编译期确定,而泛型方法的可能实例化数量是无限的。编译器不可能在虚表中为所有可能的 T 预留槽位。

规则 3:方法的参数和返回值不能使用 Self 类型
rust 复制代码
// 不兼容:返回 Self
trait Clonable {
    fn clone_self(&self) -> Self;
}

// 不兼容:参数使用 Self
trait Mergeable {
    fn merge(&self, other: Self) -> Self;
}

为什么 :当通过 dyn Trait 调用时,编译器不知道 Self 是什么类型,因此不知道返回值需要多大的栈空间,也不知道参数该如何传递。

但有一个重要的例外------self 参数本身可以是 Self,因为它通过指针传递:

rust 复制代码
// 这是合法的!self: Self 被特殊处理
trait Consumable {
    fn consume(self); // self: Self,但通过所有权转移,可以工作
}
规则 4:方法必须有可分发的 receiver

合法的 receiver 类型包括:&self&mut selfself: Box<Self>self: Rc<Self>self: Arc<Self>self: Pin<&Self> 等。不合法的包括没有 self 参数的关联函数。

rust 复制代码
// 不兼容:没有 self 参数的关联函数
trait Factory {
    fn create() -> Self; // 静态方法,无法通过虚表分发
}

编译器检查:

rust 复制代码
fn virtual_call_violations_for_method(tcx, trait_def_id, method) -> Vec<...> {
    // The method's first parameter must be named `self`
    if !method.is_method() {
        return vec![MethodViolation::StaticMethod(sugg)];
    }
    // ...
    if receiver_ty != tcx.types.self_param {
        if !receiver_is_dispatchable(tcx, method, receiver_ty) {
            errors.push(MethodViolation::UndispatchableReceiver(span));
        }
    }
    // ...
}
规则 5:超级 trait 的谓词不能以"非法"方式引用 Self
rust 复制代码
// 不兼容:超级 trait 约束引用了 Self
trait BadSuper: PartialEq<Self> {} // Self 出现在 PartialEq 的类型参数中

但以下是合法的:

rust 复制代码
// 合法:Self 只出现在关联类型的输出位置
trait GoodSuper: Iterator<Item = i32> {} // Item = i32 不是 Self
规则 6:不能有泛型关联类型(GAT)
rust 复制代码
// 不兼容:关联类型有泛型参数
trait LendingIterator {
    type Item<'a> where Self: 'a;    // GAT
    fn next(&mut self) -> Self::Item<'_>;
}
规则 7:where Self: Sized 是逃生舱口

任何方法只要加上 where Self: Sized 约束,就可以从对象安全检查中豁免------这个方法不会出现在虚表中,但 trait 本身仍然可以成为 trait object:

rust 复制代码
trait Flexible {
    fn normal_method(&self);          // 进入虚表

    fn generic_method<T>(&self, x: T) // 有泛型参数
    where Self: Sized;                // 但加了 Sized 约束 → 不进入虚表

    fn clone_self(&self) -> Self      // 返回 Self
    where Self: Sized;                // 但加了 Sized 约束 → 不进入虚表
}

// 合法!Flexible 是对象安全的
let x: &dyn Flexible = &some_value;
x.normal_method();          // 通过虚表调用
// x.generic_method(42);    // 编译错误:方法要求 Self: Sized

编译器对此的处理:

rust 复制代码
fn dyn_compatibility_violations_for_assoc_item(tcx, trait_def_id, item) -> Vec<...> {
    // Any item that has a `Self: Sized` requisite is otherwise exempt
    // from the regulations.
    if tcx.generics_require_sized_self(item.def_id) {
        return Vec::new();
    }
    // ... 继续检查
}

8.5.3 vtable safe 方法的判定

即使 trait 是对象安全的,也不是所有方法都会出现在虚表中。编译器用 is_vtable_safe_method 函数来判断:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/dyn_compatibility.rs

/// We say a method is *vtable safe* if it can be invoked on a trait
/// object. Note that dyn-compatible traits can have some
/// non-vtable-safe methods, so long as they require `Self: Sized` or
/// otherwise ensure that they cannot be used when `Self = Trait`.
pub fn is_vtable_safe_method(
    tcx: TyCtxt<'_>,
    trait_def_id: DefId,
    method: ty::AssocItem,
) -> bool {
    debug_assert!(tcx.generics_of(trait_def_id).has_self);
    // Any method that has a `Self: Sized` bound cannot be called.
    if tcx.generics_require_sized_self(method.def_id) {
        return false;
    }
    virtual_call_violations_for_method(tcx, trait_def_id, method).is_empty()
}

这个函数在虚表条目生成时被调用,决定一个方法是否应该占据虚表中的一个槽位:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/vtable.rs

fn own_existential_vtable_entries_iter(
    tcx: TyCtxt<'_>,
    trait_def_id: DefId,
) -> impl Iterator<Item = DefId> {
    let trait_methods = tcx.associated_items(trait_def_id)
        .in_definition_order()
        .filter(|item| item.is_fn());

    let own_entries = trait_methods.filter_map(move |&trait_method| {
        let def_id = trait_method.def_id;

        // Final methods should not be included in the vtable.
        if trait_method.defaultness(tcx).is_final() {
            return None;
        }

        // Some methods cannot be called on an object; skip those.
        if !is_vtable_safe_method(tcx, trait_def_id, trait_method) {
            return None;
        }

        Some(def_id)
    });

    own_entries
}

8.6 虚表生成的完整算法

8.6.1 单继承链的虚表布局

编译器在 compiler/rustc_trait_selection/src/traits/vtable.rs 中实现了完整的虚表布局算法。这是本章最核心的编译器源码。

先看最简单的情况------单继承链:

rust 复制代码
trait A {
    fn method_a(&self);
}

trait B: A {
    fn method_b(&self);
}

trait C: B {
    fn method_c(&self);
}

trait D: C {
    fn method_d(&self);
}

继承链:D --> C --> B --> A

编译器源码中的注释清楚地描述了布局策略:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/vtable.rs

/// Prepare the segments for a vtable
///
/// The following constraints holds for the final arrangement.
/// 1. The whole virtual table of the first direct super trait is included
///    as the prefix. If this trait doesn't have any super traits, then this
///    step consists of the dsa metadata.
/// 2. Then comes the proper pointer metadata(vptr) and all own methods for
///    all other super traits except those already included as part of the
///    first direct super trait virtual table.
/// 3. finally, the own methods of this trait.
///
/// For a single inheritance relationship like this,
///   D --> C --> B --> A
/// The resulting vtable will consists of these segments:
///  DSA, A, B, C, D

所以 dyn D 的虚表布局是:

css 复制代码
┌──────────────────────────────────────────────────────────┐
│  DSA 头部     │  A 的方法    │  B 的方法    │  C 的方法    │  D 的方法    │
│ drop/size/    │ method_a    │ method_b    │ method_c    │ method_d    │
│ align         │             │             │             │             │
└──────────────────────────────────────────────────────────┘
 slot 0,1,2      slot 3        slot 4        slot 5        slot 6

关键优势:dyn D 向上转换为 dyn A 是零成本的 ------dyn A 需要的虚表恰好是 dyn D 虚表的前缀。只需保持同一个虚表指针,dyn A 的方法调用直接使用 slot 3。

8.6.2 多重继承的虚表布局

当 trait 有多个超级 trait 时,情况变得复杂:

rust 复制代码
trait A {
    fn method_a(&self);
}

trait B {
    fn method_b(&self);
}

trait C: A + B {
    fn method_c(&self);
}

trait D: C {
    fn method_d(&self);
}

继承关系:

rust 复制代码
D --> C --> A
          \-> B

编译器注释说明:

rust 复制代码
/// For a multiple inheritance relationship like this,
///   D --> C --> A
///           \-> B
/// The resulting vtable will consists of these segments:
///  DSA, A, B, B-vptr, C, D

虚表布局:

css 复制代码
┌───────────────────────────────────────────────────────────────────┐
│  DSA 头部     │  A 的方法    │  B 的方法    │  B-vptr     │  C 的方法    │  D 的方法    │
│ drop/size/    │ method_a    │ method_b    │ → B 虚表    │ method_c    │ method_d    │
│ align         │             │             │             │             │             │
└───────────────────────────────────────────────────────────────────┘
 slot 0,1,2      slot 3        slot 4        slot 5        slot 6        slot 7

B-vptr(slot 5)是一个指向独立 B 虚表的指针。当你从 dyn D 向上转换为 dyn B 时,编译器需要:

  1. 读取 slot 5 获得 B 虚表的地址
  2. 用 B 虚表的地址替换胖指针中的 vtable_ptr

这就是 VtblEntry::TraitVPtr 变体存在的原因。

8.6.3 菱形继承的虚表布局

rust 复制代码
trait A { fn method_a(&self); }
trait B: A { fn method_b(&self); }
trait C: A { fn method_c(&self); }
trait D: B + C { fn method_d(&self); }

继承关系(菱形):

rust 复制代码
D --> B --> A
  \-> C -/

编译器注释:

rust 复制代码
/// For a diamond inheritance relationship like this,
///   D --> B --> A
///     \-> C -/
/// The resulting vtable will consists of these segments:
///  DSA, A, B, C, C-vptr, D

虚表布局:

css 复制代码
┌──────────────────────────────────────────────────────────────────────┐
│  DSA 头部     │  A 的方法    │  B 的方法    │  C 的方法    │  C-vptr     │  D 的方法    │
│ drop/size/    │ method_a    │ method_b    │ method_c    │ → C 虚表    │ method_d    │
│ align         │             │             │             │             │             │
└──────────────────────────────────────────────────────────────────────┘
 slot 0,1,2      slot 3        slot 4        slot 5        slot 6        slot 7

注意 A 的方法只出现一次------因为 B 是第一个超级 trait,B 的虚表(包含 A 的前缀)被完整嵌入。C 也继承了 A,但 A 已经被 B 包含了,所以 C 的段只包含 C 自己的方法,不重复 A。

dyn D 向上转换为 dyn B 是零成本的(B 的虚表是 D 虚表的前缀)。但从 dyn D 向上转换为 dyn C 需要通过 C-vptr 获取 C 的独立虚表。

8.6.4 虚表条目的具体生成

vtable_entries 函数是最终组装虚表的地方:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/vtable.rs

fn vtable_entries<'tcx>(
    tcx: TyCtxt<'tcx>,
    trait_ref: ty::TraitRef<'tcx>,
) -> &'tcx [VtblEntry<'tcx>] {
    let mut entries = vec![];

    let vtable_segment_callback = |segment| -> ControlFlow<()> {
        match segment {
            VtblSegment::MetadataDSA => {
                // 头部三个条目
                entries.extend(TyCtxt::COMMON_VTABLE_ENTRIES);
            }
            VtblSegment::TraitOwnEntries { trait_ref, emit_vptr } => {
                let existential_trait_ref =
                    ty::ExistentialTraitRef::erase_self_ty(tcx, trait_ref);

                // 获取这个 trait 自己的方法 DefId 列表
                let own_existential_entries =
                    tcx.own_existential_vtable_entries(
                        existential_trait_ref.def_id
                    );

                let own_entries = own_existential_entries.iter().copied()
                    .map(|def_id| {
                        // 为方法创建具体的泛型参数
                        let args = GenericArgs::for_item(tcx, def_id, ...);

                        // 检查方法的 where 约束是否可满足
                        if impossible_predicates(tcx, predicates) {
                            return VtblEntry::Vacant; // 不可满足 → 空槽
                        }

                        // 解析到具体的函数实例
                        let instance = ty::Instance::expect_resolve_for_vtable(
                            tcx, ..., def_id, args, ...
                        );

                        VtblEntry::Method(instance)
                    });

                entries.extend(own_entries);

                // 如果需要,添加超级 trait 虚表指针
                if emit_vptr {
                    entries.push(VtblEntry::TraitVPtr(trait_ref));
                }
            }
        }
        ControlFlow::Continue(())
    };

    prepare_vtable_segments(tcx, trait_ref, vtable_segment_callback);
    tcx.arena.alloc_from_iter(entries)
}

这里有一个精妙的细节:impossible_predicates 检查。如果一个方法的 where 约束在当前类型参数下无法满足,该方法永远不会被调用,编译器会将其标记为 Vacant,避免尝试解析一个不存在的函数实例。

8.7 Trait Upcasting

8.7.1 什么是 trait upcasting

Trait upcasting 允许你将 dyn SubTrait 转换为 dyn SuperTrait。这个特性在 Rust 1.76(2024 年 2 月)稳定。

rust 复制代码
trait Base {
    fn base_method(&self);
}

trait Derived: Base {
    fn derived_method(&self);
}

fn use_base(x: &dyn Base) {
    x.base_method();
}

fn demo(x: &dyn Derived) {
    // trait upcasting:从 &dyn Derived 转换为 &dyn Base
    use_base(x);  // 在 Rust 1.76+ 合法
}

8.7.2 upcasting 的实现机制

Upcasting 的实现依赖虚表中的 TraitVPtr 条目。编译器在 supertrait_vtable_slot 函数中查找目标超级 trait 虚表指针的位置:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/vtable.rs

/// Given a `dyn Subtrait` and `dyn Supertrait` trait object, find the
/// slot of the trait vptr in the subtrait's vtable.
///
/// A return value of `None` means that the original vtable can be reused.
pub(crate) fn supertrait_vtable_slot<'tcx>(
    tcx: TyCtxt<'tcx>,
    key: (Ty<'tcx>, Ty<'tcx>), // (Source, Target)
) -> Option<usize> {
    // ...
    let vtable_segment_callback = {
        let mut vptr_offset = 0;
        move |segment| {
            match segment {
                VtblSegment::MetadataDSA => {
                    vptr_offset += TyCtxt::COMMON_VTABLE_ENTRIES.len();
                }
                VtblSegment::TraitOwnEntries {
                    trait_ref: vtable_principal, emit_vptr
                } => {
                    vptr_offset += tcx
                        .own_existential_vtable_entries(vtable_principal.def_id)
                        .len();

                    if /* 找到目标 trait */ {
                        if emit_vptr {
                            return ControlFlow::Break(Some(vptr_offset));
                        } else {
                            // 第一个超级 trait → 可以复用虚表
                            return ControlFlow::Break(None);
                        }
                    }

                    if emit_vptr {
                        vptr_offset += 1;
                    }
                }
            }
            ControlFlow::Continue(())
        }
    };

    prepare_vtable_segments(tcx, source_principal, vtable_segment_callback)
        .unwrap()
}

返回值的含义:

  • None:目标超级 trait 是第一个超级 trait,其虚表是源虚表的前缀,可以直接复用同一个虚表指针(零成本 upcasting)。
  • Some(slot):需要从虚表的 slot 位置读取超级 trait 的虚表指针,替换胖指针中的 vtable_ptr。

8.7.3 upcasting 的运行时成本

回到我们之前的菱形继承例子:

rust 复制代码
trait A { fn method_a(&self); }
trait B: A { fn method_b(&self); }
trait C: A { fn method_c(&self); }
trait D: B + C { fn method_d(&self); }
  • dyn D → dyn B:零成本(B 是第一个超级 trait,虚表是前缀)
  • dyn D → dyn C:需要一次指针读取(从 C-vptr 槽位获取 C 虚表地址)
  • dyn D → dyn A:零成本(A 是 B 的前缀,而 B 是 D 的前缀)
  • dyn C → dyn A:零成本(A 是 C 的第一个超级 trait)

编译器的布局策略特意将第一个超级 trait 的虚表作为前缀嵌入,使得最常见的 upcasting 路径是零成本的。

8.7.4 emit_vptr 的决策逻辑

不是每个超级 trait 都需要一个 TraitVPtr 条目。编译器的决策逻辑:

rust 复制代码
// compiler/rustc_trait_selection/src/traits/vtable.rs
// (在 prepare_vtable_segments_inner 中)

// We don't need to emit a vptr for "truly-empty" supertraits,
// but we *do* need to emit a vptr for supertraits that have no
// methods, but that themselves have supertraits with methods,
// so we check if any transitive supertrait has entries here
// (this includes the trait itself).
let has_entries = ty::elaborate::supertrait_def_ids(
    tcx, inner_most_trait_ref.def_id
).any(|def_id| has_own_existential_vtable_entries(tcx, def_id));

segment_visitor(VtblSegment::TraitOwnEntries {
    trait_ref: inner_most_trait_ref,
    emit_vptr: emit_vptr && has_entries
               && !tcx.sess.opts.unstable_opts.no_trait_vptr,
});

// If we've emitted a trait that has methods present in the vtable,
// we'll need to emit vptrs from now on.
emit_vptr_on_new_entry |= has_entries;

规则是:

  1. 第一个有方法的超级 trait 不需要 vptr(它的虚表是前缀)
  2. 后续的超级 trait 如果有方法(或其传递超级 trait 有方法),需要 vptr
  3. "真正空的"超级 trait(自身和所有传递超级 trait 都没有方法)不需要 vptr
  4. 编译器有一个 no_trait_vptr 调试选项,可以禁用所有 vptr

8.8 多 Trait 约束:dyn Trait1 + Trait2

8.8.1 Marker Trait 不增加虚表大小

rust 复制代码
let x: &(dyn Display + Send + Sync) = &42;

SendSync 是 marker trait------它们没有任何方法。因此它们不会在虚表中添加任何槽位。虚表只包含 Display::fmt 的方法指针(加上公共头部)。

编译器确认这一点的方式是:own_existential_vtable_entries_iterSendSync 返回空迭代器(因为它们没有关联函数)。

8.8.2 多个有方法的 Trait

Rust 目前只允许 dyn 后面有一个 主 trait(principal trait),加上任意多个 auto trait(SendSyncUnpin 等)。如果你写:

rust 复制代码
// 编译错误:只允许一个非 auto trait
let x: &(dyn Display + Debug) = &42;

这是一个长期存在的限制。绕过方法是创建一个组合 trait:

rust 复制代码
trait DisplayDebug: Display + Debug {}
impl<T: Display + Debug> DisplayDebug for T {}

let x: &dyn DisplayDebug = &42;
// x 的虚表包含:
// - 公共头部 (drop, size, align)
// - DisplayDebug 的方法(空,因为 DisplayDebug 自身没有方法)
// - Debug 的方法 (Debug::fmt)
// - Debug 的 vptr(如果需要 upcasting 到 dyn Debug)
// - Display 的方法 (Display::fmt) --- 注意这里的顺序取决于声明顺序

8.8.3 dyn Any 的特殊性

std::any::Any trait 值得特别提及。它只有一个方法 type_id(&self) -> TypeId,但这个方法有 where Self: 'static 约束(不是 Self: Sized),所以它是对象安全的。Any 的虚表有 4 个条目:

yaml 复制代码
slot 0: drop_in_place
slot 1: size
slot 2: align
slot 3: type_id

downcast_ref 方法是通过比较 TypeId 来实现的,不需要额外的虚表条目。

8.9 dyn Trait vs impl Trait vs 泛型

8.9.1 三种多态的本质

Rust 提供三种实现多态的方式,它们在编译策略和运行时行为上完全不同:

特性 泛型 <T: Trait> impl Trait dyn Trait
分发方式 静态(单态化) 静态(单态化) 动态(虚表)
类型信息 编译期完全已知 编译期完全已知 运行时擦除
内联优化 可以 可以 通常不可以
二进制大小 每种类型生成一份代码 每种类型生成一份代码 共享一份代码
异构集合 不支持 不支持 支持
指针大小 薄指针(8 字节) 薄指针(8 字节) 胖指针(16 字节)
编译时间 较慢(单态化膨胀) 较慢 较快

8.9.2 编译器对静态分发的处理

当你写:

rust 复制代码
fn draw_shape(shape: &impl Shape) {
    shape.draw();
}

// 等价于:
fn draw_shape<S: Shape>(shape: &S) {
    shape.draw();
}

编译器会为每种调用到的具体类型生成一份独立的函数:

rust 复制代码
// 编译器生成(伪代码)
fn draw_shape_Circle(shape: &Circle) {
    Circle::draw(shape);  // 直接调用,可以内联
}

fn draw_shape_Rectangle(shape: &Rectangle) {
    Rectangle::draw(shape);  // 直接调用,可以内联
}

8.9.3 编译器对动态分发的处理

当你写:

rust 复制代码
fn draw_shape(shape: &dyn Shape) {
    shape.draw();
}

编译器只生成一份函数,通过虚表间接调用:

rust 复制代码
// 编译器生成(伪代码)
fn draw_shape(shape: &dyn Shape) {
    let (data_ptr, vtable_ptr) = shape;
    let draw_fn = vtable_ptr[3]; // 从虚表中查找 draw 方法
    draw_fn(data_ptr);           // 间接调用
}

8.9.4 何时选择动态分发

选择 dyn Trait 的典型场景:

  1. 异构集合Vec<Box<dyn Shape>> 存储不同类型的元素
  2. 减少二进制膨胀:当泛型函数被大量不同类型调用时,单态化会产生大量重复代码
  3. 插件/回调系统:运行时注册的处理器,编译期不知道具体类型
  4. 递归类型 :如 enum Tree { Leaf, Node(Box<dyn TreeVisitor>) }
  5. FFI 边界:跨动态库的接口不能使用泛型

避免 dyn Trait 的场景:

  1. 热循环:性能关键路径上,间接调用和无法内联的开销可能不可接受
  2. 类型集合小且已知:如果只有 2-3 种类型,用 enum 比 trait object 更高效
  3. 需要类型信息:如果后续需要 downcast 回具体类型,说明设计可能有问题

8.10 性能对比:静态分发 vs 动态分发

8.10.1 微基准测试

以下是一个典型的微基准测试场景------对一组形状调用 area() 方法:

rust 复制代码
// 静态分发版本
fn total_area_static(shapes: &[Circle]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

// 动态分发版本
fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

典型基准测试结果(10,000 个元素,AMD Ryzen 9 5900X):

方式 耗时 相对性能
静态分发(同类型) ~3.2 us 1.0x(基准)
动态分发(同类型) ~8.5 us 2.7x 慢
动态分发(混合类型) ~12.1 us 3.8x 慢
枚举匹配(混合类型) ~4.8 us 1.5x 慢

8.10.2 性能差距的来源

为什么动态分发慢 2-4 倍?分解来看:

  1. 间接调用本身:约 2-5ns 额外延迟,主要来自分支预测失败。当所有元素是同一类型时,CPU 的间接分支预测器可以学习到模式,失败率很低;当类型混合时,预测失败率上升。

  2. 无法内联 :这是主要因素。area() 对于 Circle 来说只是 PI * r * r,如果内联到循环中,编译器可以进行向量化(SIMD)、循环展开等优化。动态分发时,编译器不知道会调用哪个函数,这些优化全部丧失。

  3. 缓存局部性Vec<Box<dyn Shape>> 中每个元素是一个堆分配的对象,通过指针间接访问。相比 Vec<Circle> 中元素连续存储在内存中,前者的缓存命中率更低。

  4. 虚表缓存:虚表本身需要额外的缓存行。如果有多种类型的虚表,它们可能分布在不同的缓存行中,造成额外的缓存压力。

8.10.3 真实世界的性能建议

微基准测试往往夸大了差距。在真实程序中:

  • 如果方法体本身执行时间较长(比如 I/O 操作、复杂计算),间接调用的额外开销可以忽略
  • 如果集合很小(几十个元素),无论用哪种方式都很快
  • 如果类型数量已知且不多,enum 通常比 dyn Trait 更好------它允许内联和向量化,且没有堆分配开销

一个实用的决策框架:

rust 复制代码
// 类型数量少且已知?→ 用 enum
enum Shape {
    Circle(Circle),
    Rectangle(Rectangle),
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(c) => c.area(),
            Shape::Rectangle(r) => r.area(),
        }
    }
}

// 类型数量多或不确定?→ 用 dyn Trait
fn process(handlers: &[Box<dyn Handler>]) { ... }

// 性能关键且类型已知?→ 用泛型
fn fast_path<S: Shape>(shape: &S) -> f64 { shape.area() }

8.11 高级话题

8.11.1 虚表的最小条目数

编译器需要在布局计算阶段就知道虚表的最小大小(用于计算 &dyn Trait 的对齐等属性)。这通过 vtable_min_entries 函数实现:

rust 复制代码
// compiler/rustc_middle/src/ty/vtable.rs

pub(crate) fn vtable_min_entries<'tcx>(
    tcx: TyCtxt<'tcx>,
    trait_ref: Option<ty::ExistentialTraitRef<'tcx>>,
) -> usize {
    let mut count = TyCtxt::COMMON_VTABLE_ENTRIES.len(); // 3
    let Some(trait_ref) = trait_ref else {
        return count; // dyn Any 等没有 principal 的情况
    };

    // This includes self in supertraits.
    for def_id in elaborate::supertrait_def_ids(tcx, trait_ref.def_id) {
        count += tcx.own_existential_vtable_entries(def_id).len();
    }

    count
}

注意这是一个"最小估计"------实际虚表可能更大(因为还有 TraitVPtr 条目和 Vacant 条目的差异)。编译器在实际生成虚表时会验证:

rust 复制代码
// 在 vtable_allocation_provider 中
assert!(vtable_entries.len() >= vtable_min_entries(tcx, poly_trait_ref));

8.11.2 dyn Trait 的 lifetime

每个 trait object 都隐含一个 lifetime bound。完整写法是 dyn Trait + 'a。编译器根据上下文推断这个 lifetime:

  • &'a dyn Trait 中,默认 lifetime 是 'a
  • Box<dyn Trait> 中,默认 lifetime 是 'static
  • &'a (dyn Trait + 'b) 中,要求 'a: 'b(引用的 lifetime 不能超过 trait object 的 lifetime)

这个 lifetime 不影响虚表的结构------它只是编译器的借用检查信息,在运行时没有任何表示。

8.11.3 虚表的去重与合并

编译器在代码生成阶段可能会对虚表进行去重。如果两个不同的 (类型, trait) 组合恰好产生了完全相同的虚表内容(相同的函数指针、相同的 size/align),链接器可能会将它们合并为同一个虚表。这就是标准库文档中警告的原因:

rust 复制代码
// library/core/src/ptr/metadata.rs 的文档:

/// Note that while this type implements `PartialEq`, comparing vtable
/// pointers is unreliable: pointers to vtables of the same type for
/// the same trait can compare inequal (because vtables are duplicated
/// in multiple codegen units), and pointers to vtables of *different*
/// types/traits can compare equal (since identical vtables can be
/// deduplicated within a codegen unit).

换句话说:不要用虚表指针的相等性来判断两个 trait object 是否是同一类型。这不是一个可靠的操作。

8.11.4 CoerceUnsized 与自定义智能指针

要让自定义智能指针支持 dyn Trait,需要实现 CoerceUnsized trait:

rust 复制代码
use std::ops::CoerceUnsized;

struct MyBox<T: ?Sized> {
    ptr: *mut T,
}

// 让 MyBox<Concrete> 可以强制转换为 MyBox<dyn Trait>
impl<T, U> CoerceUnsized<MyBox<U>> for MyBox<T>
where
    T: Unsize<U> + ?Sized,
    U: ?Sized,
{}

这个强制转换在内存层面做的事情是:将薄指针 *mut Concrete 扩展为胖指针 *mut dyn Trait,同时注入虚表指针。

8.12 完整示例:从源码到内存

让我们用一个完整的例子,追踪从 Rust 源码到最终内存布局的全过程。

rust 复制代码
trait Logger {
    fn log(&self, msg: &str);
    fn flush(&self);
    fn level(&self) -> u8 where Self: Sized; // 不进入虚表
}

trait Formatter: Logger {
    fn format(&self, msg: &str) -> String;
}

struct ConsoleLogger {
    prefix: String,  // 24 字节 (ptr + len + cap)
    verbose: bool,   // 1 字节 + 7 字节 padding
}
// size = 32, align = 8

impl Logger for ConsoleLogger {
    fn log(&self, msg: &str) {
        println!("[{}] {}", self.prefix, msg);
    }
    fn flush(&self) {
        // no-op for console
    }
    fn level(&self) -> u8 { if self.verbose { 0 } else { 1 } }
}

impl Formatter for ConsoleLogger {
    fn format(&self, msg: &str) -> String {
        format!("[{}] {}", self.prefix, msg)
    }
}

impl Drop for ConsoleLogger {
    fn drop(&mut self) {
        println!("ConsoleLogger dropped");
    }
}

对于 dyn Formatter,编译器生成的虚表布局:

rust 复制代码
继承链: Formatter --> Logger
虚表段: DSA, Logger 的方法, Formatter 的方法

dyn Formatter 的虚表 (ConsoleLogger 实现):
┌───────────────────────────────────────────────────────────┐
│ slot │ VtblEntry                        │ 内容              │
├──────┼──────────────────────────────────┼───────────────────┤
│  0   │ MetadataDropInPlace              │ drop_in_place<ConsoleLogger> │
│  1   │ MetadataSize                     │ 32                │
│  2   │ MetadataAlign                    │ 8                 │
│  3   │ Method(ConsoleLogger::log)       │ → ConsoleLogger::log 函数地址 │
│  4   │ Method(ConsoleLogger::flush)     │ → ConsoleLogger::flush 函数地址 │
│      │ (level 被跳过,因为 Self: Sized) │                   │
│  5   │ Method(ConsoleLogger::format)    │ → ConsoleLogger::format 函数地址 │
└───────────────────────────────────────────────────────────┘
虚表总大小: 6 × 8 = 48 字节

注意 level 方法由于 where Self: Sized 约束而被排除在虚表之外。is_vtable_safe_method 返回 falseown_existential_vtable_entries_iter 过滤掉了它。

使用场景:

rust 复制代码
fn main() {
    let logger = ConsoleLogger {
        prefix: "APP".to_string(),
        verbose: true,
    };

    // 创建 trait object
    let fmt: &dyn Formatter = &logger;

    // 动态分发调用
    fmt.log("hello");     // vtable[3](data_ptr, "hello")
    fmt.flush();          // vtable[4](data_ptr)
    let s = fmt.format("world"); // vtable[5](data_ptr, "world")

    // upcasting: dyn Formatter → dyn Logger(零成本,Logger 是前缀)
    let log: &dyn Logger = fmt;
    log.log("from logger"); // vtable[3](data_ptr, "from logger")

    // 以下调用无法编译:level 要求 Self: Sized
    // log.level(); // 错误!
}

8.13 本章总结

本章深入剖析了 Rust trait object 的完整实现机制。让我们回顾关键要点:

胖指针 是 trait object 的载体。它由 (data_ptr, vtable_ptr) 两个机器字组成,无论 trait 有多少方法、附加多少 marker trait,指针大小始终是 16 字节(64 位平台)。这个设计来自标准库的 Pointee trait 和 DynMetadata 类型。

虚表 是一个编译期生成的静态数组,存储在可执行文件的只读数据段中。它的头部固定为 [drop_in_place, size, align] 三个条目,后面跟随方法指针。编译器通过 VtblEntry 枚举描述虚表的六种可能条目:MetadataDropInPlaceMetadataSizeMetadataAlignVacantMethodTraitVPtr

虚表布局算法 保证第一个超级 trait 的虚表是子 trait 虚表的前缀,使得向第一个超级 trait 的 upcasting 是零成本的。多重继承和菱形继承通过 TraitVPtr 条目处理,每个非前缀超级 trait 需要一个额外的虚表指针。

对象安全规则 (Dyn Compatibility)确保虚表可以在编译期完全构建。核心约束是:trait 不能要求 Self: Sized、方法不能有泛型类型参数、方法的参数和返回值不能直接使用 Self 类型。where Self: Sized 是豁免单个方法的逃生舱口。

性能方面 ,动态分发比静态分发慢 2-5 倍,主要原因是无法内联和间接调用带来的分支预测失败。但在真实场景中,如果方法体本身有一定计算量,这个差距会被稀释。选择 dyn Trait 还是泛型,取决于对灵活性和性能的权衡。

理解了虚表的结构,我们就完成了 Rust 类型系统中最重要的运行时机制。下一章,我们将进入 Rust 编译器最令人惊叹的领域------async/await 如何被展开为状态机,以及 Future trait 的内存布局。

相关推荐
杨艺韬2 小时前
Rust编译器原理-第3章 借用检查器:编译器如何证明内存安全
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第9章 async/await:状态机的编译器变换
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第13章 FFI:与 C 世界的桥梁
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第4章 生命周期:编译器如何推断引用的有效范围
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第7章 Trait 静态分发:零成本抽象的编译器实现
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第18章 设计哲学与架构决策
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第12章 unsafe:安全抽象的逃生舱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第17章 增量编译:让重编译只做必要的事
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第10章 Pin、Waker 与 Future:异步运行时的三大支柱
rust·编译器