Rust Dispatch

学习的过程总是 无数个一知半解 + 一个恍然大悟的循环,两者的重要性难分伯仲。

分发(Dispatch)

当代码涉及到多态(polymorphism)时,需要机制判断最终执行的代码到底是哪一个具体的实现版本。这个过程就叫分发。

首先定义一点基础代码,作为后续内容的代码示例的默认前提:

trait Show {

    fn print(&self);

}



impl Show for usize {

    fn print(&self) {

        println!("usize show: {:?}", &self);

    }

}



impl Show for String {

    fn print(&self) {

        println!("string show: {:?}", &self);

    }

}

静态分发(Static Dispatch)

泛型

类似于 C++ 里的模板,Rust 里的泛型(generic)包括加上 traits bounds 的泛型都是静态分发。具体实现是单态化(monomorphization):

In programming languages, monomorphization is a compile-time process where polymorphic functions are replaced by many monomorphic functions for each unique instantiation.

即代码在编译期间 多态方法 被多个单独的 单态方法 替换。

比如以下实现:

fn do_print<T>(x: T)

where

    T: Show,

{

    x.print();

}



fn main() {

    let number = 42usize;

    let string = "Dispatch".to_string();



    do_print(number); // usize show: 42

    do_print(string); // string show: "Dispatch"

}

实际会被展开成类似:

fn do_print_for_usize(x: usize) {

    x.print();

}



fn do_print_for_string(x: String) {

    x.print();

}



fn main() {

    let number = 42usize;

    let string = "Dispatch".to_string();



    do_print_for_usize(number); // usize show: 42

    do_print_for_string(string); // string show: "Dispatch"

}

通过编译期间的单态化,编译器去除了泛型的概念,优点是在运行期间无性能损耗,缺点是滥用泛型会导致生成的单态化代码变多,编译时间增加,生成的二进制文件体积变大。这和 C++ 模板是一致的。

impl Trait

除了泛型之外,impl Trait 也用作静态分发,impl Trait 可以用在参数类型和返回值类型里。比如上述的 fn do_print<T: Show>(x: T); 也可以用 impl Trait 的形式写成:

fn do_print(x: impl Show) {

    x.print()

}

impl Trait 作为返回值类型时,需要注意编译器需要推导出返回值的具体类型来实现单态化,因此无法写出 if - else 等不同分支下返回不同类型(虽然它们都实现了这个 Trait )的代码。编译器无法推导出单态化的目标类型,一般会在后一种类型的返回处报错:expect A but get B...

实际编码中作为返回值写 impl Trait 的形式有两种用途:

  1. 让编译器推导类型,避免写特别长的类型
  2. 用在返回闭包的场景

第一个场景:看下面这个例子,实现了一个连接两个 Vector 的函数

fn combine_vec<T>(v: Vec<T>, u: Vec<T>) -> impl Iterator<Item = T>

where

    T: Clone,

{

    v.into_iter().chain(u.into_iter()).cycle()

}



fn main___() {

    let v1 = vec![1, 2, 3];

    let v2 = vec![5, 6, 7];



    let mut v3 = combine_vec(v1, v2);

    assert_eq!(Some(1), v3.next());

    assert_eq!(Some(2), v3.next());

    assert_eq!(Some(3), v3.next());



    assert_eq!(Some(5), v3.next());

    assert_eq!(Some(6), v3.next());

    assert_eq!(Some(7), v3.next());



    assert_eq!(Some(1), v3.next());

}

泛型函数 combine_vec 的真实返回值是 std::iter::Cycle<std::iter::Chain<std::vec::IntoIter<T>, std::vec::IntoIter<T>>>,这里编译的时候会推导出返回值的类型和泛型 T 的类型,实际上会生成类似如下代码:

fn combine_vec_for_i32_with_explicit_return_type(

    v: Vec<i32>,

    u: Vec<i32>,

) -> std::iter::Cycle<std::iter::Chain<std::vec::IntoIter<i32>, std::vec::IntoIter<i32>>> {

    v.into_iter().chain(u.into_iter()).cycle()

}

很明显我们的这个函数的意图是利用迭代器的 chain 方法连接两个 Vec,因此返回的类型一定还是原先泛型 T 的迭代器,因此简单的写上 impl Iterator<...>,剩下的工作让编译器去做就好了。

因为即使我们写成确切的类型,也不会给对读代码的人提供更多有帮助的信息,徒增阅读负担而已。

第二个场景:Rust 里闭包类型是匿名的,无法显示的写出来,这种情况下我们只能只用 impl Trait 的形式来写:

fn adder_function(y: i32) -> impl Fn(i32) -> i32 {

    move |x: i32| x + y

}



fn double_positive<'a>(numbers: &'a Vec<i32>) -> impl Iterator<Item = i32> + 'a {

    numbers.iter().filter(|x| x > &&0).map(|x| x * 2)

}



fn main() {

    let add_one = adder_function(1);

    assert_eq!(3, add_one(2));



    let v = vec![-3, 2, -4, 1];

    let v2 = double_positive(&v).collect::<Vec<i32>>();

    assert_eq!(v2, vec![4, 2]);

}

动态分发(Dynamic Dispatch)

静态分发的缺点:无法返回多种类型 正是动态分发要解决的问题。使用 Trait Object 表达 实现了某种 Trait 的类型(的集合) 这种类型,这种类型有点像 OOP 语言里的基类/抽象类,本身无法实例化出对象。

