← Rust:从入门到精通
全栈开发Rust:从入门到精通

泛型与 impl Trait:一份合同,两个方向

Rust 从入门到精通系列第 7 篇:泛型与 trait bound 的完整语法、单态化为什么零成本、impl Trait 在参数与返回位置的不对称、静态分发与 dyn 的取舍,以及 2024 edition 用 + use<...> 修好的返回位置生命周期捕获。

RustGenericsimpl TraitdynMonomorphizationRust 2024

第 5 篇的 fn show(x: &impl Summary) 是一句顺手的话——“任何签署了合同的东西”。但这句话底下压着一整套语法机器:它到底是谁的语法糖?反过来写行不行?返回位置那个 impl Iterator 为什么不能用具体类型替代?dyn 又是什么时候登场的反派角色?本篇把这台机器完整拆开:泛型与 trait bound、单态化、impl Trait 的两个方向、静态与动态分发的取舍——最后偿还第 2 篇欠下的债:2024 edition 里 + use<...> 到底修好了什么。

泛型:写一次,编译器替你抄 N 遍

一切从一个每个程序员都写过的重复开始:largest_i32largest_str,函数体一字不差,只有类型不同。泛型把“类型”提为参数:

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut max = &list[0];
    for item in list {
        if item > max { max = item; }
    }
    max
}

println!("{}", largest(&[3, 7, 2]));        // 7
println!("{}", largest(&["a", "z", "m"]));  // "z"

读法:“largest 对任意类型 T 有定义,只要 T 签署了 PartialOrd。”<T: PartialOrd> 是 trait bound——第 5 篇的合同在这里变成函数签名的一部分。函数体里只允许使用 bound 承诺过的能力:这里只有 >。想要 println! 打印 T?加 + Display。bound 既是约束也是授权,一纸两面。

而 Rust 兑现“零成本”的方式叫单态化(monomorphization):编译器在构建时替你抄代码——用到 i32 就生成一份 largest::<i32>,用到 &str 再生成一份。运行时没有查表、没有装箱、没有任何“泛型税”,每个调用都是直达的具体函数。代价只有一个:二进制体积随实例化数量增长。这就是为什么 Rust 泛型敢铺满标准库——抽象在编译期结清全部账单。

顺带认识一下 turbofish:当编译器推不出类型时,"42".parse::<u32>() 里的 ::<> 让你手动点名。它丑得刻意——好让你在代码评审里一眼看见。

impl Trait 的两个方向:谁选择具体类型

impl Trait 出现在两个位置,看起来对称,含义恰好相反。整个话题只有一句话:谁选择具体类型。

参数位置——调用者选择。 fn show(x: &impl Display) 是泛型的语法糖,和 fn show<T: Display>(x: &T) 逐字等价。调用者今天传 i32、明天传 String,函数来者不拒,单态化照常发生。什么时候用糖、什么时候用完整泛型?bound 简单且只出现一次,用糖;需要给类型参数起名字(它在返回值或多个参数间复现,如 fn merge<T>(a: T, b: T) -> T),用尖括号。

返回位置——函数选择。 这才是 impl Trait 不可替代的地方:

fn evens_up_to(n: u32) -> impl Iterator<Item = u32> {
    (0..=n).filter(|x| x % 2 == 0)
}

let v: Vec<u32> = evens_up_to(10).collect();  // [0, 2, 4, 6, 8, 10]

这个函数实际返回的类型是 Filter<RangeInclusive<u32>, 闭包>——而闭包类型根本没有名字,你写不出来,也不需要写。impl Iterator 说的是:“我给你一个签署了 Iterator 合同的东西,具体是谁,别问。“这既是被迫(闭包类型不可命名),也是设计(返回类型成为实现细节,以后换实现不破 API)。注意两个位置都是静态分发——具体类型在编译期永远已知,只是不总是对你可见。

dyn:当“一个类型”装不下的时候

静态分发有一条硬边界:一次调用只服务一个具体类型。&[impl Shape] 可以装一千个 Circle,但装不下“一个 Circle 加一个 Rect”——而插件系统、事件处理器表、绘制列表恰恰需要这种异质集合。这就是 dyn 的出场时刻:

