TraitRust 最强大的特性之一,既是类型系统的支柱,也是泛型约束的核心机制。它大致有四大功能:抽象接口、泛型约束、抽象类型、标签。本博客聚焦于抽象类型。


抽象类型

Trait 对象

在很多其它语言中,都有多态的概念。其中最灵活的就是面向对象范式下,父类型可以接收子类型做参数,从而实现一个函数在不同场景下表现不同行为。

比如说,Duck可以游泳,Person可以游泳,Dog也可以游泳,它们都有swim方法。那么面向对象中可以让这三个类都去继承一个Swimmer类,在函数参数中接收Swimmer的指针,实际上你可以传入任意它的子类型。

那么Rust是否有这样的语法?Rust本身不支持面向对象,但是它同样提供了类似的机制,这个机制就是Trait对象。

Trait对象是将拥有相同行为的类型集合,抽象为一个新类型

比如说DuckPersonDog,这三个类型都实现了Swimmer特征,此时你就可以用dyn Swimmer类型作为参数,它可以接收任意实现了Swimmer特征的类型。

此处的dyn Swimmer就是一个Trait对象。

Rust 中,如果你希望一个函数参数能够接受任意实现了某个 Trait 的类型,可以使用 Trait 对象。Trait 对象的语法是:

dyn TraitName

这个语法可以用于函数参数,函数返回值等位置

现在尝试实现刚才的案例:

trait Swimmer {
    fn swim(&self);
}

struct Duck;
struct Person;
struct Dog;

impl Swimmer for Duck {
    fn swim(&self) {
        println!("Duck paddles through the pond.");
    }
}

impl Swimmer for Person {
    fn swim(&self) {
        println!("Person swims freestyle.");
    }
}

impl Swimmer for Dog {
    fn swim(&self) {
        println!("Dog does the doggy paddle.");
    }
}

首先定义Swimmer特征,以及三个类型,并为三个类型分别实现Swimmer

随后定义一个函数,接收dyn Swimmer

fn perform_swim(swimmer: &dyn Swimmer) {
    swimmer.swim();
}

此处的swimmer参数就是一个Trait对象,尝试调用:

let duck = Duck;
let person = Person;
let dog = Dog;

perform_swim(&duck);
perform_swim(&person);
perform_swim(&dog);

以上代码可以成功运行,这就是Trait对象的最基本用法。

但是这里有一个小细节,我接受Trait对象的时候,使用了借用&dyn Swimmer,而不是直接接收,因为Trait对象必须基于借用、指针、智能指针来间接使用。如果你之前学的还不错,那应该想到了,这里和动态大小类型有关系。

对于一个Trait来说,往往可以被多种类型实现,而实现它的各种类型之间,大小可能是不同的。

比如说实现Swimmer的类型中,Person可能有agenameheight等等属性,而Duckcolor之类的属性,这些属性就会导致PersonDuck两个类型占用的内存大小不同。

对于一个dyn Swimmer来说,它可能接收到的是任意实现它的类型,在运行前无法确定具体是哪一个,只能保证这个类型实现了Swimmer而已,因此dyn Swimmer可以视为一种动态大小类型。

动态大小类型不能直接在函数中使用,之前说过函数的栈帧大小在编译期就要确定,因此会通过一个指针来间接操作动态大小类型,比如借用、智能指针等等。

这是非常重要的知识点:

Trait对象必须基于指针间接使用


虚表

指向Trait对象的指针是一个胖指针,它包含两部分:datav-ptr

在这里插入图片描述

如图所示,这就是整个Trait对象的内存布局。在栈区存储的是一个借用指针,胖指针内包含datav-ptr,这两个成员也是指针。data指向了堆区的Self,这是实现了这个Trait的具体类型,而v-ptr指向一个叫做v-table的表。

此处的v-table叫做虚表,v-ptr叫做虚表指针,这种叫法来源于C++。在虚表内,存储了关于Self的众多信息:

  • 析构函数:回收内存时,可以调用这个函数回收Self的资源,避免内存泄露,这个会在后续博客详细讲解。
  • sizeSelf占用内存字节数
  • 对齐Self的对齐数,这涉及到内存的分配
  • 方法Self实现的这个Trait的方法列表

举一个切实的例子:

trait Demo {
    fn func1(&self);
    fn func2(&self);
    fn func3(&self);
}

struct Type1 {
    x: u8,
    y: u32,
    z: [u8; 7],
}

#[repr(align(16))]
struct Type2 {
    p: u64,
    q: u8,
    r: [u64; 2],
}

impl Demo for Type1 {
    fn func1(&self) { println!("Type1::func1"); }
    fn func2(&self) { println!("Type1::func2"); }
    fn func3(&self) { println!("Type1::func3"); }
}

