改进过度约束的Rust库API

语言: CN / TW / HK

在我之前的一篇文章《如何编写CRaP Rust代码》中,我警告过你不要过度使用泛型。而对于一个二进制板条箱或任何代码的初始版本来说,这仍然是一个好主意。

然而,在设计Rust库板块的API时,你经常可以使用泛型来达到良好的效果:对我们的输入更宽松,可以为调用者提供机会,避免一些分配,或者找到更适合他们的输入数据的不同表示。

在本指南中,我们将演示如何在不损失任何功能的情况下使Rust库的API更加宽松。但在开始之前,我们先来看看这样做可能带来的弊端。

首先,泛型函数为类型系统提供的关于什么是什么的信息较少。如果原来的具体类型现在变成了impl ,编译器将更难推断出每个表达式的类型(并且可能会更经常地失败)。这可能需要你的用户添加更多的类型注释来使他们的代码得到编译,从而导致可以说是更糟糕的人机工程学。

另外,通过指定一个具体的类型,我们得到的正是我们的函数的一个版本被编译成结果代码。对于泛型,我们要么支付动态调度的运行时间成本,要么冒着用多个版本膨胀二进制的风险选择单态化--用Rust的术语来说,我们选择dyn Traitimpl Trait

你选择哪一点,主要取决于用例。请注意,动态调度有一定的运行时间成本,但代码臃肿也会增加缓存失误,因此会对性能产生负面影响。一如既往,两次测量,一次编码。

即便如此,对于所有的公共方法,你可以遵循一些经验法则。

特质的片断

