没有额外开销的抽象:Rust 中的特征

2015 年 5 月 11 日 · Aaron Turon

之前的文章 已经涵盖了 Rust 设计的两个支柱

  • 没有垃圾回收的内存安全
  • 没有数据竞争的并发

这篇文章开始探索第三个支柱

  • 没有额外开销的抽象

C++ 的口号之一,也是使其成为系统编程良好选择的一个品质,是其零成本抽象原则。

C++ 实现遵循零开销原则:你不用什么,你就不用为它付费 [Stroustrup, 1994]。并且进一步:你用的东西,你不可能手写得更好。

-- Stroustrup

这个口号并不总是适用于 Rust,例如 Rust 曾经使用强制垃圾回收。但随着时间的推移,Rust 的目标变得越来越底层,零成本抽象现在成为核心原则。

Rust 中抽象的基石是 *特征*。

  • **特征是 Rust 中唯一的接口概念**。一个特征可以被多种类型实现,事实上,新的特征可以为现有类型提供实现。另一方面,当你想要抽象化一个未知类型时,特征就是你指定你需要了解该类型的少数具体事物的工具。

  • **特征可以进行静态分派**。就像 C++ 模板一样,你可以让编译器为每个实例化方式生成一个抽象的单独副本。这又回到了 C++ 的口号“你用的东西,你不可能手写得更好”——抽象最终会被完全擦除。

  • **特征可以进行动态分派**。有时你确实需要间接引用,因此在运行时“擦除”抽象是没有意义的。*相同* 的接口概念——特征——也可以在你想要在运行时进行分派时使用。

  • **特征解决了除了简单抽象之外的各种其他问题**。它们被用作类型的“标记”,例如 之前文章中 描述的 Send 标记。它们可以用来定义“扩展方法”——也就是说,为外部定义的类型添加方法。它们在很大程度上消除了对传统方法重载的需求。并且它们提供了一种简单的运算符重载方案。

总而言之,特征系统是 Rust 的秘密武器,它赋予了 Rust 高级语言的符合人体工程学、表达能力强的风格,同时保留了对代码执行和数据表示的底层控制。

这篇文章将从高层次概述上述每个要点,让你了解设计如何实现这些目标,而不会陷入太多细节。

背景: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”类型绑定(通过 impl 块头指定)。
  • 在该类型的任何值上都 *自动* 可用——也就是说,与函数不同,内在方法总是“在作用域内”。

方法的第一个参数始终是显式的“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 这样的“流畅” API。

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

特征是接口

接口指定了一段代码对另一段代码的期望,允许它们各自独立地切换。对于特征来说,这种规范主要围绕方法展开。

例如,以下简单的散列特征

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

为了对给定类型实现此特征,你必须提供一个具有匹配签名的 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 等语言中的接口不同,**新的特征可以为现有类型实现**(如上面的 Hash)。这意味着抽象可以在事后创建,并应用于现有库。

与内在方法不同,特征方法只有在它们的特征在作用域内时才在作用域内。但假设 Hash 在作用域内,你可以写 true.hash(),因此实现特征会扩展在类型上可用的方法集。

而且……就是这样!定义和实现特征实际上只不过是抽象出一个由多个类型满足的通用接口。

静态分派

事情在另一边变得更加有趣——使用特征。最常见的做法是通过 *泛型*。

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

print_hash 函数对未知类型 T 泛化,但要求 T 实现 Hash 特征。这意味着我们可以用 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 Eq {
    fn eq(&self, other: &Self) -> bool;
}

(这里的 Self 引用将解析为我们实现特征的任何类型;在 impl Eq for bool 中,它将引用 bool。)

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

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

泛型的静态编译模型将产生几个好处

  • 每个使用具体 KeyValue 类型的 HashMap 将导致不同的具体 HashMap 类型,这意味着 HashMap 可以将其键和值内联(没有间接引用)地排列在它的桶中。这节省了空间和间接引用,并提高了缓存局部性。

  • HashMap 上的每个方法同样也会生成专门的代码。这意味着在分派到对 hasheq 的调用时没有额外的成本,如上所示。这也意味着优化器可以处理完全具体的代码——也就是说,从优化器的角度来看,*没有抽象*。特别是,静态分派允许在泛型使用之间进行 *内联*。

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

**但是,与 C++ 模板不同,特征的客户端在预先进行完全类型检查**。也就是说,当你单独编译 HashMap 时,它的代码会针对抽象的 HashEq 特征进行 *一次* 类型正确性检查,而不是在应用于具体类型时重复检查。这意味着库作者可以更早、更清晰地获得编译错误,并且客户端可以获得更少的类型检查开销(即更快的编译)。

动态分派

我们已经看到了特征的一种编译模型,其中所有抽象都在静态地编译掉。但有时抽象不仅仅是关于重用或模块化——**有时抽象在运行时起着至关重要的作用,而这些作用无法编译掉**。

例如,GUI 框架通常涉及用于响应事件(例如鼠标点击)的回调

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

GUI 元素通常也允许为单个事件注册多个回调。使用泛型,你可能会想象写

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

但问题立即显而易见:这意味着每个按钮都专门针对 ClickCallback 的一个实现者,并且按钮的类型反映了该类型。这根本不是我们想要的!相反,我们想要一个单一的 Button 类型,它有一组 *异构* 的监听器,每个监听器可能都是不同的具体类型,但每个监听器都实现了 ClickCallback

这里的一个直接困难是,如果我们谈论的是一组异构类型,*每个类型都会有不同的尺寸*——那么我们如何才能排列内部向量呢?答案是通常的:间接引用。我们将在向量中存储指向回调的 *指针*

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

