零开销抽象:Rust 中的 trait

2015年5月11日 · Aaron Turon

之前的文章已经介绍了 Rust 设计的两个基石

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

本文开始探讨第三个基石

  • 零开销抽象

零成本抽象是 C++ 的信条之一,也是使其非常适合系统编程的特性之一。

C++ 实现遵循零开销原则:你不需要的,就不会为此付出代价 [Stroustrup, 1994]。更进一步地说:你所使用的,不可能通过手工编码做得更好。

-- Stroustrup

这个信条并非始终适用于 Rust,例如,Rust 以前曾强制要求垃圾回收。但随着时间推移,Rust 的目标变得越来越底层,零成本抽象现在成为了一个核心原则。

Rust 中抽象的基石是 trait

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

  • Trait 可以静态分派。就像 C++ 模板一样,你可以让编译器为抽象的每一种实例化方式生成一个单独的副本。这回到了 C++ 的信条:“你所使用的,不可能通过手工编码做得更好”——抽象最终会被完全抹掉。

  • Trait 可以动态分派。有时你确实需要一个间接层,因此在运行时“抹掉”抽象就没有意义了。相同的接口概念——即 trait——也可以在运行时需要分派时使用。

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

总而言之,trait 系统是 Rust 的秘密武器,它赋予 Rust 高级语言那种符合人体工程学且富有表现力的感觉,同时保留了对代码执行和数据表示的低级控制。

本文将从高层次概述上述各点,让你了解设计如何实现这些目标,而不至于陷入过多细节。

背景:Rust 中的方法

在深入研究 trait 之前,我们需要看看语言中一个虽小但重要的细节:方法和函数之间的区别。

Rust 提供方法和自由函数(free function)两者,它们之间关系非常紧密

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 那样。

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

Trait 是接口

接口指定了一段代码对另一段代码的期望,允许两者独立替换。对于 trait 而言,这种规范主要围绕方法展开。

例如,以下是用于哈希的简单 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 等语言中的接口不同,可以为现有类型实现新的 trait(如上面的 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 trait 的类型 T 使用泛型的哈希表

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

泛型的静态编译模型将带来几大好处

  • 每次使用具体 KeyValue 类型实例化 HashMap 都会产生一个不同的具体 HashMap 类型,这意味着 HashMap 可以在其桶中以内联方式(无需间接)布置键和值。这节省了空间和间接访问,并改善了缓存局部性。

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

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

但是,与 C++ 模板不同,trait 的使用者在编译前就进行了完整的类型检查。也就是说,当你单独编译 HashMap 时,它的代码会针对抽象的 HashEq trait 进行一次类型正确性检查,而不是在应用于具体类型时重复检查。这意味着库作者会收到更早、更清晰的编译错误,以及更少的类型检查开销(即更快的编译速度)给使用者。

动态分派

我们已经看到了一种 trait 的编译模型,在这种模型中,所有抽象都被静态编译抹掉。但有时抽象不仅仅是关于复用或模块化——有时抽象在运行时扮演着不可或缺的角色,无法被编译抹掉

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

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

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

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

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

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

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

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

在 Rust 中,像 &ClickCallbackBox<ClickCallback> 这样的类型被称为“trait 对象”,它包含一个指向实现了 ClickCallback trait 的类型 T 实例的指针,以及一个 vtable(虚函数表):一个指向 T 对该 trait 中每个方法实现的指针(此处只有 on_click)。这些信息足以在运行时正确地分派方法调用,并确保所有 T 都具有统一的表示形式。因此 Button 只编译一次,抽象在运行时仍然存在。

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

Trait 的多种用途

我们已经在上面看到了很多关于 trait 的机制和基本用法,但它们在 Rust 中也扮演着其他一些重要角色。这里列举一二。

  • 闭包(Closures)。有点像 ClickCallback trait,Rust 中的闭包本质上是特定的 trait。你可以在 Huon Wilson 关于此主题的深度文章中了解更多。

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

    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 trait,当且仅当其组件实现了 Hash trait 时——允许单个 Pair 类型在不同的上下文中使用,同时支持每个上下文中可用的最大 API。这是 Rust 中非常常见的模式,以至于有内置支持来自动生成某些类型的“机械式”实现。

    #[derive(Hash)]
    struct Pair<A, B> { .. }
    
  • 扩展方法(Extension methods)。Trait 可以用来为现有类型(在其他地方定义)添加新方法,以方便使用,类似于 C# 的扩展方法。这直接得益于 trait 的作用域规则:你只需在 trait 中定义新方法,为相关类型提供实现,然后,,方法就可以使用了。

  • 标记(Markers)。Rust 有几个用于对类型进行分类的“标记”:Send, Sync, Copy, Sized。这些标记只是带有空 body 的 trait,然后可以用于泛型和 trait 对象。标记可以在库中定义,并且它们自动提供 #[derive] 风格的实现:例如,如果一个类型的所有组件都是 Send,那么该类型也是 Send。正如我们之前所见,这些标记可以非常强大:Send 标记是 Rust 如何保证线程安全的方式。

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

  • 运算符(Operators)。Rust 允许你在自己的类型上重载像 + 这样的运算符。每个运算符都由一个相应的标准库 trait 定义,任何实现了该 trait 的类型也自动提供该运算符。

重点是:尽管它们看起来很简单,trait 是一个统一的概念,支持广泛的用例和模式,而无需堆砌额外的语言特性。

未来

语言发展的主要方向之一是其抽象设施,Rust 也不例外:我们的许多1.0 后优先级都是 trait 系统在某个方向上的扩展。以下是一些亮点。

  • 静态分派的输出。目前,函数可以对参数使用泛型,但对于返回值没有类似的机制:你无法说“这个函数返回实现了 Iterator trait 的某个类型的值”,并让该抽象在编译时被抹掉。当你想返回一个希望静态分派的闭包时,这个问题尤其突出——在今天的 Rust 中,你就是做不到。我们希望使这成为可能,并已经有一些想法了

  • 特化(Specialization)。Rust 不允许 trait 实现之间存在重叠,因此永远不会对运行哪个代码产生歧义。另一方面,有些情况下你可以为多种类型提供一个“覆盖式”(blanket)实现,但随后希望为少数特定情况提供更特化的实现,通常是出于性能原因。我们希望在不久的将来提出一个设计方案。

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

  • 高效复用。最后,虽然 trait 提供了一些代码复用机制(我们在上面没有涵盖),今天语言中仍然有一些不适合的复用模式——值得注意的是,在 DOM、GUI 框架和许多游戏等领域中发现的面向对象层次结构。在不增加过多重叠或复杂性的情况下适应这些用例是一个非常有趣的设计问题,Niko Matsakis 已就此启动了一个独立的博客系列。目前尚不清楚这一切是否可以用 trait 来完成,或者是否需要其他一些要素。

当然,我们正值 1.0 版本发布前夕,还需要一段时间尘埃落定,并且社区积累足够的经验来落地这些扩展。但这使得参与进来成为一个令人兴奋的时刻:从早期阶段影响设计,到参与实现,再到在自己的代码中尝试不同的用例——我们非常乐意获得你的帮助!