impl Demo for Type2 {
    fn func1(&self) { println!("Type2::func1"); }
    fn func2(&self) { println!("Type2::func2"); }
    fn func3(&self) { println!("Type2::func3"); }
}

现有一个Demo特征,Type1Type2两个类型,并且type2强制对齐数为16,随后分别为两个类型实现了Demo

两个Trait对象的内存布局如下:

在这里插入图片描述

左侧的Type1的作为Trait对象的布局,右侧是Type2的。

首先data指针指向了各自堆区上的Self,这很好理解,基于这个指针可以访问到结构体内部的成员。

v-ptr指向了v-table后,从上往下是析构函数的地址,大小,对齐数,以及方法列表。

Type1的大小是12 byte,对齐数是4Type2的大小是32,对齐数是16,这个可以自行验证,都被放在了v-table中。

最后是Demo中的三个方法func1func2func3。不同类型的方法实现不同,那么它们的函数地址就不同,因此左右两个列表中,对应位置的函数地址就是不同的。

在本博客的最后,我会带着大家尝试验证这个布局是否正确,这有不小的难度,以及部分超纲知识。

有了v-ptrdataTrait对象在调用方法的时候,就很容易的可以进行调用和传参。

例如:

fn process_func(demo: &dyn Demo) {
    demo.func1();
    demo.func2();
    demo.func3();
}

这个函数分别调用了demo的三个方法,问题是我编译期无法确定运行时demo具体是谁啊?我怎么知道调用哪一个类型的函数?此时v-ptr就发挥作用了。

func2为例,回看刚才的布局图,你会发现不论v-ptr指向谁,在v-table中函数的布局顺序是固定的。此处的v-table是一个数组,而func2就在数组的第五个元素。不论谁实现了Demo这个Trait,最后func2一定是v-ptr[4]这个元素。

注:此处v-ptr[4]的语法,其实类似于C语言中的数组,数组名的本质是一个指针,arr[4]等效*(arr + 4),这是一个指针先偏移,后解引用的过程。如果你没有这方面的基础,直接认为 v-ptr[4] 就是通过指针拿到数组的第五个元素即可。

那么demo.func2()会在编译期转化为以下等效的伪代码:

let (data, vptr) = demo; // 拆胖指针
let f = vptr[4];         // 偏移量 4 是 func2 的槽位
f(data);                 // 调用

首先从demo这个胖指针中拿出v-ptr,随后通过v-ptr[4]拿到func2的函数地址,最后data作为self传进函数中,并完成函数调用。

也就是说这个函数调用的过程,本质是不同类型传入的v-ptr不同,但是虚表的结构是相同的。因此不论v-ptr是哪一个类型的,都去拿表中指定偏移量位置的值,取出来做函数调用。

这个过程发生在运行时,也就是在运行时才能确定要调用的函数地址。这个过程需要查表传参,因此运行时效率会有所降低,对应的灵活性会提高。

这个运行时基于虚表和偏移量查找函数并调用的过程,称为动态分发。而动态的英文是dynamic,因此Trait对象使用了dyn这个关键字。在早期的Rust中没有dyn这个关键字,比如& Swimmer直接就是一个Trait对象,现在必须使用dyn来声明了。

最后,同一个类型的所有实例共用一张v-table,因为虚表内部的信息是完全固定的,整个程序运行期间都不会发生变化


Trait 对象约束

Trait对象中,也可以对这个对象进行约束。

语法:

dyn Trait_A + Trait_B + Trait_C ...

dyn后面,用+连接多个Trait

这代表:这是一个Trait_A Trait对象,并要求改对象的具体类型同时实现了Trait_BTrait_C

要注意的是,"这是一个Trait_A Trait对象"这句话。这意味着只有第一个Trait的方法会进入虚表,而后续的Trait只用于约束类型

例如:

trait Foo {
    fn foo(&self);
}

struct S;

impl Foo for S {
    fn foo(&self) { println!("Foo::foo()!"); }
}

fn call_foo(x: &dyn Foo + Debug) { x.foo(); }

此处的call_foo中,x参数类型为 &dyn Foo + Debug,这代表它是一个Foo的对象,但是必须实现Debug这个Trait

但如果你想要让某个Trait对象,虚表中同时拥有多个Trait的方法,那就不适合这种约束,而是要用一个Trait去继承所有Trait


dyn-compatible

并不是所有的Trait都可以作为Trait对象使用,只有符合要求的才行。

具体要求参考官方文档:Traits - The Rust Reference

