泛型与单态化

本页目录

泛型让一段算法适用于一组满足约束的类型。约束同时服务两方:调用者必须提供符合要求的类型,函数体也只能使用这些要求承诺的能力。编译器不会因为某次调用碰巧传入 String,就允许没有声明的字符串专用操作。

从定义检查到实例化

检查泛型定义函数体只能依赖已声明的类型约束1收集实际需要的实例例如 choose::<i32> 与 choose::<String>2按具体类型生成并优化可以内联、常量折叠、删除或合并代码3实例化模型不保证最终二进制保留一组可数的独立函数符号。泛型定义到最终机器码
fn choose<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    assert_eq!(choose(3, 8), 8);
    assert_eq!(choose(String::from("a"), String::from("z")), "z");
}

这个算法需要全序比较,所以要求 Ord。改成 PartialOrd 就需要明确不可比较值的策略,例如浮点 NaN;不能把 a > b 为 false 一律解释为 a 小于等于 b。

rustc 按需要收集具体类型的泛型实例,再进入代码生成。生命周期参数主要服务静态检查,不是每种借用长度都生成一套机器码。单态化

生成过程中的实例与最终符号不一一对应:未使用的代码可以不存在,内联后函数体可能消失,相同实现可能被合并。不能从三种调用直接断言 binary 中必有三个独立函数。

约束不是按当前调用倒推的隐式特化

定义 fn f<T>(value: T) 时,T 表示任意满足已知约束的类型。即使现在所有调用都传入 String,函数体也不能直接调用 String 专属方法;需要声明 AsRef<str> 等真正要求,或将参数直接写成 &str。这样调用者才能仅凭签名理解适用范围。

同样,T: Add 只表明存在相应加法实现,不一定保证结果还是 T;若算法需要闭包于 T,就应写出关联输出约束 Add<Output = T>。泛型约束应该来自算法所需的操作及类型关系,而不是遇到编译错误就把 Clone、Copy、Default 全部加上。Trait bounds

同一个算法,所有权选择会改变接口

上面的 choose 按值接收两个 T,并返回胜出者,另一个值被释放;它没有 Clone 约束,因为并未要求复制值。调用者如果只想比较已有对象而继续保留它们,可以提供借用版本。

fn choose_ref<'a, T: Ord + ?Sized>(a: &'a T, b: &'a T) -> &'a T {
    if a >= b { a } else { b }
}
fn main() {
    let first = String::from("alpha");
    let second = String::from("omega");
    assert_eq!(choose_ref(&first, &second), "omega");
    assert_eq!(first, "alpha");
    assert_eq!(second, "omega");
    // str 是 DST,函数只借用它;?Sized 放宽隐含 Sized 约束。
    assert_eq!(choose_ref::<str>("a", "b"), "b");
}

这里的 'a 约束返回引用不能超过适用输入借用的有效期,并没有延长两个字符串对象的寿命;两个对象也不必从出生到销毁都具有同样生命周期。调用处可以在两者都有效的较短范围内重借用。

?Sized 不是要求 T 必须动态大小,而是允许它不满足隐含的 Sized。函数仍通过引用访问 T,引用自身是已知大小的值;这并未让任意未知大小的 T 都能直接按值作为局部变量处理。

接口交出的东西适用目标
fn choose<T: Ord>(a: T, b: T) -> T两个值的所有权选择后不再需要另一个输入
fn choose_ref(... &T ...) -> &T输入的共享借用不复制,保留两个原值
fn choose_cloned<T: Ord + Clone>(...) -> T借用,并复制选中的值明确需要独立返回值或 Clone 所定义的共享所有权

如果实际需求只是输出名称,却为了返回 T 添加 Clone,再复制大型缓冲,泛型签名可能掩盖了不必要的工作。先确定返回的所有权,再选择 trait 约束。

静态分发与“零成本”的实际含义

具体 T 给编译器提供布局与方法实现信息,便于内联和优化;这不保证必定内联,也不保证算法、内存分配或边界检查没有成本。应把开销与同等语义的具体实现比较。

