Rust 泛型膨脹
Rust 泛型很棒,但它們會使最終代碼膨脹,並對編譯時間產生負面影響。這篇文章討論了一個幫助對抗這些影響的 “技巧”。
在滿足幾個前提條件的情況下,存在一種相當普遍的模式來解決這些問題。這種模式在標準庫和流行的庫中大量使用。
這篇文章只處理泛型函數的代碼膨脹,而不是泛型結構。
基礎
讓我們首先展示 Rust 中的一組基本泛型,以及爲什麼會出現一些問題。
當你在 Rust 中使用泛型參數時,編譯器實際上會爲它找到的每個具體類型生成一份你的代碼副本。例如:
fn genric<T>(param: T) {
// code...
}
fn main() {
generic(30); // T = i32
generic("foo"); // T = &'static str
}
變成這樣:
fn genric_i32(param: i32) {
// code...
}
fn genric_str(param: &'static str) {
// code...
}
fn main() {
generic(30);
generic("foo");
}
雖然這會在運行時產生非常高效的代碼,但也有一些潛在的缺點:
-
如果 generic() 的大小相當大 (以編譯後的二進制形式),則該代碼也會重複,從而導致潛在的 “膨脹”。
-
所有通過泛型生成的 (大致重複的) 代碼也必須單獨編譯和優化!這是 rustc 和 LLVM 需要處理的大量代碼。
-
generic() 不能完全編譯,除非編譯器知道將使用的所有具體類型
先決條件
我在前面提到過,我們將要討論的模式不是萬能的,不能在所有情況下都使用。
有一個重要的先決條件需要滿足:
泛型代碼必須有一個 “首選類型”,通常指的是泛型參數是有界的,而這個有界表示某種類型的轉換。
隨着我們的深入,這一點應該會變得更加清楚。
立即分發
解決方案是立即調度到已知類型。事後看來,這似乎是顯而易見的,但如果不是這樣也沒關係!我們馬上就能看到它的實際效果了!
首先,讓我們定義一個將用作泛型邊界的 trait。
trait Speak {
fn speak(&self) -> String;
}
接下來,我們定義一個泛型函數,該函數可用於實現 Speak 特徵的任何類型。
fn generic_speak<T: Speak>(param: &T) {
println!("It says: {}", param.speak());
}
這可以看作是我們的庫邊界。也許標準庫也有一些它在內部使用的具體類型,但這不是必需的。具體類型是內部、外部還是混合並不重要。
讓我們定義兩個具體的類型,以實現上述特徵:
struct Cat;
impl Speak for Cat {
fn speak(&self) -> String {
"meow".into()
}
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> String {
"woof".into()
}
}
最後,我們將這個泛型函數同時用於 Cat 和 Dog 結構體:
fn main() {
let whiskers = Cat;
let spot = Dog;
generic_speak(&whiskers);
generic_speak(&spot);
}
如果我們運行它,可能會得到你所期望的:
$ cargo run --quiet
It says: meow
It says: woof
計算函數數量
讓我們首先回顧並證明我在開始時所做的聲明,我們的泛型函數實際上生成了兩個具體的函數。我們可以選擇 cargo-llvm-lines 或 cargo-bloat 來查看。
我經常使用這兩種方法,爲了好玩,讓我們比較一下這兩種方法的輸出吧!
安裝這兩個工具:
cargo install cargo-llvm-lines
cargo install cargo-bloat
首先,我們將使用 cargo-llvm-lines 來查看產生的 LLVM IR 的數量:
$ cargo llvm-lines
Lines Copies Function name
----- ------ -------------
[ .. snip .. ]
80 (4.9%, 50.3%) 2 (4.9%, 17.1%) generic_speak
5 (0.3%, 98.3%) 1 (2.4%, 82.9%) <Cat as Speak>::speak
5 (0.3%, 98.6%) 1 (2.4%, 85.4%) <Dog as Speak>::speak
[ .. snip .. ]
請注意,實際上我們有兩個 generic_speak 的副本 (可以從 copies 列中看到) 和每個具體類型的實現函數(例如 < Cat as Speak>:: Speak,這是我們執行 cat 實現的 speak 代碼)。
對比上面,cargo-bloat 顯示了每個函數的二進制大小:
$ cargo bloat -n 999
Analyzing target/debug/blog_demo
File .text Size Crate Name
[ .. snip .. ]
0.0% 0.1% 193B blog_demo generic_speak
0.0% 0.1% 193B blog_demo generic_speak
0.0% 0.0% 44B blog_demo <Dog as Speak>::speak
0.0% 0.0% 44B blog_demo <Cat as Speak>::speak
[ .. snip .. ]
像 carog-llvm-lines 一樣,我們可以看到,實際上,我們有兩個 generic_speak 的副本和每個具體類型的實現函數 (例如 < Cat as Speak>:: Speak,這是我們爲 Cat 執行 impl Speak 的代碼)。
在 cargo-bloat 中,我們看到 generic_speak 的最終二進制副本都是 193 字節 (總共 386 字節)。
嘗試立即分發
我們可以添加一個私有內部函數,它接受我們實際需要 / 想要的實際類型 (即在本例中是 String),而不是僅僅堅持泛型參數。
現在 generic_speak 看起來像這樣:
fn generic_speak<T: Speak>(param: &T) {
fn generic_speak_string(param: String) {
println!("It says: {param}");
}
generic_speak_string(param.speak());
}
我們重新運行 cargo-bloat,我們現在看到:
$ cargo bloat -n 999
Analyzing target/debug/blog_demo
File .text Size Crate Name
[ .. snip .. ]
0.0% 0.1% 160B blog_demo generic_speak::generic_speak_string
0.0% 0.0% 37B blog_demo generic_speak
0.0% 0.0% 37B blog_demo generic_speak
[ .. snip .. ]
我們仍然有 generic_speak 的兩個具體實現,因爲我們仍然有一個泛型函數,但是請注意,內部的實際代碼已經從 193 個字節減少到只有 37 個字節。現在,我們所有的 “真正的代碼” 都是非泛型的。
快速計算一下我們泛型函數 + 新的內部函數,我們得到的總數是 234 字節,而原來的總數是 386 字節!
這只是一個虛構的例子,但請想象一個具有多個泛型和實際大小相當大的函數的真實庫!
此外,內部函數 (generic_speak_string) 可以立即完全編譯,因爲它的所有類型都是完全已知的。
你可能已經注意到,如果在發佈模式下編譯這些示例,函數根本不會出現!
$ cargo bloat -n 999 --release | grep speak
這是因爲代碼非常簡單,Rust/LLVM 能夠內聯所有代碼並優化它。然而,在現實世界的庫中,代碼並不總是這麼簡單的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/4hfuwyWLwEcAXFEEhps6BQ