在这里,我们使用 ClickCallback 特征就像它是一个类型一样。实际上,在 Rust 中,特征 *是* 类型,但它们是“无尺寸的”,这大致意味着它们只允许出现在像 Box(指向堆)或 &(可以指向任何地方)这样的指针后面。

在 Rust 中,像 &ClickCallbackBox<ClickCallback> 这样的类型被称为“特征对象”,它包含一个指向实现 ClickCallback 的类型 T 的实例的指针,*以及* 一个 vtable:指向 T 中每个特征方法(这里,只有 on_click)实现的指针。这些信息足以在运行时正确地分派对方法的调用,并确保所有 T 的统一表示。因此,Button 只编译一次,抽象在运行时保留下来。

静态和动态分派是互补的工具,每种工具都适用于不同的场景。**Rust 的特征提供了一种单一的、简单的接口概念,可以在两种风格中使用,并且成本最小、可预测**。特征对象满足 Stroustrup 的“按需付费”原则:当你需要时,你就有 vtable,但相同的特征可以在你不需要时被静态地编译掉。

特征的多种用途

我们已经看到了上面很多特征的机制和基本用法,但它们在 Rust 中也扮演着一些其他重要的角色。这里是一个示例

  • **闭包**。有点像 ClickCallback 特征,Rust 中的闭包只是特定的特征。你可以在 Huon Wilson 的 深入文章 中阅读更多关于它是如何工作的。

  • **条件 API**。泛型使得有条件地实现特征成为可能

    struct Pair<A, B> { first: A, second: B }
    impl<A: Hash, B: Hash> Hash for Pair<A, B> {
        fn hash(&self) -> u64 {
            self.first.hash() ^ self.second.hash()
        }
    }
    

    在这里,Pair 类型仅当它的组件实现 Hash 时才实现 Hash——允许单个 Pair 类型用于不同的上下文中,同时支持每个上下文可用的最大 API。它在 Rust 中是一个如此常见的模式,以至于有内置支持自动生成某些类型的“机械”实现

    #[derive(Hash)]
    struct Pair<A, B> { .. }
    
  • **扩展方法**。特征可以用来为现有类型(在其他地方定义)添加新的方法,以方便起见,类似于 C# 的扩展方法。这直接来自特征的作用域规则:你只需在一个特征中定义新的方法,为该类型提供一个实现,然后 *voila*,该方法就可用。

  • 标记。Rust 有几个“标记”用于对类型进行分类:SendSyncCopySized。这些标记只是具有空主体特征,可用于泛型和特征对象。标记可以在库中定义,它们会自动提供#[derive] 样式的实现:例如,如果类型的全部组件都是 Send,那么该类型也是 Send。正如我们之前所见,这些标记非常强大:Send 标记是 Rust 如何保证线程安全的。

  • 重载。Rust 不支持传统意义上的重载,即定义具有多个签名的相同方法。但特征提供了重载的大部分优势:如果一个方法是针对特征进行泛型定义的,那么它就可以使用实现该特征的任何类型进行调用。与传统重载相比,这有两个优点。首先,这意味着重载更少随意:一旦你理解了一个特征,你就会立即理解使用它的任何 API 的重载模式。其次,它是可扩展的:你可以通过提供新的特征实现,在方法的下游有效地提供新的重载。

  • 运算符。Rust 允许你对自己的类型重载运算符,例如 +。每个运算符都由一个相应的标准库特征定义,任何实现该特征的类型都会自动提供该运算符。

重点:尽管特征看似简单,但它们是一个统一的概念,支持各种用例和模式,而无需堆叠额外的语言特性。

未来

语言发展的主要方式之一是抽象机制的改进,Rust 也不例外:我们许多1.0 之后的优先事项都是特征系统在各个方向上的扩展。以下是一些亮点。

  • 静态分派输出。现在,函数可以使用泛型来定义参数,但结果没有等效的机制:你无法说“此函数返回一个实现 Iterator 特征的类型的值”,并使该抽象在编译时消失。当你想要返回一个希望进行静态分派的闭包时,这尤其成问题——在今天的 Rust 中,你根本无法做到。我们希望使这成为可能,并且已经有一些想法

  • 特化。Rust 不允许特征实现之间重叠,因此永远不会出现关于运行哪个代码的歧义。另一方面,在某些情况下,你可以为各种类型提供“通用”实现,但随后又希望为少数情况提供更专门的实现,通常是为了提高性能。我们希望在不久的将来提出一个设计方案。

  • 高阶类型 (HKT)。今天的特征只能应用于类型,而不是类型构造器——也就是说,应用于 Vec<u8> 之类的东西,而不是 Vec 本身。这种限制使得难以提供一套好的容器特征,因此它们没有包含在当前的标准库中。HKT 是一个主要的、跨领域的功能,它将代表 Rust 抽象能力的重大进步。

  • 高效重用。最后,虽然特征提供了一些代码重用机制(我们在上面没有介绍),但仍然有一些重用模式不适合今天的语言——特别是 DOM、GUI 框架和许多游戏中发现的面向对象层次结构。在不增加太多重叠或复杂性的情况下适应这些用例是一个非常有趣的设计问题,Niko Matsakis 已经开始了一个单独的博客系列来讨论这个问题。目前尚不清楚这是否可以用特征来完成,或者是否需要其他一些成分。

当然,我们正处于 1.0 版本发布的前夕,需要一些时间来尘埃落定,让社区积累足够的经验才能开始实现这些扩展。但这使得它成为参与其中的激动人心的时刻:从影响早期阶段的设计,到参与实现,再到在自己的代码中尝试不同的用例——我们非常希望得到你的帮助!