泛型也能处理 trait object:T: Trait + ?Sized 可以取 T = dyn Trait,此时内部方法仍可能动态分发。静态泛型与动态对象可以组合,不是互相排斥的语言模式。

PartialOrd 的空缺需要显式处理

浮点包含 NaN,因此只有偏序。某个比较结果为 None 时,算法可以拒绝、跳过或采用明确总序;这些策略都不是把 Ord 改成 PartialOrd 后自动得到的。

use std::cmp::Ordering;
fn choose_partial<T: PartialOrd>(a: T, b: T) -> Option<T> {
    match a.partial_cmp(&b)? {
        Ordering::Less => Some(b),
        Ordering::Equal | Ordering::Greater => Some(a),
    }
}
fn main() {
    assert_eq!(choose_partial(1.5, 2.0), Some(2.0));
    assert_eq!(choose_partial(f64::NAN, 2.0), None);
}

这个版本在不可比较时丢弃两个输入并返回 None;若调用者要保留它们,应让错误分支携带原值。trait 表达“能做什么操作”,函数还要明确操作结果如何影响业务。

控制泛型传播的范围

如果很大一段工作已经不依赖 T,可以把它移入具体类型函数。以下函数接受不同 AsRef 输入,但扫描逻辑只需要 &str:

fn count_words(input: impl AsRef<str>) -> usize {
    count_words_inner(input.as_ref())
}

fn count_words_inner(input: &str) -> usize {
    input.split_whitespace().count()
}

fn main() {
    assert_eq!(count_words("one two"), 2);
    assert_eq!(count_words(String::from("three")), 1);
}
&str各自形成适用的泛型入口String各自形成适用的泛型入口其他 AsRef<str>各自形成适用的泛型入口count_words_inner(&str)后续算法不再依赖入口类型 T这是源码层的类型边界;最终内联、函数合并和机器码数量仍由编译器决定。收缩类型依赖:多种入口,共用相同的数据表示

这种结构减少类型依赖传播,实际是否减少机器码仍受内联与优化影响。为了消除泛型而先为每个元素分配 String,可能引入更大的运行时成本;应优先寻找借用或已有的共同表示。

共同表示应尽量保留原有语义

把多个类型先转为 &str,可以共用只依赖字符串的逻辑;但若输入是路径、原始字节或结构化对象,强行转成 String 可能分配、丢失非 UTF-8 信息,或丢掉原本可表达的错误。边界收缩应发生在算法真正只需要共同表示的地方。

跨 crate 的泛型也需要按实际实例参与代码生成,不能简单认为依赖库编译过一次,所有下游实例就无需再处理。另一方面,不同生命周期参数通常不会单独复制机器码,未触达的类型组合也不应机械算入产物。

泛型传播到哪里停止?

这里比较的是类型信息传播的范围。共同算法只需 &str 时,可先通过很薄的泛型入口借用出 &str,再进入统一扫描函数。不要把图中的块数读成最终机器码份数;内联、消除和合并仍会改变产物。

正在呈现知识画面
泛型传播到哪里停止?

将共同扫描逻辑收缩到 &str 后,只让薄适配层依赖 T。

用关联类型约束表达输入项,而不是固定容器

如果算法只需要遍历 u32,不必强制接收 Vec;IntoIterator<Item = u32> 允许数组、Vec 或其他按值产生 u32 的来源。借用切片的默认迭代项是 &u32,这时可以显式 .copied(),让“是否复制元素”出现在调用处。

fn total<I>(input: I) -> u64
where
    I: IntoIterator<Item = u32>,
{
    input.into_iter().map(u64::from).sum()
}
fn main() {
    assert_eq!(total([1, 2, 3]), 6);
    assert_eq!(total(vec![4, 5]), 9);
    let values = [6, 7];
    assert_eq!(total(values.iter().copied()), 13);
    assert_eq!(values, [6, 7]);
}

这里 u32 转 u64 不丢失单项数值,但无限或足够长的输入仍可能让总和溢出;若总量可能超过 u64,需要 checked 累加和错误返回。泛化输入容器不等于扩大了数值安全范围。

const generics:常量也是类型参数

struct Buffer<T, const N: usize> {
    data: [T; N],
}