trait Shape { fn area(&self) -> f64; }

fn total_static(shapes: &[impl Shape]) -> f64 {        // 每次调用,一种类型
    shapes.iter().map(|s| s.area()).sum()
}
fn total_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {   // 一个集合,混装类型
    shapes.iter().map(|s| s.area()).sum()
}

Box<dyn Shape> 是一个胖指针:两个指针宽,一个指数据,一个指 vtable——一张“这个具体类型的每个方法住在哪”的查找表。调用从“编译期定死的直达”变成“运行期查一次表”,所有类型共享同一份机器码。价目因此翻转:静态分发付二进制体积、赚零运行时开销;dyn 付每次调用一次指针跳转、赚异质集合和更小的代码。还有一条限制知道即可:不是所有 trait 都能做成 dyn(“object safety”——带泛型方法或返回 Self 的不行),编译器会直说。

默认静态,需要 dyn 时你自己会知道:当签名里出现“这个槽位必须装得下我还没见过的类型”,就是那一刻。

2024 edition 的捕获修正:+ use<...>

还债时间。第 2 篇提过:返回位置的 impl Trait 曾在生命周期捕获上有一整类令人困惑的错误。历史是这样的——

2021 edition:返回的 impl Trait 只捕获 bound 里写出来的生命周期。fn f(s: &str) -> impl Iterator<Item = char> { s.chars() } 会被拒绝,因为隐藏类型偷偷借用了 s,签名却没承认。修法是祭出 + '_,承认这个借用。无数人在这里浪费了一个下午。

2024 edition:规则反转——返回位置默认捕获所有输入生命周期。上面的代码直接编译。但矫枉偶尔过正:

fn process<'a>(data: &'a str, buf: &mut Vec<u8>) -> impl Iterator<Item = &'a str> {
    buf.clear();
    data.split(',')
}

let mut buf = vec![1u8, 2];
let it = process("a,b,c", &mut buf);
buf.push(3);  // error[E0499]: cannot borrow `buf` as mutable more than once

buf 只是被函数用了一下,返回的迭代器根本不持有它——但默认捕获把 buf 的借用也算进了返回类型的寿命,于是 buf 被冻结到迭代器死亡。编译器自己会在错误里给出解法,这就是 use<> 语法:精确声明哪些生命周期允许逃逸进返回值

fn process<'a>(data: &'a str, buf: &mut Vec<u8>) -> impl Iterator<Item = &'a str> + use<'a> {
    buf.clear();
    data.split(',')
}

let it = process("a,b,c", &mut buf);
buf.push(3);                    // 现在合法——迭代器只借 data
let parts: Vec<&str> = it.collect();  // ["a", "b", "c"]

use<'a> 是一纸诚实声明:“返回值只欠 'a 的债。“其他参数用完即还。它和第 3 篇的生命周期标注是同一哲学的延伸:有效性的来源必须显式可追溯——只是这次,连”不追溯谁“也能写出来了。

练习,以及第 8 篇

  1. fn sort_and_dedup<T: Ord>(v: &mut Vec<T>),用 i32String 各测一次。然后尝试加一个 println!("{v:?}")——看着编译器逼你把 bound 改成 T: Ord + Debug,体会“bound 是授权”的那一面。
  2. evens_up_to 改成返回“所有约数”的迭代器,约束是不许用 Vec 中转。你会被迫留在 impl Iterator 的世界里——感受闭包类型不可命名这件事多么日常。
  3. 复现本篇的 E0499:先写不带 use<>process,读完整错误(注意编译器亲自建议了 + use<'a>),再加上它。这是 2024 edition 里你会真实遇到的错误,提前见一面胜过文档十页。

第 8 篇从语言特性转向工程现实:Cargo 与模块系统——crate、mod 与可见性的真实规则、workspace 如何组织大项目、feature flags 怎么做条件编译,以及 cargo 那些工作日每天要用但教程从不细讲的命令。类型系统再美,也要装进可维护的项目里才算数。

guest@swangnice:~$