为什么具有泛型类型参数的 trait 方法对象不安全?

问题描述

引用the Book(强调我的),

当使用 trait 时,用具体类型参数填充的泛型类型参数也是如此:具体类型成为实现 trait 的类型的一部分。当通过使用 trait 对象忘记类型时,无法知道用什么类型填充泛型类型参数。

我无法理解其中的原理。对于一个具体的例子,请考虑以下

pub trait Echoer {
    fn echo<T>(&self,v: T) -> T;
}

pub struct Foo { }

impl Echoer for Foo {
    fn echo<T>(&self,v: T) -> T {
        println!("v = {}",v);
        return v;
    }
}

pub fn passthrough<T>(e: Box<dyn Echoer>,v: T) {
    return e.echo(v);
}

fn main() {
    let foo = Foo { };
    passthrough(foo,42);
}

结果当然是错误

$ cargo run
   Compiling gui v0.1.0 (/tmp/gui)
error[E0038]: the trait `Echoer` cannot be made into an object
  --> src/main.rs:14:27
   |
14 | pub fn passthrough<T>(e: Box<dyn Echoer>,v: T) {
   |                           ^^^^^^^^^^^^^^^ `Echoer` cannot be made into an object
   |
   = help: consider moving `echo` to another trait
note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
  --> src/main.rs:2:8
   |
1  | pub trait Echoer {
   |           ------ this trait cannot be made into an object...
2  |     fn echo<T>(&self,v: T) -> T;
   |        ^^^^ ...because method `echo` has generic type parameters

error: aborting due to prevIoUs error

For more information about this error,try `rustc --explain E0038`.
error: Could not compile `gui`

To learn more,run the command again with --verbose.

根据我的理解,即使 e 在被转换成 trait 对象时忘记了它的具体类型,它仍然可以推断它需要用 {{1} 填充 echo<T> 的泛型类型参数}},因为它是在 i32 内部调用的,它在编译时被单态化为 passthrough<T>

“具体类型成为实现特征的类型的一部分”是什么意思?为什么 trait 方法不能在编译时填充它们的泛型类型参数,例如只需拨打passthrough<i32>

解决方法

这类似于 Why does a generic method inside a trait require trait object to be sized?,但我会在这里详细说明。

Rust trait 对象是使用 fat pointers 实现的 vtable

当 Rust 编译诸如

之类的代码时
pub fn passthrough<T>(e: Box<dyn Echoer>,v: T) {
    return e.echo(v);
}

它需要决定调用什么 echo 函数。 Box 基本上是一个指向值的指针,就您的代码而言,Foo 将存储在堆上,Box<Foo> 将是指向 {{1 }}。如果随后将其转换为 Foo,则新 Box 实际上包含 两个 指针,一个指向堆上的 Box<dyn Echoer>,一个指向 vtable。这个 vtable 允许 Rust 在看到 Foo 时知道要做什么。 e.echo(v) 调用的编译输出将查看 vtable 以查找 e.echo(v) 指向的任何类型的 echo 实现,然后调用它,在这种情况下传递 {{1} } 指向 e 的指针。

对于简单函数来说,这部分很容易,但由于 Foo&self 部分,这里的复杂性和问题会出现。模板函数本质上旨在使用单个定义声明许多函数,但是如果需要一个 vtable,它应该包含什么?如果您的特征包含具有类型参数(如 <T>)的方法,则可能需要未知数量的 fn echo<T>(&self,v: T) -> T; 类型。这意味着 Rust 需要要么禁止引用带有类型参数的函数的 vtable,要么它需要提前预测可能需要的每个可能的 <T> 类型,并将其包含在 vtable 中。 Rust 遵循第一个选项并抛出您所看到的编译器错误。

虽然在某些情况下提前知道完整的 T 类型集是可能的,并且对于在小型代码库中工作的程序员来说似乎很清楚,但它会非常复杂并且可能会在任何非平凡的情况。它还需要 Rust 全面了解您的整个应用程序,以便正确编译。这至少会大大减慢编译时间。

例如,Rust 通常将依赖项与您的主代码分开编译,并且在您编辑自己的项目代码时不需要重新编译您的依赖项。如果您需要提前知道所有 T 类型以生成 vtable,则需要在决定使用哪些 T 值之前处理所有依赖项和您自己的所有代码,然后才编译函数模板.类似地,假设依赖项包含与您问题中的示例类似的代码,每次您更改自己的项目时,Rust 都必须检查您的更改是否引入了对具有以前未使用过的类型参数的函数的动态调用,然后它还需要重新编译依赖项,以便使用新引用的函数创建一个新的 vtable。

至少,它会引入大量额外的复杂性。

,

trait 对象基本上是一个包含两个指针的胖指针,一个指向对象,另一个指向包含 all 方法的 vtable,因此从 trait 对象调用 echo 方法就像

trait_object.vtable.echo(trait_object.obj,"hello")

想象一下,echo 可以是泛型的,那么在 trait 对象上构建 vtable 时,可能会有 echo_string、echo_uint 等,必须枚举所有可能的类型。在分发方法时,它必须检查参数的类型并从 vtable 中找到实际的方法,如

trait_object.vtable.echo_string(trait_object.obj,"hello")

该方法可能有无限组合。并且当dispatch方法时,它应该根据T的具体类型从vtable中所有可能的方法中找到正确的方法