无额外开销的抽象:Rust中的traits

本文翻译自rust blog请遵守开源协议

 先前的文章涵盖了 Rust 设计的两个支柱:

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

 这篇文章开始探讨第三个支

  • 无额外开销的抽象

C++ 的零开销抽象原则是它适合于系统编程的重要特性

C++的实现遵循零开销原则:你不必为你不用的东西承担任何花销。进一步的说:对于你使用的东西,目前的代码已经最优。

 这句话并不总是适用于 Rust ,例如, Rust 曾经具有强制垃圾收集功能。但是随着时间的流逝, Rust 的雄心在消退,而零成本抽象现在已经成为核心原则。

Rust 抽象的基石是 traits

  • traitsRust 中唯一的接口概念。一个 traits 可以由多种类型实现。实际上,新 traits 可以实现现有类型。另一方面,当你要抽象未知类型时, traits 是你如何指定关于那个类型必须了解的具体内容。

  • 静态分发。像 C++ 模板一样,你可以让编译器为抽象的每种实例化方法生成单独的副本。这又回到了 C++ 的口头禅:“对于你使用的东西,目前的代码已经最优”–抽象最终被完全抹去了。

  • 动态分发。有时,你确实确实需要间接访问,因此在运行时“擦除”抽象没有任何意义。与接口概念相同的 traits 也可以在运行时使用。

  • traits 解决了简单抽象之外的各种其他问题。它们被用作类型的“标记”,例如上一篇文章中Send描述的标记。它们可用于定义“扩展方法”-即向外部定义的类型添加方法。它们很大程度上消除了对传统方法重载的需求。并且它们为操作符重载提供了一种简单的方案。

 总而言之, traits 是秘密的调味料,它使 Rust 具有高级语言的表现力,同时保留了对代码执行和数据表示的底层控制。

 这篇文章进一步介绍以上这些方面,让你对如何设计实现这些目标有一个了解,同时又不会陷入细节中。

背景:Rust中的方法

在研究traits之前,我们需要看一下Rust的一个小但重要的细节:方法和函数之间的差异。

Rust 提供了方法和函数,它们直接紧密相连:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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上面这样的方法称为“固有”方法,因为它们:

  • 绑定到单个具体的类型(通过impl块标题指定)。
  • 该类型的任何值都可以使用它-也就是说,与函数不同,固有方法始终在“范围内”。

 方法的第一个参数总是“self”,根据所有权不同,会是self , &mut self&self中的一个。和面向对象编程语言一样,通过.调用方法,self参数隐式借用。

1
2
3
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();   

 方法及其自动借用是 Rust 人体工程学的重要方面,它支持“流畅”的API,例如:

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

traits是接口

 接口指定了一段代码对另一段代码的期望,从而可以独立切换每段代码。对于 traits ,此规范主要围绕方法。

例如,使用以下简单哈希 traits

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

为了对给定类型实现此 traits ,必须提供与签名匹配的hash方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
impl Hash for bool {
    fn hash(&self) -> u64 {
        if *self { 0 } else { 1 }
    }
}

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

 与JavaC#Scala之类的语言中的接口不同,可以为现有类型实现新 traits (就像上面的Hash)。这意味着traits可以事后创建,并应用于现有的库。 译注:对于已经存在的i64和bool,可以给它们加上Hash traits,这点确实和Java等不一样。

 与固有方法不同, traits 方法仅在其 traits 存在时才可用。但是假设Hash在范围内,您可以编写true.hash(),因此实现特征扩展了类型上可用的方法集。

而且…就是这样!定义和实现 traits 实际上无非就是抽象出一个以上类型满足的通用接口。

静态调度

 另一方面,事情变得更有趣了–消费 traits 。最常见的方法是通过泛型:

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

 该print_hash函数的泛型参数是TT必须实现了Hash traits。也就是说i64bool可以作为参数传给print_hash

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

