改进限制太多的 Rust 库 API

语言: CN / TW / HK

关注「 Rust编程指北 」,一起学习 Rust,给未来投资

在我之前的一篇文章“ 如何编写 CRAP Rust 代码 [1] ”中,我警告过不要过度使用泛型。对于二进制 crate 或任何代码的初始版本,这仍然是一个好主意。

然而,在设计 Rust 库 crate API 时,你通常可以使用泛型来获得良好的效果:对我们的输入更加宽容可能会为调用者提供避免某些分配的机会,或者以其他方式找到更适合他们的输入数据的不同表示。

在本指南中,我们将演示如何在不丢失任何功能的情况下使 Rust 库 API 更加宽松。但在我们开始之前,让我们检查一下这样做的可能缺点。

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

此外,通过指定一种具体类型,我们可以将函数的一个版本编译到结果代码中。使用泛型,我们要么付出动态调度的运行时成本代价,要么通过选择 单态化来 [2] 冒着使二进制文件膨胀的风险——在 Rust 术语中,我们选择 dyn Trait vs. impl Trait

你选择权衡哪一点主要取决于场景。请注意,动态调度有一些运行时成本,但代码膨胀也会降低缓存命中率,从而对性能产生负面影响。一如既往,测量两次,编码一次。

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

01 部分 traits

如果可以的话,取一个切片 ( &[T] ) 而不是一个 &Vec<T> (那个实际上有一个 clippy lint [3] )。你的调用者可能会使用一个 VecDeque ,它有一个 .make_continuous() 方法,此方法返回一个 &mut [T] 而不是一个 Vec ,或者可能是一个数组。

如果你还可以取两个切片, VecDeque::as_slices 可以在不移动任何值的情况下为你的用户工作。当然,你仍然需要了解你的场景来决定这是否值得。

如果你只取消引用切片元素,则可以使用 &[impl Deref<Target = T>] . 请注意,除 Deref 之外,还有 AsRef trait,它在路径处理中经常使用,因为 std 方法可能需要一个 AsRef<T> 的引用转换。

例如,如果你使用一组文件路径, &[impl AsRef<Target = Path>] 将使用比 &[String] 更多的类型:

