C 里写通用代码,常见有三种办法:
- 用
void *加长度和回调 - 用宏生成不同类型的代码
- 手写多份类似函数
这些办法都能工作,但各有代价。void * 会丢类型信息,宏难调试,手写多份容易重复和不一致。
Rust 的泛型解决的是同一个问题:同一套代码可以用于多种类型,同时尽量保留类型检查。
C 的 void 指针会丢类型
看一个 C 里的通用查找函数:
int find_item(const void *items, size_t len, size_t item_size,
const void *target,
int (*cmp)(const void *, const void *))
{
const unsigned char *base = items;
for (size_t i = 0; i < len; i++) {
const void *item = base + i * item_size;
if (cmp(item, target) == 0) {
return (int)i;
}
}
return -1;
}
这很灵活,但调用方要自己保证:
items指向的元素类型正确item_size和真实元素大小一致target类型和数组元素一致cmp按同一类型解释两个指针
类型关系不在函数签名里,很多错误只能靠测试和运行时发现。
Rust 用类型参数保留类型
Rust 里可以先写一个只依赖相等比较的版本:
fn find_item<T: PartialEq>(items: &[T], target: &T) -> Option<usize> {
for (index, item) in items.iter().enumerate() {
if item == target {
return Some(index);
}
}
None
}
这里的 T 是类型参数。它表示:这个函数可以用于很多类型,但同一次调用里 items 的元素类型和 target 的类型必须一致。
T: PartialEq 是 trait bound,表示 T 必须支持相等比较。
调用时:
let numbers = [10, 20, 30];
let index = find_item(&numbers, &20);
let names = ["uart", "spi", "i2c"];
let index = find_item(&names, &"spi");
同一套函数可以用于整数和字符串切片,但类型不会被擦成 void *。
泛型不是运行时猜类型
泛型的关键不是“运行时根据类型分支”。编译器在编译时知道每次调用使用的具体类型。
所以这个调用不成立:
let numbers = [10, 20, 30];
let target = "20";
let index = find_item(&numbers, &target);
numbers 的元素是整数,target 是字符串。它们不是同一个 T。
C 里如果通过 void * 写错,可能只是比较函数里解释错内存。Rust 在调用点就会拒绝。
第一层可以这样理解:
C void *:把类型信息拿掉,靠调用约定补回来
Rust 泛型:把类型留在签名里,让编译器检查关系
trait bound 限定能做什么
泛型函数里不能随便对 T 做任何事。你只能使用 trait bound 允许的行为。
例如没有 bound 时:
fn debug_value<T>(value: T) {
println!("{value:?}");
}
这不能编译,因为不是所有 T 都能用 {:?} 格式化。
要写成:
fn debug_value<T: std::fmt::Debug>(value: T) {
println!("{value:?}");
}
T: Debug 表示:这个函数适用于所有实现了 Debug 的类型。
这和 C 里“调用方要传一个能打印的回调”有点像,但 Rust 把能力要求写进类型约束。
泛型结构体也保留类型
泛型不只用于函数,也可以用于结构体:
struct Buffer<T> {
items: Vec<T>,
}
impl<T> Buffer<T> {
fn len(&self) -> usize {
self.items.len()
}
}
Buffer<u8> 和 Buffer<String> 是不同的具体类型。它们共享同一份代码结构,但类型关系仍然清楚。
如果某个方法需要额外能力,可以只给这个方法加 bound:
impl<T: Clone> Buffer<T> {
fn duplicate_first(&self) -> Option<T> {
self.items.first().cloned()
}
}
只有当 T 支持 Clone 时,才有这个方法。
泛型和 dyn trait 的区别
前一篇讲过 dyn Trait。它和泛型都能表达“我不关心具体类型,只关心行为”,但方式不同。
泛型:
fn write_all<W: Writer>(writer: &mut W, data: &[u8]) {}
dyn trait:
fn write_all_dyn(writer: &mut dyn Writer, data: &[u8]) {}
第一层区别是:
泛型 T: Trait 编译期保留具体类型,通常静态分发
dyn Trait 运行时通过方法表调用,动态分发
大多数库 API 可以先用泛型。需要把不同具体类型放进同一个集合、运行时切换实现,或者隐藏具体类型时,再考虑 dyn Trait。
泛型不是宏
C 宏也能生成“泛型”效果:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
宏的问题是:它在预处理阶段替换文本,不理解类型、作用域和求值次数。比如参数可能被求值多次。
Rust 泛型不是文本替换。它发生在类型系统里:
fn max<T: Ord>(a: T, b: T) -> T {
if a >= b { a } else { b }
}
T: Ord 明确要求类型可以排序。参数只按 Rust 表达式规则求值,不是宏展开文本。
这一篇先记住什么
C 和 Rust 的第一层对应关系是:
void * + item_size + 回调 -> 泛型 T + trait bound
宏生成通用代码 -> 泛型函数 / 泛型结构体
手写 int/string 多份函数 -> 一个泛型函数
调用方保证类型一致 -> 编译器检查 T 的一致性
调用方保证支持某种操作 -> T: Trait
运行时接口对象 -> dyn Trait
泛型回答的是:这段代码能对哪些类型通用。
trait bound 回答的是:这些类型必须具备什么行为。
下一篇可以进入 unsafe。那会把 C 开发者熟悉的裸指针、FFI、全局状态和不可静态证明的边界,放回 Rust 的安全模型里。