impl<T, const N: usize> Buffer<T, N> {
    fn as_slice(&self) -> &[T] { &self.data }
}

fn main() {
    let small = Buffer { data: [1u8, 2, 3, 4] };
    let empty = Buffer::<u8, 0> { data: [] };
    assert_eq!(small.as_slice(), &[1, 2, 3, 4]);
    assert!(empty.as_slice().is_empty());
}

Buffer<u8, 4> 与 Buffer<u8, 8> 是不同类型;N 不是构造时随意变化的字段。需要运行时长度时,Vec 或切片通常更适合,不应把所有动态需求强塞进类型参数。

稳定 const generics 支持的参数种类和表达式有边界。数组长度中直接使用 N 与使用 N + 1 不等价,任意泛型常量表达式不能一概认为稳定可用。泛型参数规则

类型参数中的长度与运行时长度

Buffer<u8, 4> 的 N 在实例类型确定时固定,所以编译器可以知道布局;从用户输入得到的 len 不能直接写成 Buffer<u8, len>。若只支持少量固定尺寸,可以运行时分支选择不同静态实例;若尺寸任意变化,Vec、切片或固定最大缓冲加有效长度更自然。

const 参数并不自动证明所有算术有效。N=0 可能合法,也可能违反某个算法需求;索引 N-1、N×M 的溢出,以及栈上大数组的资源开销仍需明确处理。把数字搬到类型中,并不意味着业务条件全部变成编译期证明。

impl Trait 参数由调用者选择

参数位置的 impl Trait 表达匿名泛型参数,例如上面的 impl AsRef。两个独立 impl Trait 参数不要求具体类型相同;写成同一个 T 的两个参数则要求它们统一为同一类型。

返回位置的 impl Trait 由函数实现选择隐藏的具体类型;不是每次运行都可以返回任意实现类型。进一步见 关联类型与 GAT。

两个匿名参数不表达“同一种类型”

use std::fmt::Display;
fn show_pair(left: impl Display, right: impl Display) -> String {
    format!("{left}:{right}")
}
fn same_pair<T: Display>(left: T, right: T) -> String {
    format!("{left}:{right}")
}
fn main() {
    assert_eq!(show_pair(7u32, "ready"), "7:ready");
    assert_eq!(same_pair(7u32, 8u32), "7:8");
}

show_pair 的两个实现类型可以不同;same_pair 则要求在本次调用中统一为同一个 T。若需要描述参数之间或返回值的类型关系,显式命名 T 往往更清楚。参数位置 impl Trait 不是 dyn Trait,也不会因省略类型名就自动变成动态分发。impl Trait

特化与一致性

稳定 Rust 不允许任意用更具体的 impl 覆盖已重叠的通用 impl。specialization 仍属于实验功能,min_specialization 也不应当作稳定接口承诺。Unstable Book

稳定方案可以使用新类型、显式策略参数或不同入口,让实现范围不重叠。选择这些方案是为了明确接口,不保证自动得到编译器特化的所有效果。关注性能时同时观察编译耗时、产物体积与实际热路径,而不是只统计泛型参数数量。

怎样观察成本,而不是凭源码估计机器码份数

成本观察口径易混入的变量
编译时间同配置的干净构建与增量构建分别比较缓存、并行度、依赖版本
产物大小可执行代码段、调试信息、总文件分别记录strip、LTO、panic 策略与链接器
运行时间相同数据、语义和工作负载分配、I/O、冷热缓存与分支比例
热路径分发汇编/IR 配合实际性能采样内联和去虚拟化后的结果

可以用 rustc --edition=2024 -O --emit=llvm-ir example.rs 查看某个独立示例的优化 IR,但不应把一次符号搜索数量写成语言保证。实例化数量是编译器工作量的一部分,最终代码还可能内联、消除或合并。

如果将大循环拆到非泛型函数后构建更快,但热路径慢了,应检查原来由类型信息带来的优化是否丢失;反之,增加动态调用也可能因代码更小而改善指令缓存。选择静态或动态边界需要同时看接口表达、编译成本与实际负载,而不是把“单态化”当作必胜标签。