改進過度約束的Rust庫API

語言: CN / TW / HK

在我之前的一篇文章《如何編寫CRaP Rust程式碼》中,我警告過你不要過度使用泛型。而對於一個二進位制板條箱或任何程式碼的初始版本來說,這仍然是一個好主意。

然而,在設計Rust庫板塊的API時,你經常可以使用泛型來達到良好的效果:對我們的輸入更寬鬆,可以為呼叫者提供機會,避免一些分配,或者找到更適合他們的輸入資料的不同表示。

在本指南中,我們將演示如何在不損失任何功能的情況下使Rust庫的API更加寬鬆。但在開始之前,我們先來看看這樣做可能帶來的弊端。

首先,泛型函式為型別系統提供的關於什麼是什麼的資訊較少。如果原來的具體型別現在變成了impl ,編譯器將更難推斷出每個表示式的型別(並且可能會更經常地失敗)。這可能需要你的使用者新增更多的型別註釋來使他們的程式碼得到編譯,從而導致可以說是更糟糕的人機工程學。

另外,通過指定一個具體的型別,我們得到的正是我們的函式的一個版本被編譯成結果程式碼。對於泛型,我們要麼支付動態排程的執行時間成本,要麼冒著用多個版本膨脹二進位制的風險選擇單態化--用Rust的術語來說,我們選擇dyn Traitimpl Trait

你選擇哪一點,主要取決於用例。請注意,動態排程有一定的執行時間成本,但程式碼臃腫也會增加快取失誤,因此會對效能產生負面影響。一如既往,兩次測量,一次編碼。

即便如此,對於所有的公共方法,你可以遵循一些經驗法則。

特質的片斷

如果可以的話,拿一個切片(&[T])來代替&Vec<T> (那個其實有一個剪下性的皮特)。你的呼叫者可能會使用一個 [VecDeque](https://doc.rust-lang.org/std/collections/struct.VecDeque.html),它有一個 [.make_continuous()](https://doc.rust-lang.org/std/collections/struct.VecDeque.html#method.make_contiguous)方法返回一個&mut [T] )而不是一個 [Vec](https://doc.rust-lang.org/std/vec/struct.Vec.html),或者是一個數組。

如果你也能採取兩個片斷。 [VecDeque::as_slices](https://doc.rust-lang.org/std/collections/struct.VecDeque.html#method.as_slices)可以為你的使用者工作而不需要移動任何值。當然,你仍然需要了解你的用例來決定這是否值得。

如果你只取消引用你的片斷元素,你可以使用&[impl Deref<Target = T>] 。請注意,除了 [Deref](https://doc.rust-lang.org/std/ops/trait.Deref.html)外,還有一個 [AsRef](https://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>](https://doc.rust-lang.org/std/convert/trait.AsMut.html).同樣地,如果我們要求任何對T 的引用在平等、順序和雜湊方面與T 本身的工作相同,我們可以使用 [Borrow<T>](https://doc.rust-lang.org/std/borrow/trait.Borrow.html)/ [BorrowMut<T>](https://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](https://doc.rust-lang.org/std/cmp/trait.Eq.html), [Ord](https://doc.rust-lang.org/std/cmp/trait.Ord.html)[Hash](https://doc.rust-lang.org/std/hash/trait.Hash.html)的型別,必須保證、 和 的返回值與、 和 相同。

讓我們重新迭代

同樣,如果你只迭代一個string slice的位元組,除非你的程式碼在某種程度上需要strString 所保證的UTF-8-才能正確工作,否則你可以簡單地取一個AsRef<[u8]> 的引數。

一般來說,如果你只迭代一次,你甚至可以把一個 [Iterator<Item = T>](https://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](https://doc.rust-lang.org/std/clone/trait.Clone.html)操作可能會很昂貴,所以我不建議使用它。

進入森林

與效能無關,但也經常受到歡迎的是impl Into<_> 引數的隱式轉換。這通常可以使一個API感覺很神奇,但要注意。 [Into](https://doc.rust-lang.org/std/convert/trait.Into.html)轉換可能是昂貴的。

不過,還是有一些小技巧可以讓你在可用性方面取得不錯的成績。例如,用一個Into<Option<T>> ,而不是一個 [Option<T>](https://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.