2

(https://llogiq.github.io/2015...

30 July 2015

如标题中明示的, 今天我要写一下Rust标准库中带来的 traits, 特别是从标准库作者的角度, 向用户提供一个好的体验.

注意, 我将"内置"定义为"Rust安装包中所自带的". 这些 traits 没有特殊的语言机制.

Rust 在很多地方使用了 traits, 从非常浅显的操作符重载, 到 Send, Sync 这种非常微妙的特性. 一些 traits 是可以被自动派生的(你只需要写#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Hash, ...)] 就能得到一个神奇的实现, 它通常是对的. 至于 Send 和 Sync, 你必须主动选择关闭它们的实现.

所以, 我会试着从浅显具体的部分开始, 直到那些模糊甚至(也许)令人惊讶的部分.

(Partial)-Eq/Ord

PartialEq 定义了部分相等. 这种关系具有对称性(对于该类型的任意a,b, 有 a == b → b == a)以及传递性(对于该类型的任意a,b,c, 有 a == b ∧ b == c → a == c).

Eq 被用来表示 PartialEq 并且是自反关系(对于该类型的所有a, 有 a == a).反例: f32 和 f64 类型实现了 PartialEq, 但没有 Eq, 因为 NAN != NAN.

实现这两个 traits 是很有用的, 标准库中很多的类型会使用它们来作为辨别界限, 例如: Vec的 dedup() 函数. 自动派生出的 PartialEq 将会对你的类型中的所有部分做相等检查(例如, 对于 structs, 所有部分都会被检查, 对于 enum 类型, 将检查该变体及其所有内容).

由于 Eq 的实现通常是空的(除了预先定义的marker方法, 以确保自动派生逻辑可以工作, 它不应该在其它地方被使用), 所以自动派生不会做一些有趣的事情.

PartialOrd 定义了部分顺序, 并通过 Ordering 关系扩展了 PartialEq 的相等性. 这里的 Partial 意味着你的类型的某些实例可能不能被有意义地比较.

Ord 需要完整的顺序关系. 与 PartialEq/Eq 相反, 这两个 trait 实际上使用了不同的接口(partial_cmp(...) 方法返回 Option<Ordering>, 所以它可以返回不可比较的实例 None, 而 Ord 的 cmp(...) 方法直接返回 Ordering), 它们唯一的关系就是, 如果你想实现 Ord, 那么你也必须实现 PartialOrd, 因为后者在前者的 trait 范围内.

自动派生将按照字典序来排列 structs, 按照定义中变体出现的顺序来排列 enums(除非你为变体定义了值).

如果你选择手动实现关系, 请注意确保稳定的排序关系, 以免你的程序因混乱而崩溃.

(Partial)Ord 作用于 <, <=, => 和 > 运算符.

算数运算符

下表是算数运算符与 traits 的关系:

Operator     Trait
a + b         Add
a - b         Sub
-a           Neg
a * b         Mul
a / b         Div
a % b         Rem

Rem 是 Remainder(求余)的缩写, 在有些语言中也叫 mod. 二元运算符都必须有一个默认为 Self 的RHS(右手边) 泛型绑定, 而且实现必须声明相关的 Output 类型.

这意味着你可以实现例如: Foo 加上 Bar 返回 Baz. 注意, 虽然操作符不会以任何方式限制它们的语义, 但强烈建议不要使它们的含义与所示的算数运算完全不同, 以免你的实现给其他开发者造成隐患.

另外: 在 Rust 1.0.0 之前, 有人为 String 和 Vec 实现了 Add, 作用是连接. 直到(许多人)心痛地请求了 Rust 的神之后, 才为Vec修复了这个错误. 也就是说, 你仍然可以写 my_string + " etc." 只要 my_string 是一个 String ---- 注意, 这会消费掉 my_string 的值, 可能会使一些人感到困惑.

位运算符

以下操作符被定义用于位运算. 不同于!运算符, 短路运算符&&||不可以被重载----因为这要求它们不能 eager 地执行它们的参数, 这在 Rust 中不容易做到 ---- 即使这是可能的, 例如: 使用闭包作为解决方法, 也只会让其他开发者感到困惑.

Operator     Trait
!a             Not
a & b         BitAnd
a | b         BitOr
a ^ b         BitXor
a << b         Shl
a >> b         Shr

就像所有的操作符一样, 当你有特殊的理由要为你的类型实现它们时, 要格外小心. 例如: 为 BitSets(从1.3.0起它不再是标准库的一部分) 重定义它们中一些是有意义的, 或者是对表示巨大整数的类型.

Index 和 IndexMut

Index 和 IndexMut traits 制定了对不可变和可变类型的索引操作. 前者是只读的, 后者允许赋值和修改, 即调用一个参数为 &mut 的函数(注意这不一定是 self ).

你可以对任何 collection 类型来实现它们. 在其它情况下使用这些 traits 都会变成绊脚石.

Fn, FnMut 和 FnOnce

Fn* 类的 traits 是对调用某些东西的抽象. 它们的差别仅仅是如何处理 self: Fn 使用引用, FnMut 使用可变引用, FnOnce 消费掉它的值(这就是为什么只能被调用一次, 因为之后没有 self 可供调用了).

注意, 这些区别只针对与 self, 与其它任何参数无关. 用可变引用甚至是 owned/moved 的值作为参数来调用 Fn 是完全正确的.

这些 traits 是为函数和闭包自动派生出来的, 我还没有见过它们其它的使用场景. 它们实际上不可以在稳定版的 Rust 中被实现.

Display 和 Debug

Display 和 Debug 用于格式化值. 前者是为了产生面向用户的输出, 所以不可以自动派生, 而后者通常会产生类似JSON的表示, 并且可以安全地为大多数类型自动派生.

如果你确定手动实现 Debug, 你可能需要区分正常的{:?}和整洁格式的{:#?}表示符. 最简单的方式是使用 Debug Builder 方法. Formatter 类型具有许多非常有用的方法, 例如 debug_struct(&mut self, &str), debug_tuple(&mut self, &str) 等等.

或者, 你可以通过查询 Formatter::flags() 方法来做到这一点, 该方法将返回4比特位. 因此, 如果(f.flags() & 4) == 4为真, 那么调用者就是在请求你输出整洁格式.

说真的, 如果你有需要, 请使用自动派生 Debug 或者是 debug builders.

除此之外: 这不是很常见, 但Rust中有可能会出现循环对象图, 它会把 debug 逻辑发送到无限循环里(通常, 应用会因为栈溢出而崩溃). 在大多数情况下, 这是可以接受的, 因为循环非常罕见. 当你怀疑你的类型比平均更频繁地形成循环时, 你可能需要处理这些.

Copy 和 Clone

这两个 traits 用于复制对象.

Copy 表明你的类型可以被安全地复制. 这意味这如果你复制了类型的值所在的内存, 就会得到一个新的有效的值, 而不会引用原始数据. 它可以是自动派生的(需要 Clone, 因为根据定义, 所有可 Copy 的类型都可 Clone). 事实上, 从来不需要手动地实现它.

这里有三个不实现 Copy 的理由:

  1. 你的类型不可以被 Copy, 因为它包含了可变引用, 或者已实现了 Drop.

  2. Rust Guru eddyb 指出, 除非你使用引用, 否则 Rust 会复制所有东西, 而它们的体积可能很大.

  3. 事实上你需要的是 move 语义.

第三个原因需要进一步的解释. 默认情况下, Rust 已经有了 move 语义---- 如果你将a的值从a赋值为b, a不再具有原来的值. 然而, 对于实现了 Copy 的类型, 其值实际上被复制了(除非原来的值不再被使用, 这时LLVM可能会删除副本以提高性能). Copy 的文档中有更多的细节.

Clone 是一个更通用的解决方案, 会顾及到所有的引用. 你可能想要在大多数情况下自动派生(因为能够 Clone 的值是非常有用的), 只有类似自定义引用计数规则, 垃圾回收等等情况下, 才会需要手动实现它.

Copy 实际上改变了赋值语义, 而 Clone 是显式的: 它定义了 .clone() 方法, 你必须要手动调用.

Drop

Drop trait 的作用是当事物到达范围之外时, 归还所占用的资源. 关于此已经有过很多讨论了, 以及你为什么不应该直接调用它. 不过, 这十分适用于包装 FFI 结构, 它们需要在稍后才被回收, 同时也适用于文件, sockets, 数据库句柄以及厨房水槽.

除非你有一个合适的场景, 否则你应该避免自己实现 Drop ---- 默认情况下, 你的值总会被正确地 Drop. 一个(临时的)异常是插入一些输出来跟踪特定的值是何时被 drop 的.

Default

Default 用于声明类型的默认值. 它可以被自动派生, 但只适用于所有成员都有 Default 实现的结构.

它在标准库中被许多类型实现了, 并且可以在很多地方使用. 所以如果你的类型有一个可以被认为是"默认"的值, 那么实现这个 trait 是一个好主意.

Default 很棒的地方在于, 当你初始化一个值时, 你只需要设定非默认的部分就可以了:

let x = Foo { bar: baz, ..Default::default() }

然后 Foo 的其余42个字段都会被默认值填充. 很酷吧? 事实上, 不实现 Default 唯一的理由是你的类型并没有一个可以作为默认值的值.

Error

Error 是 Rust 中所有表示错误的值的基本 trait. 对Java熟悉的人来说, 相当于是 Throwable, 它们的行为也类似(除了我们既不 catch 也不 throw 它们).

为之后在 Result 中所使用的任何类型都实现 Error, 是一个好主意. 这会使你的函数更加便于组合, 尤其是当你可以简单地用 Box 来把 Error 变为一个 trait 对象.

查看 Rust book 的 Using try! 章节以获得更多信息.

Hash

哈希是将一包数据减少为单个值的过程,不同的数据哈希后的值依然不同,相同的依然相同,而不需要比较哈希前的数据那么多的位.

在Rust中, Hash trait 表示该类型是否可以被哈希。 请注意,这个特性并不涉及任何有关哈希算法的信息(这是封装在 Hasher trait中),它基本上只是命令这些比特位被哈希.

另外:这也是为什么HashMap自己没有实现Hash的原因,因为两个相同的哈希映射仍然可以以不同的顺序存储它们的内容,导致不同的哈希,这会破坏哈希合约。 即使这些项目是有序的(参见上面的Ord ),对它们进行哈希处理也需要进行排序,这样做太昂贵了,无法使用。 也可以使用入口哈希值,但是这需要重新使用 Hasher,至少需要一个Clone界限,缺少这样的接口。 无论如何,如果您必须用 map 作为哈希映射的key,请使用BTreeMap。 在这种情况下,你也应该考虑性能因素。

你可以安全地自动派生Hash, 除非你对于平等的一些非常具体的限制。 如果您选择手动实施,请小心不要违约,以免程序出人意料,难以调试。

Iterator 和它的朋友们

Rust的 for 循环可以遍历所有实现了 IntoIterator 的类型。 是的,这包括Iterator本身。 除此之外, Iterator特性有很多很酷的方法来处理迭代的值,比如filter, map, enumerate, fold, any, all, sum, min等等。

我有没有告诉你我喜欢迭代器? 如果你的类型包含一个以上的值,并且对所有的Iterator都做同样的事情是有意义的,考虑为它们提供一个Iterator以防万一。 :-)

实现Iterator实际上很简单 - 只需要声明Item类型并写入next(&mut self) -> Option<Self::Item>方法。 只要你有值,这个方法应该返回Some(value) ,然后返回None来停止迭代。

请注意,如果你有一个值(或一个数组或vec,你可以从borrow一个切片)的一部分,你可以直接得到它的迭代器,所以你甚至不需要自己实现它。 这可能不像自动派生那样酷,但它仍然很好。

While writing optional, I found that using a const slice’s iterator is faster in the boolean case, but creating a slice of the value is still slower than copying it for most values. Your mileage may vary.

From, Into 和 各种变化

我之前说过,设计了 From 和 Into 的人是一个天才。 它们抽象了类型之间的转换(经常使用),并允许库作者使它们的库更加容易互操作,例如通过使用Into<T>而不是T作为参数。

由于显而易见的原因,这些 traits 不能自动生成,但是在大多数情况下实现它们应该是微不足道的。 如果您选择实现它们 - 当你找到值得转换的地方时就应该这样做! - 尽可能先实现 From, 如果失败再实现 Into。

为什么? 对于U: From<T> 有一揽子的 Into<U> 的实现。 这意味着如果你已经实现了From ,你可以免费得到 Into。

为什么不在所有地方实现 From ? 不幸的是,孤儿规则禁止在其他 crates 中实现某类型的 From。 例如,我有一个Optioned<T>类型,我可能想要转换成Option<T> 。 试图实现 From :

impl<T: Noned + Copy> From<Optioned<T>> for Option<T> {
    #[inline]
    fn from(self) -> Option<T> { self.map_or_else(|| none(), wrap) }
}

我得到一个错误:类型参数T必须用一些本地定义的类型参数(例如MyStruct<T> ); 只有当前 crate 中定义的 trait 才能用于类型参数[E0210]

请注意,你可以使用多个类实现From和Into ,对于相同的类型,可以有From<Foo>和From<Bar> 。

有很多以Into - IntoIterator开始的 trait,它们是稳定的,我们已经在上面讨论过了。 也有FromIterator ,它的作用相反, 即从项目的迭代器构造你的类型的值。

然后有FromStr可以用于任何可以由字符串转换而来的类型,这对于你想要从任何文本源文件中读取的类型非常有用,例如配置或用户输入。 请注意,它的接口不同于From<&str> ,因为它返回一个Result ,因此允许将解析错误与调用者相关联。

Deref(Mut), AsRef/AsMut, Borrow(Mut) 和 ToOwned

这些都与 references 和 borrowing 有关,所以我把它们分成一个部分。

前缀操作符表示对一个引用的引用消除*,得到它的值。 这直接代表了Deref trait; 如果我们需要一个可变值(例如分配什么东西或调用一个变化函数),我们会调用DerefMut trait。

请注意,这并不一定意味着消耗这个值 - 也许我们可以在同一个表达式中引用它,例如 &*x (在处理特殊类型的指针的代码中,您可能会发现它,例如 syntax::ptr::P 在clippy和其他lints /编译器插件中被广泛使用,也许as_ref()在这些情况下会更清晰(见下文)。

Deref trait只有一个方法: fn deref(&'a self) -> &'a Self::Target; 其中 Target 是 trait 的相关类型。返回值的生命周期要与自己一样长。 这个要求将可能的实施策略限制为两个选择:

  1. 引用消除得到你的类型中的值,例如,如果您有一个struct Foo { b: Bar } ,则可以引用消除Bar 。 请注意,这并不意味着你应该这样做,但这是可能的,在某些情况下可能会有用。 只要这个部分的整个生命周期是一个整体,这就是Rust中生命周期的默认设置。

  2. 引用消除得到一个常量'static值 - 我在optional做到这一点,使OptionBool取消引用一个常量Option<bool> 。 这是有效的,因为结果是保证能够在程序的其余部分活着的。 这只有在你有一个有限的值域时才有用。 即使如此,使用Into代替Deref也许更为清楚。 我怀疑我们会经常看到这一点。

DerefMut 只有第一种策略。 它的用处仅限于实现特殊类型的指针。

为了明白为什么没有其他可能的实现,让我们进行一个思考实验:如果我们有一个返回值, 它既不是静态的,也不是被绑定到我们要引用消除的值的生命周期'a,那么定义中就有一个'b 不同于 'a 。 我们无法统一这两个生命周期 - QED。

至于其他 traits,它们主要是为了抽象某些类型的借用/引用行为(因为例如Vec, 可以借用它们的切片)。 因此,它们与From / Into属于同一类别 - 它们不会被幕后调用,但是存在某些更好用的接口。

Borrow , AsRef / AsMut和ToOwned之间的关系如下:

From↓/To→     Reference         Owned
Reference     AsRef/AsMut     ToOwned
Owned         Borrow(Mut)   (也许是Copy或Clone?)

可以看看我更早的关于std::borrow::Cow侦探故事, 里面有一些具体的例子 。

如果您决定实现 Borrow 和/或 BorrowMut ,则需要确保borrow()的结果与借入的原始值具有相同的哈希值,以免程序以奇怪和混乱的方式失​​败。

事实上,除非你的类型对所有权做了一些有趣的事情(比如Cow或owning_ref ),否则你应该避免 Borrow , BorrowMut和ToOwned ,如果你想抽象拥有/借用的值,就使用Cow 。

我还没有找到在什么情况下, AsRef/AsMut可能是有用的,除非你算上std已经提供的预定义的impl 。

Send 和 Sync

这两个trait表明, 该类型可以在线程之间传递.

你永远不需要实现它们----事实上, 除非你明确地拒绝(或者你的类型包含非线程安全的部分), 否则Rust会默认为你实现.你可以这样拒绝:

impl !Send for MyType {} // this type cannot be sent to other threads
impl !Sync for MyType {} // nor can it be used by two of them

注意, 目前在稳定版的Rust 中这是不可能的(也就是只有 std 可以使用这个技巧).

Send 表示可以再线程之间 move 你的类型, 而 Sync 允许在线程之间共享一个值. 让我们退一步看看这是什么意思, 这可能是最好的例子.

假设我们有一些问题, 我们打算通过并行计算一些值来解决(因为并发就是这样!). 为此, 我们需要一些在所有线程中都是相同的不可变数据----我们需要共享数据, 这些数据需要 Sync.

接下来, 我们要分配给每个线程. 要做到这一点, 我们需要 Send 给它们. 但是等等! 我们如何获取分享到线程的数据呢? 很简单: 我们 Send 的是引用----因为在标准库中有以下定义:

impl < 'a, T > Send & 'a T where T: Sync + ? Sized

这意味着如果有东西可以被 Sync, 你就可以在线程间 Send 它的引用.酷.

关于这些, 可以看看 Manish Goregaokar 的 Rust 如何实现线程安全 或者是 Sync docs


Thanks go to stebalien and carols10cents for (proof)reading a draft of this and donating their time, effort and awesome comments! This post wouldn’t have been half as good without them.

Have I missed, or worse, misunderstood a trait (or a facet of one)? Please write your extension requests on /r/rust or rust-lang users.

Public Domain Mark

The words on this blog are free of known copyright restrictions.


Ljzn
399 声望102 粉丝

网络安全;函数式编程;数字货币;人工智能