改進限制太多的 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程式設計指北