改進限制太多的 Rust 庫 API
在我之前的一篇文章“如何編寫 CRAP Rust 代碼[1]”中,我警告過不要過度使用泛型。對于二進制 crate 或任何代碼的初始版本,這仍然是一個好主意。
然而,在設計 Rust 庫 crate API 時,你通??梢允褂梅盒蛠慝@得良好的效果:對我們的輸入更加寬容可能會為調(diào)用者提供避免某些分配的機會,或者以其他方式找到更適合他們的輸入數(shù)據(jù)的不同表示。
在本指南中,我們將演示如何在不丟失任何功能的情況下使 Rust 庫 API 更加寬松。但在我們開始之前,讓我們檢查一下這樣做的可能缺點。
首先,泛型函數(shù)為類型系統(tǒng)提供的關于什么是什么的信息較少。如果原來的具體類型現(xiàn)在變成了impl,編譯器將更難推斷每個表達式的類型(并且可能會更頻繁地失敗)。這可能需要你的用戶添加更多類型注釋來編譯他們的代碼,從而導致更糟糕的人體工程學。
此外,通過指定一種具體類型,我們可以將函數(shù)的一個版本編譯到結(jié)果代碼中。使用泛型,我們要么付出動態(tài)調(diào)度的運行時成本代價,要么通過選擇單態(tài)化來[2]冒著使二進制文件膨脹的風險——在 Rust 術語中,我們選擇 dyn Trait vs. impl Trait。
你選擇權衡哪一點主要取決于場景。請注意,動態(tài)調(diào)度有一些運行時成本,但代碼膨脹也會降低緩存命中率,從而對性能產(chǎn)生負面影響。一如既往,測量兩次,編碼一次。
即便如此,對于所有公共方法,你都可以遵循一些經(jīng)驗法則。
01 部分 traits
如果可以的話,取一個切片 (&[T]) 而不是一個 &Vec (那個實際上有一個clippy lint[3])。你的調(diào)用者可能會使用一個 VecDeque,它有一個 .make_continuous() 方法,此方法返回一個 &mut [T]而不是一個 Vec,或者可能是一個數(shù)組。
如果你還可以取兩個切片,VecDeque::as_slices可以在不移動任何值的情況下為你的用戶工作。當然,你仍然需要了解你的場景來決定這是否值得。
如果你只取消引用切片元素,則可以使用&[impl Deref. 請注意,除Deref之外,還有AsRef trait,它在路徑處理中經(jīng)常使用,因為std方法可能需要一個AsRef的引用轉(zhuǎn)換。
例如,如果你使用一組文件路徑,&[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>?{?
//?..
現(xiàn)在filters可能是String、&str、 甚至Cow<'_, OsStr>的切片。對于可變類型,有AsMut。類似地,如果我們要求任何引用T在相等、順序和散列方面都與T它自身相同,我們可以使用Borrow/BorrowMut代替。
那有什么意思?這意味著實現(xiàn) Borrow 的類型必須保證a.borrow() == b.borrow()、a.borrow() < b.borrow()和a.borrow().hash(),如果所討論的類型分別實現(xiàn) Eq、Ord 和 Hash,則返回與 a == b、a < b ?和 a.hash() 相同。
02 讓我們再重復一遍
類似地,如果你只迭代 str 切片的字節(jié),除非你的代碼需要 UTF-8 str 和 String 以某種方式來保證正常工作,否則你可以簡單地接受一個 AsRef<[u8]> 參數(shù)。
一般來說,如果你只迭代一次,你甚至可以選擇一個 Iterator, 這允許你的用戶提供他們自己的迭代器,這些迭代器可能會使用非連續(xù)的內(nèi)存切片,將其他操作與你的代碼穿插在一起,甚至可以即時計算你的輸入。這樣做,你甚至不需要使項目類型泛型,因為如果需要,迭代器通??梢暂p松生成一個 T。
實際上,如果你的代碼只迭代一次,你可以使用 impl Iterator;如果你不止一次需要這些項目,需要使用一兩個切片。如果你的迭代器返回擁有的項目(item),例如最近添加的數(shù)組 IntoIterator,你可以放棄 impl Deref 并使用 impl Iterator。
不幸的是,IntoIterator 的 into_iter 會消耗 self,所以沒有通用的方法來獲取讓我們迭代多次的迭代器 — 除非,獲取 impl Iterator<_> + Clone的參數(shù),但 Clone 操作可能代價高昂,所以我不建議使用它。
03 Into
與性能無關,但通常受歡迎的是參數(shù) impl Into<_> 的隱式轉(zhuǎn)換。這通常會使 API 感覺很神奇,但要注意:Into 轉(zhuǎn)換可能很昂貴。
盡管如此,你還是可以使用一些技巧來獲得出色的可用性。例如,使用 一個 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);
同樣,可能存在以成本高昂的方式實現(xiàn)的 Into 類型。這是另一個例子,我們可以在漂亮的 API 和明顯的成本之間做出選擇。一般來說,在 Rust 中選擇后者通常被認為是符合 Rust 慣用法的。
04 控制代碼膨脹
Rust 將通用代碼單態(tài)化。這意味著對于你的函數(shù)被調(diào)用的每個唯一類型,將生成并優(yōu)化使用該特定類型的所有代碼的版本。
這樣做的好處是它會導致內(nèi)聯(lián)和其他優(yōu)化,從而為 Rust 提供我們都知道和喜愛的出色性能品質(zhì)。但它有一個缺點,即可能會生成大量代碼。
作為一個可能的極端示例,請考慮以下函數(shù):
use?std::fmt::Display;
fn?frobnicate_arrayconst?N:?usize>(array:?[T;?N])?{
????for?elem?in?array?{
????????//?...2kb?of?generated?machine?code
????}
}
即使我們只是迭代,也會為每個項目類型和數(shù)組長度實例化此函數(shù)。不幸的是,沒有辦法避免代碼膨脹以及避免復制/克隆,因為所有這些迭代器都在它們的類型中包含它們的大小。
如果我們可以處理引用的項目,我們可以不調(diào)整大小并迭代切片:
use?std::fmt::Display;
fn?frobnicate_slice(slice:?&[T])?{
????for?elem?in?slice?{
????????//?...2kb?of?generated?machine?code
????}
}
這將至少為每個項目類型生成一個版本。即便如此,假設我們只使用數(shù)組或切片進行迭代。然后我們可以分解出依賴于類型的 frobnicate_item 方法。更重要的是,我們可以決定是使用靜態(tài)調(diào)度還是動態(tài)調(diào)度:
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 方法現(xiàn)在只包含一個循環(huán)和一個方法調(diào)用,不需要太多的代碼來實例化。避免了代碼膨脹!
通常,最好仔細查看方法的接口并查看泛型在何處被使用或丟棄。在這兩種情況下,都有一個自然邊界,我們可以在該邊界處分解出刪除泛型的函數(shù)。
如果您不想要所有這些類型并且可以添加一點編譯時間,那么你可以使用我的momo[4] crate 來提取通用特征,例如 AsRef 或 Into。
05 代碼膨脹有什么不好?
對于某些背景,代碼膨脹有一個不幸的后果:今天的 CPU 使用緩存層次結(jié)構(gòu)。雖然這些在處理本地數(shù)據(jù)時允許非??斓乃俣?,但它們對使用產(chǎn)生非常非線性的影響。如果你的代碼占用了更多的緩存,它可能會使其他代碼運行得更慢!因此,Amdahl 定律[5]不再幫助你在處理內(nèi)存時找到優(yōu)化的地方。
一方面,這意味著通過測量微基準測試單獨優(yōu)化部分代碼可能會適得其反(因為整個代碼實際上可能會變慢)。另一方面,在編寫庫代碼時,優(yōu)化庫可能會使用戶的代碼變得更差。但是你和他們都無法從微基準測試中學到這一點。
那么,我們應該如何決定何時使用動態(tài)分派以及何時生成多個副本?我在這里沒有明確的規(guī)則,但我注意到動態(tài)調(diào)度在 Rust 中肯定沒有得到充分利用!首先,它被認為性能較差(這并不完全錯誤,考慮到函數(shù)表查找確實增加了一些開銷)。其次,通常不清楚如何在避免分配的同時[6]做到這點。
即便如此,如果測試表明它是有益的,Rust 可以很容易地從動態(tài)調(diào)度到靜態(tài)調(diào)度,并且由于動態(tài)調(diào)度可以節(jié)省大量編譯時間,我建議在可能的情況下開始動態(tài)調(diào)用,并且只有在測試顯示它時才采用單態(tài)要更快。這為我們提供了快速的運行時間,從而有更多時間改進其他地方的性能。最好有一個實際的應用程序來衡量,而不是一個微基準。
我對如何在 Rust 庫代碼中有效使用泛型的介紹到此結(jié)束??炜鞓窐返厝ナ褂?Rust 吧!
原文鏈接:https://blog.logrocket.com/improving-overconstrained-rust-library-apis/
參考資料
如何編寫 CRAP Rust 代碼: https://blog.logrocket.com/how-to-write-crap-rust-code
[2]單態(tài)化來: 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,北大碩士畢業(yè),曾在 360 等知名互聯(lián)網(wǎng)公司工作,10多年技術研發(fā)與架構(gòu)經(jīng)驗!2012 年接觸 Go 語言并創(chuàng)建了 Go 語言中文網(wǎng)!著有《Go語言編程之旅》、開源圖書《Go語言標準庫》等。
堅持輸出技術(包括 Go、Rust 等技術)、職場心得和創(chuàng)業(yè)感悟!歡迎關注「polarisxu」一起成長!也歡迎加我微信好友交流:gopherstudio
