Rust的特征对象之为何要使用动态分发

36 阅读8分钟

我在学习《Rust语言圣经》的特征对象的一章时,这段话引发了我的思考:

  • 当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。

为什么编译器无法知晓可能用于特征对象代码的类型呢?与之相似的泛型为何就没有这个限制呢?

我往后看,作者讲到动态分发要用到虚函数表,我又对这个虚函数表的工作机理产生了思考.这个表是怎么产生的呢,是编译哪一部分代码生成的呢?

根据我学CSAPP还残余的简单的编译知识,我初步思考Rust之所以这么设计是和分开编译有关系,估计编译使用了特征对象的方法的代码和编译类型实现的该特征的方法的具体实现的编译是分开的,所以才需要使用虚函数表等动态分发的方法,因为编译使用了特征对象的方法的函数时,编译器就压根不可能知道这个函数传入的参数到底是什么类型的,毕竟使用者可以随意定义一个实现了该特性的类型.

至于泛型函数为何使用了静态分发呢,作者讲到Rust泛型的实现简单来说就是为每一个泛型参数对应的具体类型生成一份代码,那我估计,这个具体类型的代码的生成是发生在使用泛型函数的代码编译的过程中,而不是泛型函数定义的代码编译的时候.

为了完善我的初步思考,了解Rust到底是怎么实现这一过程的,我问了问谷歌大圣人的AI Studio.结合AI的回答,我慢慢理清了其中具体的过程.至此,我终于可以说,我已经神功大成,王者归来了,哈哈哈!(bushi)

使用了特征对象的方法的函数的编译

//库
pub trait Drawable {
    fn draw(&self); // 假设这是 vtable 中的第 0 个方法
}

pub fn render_item(item: &dyn Drawable) {
    item.draw(); 
}

这里是一个库的代码,对于函数render_item,item是一个特征对象的引用,这个引用其实是包含了两个指针的胖指针,第一个指针指向的是实现了特征 Draw 的具体类型的实例,第二个指针指向的是虚函数表.

在编译库的时候,编译器虽然不清楚实现了该特征的具体对象的类型是什么,但是知道该类型实现的draw方法的所在的内存地址是一定保存在该对象的虚函数表的第0号位的,因为这是特征定义的时候规定的,而编译器这时是肯定知道特征定义的.

所以,即使此时编译器不知道item到底是什么类型,实现的方法在哪里,但他只要知道方法的地址在虚函数表的哪里就可以完成编译.

// my_app
use lib_renderer::{Drawable, render_item};

struct Button { width: u32 }

impl Drawable for Button {
    fn draw(&self) { println!("Drawing button {}", self.width); }
}

fn main() {
    let btn = Button { width: 10 };
    render_item(&btn); // 调用库函数
}

这是使用库函数的应用代码编译器在编译应用代码时,编译器要做三件事:

  1. 编译Button类型实现的Drawable特征中的draw函数生成机器码,放在一个内存地址中
  2. 编译Button的Drawable的实现的同时,生成一个虚函数表,把函数的内存地址存在虚函数表的0号位置,这个虚函数表是绑定在<特征,类型>对上的,每有一个<特征,类型>对,就有一个虚函数表.
  3. 调用render_item,对于此时的编译器来说,这就是一个正常的函数调用,只不过要把btn的引用转化为特征对象的引用罢了,这个引用就包含虚函数表的地址

由此可见,使用了特征对象的函数和正常的函数没有任何区别,只是使用了一种特殊的对特征对象的引用,此外,这个引用实际上完全丢失了他指向的实例的具体类型,他只知道这个引用指向的类型实现了该特征,所以这个引用只能调用该特征定义的方法,原类型的其他方法及字段均无法通过特征对象的引用来调用.

为什么要这么实现

可以这么理解,对于Rust编译器来说,特征对象的引用是一个很明确的类型,他就是一个胖指针,大小和行为都是确定的,所以Rust编译器就可以直接生成对应的机器码,无论传入的特征对象引用指向的是哪一种实现了该特征的类型的实例,我都可以使用同一份机器码来运行.正是因为有动态分发的机制在,有这么一个虚函数表的结构来交互,所以特征对象的引用才能具备以上特征.

item.draw();

具体来说,对于这个方法调用,只有特征对象的引用item的编译器不知道这个引用指向的实例的类型实现的方法的地址在哪里,而且每一种不同的类型其实现的方法的地址都不一样,所以编译器无法像编译其他方法调用一样直接把这个方法的内存地址直接写在调用命令后,那编译器就规定一个规则,类型在实现特征方法的时候把方法的机器码对于的内存地址存在规定的地方,然后规定特征对象的引用能通过一定的方式读取到这个内存地址.这个规则就是动态分发,这个地方就是虚函数表,特征对象的引用能够读取到方法的内存地址的方式就是引用保存了指向虚函数表的指针,编译器通过该指针读取特征对象的引用.

简单来说,编译器通过动态分发,实现了在不知道特征对象的引用指向的实例的具体类型的时候,可以调用该类型实现的特征方法,那这有什么用呢?

  1. 如我举的例子一样,可以实现一个通用的函数,只要实现了该特征的类型都可以调用.但是泛型也可以做到这一点,下面的用处泛型就无法做到了.
  2. 将实现了相同特征的不同的类型的实例存到一个容器中.比如Vec<&dyn Drawable>,所有实现了Drawable的类型都可以存在这里,Vec<&dyn Drawable>实际上存了不同类型的实例的引用.而泛型是做不到这一点的,比如Vec,他只能做到能够创建能保存不同类型的实例的Vec,但是每种Vec只能保存一种类型的实例.事实上,Vec<&dyn Drawable>是Vec的一种实现 3.能够让函数和方法返回不同类型的返回值.如果使用泛型,那函数只能有一种返回值,不能在一个函数中返回多个类型,泛型实际上是为不同的返回值类型创建多个函数.但特征对象可以做到不同的情况返回不同的实现该特征的实例.

泛型的静态分发是怎么做的呢

Rust会为每一个使用泛型的具体参数实现一份对于的代码,泛型定义的地方是没有机器码编译出来的,只有一套模板,只有为泛型赋予具体的类型后,才会为该类型生成一套独立的机器码.这也是为什么Rust的泛型是零成本的抽象,几乎没有任何性能损耗.

感想

实际上这个东西和C++/Java的多态很相似,也和编译的动态链接很像,他们都是同一种思想,就是找个大家都知道的地方存函数地址,C++/Java也都有虚函数表.当时我了解他们的时候,就了解的很粗浅,也因为学习的时候没有深入思考而吃过很多亏.所以,我痛下决心,要真正深入下去来学习Rust,所以才萌生了做这个博客的想法.很多地方,我感觉我看明白了,也问ai了,但是当真正写这个博客的时候,我才发觉还有很多关隘我没有想清楚.所以我做博客的目的呢,一是想帮助大家能够理解和思考技术上的难点,二也是帮助我真正的思考清楚.这是我第一次写博客,可能有表述的不清楚或者错误之处,请各位多多包涵,也欢迎直接了当的之处,我不会伤心的(只会偷偷的掉小珍珠(bushi)),谢谢大家.