改进限制太多的 Rust 库 API
共 1514字,需浏览 4分钟
·
2021-12-22 18:05
在我之前的一篇文章“如何编写 CRAP Rust 代码[1]”中,我警告过不要过度使用泛型。对于二进制 crate 或任何代码的初始版本,这仍然是一个好主意。
然而,在设计 Rust 库 crate API 时,你通常可以使用泛型来获得良好的效果:对我们的输入更加宽容可能会为调用者提供避免某些分配的机会,或者以其他方式找到更适合他们的输入数据的不同表示。
在本指南中,我们将演示如何在不丢失任何功能的情况下使 Rust 库 API 更加宽松。但在我们开始之前,让我们检查一下这样做的可能缺点。
首先,泛型函数为类型系统提供的关于什么是什么的信息较少。如果原来的具体类型现在变成了impl
,编译器将更难推断每个表达式的类型(并且可能会更频繁地失败)。这可能需要你的用户添加更多类型注释来编译他们的代码,从而导致更糟糕的人体工程学。
此外,通过指定一种具体类型,我们可以将函数的一个版本编译到结果代码中。使用泛型,我们要么付出动态调度的运行时成本代价,要么通过选择单态化来[2]冒着使二进制文件膨胀的风险——在 Rust 术语中,我们选择 dyn Trait
vs. impl Trait
。
你选择权衡哪一点主要取决于场景。请注意,动态调度有一些运行时成本,但代码膨胀也会降低缓存命中率,从而对性能产生负面影响。一如既往,测量两次,编码一次。
即便如此,对于所有公共方法,你都可以遵循一些经验法则。
01 部分 traits
如果可以的话,取一个切片 (&[T]
) 而不是一个 &Vec
(那个实际上有一个clippy lint[3])。你的调用者可能会使用一个 VecDeque
,它有一个 .make_continuous()
方法,此方法返回一个 &mut [T]
而不是一个 Vec
,或者可能是一个数组。
如果你还可以取两个切片,VecDeque::as_slices
可以在不移动任何值的情况下为你的用户工作。当然,你仍然需要了解你的场景来决定这是否值得。
如果你只取消引用切片元素,则可以使用&[impl Deref
. 请注意,除Deref
之外,还有AsRef
trait,它在路径处理中经常使用,因为std
方法可能需要一个AsRef
的引用转换。
例如,如果你使用一组文件路径,&[impl AsRef
将使用比&[String]
更多的类型:
fn run_tests(
config: &compiletest::Config,
filters: &[String],
mut tests: Vec,
) -> 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],
mut tests: Vec,
) -> Result<bool, io::Error> {
// ..
现在filters
可能是String
、&str
、 甚至Cow<'_, OsStr>
的切片。对于可变类型,有AsMut
。类似地,如果我们要求任何引用T
在相等、顺序和散列方面都与T
它自身相同,我们可以使用Borrow
/BorrowMut
代替。
那有什么意思?这意味着实现 Borrow
的类型必须保证a.borrow() == b.borrow()
、a.borrow() < b.borrow()
和a.borrow().hash()
,如果所讨论的类型分别实现 Eq
、Ord
和 Hash
,则返回与 a == b
、a < b
和 a.hash()
相同。
02 让我们再重复一遍
类似地,如果你只迭代 str
切片的字节,除非你的代码需要 UTF-8 str
和 String
以某种方式来保证正常工作,否则你可以简单地接受一个 AsRef<[u8]>
参数。
一般来说,如果你只迭代一次,你甚至可以选择一个 Iterator
, 这允许你的用户提供他们自己的迭代器,这些迭代器可能会使用非连续的内存切片,将其他操作与你的代码穿插在一起,甚至可以即时计算你的输入。这样做,你甚至不需要使项目类型泛型,因为如果需要,迭代器通常可以轻松生成一个 T
。
实际上,如果你的代码只迭代一次,你可以使用 impl Iterator
;如果你不止一次需要这些项目,需要使用一两个切片。如果你的迭代器返回拥有的项目(item),例如最近添加的数组 IntoIterator
,你可以放弃 impl Deref
并使用 impl Iterator
。
不幸的是,IntoIterator
的 into_iter
会消耗 self
,所以没有通用的方法来获取让我们迭代多次的迭代器 — 除非,获取 impl Iterator<_> + Clone
的参数,但 Clone
操作可能代价高昂,所以我不建议使用它。
03 Into
与性能无关,但通常受欢迎的是参数 impl Into<_>
的隐式转换。这通常会使 API 感觉很神奇,但要注意:Into
转换可能很昂贵。
尽管如此,你还是可以使用一些技巧来获得出色的可用性。例如,使用 一个 Into
而不是一个 Option
,将使用户省略 Some
。例如:
use std::collections::HashMap;
fn with_optional_args<'a>(
_foo: u32,
bar: impl Into<Option<&'a str>>,
baz: impl Into<OptionString, 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
类型。这是另一个例子,我们可以在漂亮的 API 和明显的成本之间做出选择。一般来说,在 Rust 中选择后者通常被认为是符合 Rust 惯用法的。
04 控制代码膨胀
Rust 将通用代码单态化。这意味着对于你的函数被调用的每个唯一类型,将生成并优化使用该特定类型的所有代码的版本。
这样做的好处是它会导致内联和其他优化,从而为 Rust 提供我们都知道和喜爱的出色性能品质。但它有一个缺点,即可能会生成大量代码。
作为一个可能的极端示例,请考虑以下函数:
use std::fmt::Display;
fn frobnicate_arrayconst N: usize>(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
方法现在只包含一个循环和一个方法调用,不需要太多的代码来实例化。避免了代码膨胀!
通常,最好仔细查看方法的接口并查看泛型在何处被使用或丢弃。在这两种情况下,都有一个自然边界,我们可以在该边界处分解出删除泛型的函数。
如果您不想要所有这些类型并且可以添加一点编译时间,那么你可以使用我的momo[4] crate 来提取通用特征,例如 AsRef
或 Into
。
05 代码膨胀有什么不好?
对于某些背景,代码膨胀有一个不幸的后果:今天的 CPU 使用缓存层次结构。虽然这些在处理本地数据时允许非常快的速度,但它们对使用产生非常非线性的影响。如果你的代码占用了更多的缓存,它可能会使其他代码运行得更慢!因此,Amdahl 定律[5]不再帮助你在处理内存时找到优化的地方。
一方面,这意味着通过测量微基准测试单独优化部分代码可能会适得其反(因为整个代码实际上可能会变慢)。另一方面,在编写库代码时,优化库可能会使用户的代码变得更差。但是你和他们都无法从微基准测试中学到这一点。
那么,我们应该如何决定何时使用动态分派以及何时生成多个副本?我在这里没有明确的规则,但我注意到动态调度在 Rust 中肯定没有得到充分利用!首先,它被认为性能较差(这并不完全错误,考虑到函数表查找确实增加了一些开销)。其次,通常不清楚如何在避免分配的同时[6]做到这点。
即便如此,如果测试表明它是有益的,Rust 可以很容易地从动态调度到静态调度,并且由于动态调度可以节省大量编译时间,我建议在可能的情况下开始动态调用,并且只有在测试显示它时才采用单态要更快。这为我们提供了快速的运行时间,从而有更多时间改进其他地方的性能。最好有一个实际的应用程序来衡量,而不是一个微基准。
我对如何在 Rust 库代码中有效使用泛型的介绍到此结束。快快乐乐地去使用 Rust 吧!
原文链接:https://blog.logrocket.com/improving-overconstrained-rust-library-apis/
参考资料
如何编写 CRAP Rust 代码: https://blog.logrocket.com/how-to-write-crap-rust-code
[2]单态化来: https://en.wikipedia.org/wiki/Monomorphization
[3]T]) 而不是一个
&Vec
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
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio