【译】没有开销的抽象:Rust 中的 traits

2020-08-29  本文已影响0人  袁世超

原文链接:Abstraction without overhead: traits in Rust

May 11, 2015 · Aaron Turon

之前的文章已经介绍了 Rust 设计的两大支柱:

本文将会探讨第三大支柱:

使得 C++ 非常适合系统编程的信条和品质就是零成本抽象原则:

C++ 实现遵循零开销原则:不用的东西不需要负担成本 [Stroustrup, 1994]。进一步:你所使用的代码,你不可能手写得更好。

-- Stroustrup

该信条并不总是适用于 Rust,例如它曾经具有强制的垃圾收集。但是随着时间的推移,Rust 的发展方向越来越低层,现在零成本抽象(zero-cost abstraction)是一个核心原则。

Rust 中抽象的基石是 traits

总而言之,trait 系统是 Rust 的秘密武器,这使得 Rust 既有高级语言的人机工程学和表达感,又保留了对代码执行和数据表示的低层控制。

本文将会从较高的层面逐一介绍以上几点,让你了解如何实现这些设计目标,并且不纠缠于细节。

背景:Rust 中的方法

在深入研究 traits 之前,我们需要看一下 Rust 语言的一个小而重要的细节:方法与函数的区别。

Rust 既提供了方法,也提供了独立的函数,这两者是密切相关的:

struct Point {
    x: f64,
    y: f64,
}

// a free-standing function that converts a (borrowed) point to a string
fn point_to_string(point: &Point) -> String { ... }

// an "inherent impl" block defines the methods available directly on a type
impl Point {
    // this method is available on any Point, and automatically borrows the
    // Point value
    fn to_string(&self) -> String { ... }
}

像上面 to_string 这样的方法被称为“固有”方法,这是因为它们:

方法的第一个参数总是明确的“self”,具体取决于所需的所有权级别,可以是self&mut self 或者 &self。使用面向对象编程熟悉的 . 符号调用方法,基于方法中 self 的形式隐式借用 self 参数。

let p = Point { x: 1.2, y: -3.7 };
let s1 = point_to_string(&p);  // calling a free function, explicit borrow
let s2 = p.to_string();        // calling a method, implicit borrow as &p

方法及其自动借用是 Rust 人机工程学的一个重要方面,支持下面这样的“流畅”API:

let child = Command::new("/bin/cat")
    .arg("rusty-ideas.txt")
    .current_dir("/Users/aturon")
    .stdout(Stdio::piped())
    .spawn();

Traits 是接口

接口指定一段代码对另一段代码的期望,使得各自可以独立切换。对于 traits 来说,这个规范主要围绕着方法。

以下面这个简单的哈希 trait 为例:

trait Hash {
    fn hash(&self) -> u64;
}

为给定类型实现该 trait,你必须提供一个签名匹配的 hash 方法:

impl Hash for bool {
    fn hash(&self) -> u64 {
        if *self { 0 } else { 1 }
    }
}

impl Hash for i64 {
    fn hash(&self) -> u64 {
        *self as u64
    }
}

与 Java、C# 或 Scala 这些语言不同的是,可以为存在的类型实现新的 traits(就像上面的 Hash)。这意味着可以事后创建抽象,应用在现有的库上。

与固有方法不同的是,trait 方法只有在 trait 存在时才有效。假设 Hash 在作用域内,你可以写 true.hash(),所以实现一个 trait 将会扩展类型的可用方法。

另外... 就是这样!定义和实现一个 trait 实际上只不过是抽象出一个满足多个类型的公共接口。

静态分发

在使用 trait 这方面,事情就更有趣了。最常见的方法是通过泛型

fn print_hash<T: Hash>(t: &T) {
    println!("The hash is {}", t.hash())
}

函数 print_hash 对于未知类型 T 是通用的,但是需要 T 实现了 Hash trait。这意味着我们可以使用 booli64 类型的值:

print_hash(&true);      // instantiates T = bool
print_hash(&12_i64);    // instantiates T = i64

泛型会被编译掉,实现静态分发。就像 C++ 的模板,编译器将会为上述代码生成两个版本print_hash 方法,每个版本对应一个具体的参数类型。这意味着对 t.hash() 的内部调用——实际使用的抽象——是零成本的:这将被编译为对相关实现的直接静态调用:

// The compiled code:
__print_hash_bool(&true);  // invoke specialized bool version directly
__print_hash_i64(&12_i64);   // invoke specialized i64 version directly

这种编译模型对 print_hash 之类的函数不是很有用,但是对更实际的哈希用法非常有用。假设我们也为相等比较引入一个 trait:

trait Eq {
    fn eq(&self, other: &Self) -> bool;
}

(这里对 Self 的引用将会被解析为实现 trait 的具体类型;在 impl Eq for bool 中将会指向 bool。)

然后我们可以定义一个哈希映射,泛化实现 HashEq 的类型 T

struct HashMap<Key: Hash + Eq, Value> { ... }

泛化的静态编译模型将带来许多好处:

总之,就像 C++ 中的模板一样,泛型的这些方面意味着你可以编写相当高级的抽象,而这些抽象可以保证向下编译成“你不可能手写得更好”的完全具体的代码。

但是与 C++ 模板不同的是,trait 的使用者提前进行了完全的类型检查。也就是说,当你单独编译 HashMap 时,对于抽象 HashEq traits 的类型正确性只检查一次,而不是在应用具体类型时反复检查。对于库作者来说这意味着更早更清晰的编译错误,对于使用者来说这意味着更少的类型检查开销(也就是,更快的编译)。

动态分发

我们已经看过了一种编译模型,其中所有的抽象都被静态地编译掉。但是有时候抽象不仅仅是关于重用和模块化——有时候抽象在运行时扮演着重要角色,这不能被编译掉

例如,GUI 框架经常涉及响应事件的回调,例如鼠标点击:

trait ClickCallback {
    fn on_click(&self, x: i64, y: i64);
}

对于允许注册多个回调到单个事件的 GUI 元素也很常见。通过泛型,你可能会这样编写代码:

struct Button<T: ClickCallback> {
    listeners: Vec<T>,
    ...
}

但是问题很明显:这意味着每个按钮精确指定一个 ClickCallback 的实现,也就是说按钮的类型反映了 ClickCallback 的类型。这根本不是我们想要的!相反,我们想要单个 Button 类型关联一组异构的监听器,每个监听器可以是不同的具体类型,但是每个监听器都实现了 ClickCallback

一个直接的困难是,如果我们讨论的是一组异构类型,每个类型都有不用的大小——那么我们如何布局内部向量呢?答案通常是:间接。我们将会在向量中存储回调的指针

struct Button {
    listeners: Vec<Box<ClickCallback>>,
    ...
}

在这里我们就像使用类型那样使用 ClickCallback trait。实际上在 Rust 中,traits 类型,但是“不确定大小”,这意味着它只允许出现在 Box(指向堆)或 &(可以指向任意地方)这样的指针里面。

在 Rust 中,像 &ClickCallbackBox<ClickCallback> 这样的类型被称为“trait 对象”,它包含一个指向实现了 ClickCallbackT 类型实例的指针,以及一个 vtable:指向 trait 中每个方法对应 T 实现的指针(在这里就是 on_click)。这些信息足以在运行时正确分发方法调用,并且可以支持对所有 T 统一表示。所以 Button 只需要编译一次,而抽象存在与运行时。

静态分发和动态分发是互补的工具,各自适合不同的场景。Rust 的 traits 提供了统一简单的接口表示,可以用最小、可预期的成本在两种风格中使用。trait 对象满足 Stroustrup 的“现用现付”原则:你需要 vtables 的时候就有,但是当你不需要的时候,同一个 trait 也可以被静态编译。

traits 的许多用途

上面已经介绍了一些 traits 的机制和基本用法,但是它在 Rust 中还扮演着许多其它重要角色。如下:

要点:尽管 traits 看起来很简单,但是 trait 是一个统一的概念,支持广泛的用例和模式,而不需要额外的语言特性。

未来

语言进化的的主要方式之一就在于抽象设施中,Rust 也不例外:许多1.0 后优先事项 都是 trait 系统在某个方向上的扩展。以下是一些亮点。

当然,我们还处在 1.0 发布的前夕,这还需要一些时间才能尘埃落定,社区需要有足够的经验来开启这些扩展。但这正是参与进来的好时机:从影响早期阶段的设计,到进行实现,一直到在代码中尝试不同的用例——我们希望得到你的帮助!

上一篇 下一篇

猜你喜欢

热点阅读