Trait Object

Trait Object 本身可以理解为有固定大小的类型,其包含两个指针,一个指向其实际的类型,一个指向实现了 TraitObject 的这个 Trait 的虚表,可以理解为如下表达:

pub struct TraitObject {

    pub data: *mut (),

    pub vtable: *mut (),

}

虽然 Trait Object 大小是确定,但是并不能写出形如 fn x() -> Trait Object 的代码,你会得到编译器的警告:

return type cannot have an unboxed trait object, doesn't have a size known at compile-time

嗯?这是因为 Trait Object 所表达的实现了该 Trait 的类型的集合,其中的元素的大小是不确定的。Trait Object 本身是无法实例化的,作为返回值自然是没有意义的,真正要作为返回值的是某个实现了该 Trait 的类型的实例。因此要明确并不是因为 Trait Object 本身大小不固定,大小就是宽指针是固定的。

dyn Trait

明白了 Trait Object 的概念,代码中要表示一个 Trait Object,需要使用 dyn 关键字:dyn SomeTrait 表示 SomeTrait 的 Trait Object

这里有些历史,在 2016 年之前还没有 dyn 这个关键字,RFC-2113 中引入了这个关键字语法,并在 rust 1.26/2021 edition 后必须使用 dyn 才能表示 Trait Object

要求使用 dyn 就是为了清晰表达含义,区分 Trait 和 TraitObject。提案里给出了几个实例:比如下面代码:

impl SomeTrait for AnotherTrait {...}

impl SomeTrait {...}

这在引入 dyn 关键字之前都是合法的代码(现在会提示了),能分清楚这里的几个 xxxTrait,哪个是 Trait ,哪个是 Trait Object 么 :)

第一条按照 impl trait for type 的语法,SomeTrait 是 Trait, AnotherTrait 应该是 Trait Object;

第二条很容易理解为给 SomeTrait 实现一些默认方法,但是应该在定义处 trait SomeTrait {...} 里实现默认方法,这里其实是表达给 SomeTrait 的 Trait Object 实现方法。

当然如今这样写已经会被编译器警告了,正确的写法是:

impl SomeTrait for dyn AnotherTrait {...}

impl dyn SomeTrait {...}

拓展知识: Trait Object 都是 unsized ,所以 Trait 在设计的时候都是默认 ?Sized 来支持 Trait Object:Trait Object Sizeness

因此如果定义 trait SomeTrait : Sized ,那么这个 Trait 就不能使用 Trait Object 了。换句话说,Trait Object 要求不能包含 Sized Bounds。

traits#object-safety

  • All supertraits must also be object safe.
  • Sized must not be a supertrait. In other words, it must not require Self: Sized.

vtable in Rust

首先先看一下 C++ 里的虚表实现,对于一个子类对象,其内存布局包括:(注:这里只考虑继承包含虚函数父类的子类的情况,仅为和 Rust 虚表对比,不能完全代表真实内存布局实现)

子类内存布局 vtable
虚表指针 > 析构方法指针
子类成员/数据 成员方法指针

指向虚表的指针和自身成员数据。

对于多继承的情况,会有多个虚表指针:

vtable 子类内存布局 vtable
虚表指针 > 析构方法指针
析构方法指针 < 虚表指针 成员方法指针
成员方法指针 子类成员/数据

而 Rust 里,如上 TraitObject 的 raw code:很容易得出单 Trait 下的 TraitObject 布局:

子类布局 Trait Object vtable
成员数据 < 数据指针
虚表指针 > 析构方法指针
成员方法指针

那么如果我想写出多个 Trait Bounds 的 Trait Object 呢?如下代码:

trait FirstTrait {

    fn first(&self);

}



trait SecondTrait {

    fn second(&self);

}



struct Subject {

    subjects: dyn FirstTrait + SecondTrait, // error: only auto traits can be used

                                            // as additional traits in a trait object

}

会在 + SecondTrait 处得到如上提示,以及如下建议:

consider creating a new trait with all of these as supertraits and using that trait here instead: trait NewTrait: FirstTrait + SecondTrait {}

(rustc 真贴心地教你写代码)按照提示需要这样写:

trait AllTrait: FirstTrait + SecondTrait {}



struct Subject {

    subjects: dyn AllTrait,

}

很容易猜测到这是因为 TraitObject 的实现方式:仅包含两个指针,无法增加更多的 vtable 指针来达成类似 C++ : public A, public B 多继承的效果。

拓展知识:目前 supertrait 下还不支持 upcasting coercion: issues

fn need_first_trait(o: &dyn FirstTrait) {}



impl Subject {

    fn call_as_first_trait(&self) {

        need_first_trait(&self.subjects) // error: cannot cast `dyn AllTrait` to `dyn FirstTrait`,

                                         // trait upcasting coercion is experimental

    }

}

目前编译器还无法识别出应该放入哪个 FirstTrait 的 Trait Object 的虚表,可以用下面的方式手动补充实现:

trait AllTrait: FirstTrait + SecondTrait {

    fn as_first_trait(&self) -> &dyn FirstTrait;

}



impl<T: FirstTrait + SecondTrait> AllTrait for T {

    fn as_first_trait(&self) -> &dyn FirstTrait {

        self

    }

}



impl Subject {

    fn call_as_first_trait(&self) {

        need_first_trait(self.subjects.as_first_trait())

    }

}

参考资料

rust example - impl Trait

拓展阅读