阶段五 · 泛型与特征

泛型

一句话总结

泛型(Generics)让一份代码适配多种类型,消除重复.
Rust 泛型语法与 Go 1.18+ 相似,但底层用单态化(monomorphization)--为每个具体类型生成专属代码,因此零运行时开销.
无约束的泛型类型能力很弱,真正的威力要等下一篇的特征约束来释放.

为什么需要泛型

假设要写"取序列中第一个元素"的函数.没有泛型,得为每种类型各写一遍:

fn first_i32(list: &[i32]) -> &i32 { &list[0] }
fn first_str(list: &[&str]) -> &&str { &list[0] } // 逻辑一模一样

逻辑相同,只是类型不同--这正是泛型要消除的重复,和 Go 1.18 之前的处境一样(要么复制粘贴,要么用 interface{} 牺牲类型安全).泛型给出第三条路:一份代码,编译期保持类型安全.

泛型函数

用尖括号声明类型参数 <T>,之后 T 就是一个类型占位符:

fn first<T>(list: &[T]) -> &T {
&list[0]
}

let nums = vec![1, 2, 3];
let words = vec!["a", "b"];
println!("{}", first(&nums)); // T = i32
println!("{}", first(&words)); // T = &str

Rust 的 fn first<T>(list: &[T]) 几乎等同于 Go 的 func First[T any](list []T) T.
命名习惯一致(T 表示 type),思路也一致:把类型抽出来当参数.

无约束的 T 能力极弱

first 能编译,是因为它只做了"取引用"这种对任意类型都成立的操作.一旦想 a > b 比较,a + b 相加,编译器就会拒绝--它无法保证任意 T 都支持这些操作.

解决办法是特征约束(下一篇核心).这是 Rust 泛型与 Go 泛型共同的关键认知:泛型类型只能做"约束允许"的事.

泛型结构体与枚举

结构体,枚举也能泛型化.早就用过的 Option<T>,Vec<T> 就是泛型枚举/结构体:

struct Point<T> {
x: T,
y: T,
}

let int_point = Point { x: 1, y: 2 }; // Point<i32>
let float_point = Point { x: 1.0, y: 2.0 }; // Point<f64>

// 多个类型参数
struct Pair<K, V> {
key: K,
value: V,
}
let p = Pair { key: "age", value: 30 }; // Pair<&str, i32>

在 impl 中使用泛型

给泛型结构体写方法,impl 后也要声明类型参数:

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

impl<T> 里的 T声明,Point<T> 里的 T使用.还能只为特定具体类型实现方法:

// 只有 Point<f64> 才有这个方法
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

这种"只给某具体类型加方法"的能力,Go 泛型目前做不到,是 Rust 较灵活的一点.

单态化:零成本的秘密

这是 Rust 泛型与 Go 泛型在实现机制上的关键分野.编译 first<T> 时,Rust 会针对每个实际用到的类型,生成一份独立的具体代码:

泛型代码                  编译器单态化后(概念上)
────────────── ──────────────────────
fn first<T>(...) → fn first_i32(list: &[i32]) -> &i32 { ... }
(被 i32 和 &str 调用) fn first_str(list: &[&str]) -> &&str { ... }

结果:运行时没有任何泛型开销,等同于手写了多个专门版本.代价是编译产物可能变大,编译变慢.

单态化像"提前为每位客人定制专属餐具":用餐时(运行时)拿起就用,毫无适配成本;代价是后厨(编译期)要多做几套.
Go 泛型实现更接近"通用餐具 + 少量运行时适配",取舍不同--Rust 把开销全压到编译期,追求极致运行时性能.

类型推断与 turbofish

多数时候编译器能自动推断类型参数.无法推断时,用 ::<Type> 语法(俗称 turbofish)显式指定:

let s = "42";
let n = s.parse::<i32>().unwrap(); // 告诉 parse 目标类型是 i32

let v = (1..=5).collect::<Vec<i32>>(); // 告诉 collect 收集成 Vec<i32>
// 等价写法:let v: Vec<i32> = (1..=5).collect();

第 09 篇里 collect 常需类型标注,正是这个原因--它能产出多种集合,编译器需要明确目标类型.

下一篇预告

本篇的泛型还很"弱",因为类型参数没有约束.一旦给 T 加上特征约束(如"T 必须可比较大小""T 必须可打印"),泛型才真正强大起来.这正是下一篇「特征 Trait(上)」的主题--Rust 的接口系统.

快速回顾

  • 泛型作用:一份代码适配多类型,消除重复,保持编译期类型安全.
  • 语法:函数 fn f<T>,结构体 struct S<T>,方法 impl<T> S<T>,与 Go 高度相似.
  • 无约束 T:只能做对任意类型都成立的操作;要比较,运算需特征约束(下一篇).
  • 特化 impl:可只为某具体类型(如 Point<f64>)实现方法,Go 做不到.
  • 单态化:为每个具体类型生成专属代码,运行时零开销,代价是产物变大.
  • turbofish:parse::<i32>() 在推断不足时显式指定类型参数.

动手练习

  1. 泛型函数:写一个泛型函数 fn last<T>(list: &[T]) -> &T 返回切片最后一个元素.
  2. 约束不足:尝试写 fn largest<T>(list: &[T]) -> &T 并在体内比较大小,观察编译错误,记住它(下一篇解决).
  3. 泛型结构体:定义泛型结构体 Wrapper<T>,并在 impl 中实现取出内部值的方法.
  4. 特化 impl:给一个泛型结构体只为某具体类型(如 Wrapper<String>)实现一个额外方法.
  5. turbofish 使用:用 turbofish 把 "3.14" 解析为 f64,再用类型标注的等价写法实现一次.