对象安全性带来的一个大坑

发布时间:2024-10-07 21:37
最后更新:2024-10-07 21:37
所属分类:
Rust

在最近的编码中遇到了一个提示the trait cannot be made into an object的问题。这个问题其实是从其他语言中直接迁移来的习惯造成的,这里记录一下问题所在。

这个问题涉及到了Rust语言中“对象安全性”的问题,这个问题在其他语言中大多都是被语言自动处理掉的,但是在Rust这种“手动档”语言中,就需要自己解决一下了。就不要说这个“对象安全性”问题也经常是被忽略的事情。

起因

这个问题的起因实际上非常简单,我在一个特征中定义了一个异步方法。比如下面这样:

1
2
3
trait Protocol {
  async fn device_name(&self) -> Option<String>;
}

单看这个定义其实是没有什么错的,在很多其他语言中也的确是这么定义的。但是在使用动态分发来完成多态特性的时候就出现问题了,比如这样定义一个结构。

1
2
3
pub struct State {
  pub protocol: Arc<Mutex<Option<Box<dyn Protocol>>>>,
}

然后在dyn Protocol这里就开始提示了:the trait Protocol cannot be made into an object

单看这个错误提示是会非常懵圈的,这个特征到底怎么了,为什么就不能通过动态分发实现多态的特性呢?

相关知识点

在解决这个问题之前先来回顾一下相关的知识点。

动态分发和静态分发

在Rust中特征可以用来指代一个对象,这和在其他语言中,使用接口来指代一个对象的特性基本是一样的。但是Rust中的分发存在动态分发和静态分发的区别的,编码上的区别就是动态分发是使用dyn关键字,静态分发是使用impl关键字。

可能在很多例程中见到最多的就是impl Trait这种静态分发了。这种静态分发是在编译期就已经能够确定具体类型的分发,此时Rust会在编译期间给每一个具体的类型生成代码以避免运行时的开销。

静态分发主要适用于函数参数和返回值中,在这种情况下,这个函数接受的参数和返回值虽然被定义为了实现了Triat的对象,但函数所涉及的具体类型在调用时是可以确定的。另外在不担心类型擦除带来的影响的时候也是可以使用静态分发的。

在函数返回值中使用静态分发可以免于暴露具体类型的细节。

动态分发则是在运行时通过虚表(vtable)来进行调用,也就是说在运行时即时决定某个对象的具体类型。这种动态的特性听上去就会产生一些性能开销。动态分发主要用在需要访问存储的不同类型但是却实现了相同的特征的对象时,或者在一些需要多态性的情况下,比如创建了一个异构容器。另外,相比静态分发,动态分发会擦除类型(隐藏具体类型),而只暴露其所实现的特征。

对象安全性

Rust中对象安全性的限制是指一个特征是否能够作为特征对象(trait object,&dyn Trait或者Box<dyn Trait>)使用。对象安全性是Rust编译器要求特征满足的条件,以确保可以通过特征对象来动态的调用特征中定义的方法。这种限制一般更多的存在于动态分发中。

注意动态分发和静态分发的区别,静态分发通常在编译期就已经我具体类型生成了代码。

那么要保证一个特征的对象安全,就必须要遵守以下规则。

方法不能返回Self

这是因为特征的具体类型在编译时未知,而Self指代一个具体的类型,这就导致Rust在处理&dyn Trait的时候无法确定实际返回的类型。

本条规则可以通过返回Box<dyn Trait>类型来解决。

方法不能使用泛型参数

Rust编译器会在编译时为泛型方法生成不同类型的实现,而动态分发要求函数的签名是固定的,这就导致编译器不无法确定在运行时该使用方法的哪一个实现。

本条规则可以使用特征对象(&dyn Trait)来代替泛型作为泛型参数。

Sized不能作为隐式要求

从上面的回顾可以看出来,特征对象在运行时的实际大小是不能确定的,所以这就不可能要求where Self: Sized

方法不能是async fn

async fn是一个异步函数,通常我们在使用tokio这些异步库的时候会经常使用到。但是async fn在编译期会生成一个匿名Future类型的实现,而且对于每一个async fn来说这个实现都是独特的。所以如果一个特征中含有async fn,编译器同样会无法在运行时确定具体的Future类型。换句话说就是Future类型不是对象安全的。

本条规则可以定义一个普通的返回Pin<Box<dyn Future<Output = T> + Send>>类型值的函数代替async fn。这个复杂的类型声明表示一个异步任务,这个异步任务执行返回的结果类型是T

解决

回顾完动态分发和对象安全性的规则,就可以确定前面的代码实际上是违反了对象安全性规则的第四条:定义了一个async fn。那么解决方法也就确定了,代替Rust编译器默认完成的工作,显式在特征中声明异步函数的返回值,确定返回的Future类型。所以修改一下前面的代码就变成了下面的样子。

1
2
3
trait Protocol {
  fn device_name(&self) -> Pin<Box<dyn Future<Output = String> + Send>>;
}

这样在实现的时候可以这样来做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
impl Protocol for Driver {
  // 注意这里如果不显式声明生命期,会提示生命期不够长的错误。
  fn device_name(&'_ self) -> Pin<Box<dyn Future<Output = String> + Send>> {
    let device = Arc::clone(&self.device);
    Box::pin(async move {
      let device = device.lock().await;
      let properties = device.properties().await;
      properties.local_name.clone()
    })
  }
}

Box::pin可以将Future对象放入堆内存中,并且可以保证其生命期足够长。与Box<T>不同的是,Pin<Box<T>>是一个固定的堆内存分配指针,这种稳定内存地址的数据结构在多线程和异步操作中非常有用(数据结构中存在自引用也需要)。一个数据一旦被固定,那么就不能再移动或者替换。

尝试使用mem::swap()来交换被固定的值会出现编译错误。

另外一个不同就是Box<T>这种可移动的堆分配指针是可以把数据中一个Box<T>中移动到另一个的。

只有在类型T实现了特征Unpin的时候,才可以将Box<T>转换为Pin<Box<T>>,否则就需要使用Box::pin来固定数据。
Box::pin中出现的async move {}是一个异步代码块,它会立即返回一个实现了Future特征的值,但并不直接执行其中的代码。这个返回的Future会在后续使用.await调用的时候运行其中的实际逻辑。由于返回的这个Future是匿名的,无法直接在已有结构中显式存储,所以就需要使用Box::pin将其固定在堆上,并在运行时中保持其内存地址的稳定。

总结

如果需要在特征中定义一个异步方法,请手动完成它的具体返回值类型。

在编码的过程中,Rust编译器实际上做了很多的自动化工作,这虽然节省了很多时间,但是也带来了一些非常容易忽视的问题,对于这些从其他语言中搬移过来的设计方法和编码习惯,在Rust中需要格外的留心一些。


索引标签
Rust
对象安全性
动态分发