泛型(Generic Types)
本教程共 78 篇 · 第 43 篇 · 更新于 2026-08-08 · 约 10 分钟阅读
本节目标:用泛型让一套函数、结构体、枚举适配多种类型,理解为什么泛型参数需要 trait 约束,以及 Rust 泛型为何”零成本”。
写两个数相加,整数要一份、浮点要一份、自定义类型再来一份——重复得让人头大。泛型就是为解决这个而生的:一个”通用炮管”,什么类型的”炮弹”都能打。
从更高处看,泛型其实是”多态”的一种:同一份逻辑,适配多种类型。它最大的价值不是少写几行,而是”只维护一份源码,却能正确作用于无数类型”,而且——下面会讲——运行时零开销。
1-1 泛型函数初见
没有泛型时,得为每种类型写一个函数:
fn add_i8(a: i8, b: i8) -> i8 { a + b }
fn add_i32(a: i32, b: i32) -> i32 { a + b }
fn add_f64(a: f64, b: f64) -> f64 { a + b }
用泛型,一个搞定:
fn add<T>(a: T, b: T) -> T {
a + b
}
<T> 是泛型参数声明,T 是类型占位符(惯例用 T,取自 type)。用之前必须先声明 add<T>,随后参数和返回值都能用 T。
Note上面这段其实编不过,因为
T不一定是能相加的类型。这点下一节讲。先对泛型有个直观印象即可。
1-2 泛型需要 trait 约束
T 可以是任何类型,但不是所有类型都能比较、能相加。所以编译器要求你对 T 加限制,这就是trait 约束:
use std::cmp::PartialOrd;
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
T: PartialOrd 表示”T 必须实现 PartialOrd 这个 trait(即可比较)“。不满足的类型,编译器会直接拦下,还贴心地提示你加约束。
回到开头的 add,要能相加得约束 Add:
use std::ops::Add;
fn add<T: Add<Output = T>>(a: T, b: T) -> T {
a + b
}
Add<Output = T> 表示加法的结果类型还是 T。加上约束后就能编过了。
1-3 结构体里的泛型
结构体字段也能用泛型,比如一个能装任意类型坐标的点:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
注意要提前声明 Point<T>。这里 x 和 y 必须是同一类型——一旦 x 被推断成整数,y 也得是整数,否则报错。
想让 x、y 类型不同,就上两个不同的泛型参数:
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let p = Point { x: 1, y: 1.1 };
}
Tip泛型参数别贪多。要是写出
struct Woo<T,U,V,W,X>,说明这个结构体该拆了。参数越多,代码越难读。
1-4 枚举里的泛型
你早已见过两个带泛型的枚举。Option<T> 用 T 装可能存在的值:
enum Option<T> {
Some(T),
None,
}
Result<T, E> 用 T 装成功值、用 E 装错误:
enum Result<T, E> {
Ok(T),
Err(E),
}
正是泛型让这两个枚举能套在任何类型上,成为 Rust 错误处理的基石。
1-5 方法里的泛型
在带泛型的结构体上实现方法,要在 impl 后也声明泛型:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl<T> 的声明让编译器知道尖括号里是泛型。这里 Point<T> 已经是个完整类型,不再是声明。
方法还能有自己额外的泛型参数,和结构体上的不冲突:
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point { x: self.x, y: other.y }
}
}
T, U 是结构体的泛型,V, W 是方法 mixup 自己的泛型。
甚至可以为某个具体类型单独实现方法:
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
只有 Point<f32> 才有这个方法,其他 T 的 Point 没有。
1-6 const 泛型:针对”值”的泛型
前面的泛型都针对”类型”。数组 [i32; 3] 和 [i32; 2] 是不同类型,想写一个函数打印任意长度数组,光靠类型泛型不够。这时用 const 泛型,针对”值”(这里是长度):
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
println!("{:?}", arr);
}
fn main() {
let arr1 = [1, 2, 3];
let arr2 = [1, 2];
display_array(arr1);
display_array(arr2);
}
const N: usize 表示”值泛型 N”,用来替代数组长度。const 泛型从 Rust 1.51 起稳定,写固定大小的缓冲区、矩阵时特别有用。
1-7 零成本:单态化
你可能会担心:泛型会不会拖慢运行速度?答案是不会。Rust 在编译期做单态化(monomorphization):编译器找出所有用到泛型的地方,为每种具体类型生成一份专属代码。
比如 Some(5) 和 Some(5.0),编译器实际会生成 Option_i32 和 Option_f64 两套定义。运行时执行的,就跟你手写每个具体版本一模一样——没有任何性能损耗。
代价是编译时间变长、最终二进制稍大。换来的是”写一份代码、跑出专属性能”,这就是 Rust 津津乐道的”零成本抽象”。
1-8 泛型约束的写法变体
随着约束变多,把 T: Trait 全写在一对尖括号里会越来越挤。Rust 提供两种等价的写法,挑读起来顺的用。
其一是内联写法:fn f<T: Clone + std::fmt::Debug>(x: T)。其二是 where 子句,把约束挪到函数签名之后,适合约束长或涉及多个类型参数的情况:
fn f<T, U>(x: T, y: U)
where
T: Clone + std::fmt::Debug,
U: std::fmt::Display,
{
println!("{:?} / {}", x, y);
}
为结构体或枚举实现方法时,泛型参数要紧跟 impl 写一遍,再在类型上写一遍:impl<T> Wrapper<T> { ... }。如果一个方法只对”满足某约束的 T”才有意义,就在这个方法自己的 impl 块上加约束——这叫”条件实现”,后面章节会展开。
Note单态化意味着每个用到的具体类型都会生成一份独立代码。所以泛型不是”运行时万能盒子”,而是”编译期为每种类型各生成一份专用代码”,既通用又零成本。
1-9 const 泛型与固定大小数组
除了”对类型”的泛型,Rust 还有”对值”的泛型——const 泛型,最典型就是数组的长度。你可以写出”长度也是参数”的结构体:
struct Buffer<T, const N: usize> {
data: [T; N],
}
impl<T, const N: usize> Buffer<T, N> {
fn len(&self) -> usize { N }
}
这里 N 是编译期已知的常量,所以 Buffer<i32, 8> 和 Buffer<i32, 16> 是两个不同类型,编译器能为各自生成专用代码,且数组大小在编译期就固定,没有运行时堆分配。
这和普通 Vec<T> 形成对比:Vec 的长度是运行期可变的、数据在堆上;数组 [T; N] 的长度在编译期定死、数据通常在栈上。需要”大小固定又能通用”时,const 泛型最合适。
Note
const泛型不限于usize长度,也可以是其它整数类型或bool等常量,但入门阶段记住”数组长度参数化”这一个用法就够用了。
1-10 泛型与 trait 一起出现的经典模式
泛型单独用已经能省代码,但真正威力爆发是在它和 trait 约束合用的时候。一个”既通用又安全”的函数长这样:类型参数 T 负责”通吃各种类型”,trait 约束负责”限定 T 必须会做什么”,两者配合才既灵活又不会写出对 T 一无所知、编译不过的代码。
回忆求最大元素的例子:fn largest<T: PartialOrd + Copy>(list: &[T]) -> T,T 让整数、浮点、字符都能用同一份逻辑,PartialOrd 保证能比较、Copy 保证能复制返回。缺了约束,编译器不知道 T 能不能比大小,直接报错——这就是”约束定义能力”的体现。
还有一个心智模型:当你发现自己在为 i32、f64、String 各写一份几乎一样的函数时,就该问”能不能提成泛型”。提成泛型后,单态化会为每种类型各生成专用代码,性能和手写一模一样,却只维护一份源码。
Tip泛型不是越多越好。只给”确实需要通吃多种类型”的地方用泛型;如果永远只有一种类型,写死反而更清楚。
1-11 当编译器推断不出类型:显式标注
大多数时候编译器能从参数反推出 T 是什么。但有些情况它做不到,比如函数本身没有参数、或者某个值能转成多种类型时。这时要手动告诉它 T 是谁,语法是在函数名后加 ::<类型>,江湖人称 “turbofish”:
fn make<T>() -> Vec<T> {
Vec::new()
}
fn main() {
let v = make::<i32>(); // 明确 T 是 i32
println!("{}", v.len());
}
make::<i32>() 相当于喊一声”这里 T 就是 i32”。更常见的场景是 collect:它既能收成 Vec 也能收成 HashMap,编译器需要你点明:
use std::collections::HashMap;
let v = vec![(1, "a"), (2, "b")];
let m: HashMap<i32, &str> = v.into_iter().collect(); // 类型标注也能替代 turbofish
那为什么之前第 38 章我们写的是 collect::<HashMap<_, _>>()?因为 collect 的目标类型要靠”上下文”推断,而用 ::<HashMap<_, _>> 显式点明最稳。本质上,类型标注 : HashMap<...> 和 turbofish ::<...> 是同一件事的两种写法,挑顺手的就行。
Note记住 turbofish 这个写法
::<T>:遇到”编译器说推断不出泛型”的报错时,它几乎总是解药。
1-12 小结
- 泛型用
<T>声明,让函数/结构体/枚举适配多种类型; - 泛型参数常需 trait 约束(如
T: PartialOrd),否则编译器不知道能做什么操作; - 结构体、枚举、方法都能用泛型;还能为具体类型单独实现方法;
- const 泛型
const N: usize针对”值”,常用来处理数组长度; - 泛型通过编译期单态化实现零成本抽象,运行时无开销。
下一章看 trait,它是 Rust 表达”共享行为”的核心机制,也正是泛型约束要引用的那些 trait。