跳转到主内容泛型:别再用 void 指针表达通用代码 | 物联网民工

泛型:别再用 void 指针表达通用代码

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 的安全模型里。