泛型被编译掉,结果是静态调度。也就是说,与 C++ 模板一样,编译器将生成print_hash方法的两个副本来处理上述代码,每种具体参数类型一个。反过来,这意味着对内部的调用t.hash()(实际上是抽象)的成本为零:它将被编译为具体参数类型的直接静态调用: 译注:c++的模板和java的泛型非常的不一样,java并不会像c++那样生成非常多的副本,而是采用类型擦除,所以java的泛型会有非常多的限制。可以参考stackoverflow

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

 对于像这样的函数print_hash,此编译模型并不是很有用,但对于更现实的哈希使用非常有用。假设我们还引入了一个相等比较的特征:

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

Self这里的引用将解析为我们实现 traits 的任何类型;impl Eq for bool其中将引用bool。) 然后,我们可以定义一个散列图,该散列图对T同时实现Hash和的类型通用Eq

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

泛型的静态编译模型将产生以下好处:

  • 每次对HashMapConcrete KeyValueType的使用都会产生不同的具体HashMap类型,这意味着HashMap可以在其存储桶中以直列方式(无间接方式)布置键和值。这样可以节省空间和间接寻址,并改善缓存的局部性。
  • 每种方法HashMap同样会生成专门的代码。这意味着,如上所述,无需额外分配到hash和的调用eq。这也意味着优化器可以使用完全具体的代码-也就是说,从优化器的角度来看,没有抽象。特别是,静态分派允许在泛型使用之间进行内联。

  总的来说,就像在C ++模板中一样,泛型的这些方面意味着您可以编写相当高级的抽象,这些抽象可以保证编译为完全具体的代码,即“您无法再编写任何更好的代码”。 但是,与C ++模板不同,特质的客户端需要预先进行全面的类型检查。也就是说,当您单独进行编译HashMap时,将针对其抽象和特征对类型代码正确性进行一次检查,而不是在应用于具体类型时对其进行重复检查。这意味着库作者的编译错误更早,更清晰,并且客户端的类型检查开销(即,更快的编译)更少。HashEq

动态调度

 我们已经看到了一种用于特征的编译模型,其中所有抽象都是静态编译的。但是有时抽象不仅仅是重用或模块化- 有时抽象在运行时扮演着重要角色,不能被编译掉。 例如,GUI框架通常涉及用于响应事件(例如鼠标单击)的回调:

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

GUI元素通常允许为一个事件注册多个回调。使用泛型,您可能会想到编写:

1
2
3
4
struct Button<T: ClickCallback> {
    listeners: Vec<T>,
    ...
}

但是问题是显而易见的:这意味着每个按钮专门针对的一个实现者ClickCallback,并且按钮的类型反映了该类型。那根本不是我们想要的!相反,我们想要一个Button带有一组异构侦听器的单一类型,每个侦听器可能都是不同的具体类型,但每个实现都是ClickCallback。 这里的一个直接困难是,如果我们谈论的是一组异构类型,则每个类型都有一个不同的大小 -那么我们如何布置内部向量呢?答案是通常的答案:间接。我们将在向量中存储 指向回调的指针:

1
2
3
4
struct Button {
    listeners: Vec<Box<ClickCallback>>,
    ...
}

在这里,我们将ClickCallback特征当作类型使用。实际上,在 Rust 中,特征是类型,但是它们是“未调整大小”的,这大致意味着它们仅允许显示在指针Box(指向堆)或&(可以指向任何地方)后面。

拉斯特,像一个类型&ClickCallbackBox<ClickCallback>称为“性状对象”,并包括一个指针指向一个类型的一个实例T实现 ClickCallback,和一个虚函数表:一个指针T的实现中该性状的每个方法的(这里,只on_click)。该信息足以在运行时正确地调度对方法的调用,并确保所有方法的统一表示T。因此只Button被编译一次,并且抽象在运行时继续存在。

静态和动态调度是互补的工具,每种工具都适合不同的情况。 Rust 的特征提供了一个单一的,简单的界面概念,可以在两种样式中使用该界面,而且成本最低且可预测。特性对象满足Stroustrup的“随用随付”原则:需要时有vtable,但不需要时可以静态编译相同的特征。

