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<T>()不能完全编译,除非编译器知道将使用的所有具体类型
我在前面提到过,我们将要讨论的模式不是万能的,不能在所有情况下都使用。
泛型代码必须有一个“首选类型”,通常指的是泛型参数是有界的,而这个有界表示某种类型的转换。
解决方案是立即调度到已知类型。事后看来,这似乎是显而易见的,但如果不是这样也没关系!我们马上就能看到它的实际效果了!
trait Speak {
fn speak(&self) -> String;
}
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()
}
}
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
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 -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),而不是仅仅坚持泛型参数。
fn generic_speak<T: Speak>(param: &T) {
fn generic_speak_string(param: String) {
println!("It says: {param}");
}
generic_speak_string(param.speak());
}
cargo bloat -n 999
Analyzing target/debug/blog_demo
File .text Size Crate Name
.. snip .. ]
0.1% 160B blog_demo generic_speak::generic_speak_string
0.0% 37B blog_demo generic_speak
0.0% 37B blog_demo generic_speak
.. snip .. ]
我们仍然有generic_speak的两个具体实现,因为我们仍然有一个泛型函数,但是请注意,内部的实际代码已经从193个字节减少到只有37个字节。现在,我们所有的“真正的代码”都是非泛型的。
快速计算一下我们泛型函数+新的内部函数,我们得到的总数是234字节,而原来的总数是386字节!
这只是一个虚构的例子,但请想象一个具有多个泛型和实际大小相当大的函数的真实库!
此外,内部函数(generic_speak_string)可以立即完全编译,因为它的所有类型都是完全已知的。
cargo bloat -n 999 --release | grep speak