[译] 快速探索 Rust 中的 trait 对象

快速探索 Rust 中的 trait 对象

我在网上看到过不少关于Rust中trait对象的文章或教程,但要么云里雾里没讲清楚,要么东拉西扯难以理解,这篇文章虽然是写于2019年,但作者以静态分派和动态分派作为切入点,介绍了trait对象的作用以及实现方式,看完之后个人感觉有所收获,稍微翻译一下,希望对其他人也有所帮助。

原文地址:Laurence Tratt:A Quick Look at Trait Objects in Rust

前言

Rust 是一门很有趣的编程语言:它有许多在主流语言中前所未有的功能,这些功能可以用令人惊讶和有趣的方式组合。在许多情况下,它可以成为C的替代品:它的代码运行速度非常快,因为它没有垃圾回收器,所以它的内存使用更可预测, 这对某些程序很有用。在过去3到4年中,我越来越多的使用Rust编程 ,其中最重要的是 grmtools 库集(浏览快速入门指南了解它)。然而在我看来,毫无疑问,Rust不是最容易学习的语言。这在一定程度上是因为它是一种相当庞大的语言:语言复杂性随着语言规模的增加呈指数级增长。但这在一定程度上也是因为很多 Rust 的东西对我们来说都很陌生。学习 Rust 让我回想起了早些年学习编程时磕磕绊绊摸索的经历,花了很多时间,却只得到了有限的成果。然而,这都是值得的, 事实证明,Rust 非常适合我想做的许多事情。

很长一段时间以来,让我感到困惑的一件事是 Rust 的"trait对象":它感觉像是语言中一个奇怪的部分,我甚至都不确定我是否在使用它,即使我很想使用它。 由于我最近有理由更详细地研究它,我想写下一些东西,希望可能会对其他人有所帮助。这篇博文的第一部分介绍基础知识,第二部分介绍 Trait 对象对性能的影响以及在 Rust 中的实现方式。

基础知识

总的来说,Rust 对静态分派 函数调用有非常强烈的偏好,即在编译时就确定调用所匹配的函数。换句话说,当我写一个函数调用f()时, 编译器静态地计算出我指的是哪个函数f,并使我的函数调用直接指向该f函数。 这与动态分派形成鲜明对比,在动态分派 中, 与调用匹配的函数只能在运行时确定。动态分派在 Java 等语言中很常见。例如,考虑一个类C,定义了一个m方法,该方法或许有多个子类重写。如果我们有一个C类的对象o和方法o.m()调用,那么我们必须等到运行时才能确定我们是应该调用C类的实现还是其子类之一的实现。简单来说, 静态分派可提高性能 ,而动态分派在构建程序时提供了更多的灵活性。二者可以通过多种方式组合:有些语言仅提供静态分派,有些语言只提供动态分派,有些语言则要求用户选择是否加入动态分派。

人们很容易以为 Rust 的trait本身就意味着动态分派,但事实并非如此。请考虑以下代码:

rust 复制代码
trait T {
  fn m(&self) -> u64;
}

struct S {
  i: u64
}

impl T for S {
  fn m(&self) -> u64 { self.i }
}

fn main() {
  let s = S{i : 100};
  println!("{}", s.m());
}

这里的Trait T看起来有点像 Java 的接口,它要求一个任意的 class/struct 实现m方法并返回一个整数:实际上,第15行中的s.m()调用语法貌似是在一个对象上调用方法,我们很可能会将其理解为动态分派。 但是,Rust 编译器会静态地将m()调用编译为T::m。这是可行的,因为第14行的变量s被静态的确定为类型S,并且实现了具有m方法的trait,因此该函数是唯一可能的匹配项。正如这句话所暗示的那样,Rust 的 traits 并不像 Java 接口,而 structs 也不像类。

然而,Rust 确实允许动态分派,尽管一开始感觉像魔法,让我们一步步将它变为现实。假设我们想写一个函数f,它可以接受任何实现了 trait T的结构体,并调用其m函数。我们可以尝试这样写:

rust 复制代码
fn f(x: T) {
  println!("{}", x.m())
}

但是,编译会导致以下错误:

rust 复制代码
error[E0277]: the size for values of type `(dyn T + 'static)` cannot be known at compilation time
  --> src/main.rs:21:6
   |
