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");
}

雖然這會在運行時產生非常高效的代碼,但也有一些潛在的缺點:

先決條件

我在前面提到過,我們將要討論的模式不是萬能的,不能在所有情況下都使用。

有一個重要的先決條件需要滿足:

泛型代碼必須有一個 “首選類型”,通常指的是泛型參數是有界的,而這個有界表示某種類型的轉換。

隨着我們的深入,這一點應該會變得更加清楚。

立即分發

解決方案是立即調度到已知類型。事後看來,這似乎是顯而易見的,但如果不是這樣也沒關係!我們馬上就能看到它的實際效果了!

首先,讓我們定義一個將用作泛型邊界的 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