fn run_tests(
    config: &compiletest::Config,
    filters: &[String],
    mut tests: Vec<tester::TestDescAndFn>,
) -> Result<bool, io::Error> { 
    // 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<Path>],
    mut tests: Vec<tester::TestDescAndFn>,
) -> Result<bool, io::Error> { 
// ..

现在 filters 可能是 String&str 、 甚至 Cow<'_, OsStr> 的切片。对于可变类型,有 AsMut<T> 。类似地,如果我们要求任何引用 T 在相等、顺序和散列方面都与 T 它自身相同,我们可以使用 Borrow<T> / BorrowMut<T> 代替。

那有什么意思?这意味着实现 Borrow 的类型必须保证 a.borrow() == b.borrow()a.borrow() < b.borrow()a.borrow().hash() ,如果所讨论的类型分别实现 EqOrdHash ,则返回与 a == ba < ba.hash() 相同。

02 让我们再重复一遍

类似地,如果你只迭代 str 切片的字节,除非你的代码需要 UTF-8 strString 以某种方式来保证正常工作,否则你可以简单地接受一个 AsRef<[u8]> 参数。

一般来说,如果你只迭代一次,你甚至可以选择一个 Iterator<Item = T> , 这允许你的用户提供他们自己的迭代器,这些迭代器可能会使用非连续的内存切片,将其他操作与你的代码穿插在一起,甚至可以即时计算你的输入。这样做,你甚至不需要使项目类型泛型,因为如果需要,迭代器通常可以轻松生成一个 T

实际上,如果你的代码只迭代一次,你可以使用 impl Iterator<Item = impl Deref<Target = T>> ;如果你不止一次需要这些项目,需要使用一两个切片。如果你的迭代器返回拥有的项目(item),例如最近添加的数组 IntoIterator ,你可以放弃 impl Deref 并使用 impl Iterator<Item = T>

不幸的是, IntoIteratorinto_iter 会消耗 self ,所以没有通用的方法来获取让我们迭代多次的迭代器 — 除非,获取 impl Iterator<_> + Clone 的参数,但 Clone 操作可能代价高昂,所以我不建议使用它。

03 Into

与性能无关,但通常受欢迎的是参数 impl Into<_> 的隐式转换。这通常会使 API 感觉很神奇,但要注意: Into 转换可能很昂贵。

尽管如此,你还是可以使用一些技巧来获得出色的可用性。例如,使用 一个 Into<Option<T>> 而不是一个 Option<T> ,将使用户省略 Some 。例如:

use std::collections::HashMap;

fn with_optional_args<'a>(
    _foo: u32,
    bar: impl Into<Option<&'a str>>,
    baz: impl Into<Option<HashMap<String, u32>>>
) {
    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 惯用法的。

04 控制代码膨胀

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

这样做的好处是它会导致内联和其他优化,从而为 Rust 提供我们都知道和喜爱的出色性能品质。但它有一个缺点,即可能会生成大量代码。

作为一个可能的极端示例,请考虑以下函数:

use std::fmt::Display;

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

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

如果我们可以处理引用的项目,我们可以不调整大小并迭代切片:

use std::fmt::Display;

fn frobnicate_slice<T: Display>(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 方法现在只包含一个循环和一个方法调用,不需要太多的代码来实例化。避免了代码膨胀!

通常,最好仔细查看方法的接口并查看泛型在何处被使用或丢弃。在这两种情况下,都有一个自然边界,我们可以在该边界处分解出删除泛型的函数。

如果您不想要所有这些类型并且可以添加一点编译时间,那么你可以使用我的 momo [4] crate 来提取通用特征,例如 AsRefInto

05 代码膨胀有什么不好?

对于某些背景,代码膨胀有一个不幸的后果:今天的 CPU 使用缓存层次结构。虽然这些在处理本地数据时允许非常快的速度,但它们对使用产生非常非线性的影响。如果你的代码占用了更多的缓存,它可能会使其他代码运行得更慢!因此, Amdahl 定律 [5] 不再帮助你在处理内存时找到优化的地方。

一方面,这意味着通过测量微基准测试单独优化部分代码可能会适得其反(因为整个代码实际上可能会变慢)。另一方面,在编写库代码时,优化库可能会使用户的代码变得更差。但是你和他们都无法从微基准测试中学到这一点。

那么,我们应该如何决定何时使用动态分派以及何时生成多个副本?我在这里没有明确的规则,但我注意到动态调度在 Rust 中肯定没有得到充分利用!首先,它被认为性能较差(这并不完全错误,考虑到函数表查找确实增加了一些开销)。其次,通常不清楚如何 在避免分配的同时 [6] 做到这点。

即便如此,如果测试表明它是有益的,Rust 可以很容易地从动态调度到静态调度,并且由于动态调度可以节省大量编译时间,我建议在可能的情况下开始动态调用,并且只有在测试显示它时才采用单态要更快。这为我们提供了快速的运行时间,从而有更多时间改进其他地方的性能。最好有一个实际的应用程序来衡量,而不是一个微基准。

我对如何在 Rust 库代码中有效使用泛型的介绍到此结束。快快乐乐地去使用 Rust 吧!

原文链接:https://blog.logrocket.com/improving-overconstrained-rust-library-apis/

参考资料

[1]

如何编写 CRAP Rust 代码: https://blog.logrocket.com/how-to-write-crap-rust-code

[2]

单态化来: https://en.wikipedia.org/wiki/Monomorphization

[3]

T] ) 而不是一个 &Vec ` (那个实际上有一个[clippy lint: https://rust-lang.github.io/rust-clippy/master/index.html#ptr_arg

[4]

momo: https://github.com/llogiq/momo

[5]

Amdahl 定律: https://en.wikipedia.org/wiki/Amdahl's_law

[6]

在避免分配的同时: https://llogiq.github.io/2020/03/14/ootb.html

推荐阅读

觉得不错,点个赞吧

扫码关注「 Rust编程指北