特质的多种用途

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

  • 关闭。像ClickCallback特征一样, Rust 中的闭包只是特殊的特征。您可以在Huon Wilson 关于该主题的深入文章中阅读有关此工作原理的更多信息。

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

1
2
3
4
5
6
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仅在其组件起作用时才实现-允许Pair在不同的上下文中使用单个类型,同时支持每种上下文可用的最大API。这是 Rust 中的一种常见模式,内置了对自动生成某些“机械”实现的支持:

1
2
#[derive(Hash)]
struct Pair<A, B> { .. }
  • 扩展方法。与C#的扩展方法类似,为了方便起见,可以使用 traits 来使用新方法扩展现有类型(在其他地方定义)。这直接不属于特征的范围规则:您只需在特征中定义新方法,为所讨论的类型提供实现,然后voila可用。

  • 标记。锈病的“标志”是分类类型屈指可数:Send, Sync,Copy,Sized。这些标记都只是性状空体,然后可以用了泛型和特质对象使用。标记可以在库中定义,它们会自动提供#[derive]-style实现:Send例如,如果所有类型组件都是,则类型也是如此。如我们之前所见,这些标记可以非常强大:SendRust保证线程安全的标记。

  • 重载。在使用多个签名定义相同方法的情况下, Rust 不支持传统重载。但是特性提供了重载的许多好处:如果在特性上泛型定义了一个方法,则可以使用实现该特性的任何类型来调用它。与传统的重载相比,这有两个优点。首先,这意味着重载不是 特别的:一旦了解了特征,就立即了解使用该特征的所有API的重载模式。其次,它是可扩展的:您可以通过提供新的特征实现,有效地在方法的下游提供新的重载。

  • 运营商。 Rust 允许您像+自己的类型一样重载运算符。每个运算符都由相应的标准库特征定义,并且实现该特征的任何类型也将自动提供该运算符。

 关键是:尽管特征看起来很简单,但特征是一个统一的概念,它支持各种用例和模式,而不必依赖其他语言功能。

未来

 语言趋向于发展的主要方式之一是在其抽象设施中, Rust 也不例外:1.0版之后的许多优先事项 是特征系统在一个方向或另一个方向上的扩展。这里有一些亮点。

  • 静态调度输出。现在,函数可以将泛型用作其参数,但其结果却没有等效项:您不能说“此函数返回实现该Iterator特征的某种类型的值”并将抽象抽象化。当您要返回您希望静态分配的闭包时,这尤其成问题-在当今的Rust中,您根本无法这样做。我们希望使之成为可能,并且已经有了一些想法。

  • 专业化。 Rust 不允许特征实现之间的重叠,因此,对于要运行的代码始终没有歧义。另一方面,在某些情况下,您可以为各种类型提供“空白”实现,但是随后出于性能原因,想在少数情况下提供更专业的实现。我们希望在不久的将来提出设计方案。

  • 高型类型(HKT)。今天的特性只能应用于类型,而不能应用于类型构造函数,也就是说Vec<u8>,不能应用于Vec自身。此限制使得很难提供一组良好的容器特征,因此,这些特征不包含在当前的标准库中。HKT是一项重要的跨领域功能,它将在 Rust 的抽象功能上迈出一大步。

  • 高效重复利用。最后,尽管特征提供了一些重用代码的机制(我们在上面没有介绍),但仍有一些重用模式不适用于当今的语言-尤其是在诸如DOMGUI框架和许多游戏。在不增加过多重叠或复杂性的情况下适应这些用例是一个非常有趣的设计问题,而Niko Matsakis已针对该问题开始了单独的博客系列。尚不清楚这是否可以用特质来完成,或者是否需要其他成分。

 当然,我们正处在1.0版本的前夕,这需要一些时间才能尘埃落定,并且社区需要足够的经验来开始着手这些扩展。但是,这使它成为一个令人兴奋的时间:从在早期阶段影响设计到实施,再到在您自己的代码中尝试不同的用例,我们很乐意为您服务!

Licensed under CC BY-NC-ND 4.0
使用 Hugo 构建
主题 StackJimmy 设计