在 Rust
编程中,实现多态(Polymorphism)主要有两种核心机制:Trait
Bound 和 Trait Object。虽然两者都基于
trait
,但它们的设计理念、底层实现和适用场景却截然不同。本文将带你从概念到具体的内存布局,深入探究这两种多态方式的本质。
1. 从一个基本问题说起
设想我们有一个
trait Draw
,它定义了绘制的方法。Square
结构体实现了这个 trait
。
1 | trait Draw { |
现在,我们如何编写一个函数来处理 Square
,并调用它的
bounds
方法呢?这就是 Trait Bound 和
Trait Object 登场的时机。
2. Trait Bound:编译期的静态多态
Trait Bound
的核心思想是编译期特化(Monomorphization)。它通过泛型参数
T
来约束类型,确保该类型实现了某个 trait
。
1 | fn print_bounds<T: Draw>(item: T) { |
底层原理:静态分发(Static Dispatch)
在编译时,编译器会为 Square 类型生成一份 print_bounds 函数的独立代码。当调用 print_bounds(square) 时,程序直接调用为 Square 特化的版本,无需在运行时查找。
优点与缺点
- 零运行时开销:性能极致,与直接调用具体函数无异。
- 代码膨胀(Code
Bloat):如果有很多不同的类型都实现了
Draw
,编译器就会生成多份print_bounds
的代码。 - 语法糖:
fn print_bounds(item: impl Draw)
是fn print_bounds<T: Draw>(item: T)
的语法糖,两者在底层实现和性能上是完全等价的。
3. Trait Object:运行时的动态多态
现在,我们面临一个新问题:如果想把不同类型但都可绘制的对象放入同一个
Vec
集合中怎么办?例如,我们有一个 Square
和一个 Circle
(假设 Circle
也实现了
Draw
),我们不能直接
vec![square, circle]
,因为 Vec
要求所有元素是同一种具体类型。
Trait Object 的核心思想是类型擦除(Type
Erasure),它允许我们将实现了相同 trait
的不同类型实例统一处理。
1 | // 假设 Circle 也实现了 Draw trait |
底层原理:动态分发(Dynamic Dispatch)
Box<dyn Draw> 是一个胖指针(Fat Pointer)。它包含两个部分:
- 数据指针:指向堆上实际的对象(例如
Square
实例)。 - 虚表指针:指向一张静态生成的虚函数表(vtable)。
当调用 draw_object.bounds()
时,程序会在运行时通过胖指针找到虚表,再从虚表中找到正确的方法地址并执行。

上图展示了 &dyn Draw
这个 trait object
的内存布局:
栈(Stack):
square
:原始的Square
实例,其数据(top_left.x
,top_left.y
,size
)直接存储在栈上,大小在编译时可知。draw
:这是一个&dyn Draw
类型的 胖指针。它也存储在栈上,但其大小是固定的(两个指针的大小,通常是 16 字节在 64 位系统上)。- 胖指针的第一个部分指向
square
实例的实际数据地址。 - 胖指针的第二个部分指向
Draw for Square vtable
。
- 胖指针的第一个部分指向
虚表(Vtable):
Draw for Square vtable
:这是一个在编译时为Square
类型和Draw
trait
的组合而生成的静态只读表。它包含了Square
实现Draw
trait
所需的所有信息,其中最重要的是Square::bounds()
方法的实际内存地址。
通过 draw
胖指针调用 draw.bounds()
时,Rust
运行时会:
- 读取
draw
胖指针中的虚表指针。 - 通过虚表指针找到
Draw for Square vtable
。 - 从虚表中找到
bounds()
方法的地址(即Square::bounds()
的地址)。 - 调用该地址处的函数,并将胖指针中的数据指针作为
self
参数传递。
虚表是与类型-trait 组合绑定的,而不是与实例绑定的。 无论有多少个
&dyn Draw
类型的胖指针,只要它们都引用同一个Square
实例,或者不同的Square
实例,它们的虚表指针都会指向同一张静态生成的Draw for Square vtable
。虚表是全局唯一的,为每种类型-trait 组合只生成一份。
4. 复杂场景下的内存布局:组合 Trait Object
当 trait object
组合多个 trait
时,比如
&dyn Draw + Shape
,底层机制会更加精巧。
- 单 Trait Object:
&dyn Draw
和&dyn Shape
是两个独立的胖指针,分别指向为Square
-Draw
和Square
-Shape
组合生成的独立虚表。 - 组合 Trait
Object:
&dyn Draw + Shape
是一个单一的胖指针。它指向一张包含了所有组合trait
方法地址的联合虚表。
假如说我们定义的 Shape
trait 如下:
1 | /// Anything that implements `Shape` must also implement `Draw`. |
现有如下代码:
1 | let square = Square { |

栈(Stack):
square
:原始Square
实例,不变。draw
:&dyn Draw
胖指针,指向Square
数据和Draw for Square vtable
。shape
:这是一个新的、独立的&dyn Shape
胖指针。它同样指向Square
数据,但其虚表指针指向的是Shape for Square vtable
。
虚表(Vtable):
Draw for Square vtable
:为Square
和Draw
组合生成的虚表,它包含了bounds()
方法的指针。Shape for Square vtable
:为Square
和Shape
组合生成的另一个独立的虚表。它包含了Square::render_in()
、Square::bounds()
和Shape::render()
方法的地址。
总结:如果你有多个独立的
trait object
类型(如&dyn Draw
和&dyn Shape
),即使它们引用的是同一个底层数据,它们各自的胖指针也会指向各自独立的虚表。
5. Trait Object 的安全约束
为了在实现动态多态的同时保证内存安全,Rust 对 trait object 施加了严格的限制:
Sized
约束:dyn Trait
是一个 DST,其大小在编译时未知。因此,它必须通过指针(&
、Box
、Rc
、Arc
等)引用。- 方法限制:
trait object
的trait
方法不能是泛型方法,也不能返回Self
。这是因为编译器无法为泛型方法生成虚表条目,也无法确定返回Self
的返回值大小。例如,Clone
trait
因为其clone
方法返回Self
,所以不能直接作为trait object
。 - 生命周期:
trait object
的生命周期会与它所引用的数据的生命周期绑定,防止悬空指针(use-after-free
)问题。
总结
特性 | Trait Bound (泛型) | Trait Object (动态) |
---|---|---|
多态类型 | 静态多态 | 动态多态 |
分发方式 | 静态分发 (编译时) | 动态分发 (运行时) |
性能开销 | 零开销 | 轻微开销 (虚表查找) |
底层原理 | 编译期特化 | 类型擦除 + 胖指针/虚表 |
大小类型 | Sized |
Unsized (必须通过指针引用) |
典型应用 | 极致性能、类型已知 | 异构集合、插件化、通用接口 |