Trait对象必须符合以下要求:

  1. 所有的 Super Trait 也必须是 dyn-compatible
  2. TraitSelf类型参数不能被限定为Sized(不能继承Sized
  3. 不能有任何关联常量
  4. 所有函数必须要么是可动态分发的函数,要么显式标记为不可调用
  5. 不能有带泛型的关联类型

如果符合以上要求,就称为 dyn-compatible,就可以被作为 Trait对象使用。

接下来分点解释。


Sized

TraitSelf类型参数不能被限定为Sized

Sized是一个原生的Trait,它表示一个类型的大小是固定的。与之对应的是Unsize,表示动态大小类型。

绝大部分Trait默认就是?Sized,它表示不限制Self的大小,既可以是Sized也可以是Unsize

你可以显式的指定:

trait Demo: Sized {}

这个Demo继承了Sized,那么它就无法成为一个Trait对象。

为什么不能继承Sized?在Rust官方文档有这样一句话:

Trait objects implement the base trait, its auto traits, and any supertraits of the base trait
译:特征对象会实现它的基 Trait、它的 auto trait,以及所有的 Super Trait

这句话来自:Trait object types - The Rust Reference

简单来说,特征对象会实现它自己对应的Trait继承的所有Trait

例如:

trait Demo: Display + Debug {
	fn func();
}

这是一个Trait,它继承了DisplayDebug,这两个算作Super trait。它还隐式的继承了?Sized,这属于auto trait

那么对应的特征对象dyn Demo作为一个类型,它也会自动实现DisplayDebug?SizedTrait,以及它自己Demo

你有没有想过一个问题,动态分发的时候,为什么Trait对象可以随意的调用Trait内部的方法:

fn process_func(demo: &dyn Demo) {
    demo.func();
}

dyn Demo自己就是一个类型,它想要调用Demo这个Trait内部的func方法,它自己就必须实现Demo这个Trait,并满足所有Self的约束,因为此时dyn Demo本身就是Self。而这个过程,已经由Rust编译期自动帮你实现了。

假设Demo受到Size约束:

trait Demo: Sized {}

也就是Self收到Sized约束,如果需要Trait 对象,那么dyn Demo就要受到Sized约束。问题在于,dyn Demo是一个动态大小类型,它根本不可能有确定的大小,根本不可能受到Sized约束

因此当一个Trait收到Sized约束,它不能使用Trait对象。


关联常量

不能有任何关联常量

不同实现者可以给同一个关联常量不同的值。

trait Foo {
	const A: i32;
}

impl Foo for u8  { const A: i32 = 1; }
impl Foo for u16 { const A: i32 = 2; }

如果你有一个 Box<dyn Foo>,那么问题来了: dyn Foo::A 应该是 1 还是 2

在方法调用时,编译器可以通过 v-table 动态分发到正确的函数指针,但常量不是函数调用,无法通过v-table动态查找一个常量的值。所以编译器无法在运行时决定该取哪个值。

所以Trait对象要禁止关联常量,它无法进行动态分发


可动态分发的函数

所有函数必须要么是可动态分发的函数,要么显式标记为不可调用

这里对于 “可动态分发的函数” 的定义又是一个非常复杂的条例,我们已经知道Trait对象会维护一张虚表,内部会存放元信息和众多方法地址。但并不是Trait内部所有的方法都会进入虚表,只有可通过Trait对象动态分发的方法才会进入虚表

可动态分发的函数必须满足:

  1. 不能有泛型参数
  2. 必须使用Self或者可解引用为Self的类型作为第一个参数
  3. 函数签名中,不能在第一个参数以外的位置使用Self
  4. 返回值不能是不透明类型

如果不能满足以上条件,必须显式标记为不可调用

分点一个一个讲解:

  1. 不能有泛型参数

当一个函数带有泛型参数,那么经过单态化后,它就可能派生出无数种具体实现。我们先前说过,动态分发的基础是v-ptr以及指定的偏移量,而这个又建立在同一个Trait所有类型的v-table格式是一模一样的基础上。

当使用泛型参数后,首先无法预知会单态化出多少个版本的函数,那么无论v-table有多大,都装不下这么多函数地址。其次,不同类型单态化出的版本不同,那么每个v-table格式就不一样了,这会撼动整个动态分发体系的根基。

  1. 必须使用Self或者可解引用为Self的类型作为第一个参数

动态分发过程中,从虚表拿到函数地址后,会把data作为第一个参数传入,如果函数第一个参数不是Self或者Self的指针,此时参数传递就会出错,直接错误。因此可动态分发的函数必须有Self作为第一个参数。

  1. 函数签名中,不能在第一个参数以外的位置使用Self

此处的第一个参数外,包括从第二个参数往后的所有参数,以及函数返回值都不能用Self

这个点同样从两个方面来理解。

假设有一个违背以上条件的方法,函数签名是func(&self, other: Self) -> Self

Trait对象本身就是一个实现了自己的Base Trait的类型,那么这个方法也会被Trait对象自己所实现。对于第一个参数,Trait对象会做特殊处理,最后传入一个data表示具体类型。请问第二个参数Self怎么办?它不会被特殊处理,此时Self就是Trait对象自己,是一个动态大小类型。怎么可以把一个动态大小类型放到函数的栈区?因此这条路走不通,Trait对象根本无法实现自己的Base Trait

另一个方面,假设某个函数使用动态分发调用了这个方法:

fn process_func(d: &dyn Trait) {
	let arg; // ?
	let ret = d.func(arg); // ?
}

此处我打了两个问号,d.func(arg)这个动态分发调用,接收一个Self参数,返回一个Self参数,在process_func内,Self = dyn Trait,这又是一个动态大小类型被直接放到了栈区。

  1. 返回值不能是不透明类型

这个会在一会讲解impl Trait时讲到。

只要满足以上四点,就是一个可动态分发的函数,它的地址就会进入虚表,从而实现动态分发。

如果说某个函数违背了以上要求,那么你必须显式标记为不可调用,最常见的手法是在方法中限制Self: Sized,这样这个函数就不会进入虚表

但是如果某个函数违背了四条规则之一,并且还没有显式标注不可调用,那么整个Trait都将无法动态分发了

例如:

trait Demo {
    fn demo(&self);
    fn func(&self, other: Self) -> Self;
}

struct Type1;

impl Demo for Type1 {
    fn demo(&self) {
        println!("Type1 demo");
    }

    fn func(&self, other: Type1) -> Type1 {
        println!("Type1 func");
        other
    }
}

fn process_demo(d: &dyn Demo) {
    d.demo();
}

Demo中,它的demo函数是一个可动态分发的函数,满足所有要求。而func函数在其他位置出现了Self,它不可动态分发,还没有显式标注不可调用。那么整个Demo都无法作为Trait对象使用,process_demo中就算没有调用func方法,d: &dyn Demo这个语法也会报错。

想要解决,只需要在func中对Self进行限制:

trait Demo {
    fn demo(&self);
    fn func(&self, other: Self) -> Self
    where Self: Sized; // 添加 Trait 约束
}

struct Type1;

impl Demo for Type1 {
    fn demo(&self) {
        println!("Type1 demo");
    }

    fn func(&self, other: Type1) -> Type1
    where Self: Sized {  // 添加 Trait 约束
        println!("Type1 func");
        other
    }
}

fn process_demo(d: &dyn Demo) {
    d.demo();
}

在以上代码中的两处位置,添加了Sized约束,此时这个func函数就不会进入虚表,最后demo也就可以正常调用了。


泛型关联类型

不能有带泛型的关联类型

在理解完刚才的一点后,其实这个就很好理解了。关联类型往往会在对应的方法中进行使用,用户每指定一个泛型作为关联类型,就会单态化出一个新版的函数,最后导致v-table大小不确定,格式不一致,和之前不能有泛型参数是一个逻辑


总结

看到这里你估计已经头昏眼花了,我写的也快头昏眼花了。我把每个要点都拆出来理清了内部的逻辑,最后总结一下所有规则。

dyn-compatible的要求有:

  1. 所有的 Super Trait 也必须是 dyn-compatible
  2. TraitSelf类型参数不能被限定为Sized(不能继承Sized
  3. 不能有任何关联常量
  4. 所有函数必须要么是可动态分发的函数,要么显式标记为不可调用。可动态分发的函数必须满足:
    • 不能有泛型参数
    • 必须使用Self或者可解引用为Self的类型作为第一个参数
    • 函数签名中,不能在第一个参数以外的位置使用Self
    • 返回值不能是不透明类型
  5. 不能有带泛型的关联类型

impl Trait

Rust 2018,又引入了一个新特性impl Trait。它也属于抽象类型,并且是静态分发的。它和Trait对象属于相反的特性。

Trait对象中,基于指针把类型放到堆上管理,称为装箱。而impl Trait则会把值放到栈区,称为拆箱。前者是运行时动态分发,后者是编译期静态分发。

impl Trait用于描述实现了某个Trait的类型,它可以放在函数参数和返回值中。但是不能用作 let 绑定、结构体字段类型或类型别名等位置。

函数参数

语法:

fn func(arg: impl Trait_1 + Trait_2);

此处的arg参数,就是一个受到约束的类型,它必须是实现了Trait_1Trait_2的类型。

其实它等效与以下代码:

fn func<T: Trait_1 + Trait_2>(arg: T);

也就是之前的泛型约束,它们两个几乎是完全等价的。只是impl Trait语法下,不用显式写出泛型,更简洁。

它们也有相同的编译期单态化机制,当func函数传入多少种参数,最后就会单态化出多少实例。

但不同的是,泛型可以由用户自己指定,而impl Trait只能通过编译器自行推导。


返回值

语法:

fn func() -> impl Trait_1 + Trait_2;

此时func函数限制了返回的类型必须是同时实现了Trait_1Trait_2的类型。

与泛型不同的是,泛型由调用方决定具体类型,而impl Trait是在函数内部自己决定返回类型的。

例如:

fn make_number() -> impl std::fmt::Display {
    42
}

最终返回的是一个i32类型,它实现了std::fmt::Display

但是这不代表可以在一个函数中返回不同类型。例如:

fn make_number(num: i32) -> impl std::fmt::Display {
	if num > 0 {
		10
	} else {
		"hello"
	}
}

这个代码是错误的,你不能让一个函数返回多种类型。

那有人就有疑问了,既然如此为什么不直接把返回值写死,还要搞个impl Trait语法?

在返回值中impl Trait与其说是一个约束,不如说是一种承诺,承诺我的返回值一定是实现了某些Trait的类型。

当外部调用这个函数,拿到一个类型后。用户不必关心类型的具体值,只要知道它实现了某些Trait,并且可以使用这个Trait就好。

你可以尝试运行以下代码:

fn make_number() -> impl std::fmt::Display {
    42
}

let mut num = make_number();

println!("{}", num);
num += 10;

这行代码会报错,因为实现了Display的类型不一定可以使用+=。即便num真的是一个i32类型,但是在外层调用时,编译器认为num相当于一个impl Display的类型,你只能使用在Display范围内的方法。

甚至编译器不允许你去指定类型:

let mut num: impl Display = make_number();
let mut num: i32 = make_number();

这两个写法都是错误的,你既不能直接判断这是一个i32,甚至你不能说它是一个impl Display,只能让编译器自己去推出它是一个impl Display

这种类型就是刚才说的不透明类型,你只知道这个类型具有某些特征,但不会让你知道这个类型具体是什么,从而实现封装。

在后续学习中,比如闭包这一块内容,你很难去写出一个类型,有一些类型是编译器生成的匿名类型,非常复杂。但是你只要知道这个类型实现了某些Trait,然后用impl Trait语法来使用它,到时候才能感受到这个语法的实际作用。

现在的主要作用,可以理解为部分场景下可以简化泛型,提高代码可读性。


多态

如果你有其它语言的学习经验,可能常常听说多态,而多态分为静态多态和动态多态。这可以说是计算机界的元老级别八股文了,面试常考。

但是作为多范式语言,我希望基于Rust带大家理解更加广泛的多态体系,而不是以简单的动静区分。

在类型系统里,多态性通常分为三类:

  • 参数多态(Parametric Polymorphism):属于静态多态,函数或类型对任意类型参数都能工作,不依赖具体实现。
    • Rust的泛型,impl trait
    • C++的模板
  • 特设多态(Ad-hoc Polymorphism):属于静态多态,同一个函数名在不同类型上有不同实现。
    • RustTrait
    • Haskelltypeclass
    • C++函数重载
  • 子类型多态(Subtype Polymorphism):属于动态多态,面向对象语言里常见,子类可以替代父类使用。
    • RustTrait对象
    • C++ 的虚函数
    • Java的继承

参数多态

通过 泛型 或 impl Trait 实现,编译期静态分发。

例如:

// 泛型写法
fn swap<T>(a: &mut T, b: &mut T) {
    std::mem::swap(a, b);
}

// impl Trait 写法
fn print_debug(x: impl std::fmt::Debug) {
    println!("{:?}", x);
}

这种多态在编译期可以传入多种类型,并且每种类型最后的实现都是相同的,根据调用来进行单态化,从而产生符合各个类型的函数版本。


特设多态

通过 trait 为不同类型提供不同实现,本质就是函数重载。

trait Hello {
    fn hello(&self);
}

impl Hello for i32 {
    fn hello(&self) {
        println!("i32 says hello: {}", self);
    }
}

impl Hello for String {
    fn hello(&self) {
        println!("String says hello: {}", self);
    }
}

虽然Rust没有提供直接的函数重载,但是Trait可以看成一种函数重载

比如Stringi32都能调用hello这个函数,可以理解为在hello这个同名函数中,你可以给第一个参数传入不同的self,从而调用到不同版本的函数。函数名相同,而根据不同参数来选择版本,这其实就是函数重载。


子类型多态

通过 trait object 实现,运行时动态分发。

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { r: f64 }
struct Square { a: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { 3.14 * self.r * self.r }
}

impl Shape for Square {
    fn area(&self) -> f64 { self.a * self.a }
}

fn print_area(s: &dyn Shape) {
    println!("area = {}", s.area());
}

Rust是一个混合范式语言,而不是一个面向对象语言,但是Rust汲取了面向对象的核心思想,把他们最重要的一些理论抽出来,做符合Rust的表达。

Rust中,几乎没有别的位置有对象这样的OOP风格表述。唯独dyn Trait这里使用了特征对象的表述,就是因为这里融合了非常类似于面向对象的多态思想。并且是以C++风格为主的多态思想。

你也许困惑为什么要叫虚表,为什么叫虚指针,为什么叫析构函数。对Rust来说,其实就是从C++中借鉴后,做出了一定的机制修改。

这里可以理解为Trait Object是一种父类型,而所有实现了Trait的类型都是子类型。父类型的引用或者指针,可以接收子类型的实例作为参数,然后运行时动态调用方法。因此 dyn Trait 从思想上就是一个子类型多态。

面向对象三大思想继承多态封装

首先Rust舍弃了继承,采用组合优先的思想。但是也有Trait继承这样的语法,以及刚才说的 dyn Trait 融合了继承的思想在里面。

在多态中,Rust有丰富全面的多态体系,你可以舍弃性能换取灵活性,你也可以舍弃灵活性以获取最高的性能,这一切都基于强大的Trait体系,它的知识点最庞杂。

对于封装来说,Rust有合理的函数封装体系,pub控制可见性,这一块知识点也会在后续讲解。

可见的是,Rust虽然不支持面向对象,但是其最核心,最精华的思想都已被Rust收入囊中。


验证虚表 与 模拟动态分发

博客的最后,如果你有兴趣,可以一起往下用代码验证虚表的存在。

使用的案例是之前的Demo

trait Demo {
    fn func1(&self);
    fn func2(&self);
    fn func3(&self);
}

struct Type1 {
    x: u8,
    y: u32,
    z: [u8; 7],
}

#[repr(align(16))]
struct Type2 {
    p: u64,
    q: u8,
    r: [u64; 2],
}

impl Demo for Type1 {
    fn func1(&self) { println!("Type1::func1 调用完毕"); }
    fn func2(&self) { println!("Type1::func2 调用完毕"); }
    fn func3(&self) { println!("Type1::func3 调用完毕"); }
}

impl Demo for Type2 {
    fn func1(&self) { println!("Type2::func1 调用完毕"); }
    fn func2(&self) { println!("Type2::func2 调用完毕"); }
    fn func3(&self) { println!("Type2::func3 调用完毕"); }
}
  • 输出真实信息:
// 打印真实元信息
fn print_real_info<T: Demo>() {
    let f1: fn(&T) = T::func1;
    let f2: fn(&T) = T::func2;
    let f3: fn(&T) = T::func3;

    println!("=== 真实信息 ===");
    println!("size: {} align: {}", size_of::<T>(), align_of::<T>());
    println!("func1: {:p}", f1 as *const ());
    println!("func2: {:p}", f2 as *const ());
    println!("func3: {:p}", f3 as *const ());
}

这是第一个函数,用于输出某个类型的真实信息,包括size,对齐数,以及三个函数的地址。其中fn (&T)是一个函数指针类型,会在后续博客深入讲解。使用{:p}就可以输出这个指针指向的具体地址,也就是函数的地址。

  • 输出虚表信息:
unsafe fn print_vtable_info<T: Demo>(obj: &dyn Demo) {
	// 拆解胖指针
    let raw = obj as *const dyn Demo;
    let (data, vptr): (*const (), *const ()) = transmute(raw);
    let data: &T = transmute(data);
    let vt = vptr as *const *const ();
	
	// ...
}

首先定义一个 print_vtable_info 函数,它被unsafe修饰,表示内部会进行一些不安全操作。接收一个&dym Demo,这是一个胖指针,函数开头先把胖指针中的datavtable拆解出来。

let raw = obj as *const dyn Demo;

这行代码的意思是把obj转化为一个原生指针,*const是一种原生指针,会在后续博客讲解,基于这种指针可以进行一些类似C语言的不安全操作。*const后面接着的是它具体指向的类型,*const dyn Demo就是一个指向Demo特征对象的指针。

let (data, vptr): (*const (), *const ()) = transmute(raw);

这里用到了一个transmute方法,它类似于强制类型转化,强行把raw内部的信息解释为(*const (), *const ())这个类型。而*const ()是指向一个单元类型的原生指针,可以理解为C语言中的void*。也就是说这里我不在乎它具体指向什么类型,我只是要把两个指针拿出来而已。

let data: &T = transmute(data);

这里也是基于 transmute,把data转化为&T这个类型,也就是&self,后续才能正常传参。

let vt = vptr as *const *const ();

这行代码是通过v-ptr拿到虚函数表,而*const *const ()可以看作一个二级指针,它指向的是*const ()的指针。

为什么这里要用二级指针?可以看以下布局:

在这里插入图片描述

我把所有变量名都变为了具体的数值,0x开头的是十六进制的地址。

一开始我们从胖指针中拆出了v-ptr,它的值就是整个v-table的起始地址0x11223340。对它直接解引用,拿到的就是0x66778899这个地址,也就是析构函数的地址。但是目前它是*const ()类型,解引用拿到的是一个单元类型,而我们期望解引用之后还是一个指针。因此把他转为*const *const ()类型。此时对其解引用,拿到的就是*const ()类型,还是一个指针,那么0x66778899这个地址就会被解释为一个指针,方便我们进行后续操作了。(这地方真挺绕的,有C/C++指针经验的同学会更好理解)。

这个过程我们只是修改它的类型,从而保证解引用后拿到的值会被解析为一个指针。

拆完胖指针,接下来基于vt提取出重要信息:

unsafe fn print_vtable_info<T: Demo>(obj: &dyn Demo) {
    // 拆胖指针

    // 提取信息
    let drop_fn: unsafe fn(*mut ()) = transmute(*vt.add(0));
    let size = *vt.add(1) as usize;
    let align = *vt.add(2) as usize;

    let f1: fn (&T) = transmute(*vt.add(3));
    let f2: fn (&T) = transmute(*vt.add(4));
    let f3: fn (&T) = transmute(*vt.add(5));
    
    // ...
}

这部分将函数地址和元信息都从虚表中提取了出来。

let drop_fn: unsafe fn(*mut ()) = transmute(*vt.add(0));

这里提取出析构函数的地址。*vt.add(0)中,vt本身已经指向第一个元素,add(0)不修改指针大小,随后对指针解引用,拿到了一个*const ()的指针。基于 transmute 函数,强行把这个指针的类型转化为 unsafe fn(*mut ()),这是一个函数指针,这里不讲解为什么是这样的格式,你只要知道drop_fn现在是一个指向析构函数的指针就好了。

let size = *vt.add(1) as usize;
let align = *vt.add(2) as usize;

这里提取出虚表中的size字段,也就是第二个元素。*vt.add(1),先把vt的指针往后偏移一个元素的位置,此时它指向第二个元素,再对其解引用,就拿到了第二个元素的值。但是类型是*const (),于是使用as usize强行把它转换为一个数字,这样就拿到了size

下一行的align同理。

let f1: fn (&T) = transmute(*vt.add(3));
let f2: fn (&T) = transmute(*vt.add(4));
let f3: fn (&T) = transmute(*vt.add(5));

这里是拆出三个指针,实际上是三个函数指针,类型是 fn (&T)。通过*vt.add(偏移量)拿到对应偏移量的值,并通过 transmute 强制转化为函数指针类型。

到此为止所有有难度的内容都完成了,最后输出这些信息:

unsafe fn print_vtable_info<T: Demo>(obj: &dyn Demo) {
    // 拆胖指针

    // 提取信息

    println!("\n== 基于虚表调用函数 ==");
    f1(data);
    f2(data);
    f3(data);

    println!("\n== 虚表信息 ==");
    println!("data:   {:p}", data);
    println!("vptr: {:p}", vptr);

    println!("  [元信息]");
    println!("  drop fn: {:p}", drop_fn as *const ());
    println!("  size:    {}", size);
    println!("  align:   {}", align);

    println!("  [函数表]");
    println!("  func1: {:p}", f1);
    println!("  func2: {:p}", f2);
    println!("  func3: {:p}", f3);
}

首先直接拿f1f2f3去调用函数:

println!("\n== 基于虚表调用函数 ==");
f1(data);
f2(data);
f3(data);

这个过程已经是在模拟动态分发了,我们通过v-ptrdata、偏移量,终于凑齐了调用这个函数的所有必要条件,此时可以直接进行函数调用。

后续就是输出各种之前存储的信息,比如元数据,函数表地址等等。

main函数中调用:

fn main() {
    let obj1: Box<dyn Demo> = Box::new(Type1 { x: 1, y: 2, z: [0; 7] });
    let obj2: Box<dyn Demo> = Box::new(Type2 { p: 2, q: 1, r: [0; 2] });

    // Type1
    println!("验证 Type1: ");
    print_real_info::<Type1>();
    unsafe { print_vtable_info::<Type1>(&*obj1); }

    // Type2
    println!("验证 Type2: ");
    print_real_info::<Type2>();
    unsafe { print_vtable_info::<Type2>(&*obj2); }
}

首先定义了两个Trait对象,随后调用print_real_info输出真实信息,再调用 print_vtable_info 输出虚表中的信息。

输出结果:

验证 Type1:
=== 真实信息 ===
size: 12 align: 4
func1: 0x7ff7ad013b40
func2: 0x7ff7ad013b70
func3: 0x7ff7ad013ba0

== 基于虚表调用函数 ==
Type1::func1 调用完毕
Type1::func2 调用完毕
Type1::func3 调用完毕

== 虚表信息 ==
data:   0x20aabc43130
vptr: 0x7ff7ad02bce0
  [元信息]
  drop fn: 0x0
  size:    12
  align:   4
  [函数表]
  func1: 0x7ff7ad013b40
  func2: 0x7ff7ad013b70
  func3: 0x7ff7ad013ba0
验证 Type2:
=== 真实信息 ===
size: 32 align: 16
func1: 0x7ff7ad013bd0
func2: 0x7ff7ad013c00
func3: 0x7ff7ad013c30

== 基于虚表调用函数 ==
Type2::func1 调用完毕
Type2::func2 调用完毕
Type2::func3 调用完毕

== 虚表信息 ==
data:   0x20aabc44200
vptr: 0x7ff7ad02bd10
  [元信息]
  drop fn: 0x0
  size:    32
  align:   16
  [函数表]
  func1: 0x7ff7ad013bd0
  func2: 0x7ff7ad013c00
  func3: 0x7ff7ad013c30

可以看到,真实信息和虚表中提取出来的信息是完全一致的。并且我们手动实现的动态分发也完成了最终的函数调用。

总代码:

use std::mem::{align_of, size_of, transmute};

trait Demo {
    fn func1(&self);
    fn func2(&self);
    fn func3(&self);
}

struct Type1 {
    x: u8,
    y: u32,
    z: [u8; 7],
}

#[repr(align(16))]
struct Type2 {
    p: u64,
    q: u8,
    r: [u64; 2],
}

impl Demo for Type1 {
    fn func1(&self) { println!("Type1::func1 调用完毕"); }
    fn func2(&self) { println!("Type1::func2 调用完毕"); }
    fn func3(&self) { println!("Type1::func3 调用完毕"); }
}

impl Demo for Type2 {
    fn func1(&self) { println!("Type2::func1 调用完毕"); }
    fn func2(&self) { println!("Type2::func2 调用完毕"); }
    fn func3(&self) { println!("Type2::func3 调用完毕"); }
}

// 打印真实元信息
fn print_real_info<T: Demo>() {
    let f1: fn(&T) = T::func1;
    let f2: fn(&T) = T::func2;
    let f3: fn(&T) = T::func3;

    println!("=== 真实信息 ===");
    println!("size: {} align: {}", size_of::<T>(), align_of::<T>());
    println!("func1: {:p}", f1 as *const ());
    println!("func2: {:p}", f2 as *const ());
    println!("func3: {:p}", f3 as *const ());
}

// 打印虚表信息(drop/size/align + vtable 方法地址)
unsafe fn print_vtable_info<T: Demo>(obj: &dyn Demo) {
    // 拆胖指针
    let raw = obj as *const dyn Demo;
    let (data, vptr): (*const (), *const ()) = transmute(raw);
    let data: &T = transmute(data);
    let vt = vptr as *const *const ();

    // 提取信息
    let drop_fn: unsafe fn(*mut ()) = transmute(*vt.add(0));
    let size = *vt.add(1) as usize;
    let align = *vt.add(2) as usize;

    let f1: fn (&T) = transmute(*vt.add(3));
    let f2: fn (&T) = transmute(*vt.add(4));
    let f3: fn (&T) = transmute(*vt.add(5));

    println!("\n== 基于虚表调用函数 ==");
    f1(data);
    f2(data);
    f3(data);

    println!("\n== 虚表信息 ==");
    println!("data:   {:p}", data);
    println!("vptr: {:p}", vptr);

    println!("  [元信息]");
    println!("  drop fn: {:p}", drop_fn as *const ());
    println!("  size:    {}", size);
    println!("  align:   {}", align);

    println!("  [函数表]");
    println!("  func1: {:p}", f1);
    println!("  func2: {:p}", f2);
    println!("  func3: {:p}", f3);
}

fn main() {
    let obj1: Box<dyn Demo> = Box::new(Type1 { x: 1, y: 2, z: [0; 7] });
    let obj2: Box<dyn Demo> = Box::new(Type2 { p: 2, q: 1, r: [0; 2] });

    // Type1
    println!("验证 Type1: ");
    print_real_info::<Type1>();
    unsafe { print_vtable_info::<Type1>(&*obj1); }

    // Type2
    println!("验证 Type2: ");
    print_real_info::<Type2>();
    unsafe { print_vtable_info::<Type2>(&*obj2); }
}

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