21 | fn f(x: T) {
   |      ^ doesn't have a size known at compile-time
   |
   = help: the trait `std::marker::Sized` is not implemented for `(dyn T + 'static)`
   = note: to learn more, visit <https://doc.rust-lang.org/book/second-edition/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
   = note: all local variables must have a statically known size
   = help: unsized locals are gated as an unstable feature

虽然错误长度有点吓人,但它实际上是用三种不同的方式说了同样的事情:我们将一个运行时的值移动 到了f的参数中。然而------我更愿意从运行代码的方式来考虑这个问题------因为我们不知道结构体T会有多大, 因此我们无法生成单一机器码来处理它(机器码需要结构体是 8 字节、还是 128 字节、还是......函数需要的堆栈大小是多少?)

该问题的一种解决方案是将f的后面改为:

rust 复制代码
fn f<X: T>(x: X) {
  println!("{}", x.m())
}

这段代码现在可以编译,但它依旧是静态分派------通过复制代码的方式来实现。尖括号<X: T>之间的代码定义了一个类型参数,X 为该参数传递的类型 (可能是隐式的) 必须实现 trait T 。这看起来有点像fn f(x: T)的冗余写法,但类型参数意味着单态化机制 会生效:对于我们给函数传递的每个不同类型X,都会生成一个专门的f版本, 从而让 Rust 将所有函数都静态分派。有时这就是我们想要的,但它并不总是明智的:单态化意味着代码膨胀会成为一个真切的关注点(我们最终会得到许多f函数独立的机器码版本),这可能会限制我们合理构建程序的能力。

幸运的是,有一个替代解决方案可以实现动态分派,如以下代码所示:

rust 复制代码
trait T {
  fn m(&self) -> u64;
}

struct S1 {
  i: u64
}

impl T for S1 {
  fn m(&self) -> u64 { self.i * 2 }
}

struct S2 {
  j: u64
}

impl T for S2 {
  fn m(&self) -> u64 { self.j * 4 }
}

fn f(x: &T) {
  println!("{}", x.m())
}

fn main() {
  let s1 = S1{i : 100};
  f(&s1);
  let s2 = S2{j : 100};
  f(&s2);
}

译注:Rust 1.26及以下的版本可以如上所示直接用&T表示trait对象的引用,新版本的 Rust 必须用dyn关键字显式标记trait对象,上述代码将无法编译。

运行此程序会打印出 200 和 400 ,这是通过动态分派实现的!太棒了!但为什么它起作用了呢?

与以前的版本唯一真正的区别是,我们将f函数更改为T类型对象的引用(第 21 行),虽然我们不知道结构体T有多大,但对每个实现T的对象的引用都会有相同的大小,所以我们只需要生成一个机器码版本即可。在这里,一个神奇的事情发生了:在第27行中,我们传递了一个S1类型对象的引用给f ,但f函数需要的是一个T类型对象的引用。为什么这样是有效的呢?这是因为在这种情况下,编译器会隐式的将&S1转换为&T,因为它知道结构体S实现了trait T,更重要的是,这种转换还会附加一些额外的信息,以便运行时系统知道它需要在该对象上调用S1的方法(而不是S2)。

这种强制隐式转换是可能的,根据我的经验,这会让那些刚接触 Rust 的人感到惊讶。要说有什么值得欣慰的,那就是甚至经验丰富 Rust 程序员可能无法发现这些转换:函数的签名中没有任何信息会告诉你这种转会发生,除非你发现了T是一个trait而不是结构体。为此,新的 Rust 的版本允许你添加一个语法标记来使其更清晰:

rust 复制代码
fn f(x: &dyn T) {
  println!("{}", x.m())
}

额外的dyn关键字没有语义效果,但你可能会感觉到trait对象的转换更明显。不幸的是,因为目前大家对该关键字的使用有点随意,你不能因为没有看到它就断定没有发生动态分派。

译注:正如上面所说,新版本 Rust 强制要求添加该关键字,一般不存在上述这个问题了。

有趣的是,还有另一种方法可以执行同样的转换,而不需要使用引用:我们可以将trait对象放在Box中(即把它放在堆上)。这样一来,无论Box里的结构体有多大,Box的大小都是一样的。下面是一个简单的示例:

rust 复制代码
fn f2(x: Box<T>) {
  println!("{}", x.m())
}

fn main() {
  let b: Box<S1> = Box::new(S1{i: 100});
  f2(b);
}

在第6行,我们有一个Box<S1>类型的变量,但传递给f2时他会自动强制转换为Box<T>。从某种意义上说,这只是引用转换的一种变体:在这两种情况下,我们都将一个大小未知的事物(trait对象)转换为大小已知的事物(引用或Box)。

胖指针与内部虚指针

在我之前的解释中,我故意忽略了一点:虽然所有指向trait T 对象的引用具有相同的大小,但指向不同类型对象的引用,却不一定具有相同的大小。你很容易在下面的代码中发现这一点,它的执行不会有错误:

rust 复制代码
use std::mem::size_of;

trait T { }

fn main() {
    assert_eq!(size_of::<&bool>(), size_of::<&u128>());
    assert_eq!(size_of::<&bool>(), size_of::<usize>());
    assert_eq!(size_of::<&dyn T>(), size_of::<usize>() * 2);
}

这说明一个bool值的引用大小与一个的u128值的引用大小相同(第 6 行),并且两者都是一个机器字长(第 7 行)。这并不奇怪:引用会被编码为指针。但令人惊讶的是,Trait 对象的引用却是两个机器字长(第 8 行)。这是怎么回事?

Rust 广泛使用胖指针 ,其中就包括使用 trait 对象。一个胖指针是一个指针加上一些额外的东西,所以至少有两个机器字长。对于trait对象的引用,第一个机器字长是指向内存中对象的指针,第二个机器字长是指向该对象的虚表(vtable ,就是指向自身结构体内部动态分派函数的指针条目)。让我们将指向虚表的指针称为虚指针(vpointer),虽然这不是一个普遍使用的术语。现在我们可以理解我在前面部分提到的将结构体对象强制转换为trait对象的"魔法":将结构体的虚指针添加到作为结果的指针上,也就将指针强制转换为了胖指针。换句话说,从任意S1结构体转换而来的trait对象都将拥有一个包含虚指针v1 的胖指针,而从任意S2结构体转换而来的trait对象也都将拥有虚指针v2 。从概念上讲,v1 的指针条目中有一个指向S1::m的指针,而 v2 的指针条目中有一个指向S2::m的指针。如果有需要,使用不安全(unsafe)代码,你可以轻松地将对象指针和虚指针分离

如果你是 Haskell 或 Go 程序员,这种胖指针的使用方式可能就是你期望的样子。就我个人而言,我习惯于虚指针与对象并列存在,而不是与对象的指针并列存在:据我所知,前一种技术没有一个统一的名字,所以让我们称它为内部虚指针(inner vpointers)。例如,在典型的面向对象的语言里,每个对象都是动态分派的,所以每个对象随身携带自己的虚指针。换句话说,就是要在两种情况中做出选择:要么是向指针添加额外的机器字长(胖指针),要么向对象自身添加机器字长(内部虚指针)。

为什么 Rust 会喜欢胖指针而不是内部虚指针? 如果将性能作为唯一参考标准:内部虚指针的缺点是,每个对象的大小会不断增长。如果每个函数调用都使用对象的虚指针,那问题不大,但正如我之前所展示的,Rust 强烈鼓励你使用静态分派:如果它使用内部虚指针,那虚指针可能在大部分时间里都会被闲置。 因此,胖指针的优点是只会在要使用动态分派的特定程序位置增加额外成本。

译注:原文其实还有一节内容,主要是测试对比胖指针和内部虚指针的性能,结论是通常情况下胖指针性能更好,Rust选择胖指针是正确的,个人感觉相对不那么重要,所以就没有翻译,感兴趣的建议去阅读原文。

相关推荐
姜学迁13 小时前
Rust-枚举
开发语言·后端·rust
凌云行者13 小时前
rust的迭代器方法——collect
开发语言·rust
QMCY_jason20 小时前
Ubuntu 安装RUST
linux·ubuntu·rust
碳苯1 天前
【rCore OS 开源操作系统】Rust 枚举与模式匹配
开发语言·人工智能·后端·rust·操作系统·os
zaim11 天前
计算机的错误计算(一百一十四)
java·c++·python·rust·go·c·多项式
凌云行者1 天前
使用rust写一个Web服务器——单线程版本
服务器·前端·rust
cyz1410012 天前
vue3+vite@4+ts+elementplus创建项目详解
开发语言·后端·rust
超人不怕冷2 天前
[rust]多线程通信之通道
rust
逢生博客2 天前
Rust 语言开发 ESP32C3 并在 Wokwi 电子模拟器上运行(esp-hal 非标准库、LCD1602、I2C)
开发语言·后端·嵌入式硬件·rust
Maer092 天前
WSL (Linux)配置 Rust 开发调试环境
linux·运维·rust