如果可以的话,拿一个切片(&[T])来代替&Vec<T> (那个其实有一个剪切性的皮特)。你的调用者可能会使用一个 [VecDeque](http://doc.rust-lang.org/std/collections/struct.VecDeque.html),它有一个 [.make_continuous()](http://doc.rust-lang.org/std/collections/struct.VecDeque.html#method.make_contiguous)方法返回一个&mut [T] )而不是一个 [Vec](http://doc.rust-lang.org/std/vec/struct.Vec.html),或者是一个数组。

如果你也能采取两个片断。 [VecDeque::as_slices](http://doc.rust-lang.org/std/collections/struct.VecDeque.html#method.as_slices)可以为你的用户工作而不需要移动任何值。当然,你仍然需要了解你的用例来决定这是否值得。

如果你只取消引用你的片断元素,你可以使用&[impl Deref<Target = T>] 。请注意,除了 [Deref](http://doc.rust-lang.org/std/ops/trait.Deref.html)外,还有一个 [AsRef](http://doc.rust-lang.org/std/convert/trait.AsRef.html)特质,它在路径处理中经常被使用,因为std 方法可能需要一个AsRef<T> 来进行廉价的引用转换。

例如,如果你要取一组文件路径,&[impl AsRef<Target = Path>] 会比&[String] 工作的类型多得多。

``` fn run_tests( config: &compiletest::Config, filters: &[String], mut tests: Vec, ) -> Result { // much code omitted for brevity for filter in filters { if dir_path.ends_with(&*filter) { // etc. } } // .. }

```

上述情况可能会被表达为。

``` fn run_tests( config: &compiletest::Config, filters: &[impl std::convert::AsRef], mut tests: Vec, ) -> Result { // ..

```

现在filters 可以是String&str ,甚至是Cow<'_, OsStr> 的一个片断。对于可变类型,有 [AsMut<T>](http://doc.rust-lang.org/std/convert/trait.AsMut.html).同样地,如果我们要求任何对T 的引用在平等、顺序和散列方面与T 本身的工作相同,我们可以使用 [Borrow<T>](http://doc.rust-lang.org/std/borrow/trait.Borrow.html)/ [BorrowMut<T>](http://doc.rust-lang.org/std/borrow/trait.BorrowMut.html)来代替。

这到底是什么意思呢?它意味着实现Borrow 的类型必须保证a.borrow() == b.borrow(),a.borrow() < b.borrow()a.borrow().hash() 的返回与a == b,a < ba.hash() 相同,如果该类型实现了 [Eq](http://doc.rust-lang.org/std/cmp/trait.Eq.html), [Ord](http://doc.rust-lang.org/std/cmp/trait.Ord.html)[Hash](http://doc.rust-lang.org/std/hash/trait.Hash.html)的类型,必须保证、 和 的返回值与、 和 相同。

让我们重新迭代

同样,如果你只迭代一个string slice的字节,除非你的代码在某种程度上需要strString 所保证的UTF-8-才能正确工作,否则你可以简单地取一个AsRef<[u8]> 的参数。

一般来说,如果你只迭代一次,你甚至可以把一个 [Iterator<Item = T>](http://doc.rust-lang.org/std/iter/trait.Iterator.html).这允许你的用户提供他们自己的迭代器,这些迭代器可能使用非连续的内存片,在你的代码中穿插其他操作,甚至在运行中计算你的输入。这样做,你甚至不需要使项目类型通用,因为如果需要的话,迭代器通常可以很容易地产生一个T

实际上,如果你的代码只迭代一次,你可以使用一个impl Iterator<Item = impl Deref<Target = T>> ;如果你需要项目超过一次,就使用一个或两个片断。如果你的迭代器返回拥有的项目,比如最近添加的数组IntoIterators,你可以放弃impl Deref ,使用impl Iterator<Item = T>

不幸的是,IntoIterator'sinto_iter 将会消耗self ,所以没有通用的方法来取一个迭代器,让我们迭代多次--除非,也许,取一个impl Iterator<_> + Clone 的参数,但是这个 [Clone](http://doc.rust-lang.org/std/clone/trait.Clone.html)操作可能会很昂贵,所以我不建议使用它。

进入森林

与性能无关,但也经常受到欢迎的是impl Into<_> 参数的隐式转换。这通常可以使一个API感觉很神奇,但要注意。 [Into](http://doc.rust-lang.org/std/convert/trait.Into.html)转换可能是昂贵的。

不过,还是有一些小技巧可以让你在可用性方面取得不错的成绩。例如,用一个Into<Option<T>> ,而不是一个 [Option<T>](http://doc.rust-lang.org/std/option/enum.Option.html)会让你的用户省略Some 。比如说。

``` use std::collections::HashMap;

fn with_optional_args<'a>( _foo: u32, bar: impl Into<Option<&'a str>>, baz: impl Into>> ) { let _bar = bar.into(); let _baz = baz.into(); // etc. }

// we can call this in various ways: with_optional_args(1, "this works", None); with_optional_args(2, None, HashMap::from([("boo".into(), 0)])); with_optional_args(3, None, None);

```

同样,可能有一些类型以一种昂贵的方式实现Into<Option<T>> 。这是另一个例子,我们可以在漂亮的API和使成本明显化之间做出选择。一般来说,选择后者在Rust中通常被认为是习惯性的。

控制代码的膨胀

Rust对通用代码进行单态化。这意味着对于你的函数被调用的每一个独特的类型,它的所有代码的一个版本将被生成和优化,使用该特定类型。

这样做的好处是,它导致了内联和其他优化,使Rust具有我们所熟知和喜爱的强大性能。它也有一个缺点,那就是可能会有大量的代码被生成。

作为一个可能的极端例子,考虑以下函数。

``` use std::fmt::Display;

fn frobnicate_array(array: [T; N]) { for elem in array { // ...2kb of generated machine code } }

```

这个函数将为每个项目类型和数组长度都被实例化,即使我们只是迭代。不幸的是,没有办法避免代码的臃肿,也没有办法避免复制/克隆,因为所有这些迭代器的类型中都包含它们的大小。

如果我们能做到引用项目,我们可以不使用大小,而是在片上进行迭代。

``` use std::fmt::Display;

fn frobnicate_slice(slice: &[T]) { for elem in slice { // ...2kb of generated machine code } }

```

这至少可以使每个项目类型只产生一个版本。即使如此,假设我们只使用数组或切片来迭代。然后我们可以把一个依赖于类型的frobnicate_item 方法考虑进去。更重要的是,我们可以决定是使用静态调度还是动态调度。

``` use std::fmt::Display;

/// This gets instantiated for each type it's called with fn frobnicate_with_static_dispatch(_item: impl Display) { todo!() }

/// This gets instantiated once, but adds some overhead for dynamic dispatch /// also we need to go through a pointer fn frobnicate_with_dynamic_dispatch(_item: &dyn Display) { todo!() }

```

外围的frobnicate_array 方法现在只包含一个循环和一个方法调用,这就没有那么多代码需要实例化。避免了代码臃肿!

一般来说,好好看看你的方法的接口,看看泛型在哪里被使用或被抛弃,这是一个好主意。在这两种情况下,都有一个自然的边界,我们可以用一个函数来移除泛型。

如果你不想要所有这些类型,并且可以接受增加一小部分编译时间,你可以使用我的momocrate来去除泛型特征,如AsRefInto

代码臃肿有什么不好?

对于一些背景,代码膨胀有一个不幸的后果:今天的CPU采用了一个层次结构的缓存。虽然这些缓存在处理本地数据时有很好的速度,但它们导致了非常非线性的使用效果。如果你的代码占用了更多的缓冲区,可能会使其他的代码变得更慢所以阿姆达尔定律不再能帮助你在处理内存时找到需要优化的地方。

首先,这意味着通过测量微基准来孤立地优化你的一部分代码可能会适得其反(因为整个代码实际上可能会变得更慢)。另一个方面,在编写库的代码时,优化你的库可能会使你的用户的代码变得悲观。但你和他们都无法从微观基准中了解到这一点。

那么,我们应该如何决定何时使用动态调度,何时生成多个副本?我在这里没有一个明确的规则,但我确实注意到,动态调度在Rust中肯定没有得到充分的使用!首先,它被认为是比较慢的(这并不完全是错的,考虑到vtable的查找确实增加了一些开销)。其次,如何在避免分配的同时达到目的这一点往往是不清楚的。

即便如此,如果测量结果显示从动态调度到静态调度是有益的,Rust使之变得足够容易,而且由于动态调度可以节省大量的编译时间,我建议在可能的情况下从动态调度开始,只有在测量结果显示单态调度更快时才去做。这给了我们一个快速的周转时间,从而有更多的时间来提高其他方面的性能。最好是有一个实际的应用来测量,而不是一个微基准。

我关于如何在Rust库代码中有效使用泛型的咆哮到此结束。去吧,快乐的Rust!

The postImproving overconstrained Rust library APIsappeared first onLogRocket Blog.