做C开发的人,一定遇过这种尴尬场面:写了一个链表,只能存int;换了个项目,需要存字符串,于是把链表复制一份改成char版本;再过一阵要存结构体,又复制一份。代码越堆越多,维护起来恨不得连夜提桶跑路。你说用C++、Rust,项目又没这条件。这时候你会在代码里看到老朋友void,再配上函数指针,一套"C语言手工泛型"就搭起来了。这套路在Linux内核、GTK、glibc里到处都是,属于C语言开发者必备的看家本领。这篇博文就把这套路的原理、实现、坑点一次讲透,适合被重复代码折磨过、想提升C代码复用能力的中级开发者,也适合刚学完指针、想看看真实工程怎么用指针的初学者。
1. 为什么 C 语言需要"泛型":两个工具的真实动机
1.1 void*:C语言里最像"任意类型"的指针
很多初学者第一次看到void*,脑子里只有一个词:void不是"无"吗,为什么还能有指针?这里其实是个历史遗留的命名误会。void表示"没有指定的类型",而不是"没有东西"。void*就是"指向未知类型的指针",它不做类型检查,能接受任意类型的指针赋值,也能被赋给任意类型的指针。
c复制int a = 10;
char c = 'x';
double d = 3.14;
void *p = &a; // 没问题
p = &c; // 类型变了,还是没问题
p = &d; // 无缝切换
这就是"泛型"的第一块基石:我只负责打包运输,不关心里面装的是什么。这个特性在我们写通用容器、通用算法时是命根子,因为容器的节点里需要存"任意类型的数据",而C语言里只有void*能做到"我不关心类型,但我还能拿着地址干活"。
但void有个致命缺陷:它丢了类型信息。你知道里面有数据,但不知道这块数据占几个字节,也不知道该怎么解释这块内存。所以单靠void还不够,必须配套一个东西来弥补——那就是size和函数指针。
1.2 函数指针:把行为当成数据传递
函数指针是C语言里最容易被低估的一个特性。很多人在大学学到函数指针后就再也没碰过,觉得它只是语法糖,直到有一天你需要在代码里传递"某种行为"。
举个例子。你在写一个通用的遍历函数,希望它对数组里每个元素都做同样的事情——但这个"事情"到底是什么,调用方说了算。这时候函数指针就派上用场了:把"要执行的操作"作为参数传进去,函数内部在合适的地方调用它。这个模式叫回调(callback),是C语言里实现多态的核心手段。
c复制void apply_to_array(void *arr, int len, void (*callback)(void *elem)) {
for (int i = 0; i < len; i++) {
callback((char *)arr + i * element_size);
}
}
看到没,(char *)arr + i * element_size这行是泛型的关键。void不能做指针算术,但转成char之后,char是1字节,于是i个元素的位置就是起始地址 + i * 单个元素大小。这里又需要知道element_size是多少。所以泛型不是单纯靠void就行的,而是**void负责"我不知道类型",size_t负责"我知道每个元素多大",函数指针负责"我知道怎么处理这个元素"**,三者配合,才真正实现了"类型无关"。
1.3 void* + 函数指针的组合哲学
当我们把这俩东西放一起,实际上是在用C语言模拟面向对象里的"多态"。void*扮演的是"基类引用",函数指针扮演的是"虚函数"。
让我用一个生活化的类比:void*像一个万能快递盒,不管你买的是手机、书还是螺丝钉,快递盒都长一个样,只负责搬运;size_t就是这个盒子上标注的"内物尺寸",让搬运工知道不能压、不能挤;函数指针则是"拆箱说明书",告诉你是该用螺丝刀、还是开瓶器。
这套组合在真实项目里非常常见。比如glibc的qsort函数,你传入一个数组、数组长度、元素大小、一个比较函数,它就能对任意类型的数组排序。你不需要为int数组写一个排序、为double数组再写一个排序,只需要换一个比较函数,算法逻辑完全复用。这套话术听起来是不是很像C++里的模板?确实像,但两者机制完全不同,文章后面会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个通用动态数组:从零开始
2.1 先想清楚"通用"意味着什么
我们直接动手写一个能存任意类型的动态数组。目标很明确:写一次,以后所有项目都能用,不管数组里存int、存自定义结构体、还是存指针。
在设计之前,先给自己定几个硬性要求:
- 添加元素时,传入该元素的内存地址,容器负责把它拷贝进内部存储。
- 删除元素时,容器负责把后面的元素往前挪。
- 访问元素时,返回void*,调用方自己转回原类型。
- 销毁容器时,如果元素是复杂结构体,需要调用外部传入的"元素释放函数"来清场。
这个设计思路跟C++标准库vector很像,但vector使用模板在编译期就知道元素类型,而我们的C版本必须在所有数据结构里额外存储element_size,并且为"构造/拷贝/销毁"提供回调。
2.2 数据结构定义与初始化
c复制typedef struct {
void *data; // 实际存储数据的连续内存
size_t element_size; // 单个元素占用的字节数
size_t capacity; // 当前容量,最多能存多少元素
size_t size; // 当前实际元素个数
void (*free_elem)(void *); // 元素释放回调,可为NULL
} GenericArray;
data字段是核心,它指向一块连续内存,我们用malloc申请。element_size是灵魂,没有它,内存操作全都寸步难行。capacity和size分别是容量和现有元素个数,跟普通动态数组的实现一致,扩容时按两倍来扩。
初始化函数长这样:
c复制void ga_init(GenericArray *arr, size_t element_size, void (*free_elem)(void *)) {
arr->data = NULL;
arr->element_size = element_size;
arr->capacity = 0;
arr->size = 0;
arr->free_elem = free_elem;
}
这里有个细节:为什么初始data不直接分配一点空间?因为很多场景下用户创建数组只是为了传给别人用,不一定马上往里塞数据,所以延迟分配可以省内存。但如果你预期会有频繁的小规模插入,初识就分配4个元素大小会更划算。这个取舍没有标准答案,取决于使用场景。
2.3 扩容算法与临界状态处理
添加元素是动态数组最核心的操作。逻辑分三步:检查容量、扩容、拷贝元素。
c复制static int ga_reserve(GenericArray *arr, size_t extra) {
if (arr->size + extra <= arr->capacity) {
return 0;
}
size_t new_capacity = arr->capacity ? arr->capacity : 4;
while (new_capacity < arr->size + extra) {
new_capacity *= 2;
}
void *new_data = realloc(arr->data, new_capacity * arr->element_size);
if (new_data == NULL) {
return -1; // 扩容失败,原数组仍然有效
}
arr->data = new_data;
arr->capacity = new_capacity;
return 0;
}
几个关键决策我要解释一下:
第一,为什么从4开始扩?因为4是cache line友好的起步值,太小了频繁扩容,太大了浪费内存。这是工程经验的权衡,不是数学推导。
第二,为什么每次扩到两倍?2倍是最经典的扩容因子。如果每次只加一个元素的空间,插入n个元素触发O(n²)次realloc,性能直接崩掉。2倍扩容让均摊复杂度降到O(1)。
第三,realloc的返回值必须先存到一个临时变量里。这个是最常见的坑,如果realloc失败返回NULL,你直接赋给arr->data,原来的内存就泄漏了——你再也找不到那个指针了。所以必须先保存到new_data,判断非空后再赋值。
插入元素的核心函数:
c复制int ga_push_back(GenericArray *arr, const void *elem) {
if (arr == NULL || elem == NULL) {
return -1;
}
if (ga_reserve(arr, 1) != 0) {
return -1;
}
void *dest = (char *)arr->data + arr->size * arr->element_size;
memcpy(dest, elem, arr->element_size);
arr->size++;
return 0;
}
注意这个计算:(char *)arr->data + arr->size * arr->element_size。arr->size是当前已有元素个数,那么下一个空闲位置就是第size个元素的位置。用(char*)做指针运算,是因为void*不能做算术,而char恰好是1字节。
2.4 访问、删除与销毁
访问逻辑和插入类似,返回void*,让调用方自己转类型:
c复制void *ga_get(const GenericArray *arr, size_t index) {
if (arr == NULL || index >= arr->size) {
return NULL;
}
return (char *)arr->data + index * arr->element_size;
}
删除操作需要考虑到"如果元素占用了外部资源,应该先释放":
c复制int ga_erase(GenericArray *arr, size_t index) {
if (arr == NULL || index >= arr->size) {
return -1;
}
if (arr->free_elem != NULL) {
arr->free_elem((char *)arr->data + index * arr->element_size);
}
if (index < arr->size - 1) {
void *dest = (char *)arr->data + index * arr->element_size;
const void *src = (char *)arr->data + (index + 1) * arr->element_size;
memmove(dest, src, (arr->size - index - 1) * arr->element_size);
}
arr->size--;
return 0;
}
销毁函数就是在erase的基础上循环一遍,把每个元素都释放掉,再释放容器自身的data:
c复制void ga_destroy(GenericArray *arr) {
if (arr == NULL) return;
if (arr->free_elem != NULL) {
for (size_t i = 0; i < arr->size; i++) {
arr->free_elem((char *)arr->data + i * arr->element_size);
}
}
free(arr->data);
arr->data = NULL;
arr->size = 0;
arr->capacity = 0;
}
注意一个细节:删除中间元素用的是memmove而不是memcpy。因为当源区域和目标区域有重叠时(删除元素时后面的往前挪,必然重叠),memcpy的行为是未定义的,memmove才能保证正确处理重叠。这个知识点在面试里很常考,但在实际工程中很多人直接写memcpy然后出诡异bug。
3. 通用算法:排序、查找、遍历一个都不能少
3.1 写一个对任意类型都适用的遍历打印函数
有了通用容器,下一步就是给它配通用算法。先从最简单的遍历打印开始。
其实上面的apply_to_array已经给了思路。但打印有个问题:不同类型打印格式不一样,int要用%d,double要用%f,字符串要用%s。所以我们必然需要函数指针来封装"如何打印"。
c复制typedef void (*print_func)(const void *elem);
void ga_print(const GenericArray *arr, print_func printer) {
printf("[");
for (size_t i = 0; i < arr->size; i++) {
printer(ga_get(arr, i));
if (i < arr->size - 1) {
printf(", ");
}
}
printf("]\n");
}
使用方式:
c复制void print_int(const void *elem) {
printf("%d", *(const int *)elem);
}
void print_point(const void *elem) {
const Point *p = (const Point *)elem;
printf("(%d, %d)", p->x, p->y);
}
这里有个很实用的经验:回调函数里拿到的void*,转成具体类型后必须只读,不要在打印、遍历这类"观察型"操作里去修改数据。如果要修改,请另外设计修改接口。这能让代码的意图清晰,也能防止意外副作用。
3.2 基于函数指针的通用排序:手写qsort
标准库的qsort是一种快速排序的泛型实现,我们完全可以写一个简化版来理解它的原理。我在学习这套机制时,手写过一堆经典排序演变成的泛型版本,这个过程中对函数指针的理解会突飞猛进。
先定义比较函数类型:
c复制typedef int (*compare_func)(const void *a, const void *b);
返回值约定:a < b返回负数,a > b返回正数,a == b返回0。
拿最简单的冒泡排序演示:
c复制void generic_bubble_sort(void *base, size_t count, size_t size, compare_func cmp) {
for (size_t i = 0; i < count - 1; i++) {
for (size_t j = 0; j < count - i - 1; j++) {
void *left = (char *)base + j * size;
void *right = (char *)base + (j + 1) * size;
if (cmp(left, right) > 0) {
// 交换两个元素
unsigned char tmp_buf[128];
memcpy(tmp_buf, left, size);
memcpy(left, right, size);
memcpy(right, tmp_buf, size);
}
}
}
}
这个实现里有几个小而关键的点:
第一,size必须作为参数传入,否则无法定位第j个元素的位置。很多人以为void*是"什么都能存"就完事了,但如果不传size,你连第2个元素在哪都不知道。
第二,交换时不能直接*temp = *left,因为void*没法解引用,而且你也不知道类型大小。这里用了一个栈上的固定数组tmp_buf做临时存储。如果元素大小超过128字节,这个就崩了。更稳妥的做法是用malloc动态分配临时空间,但那样性能会差一点。两个方案各有取舍,实际项目中可以做个判断,或者配置一个宏开关。
第三,变量size和函数指针cmp在很多编译器的优化下会被放到寄存器里,循环性能不会太差。但如果你追求极致性能,可以把size和cmp塞进一个结构体里,一次性传入,减少参数传递开销——这就是传说中的"上下文打包"技巧,在写大量递归回调时会显著减少寄存器压力。
3.3 标准库自带的qsort和bsearch,别重复造轮子
其实C标准库已经提供了qsort,它内部是高优化的快速排序实现,使用起来跟上面写的generic_bubble_sort一模一样:传base地址、元素个数、元素大小、比较函数。工程上直接用它就够了,大部分场景下不需要自己写排序。
c复制int int_compare(const void *a, const void *b) {
int ia = *(const int *)a;
int ib = *(const int *)b;
return (ia > ib) - (ia < ib); // 安全地返回 -1/0/1
}
int main(void) {
int arr[] = {5, 2, 8, 1, 9};
qsort(arr, 5, sizeof(int), int_compare);
return 0;
}
bsearch是配套的二分查找,使用前要求数组必须有序:
c复制int key = 8;
int *result = bsearch(&key, arr, 5, sizeof(int), int_compare);
if (result != NULL) {
printf("找到了: %d\n", *result);
} else {
printf("没找到\n");
}
注意bsearch传的不是key本身,而是key的地址&key。因为整个接口是以void*为核心的,它需要统一地"指向待比较对象"的指针。这里的指针指向一个暂存变量,内部会把该地址传给你的比较函数,所以比较函数内部要解引用。这是新手最容易踩的一个坑,很多人直接传key,编译会警告把整数转成指针,运行时直接崩溃。
为什么我建议先自己手写一个通用排序,再用qsort?因为理解了内部机制,你才能预判qsort可能带来的问题。比如qsort是不稳定的,如果你需要对结构体按字段排序且保持原来的相对顺序,就得自己处理稳定性。再比如qsort的内部实现可能因为数据分布不同走快排分支或插入排序分支,性能表现并不总是一致。
4. 内存管理:泛型路上的最大关卡
4.1 深拷贝和浅拷贝的边界
当你把一个结构体塞进GenericArray时,push_back内部用memcpy把元素字节复制了一份。如果结构体里只有int、double这些简单的值,这一份拷贝是完整的,没问题。但如果结构体里带指针指向堆内存,那就有大问题了:
c复制typedef struct {
int id;
char *name; // 指向malloc出来的内存
} Student;
Student stu;
stu.id = 1;
stu.name = strdup("Alice");
ga_push_back(&arr, &stu);
ga_push_back(&arr, &stu);
执行完之后,arr里两个元素的name指针都指向同一个地址。如果你对其中一个元素动手(比如修改name内容),另一个元素的name也跟着变了。更危险的是销毁时,free_elem如果对每个元素执行free(name),同一个地址会被释放两次——未定义行为,程序可能崩溃,也可能不崩只在某个夜深人静的晚上给你报"malloc(): double free detected"。
解决这个问题的思路是:在push_back的时候不直接用memcpy,而是调用深拷贝回调。我后来设计的GenericArray里会加一个copy_func,元素进容器时调用它完成深度复制:
c复制typedef void *(*copy_func)(const void *elem);
int ga_push_back_copy(GenericArray *arr, const void *elem, copy_func copier) {
if (ga_reserve(arr, 1) != 0) return -1;
void *dest = (char *)arr->data + arr->size * arr->element_size;
if (copier != NULL) {
void *copy = copier(elem);
if (copy == NULL) return -1;
memcpy(dest, copy, arr->element_size);
free(copy); // 释放临时拷贝
} else {
memcpy(dest, elem, arr->element_size);
}
arr->size++;
return 0;
}
这样Student的深拷贝函数可以长这样:
c复制void *student_copy(const void *elem) {
const Student *src = (const Student *)elem;
Student *dst = malloc(sizeof(Student));
dst->id = src->id;
dst->name = strdup(src->name);
return dst;
}
4.2 释放时要知道每个元素到底怎么释放
有些人写通用容器会偷懒,销毁时直接free(arr->data),这个对纯值类型(int、double)的数组没问题,但如果元素是结构体,且结构体内部有malloc出来的成员,你直接free(data)只释放了结构体本身,内部name指向的内存就泄漏了。所以free_elem回调的设计是必须的,它不是锦上添花,而是通用容器的刚需。
但这里有个小陷阱:free_elem的签名。
c复制void free_student(void *elem) {
Student *s = (Student *)elem;
free(s->name);
}
它接收的是"元素在容器中的地址",而不是元素本身的副本。所以传入一个指向Student结构体位置的指针,直接在原处释放内部资源即可。注意不要在里面free(s)本身——那会把容器内部的存储空间也释放掉,随后容器在ga_destroy里再free(data)就是二次释放,直接崩。
4.3 对齐问题:为什么我的结构体变大后会崩溃
这是我最想让大家注意的一个细节。malloc返回的内存是最大对齐的,所以数组首地址没问题。但如果你做packet buffer、序列化、或者手动计算偏移量来把结构体内存拼进一个byte数组,对齐问题就开始作妖了。
举个例子。假设一个结构体:
c复制struct Foo {
char c;
int i;
};
编译器一般会把结构体大小padding成8字节,i在offset 4的位置对齐到4字节边界。但如果你手动用一个字节数组模拟存储,把i放到offset 1的位置,访问((struct Foo *)buf)->i就可能触发align fault,在ARM平台上直接崩溃,在x86上慢到爆炸。
我在一个较底层的项目里踩过这个坑:手动拼接二进制协议消息,消息里带各种类型的字段,我用char数组装下所有字段,然后强制转成结构体指针去访问。字段少的没问题,一加进double类型就出诡异错误。后来改成用memcpy从缓冲区中拷贝到本地结构体,才彻底解决。
所以,无论什么时候,访问未对齐地址时,不要用类型指针转换,用memcpy。memcpy在编译器内部会处理对齐问题,而且开启优化后,对于小对象来说它不会真正调用函数,而是生成若干mov指令,性能损耗几乎可以忽略。
5. 与 C++ 模板的对比:为什么C程序员还得学这套
5.1 编译期泛型 vs 运行时泛型
C++的template是在编译期实例化的。你写一个template <typename T> void sort(T *arr, int n);,编译器会为int类型、double类型、你的自定义类分别生成一份排序代码。这带来的好处是:
- 类型安全:类型检查在编译期完成,错误能尽早发现。
- 性能最优:T在编译期就确定了,所有操作都可以内联,编译器能做各种优化。
而C语言的void泛型是运行时泛型。你的GenericArray在运行时并不知道元素到底是什么类型,所有操作都要通过void、size、回调函数来完成。它的优势是:
- 代码量少,一个实现到处用。
- 编译器不需要生成多份拷贝,二进制体积小。
- 可以做运行时类型选择,动态、灵活。
5.2 性能和代码量的权衡
C++模板的问题恰恰是代码膨胀。一个模板vector用在10种类型上,代码段里可能膨胀出10份不同的指令。这对嵌入式等对代码大小敏感的场景是致命的。而C语言的void*只要一份实现,代价是在函数调用层多做几次间接跳转(回调),主流CPU的分支预测对这类间接跳转非常友好,实际性能差距往往在5%以内,远没有想象中可怕。
我做嵌入式开发时特别喜欢用void*方案,因为MCU的Flash本来就256KB,再用C++模板搞几份代码副本,存储空间直接告急。反而用C的泛型容器,代码段小,行为又统一可控。但对于追求极致性能的大型C++工程,模板依然是首选。
5.3 什么时候必须用void*方案
纯C项目自然不必说,没别的手艺。但就算在C++项目里,也有几个场景void*是刚需:
- 跨模块、跨库接口:C++的模板很难做动态链接库接口,因为模板实例化在编译期,库的API没法导出模板。而void*的接口是纯C ABI,稳定、兼容性好。
- 插件系统:插件可能是C写的,也可能是别的语言绑定的,C ABI是天选接口。
- 运行时代码灵活替换:函数指针本质上是个变量,可以运行时替换,模板做不到。
当然,现在的C编程里,写泛型有一个更好的选择——C11的_Generic。它是编译期的类型分派,配合宏可以让调用方写起来很自然,但它跟void不是一回事。_Generic能根据类型选择不同的函数调用,避免了void可能导致类型不安全的问题,但因为它是基于宏的,不支持跨文件共享,更像语法糖而不是通用机制。我自己在C语言项目里经常混用:对外接口用函数指针和void*,给用户舒服的调用体验;内部则用_Generic做类型分支,降低误用概率。
6. 常见问题速查:这些坑我已经替你踩过了
6.1 忘了传元素大小,直接类型转换
c复制// 错误示范
void print_int_array(void *arr, int count) {
for (int i = 0; i < count; i++) {
printf("%d ", ((int *)arr)[i]);
}
}
这段代码能跑是对的,但一旦arr实际上是double数组,你按int去切分内存,输出的数据将是一堆乱码。void丢了类型信息,你需要额外参数来恢复它。经验法则:**如果你有一个void参数,但函数里没有size_t参数,你八成写错了**。
6.2 函数指针签名不匹配导致的诡异崩溃
声明回调函数时,形参写void *,结果实参写const void *,编译器可能只给一个warning,但函数按错误的方式访问内存后,一切数据都可能错位。比如比较函数把const void *强转成int *并解引用,如果在只读内存区域写入数据,程序直接段错误。
我的建议是初学者一开始就统一使用const void *,只在绝对明确要修改的时候才去掉const。这样编译器能帮你抓住大量粗心错误。
6.3 回调函数中的类型转换
把void*转成具体类型指针,这是泛型的常态操作。但转换时要小心:
c复制// 错误:直接解引用void*
void bad_print(const void *elem) {
printf("%d\n", *elem); // 编译错误
}
// 正确:先转成具体类型指针再解引用
void good_print(const void *elem) {
printf("%d\n", *(const int *)elem);
}
强制类型转换是C语言里最强大的操作之一,但权力越大责任越大。转换后类型的字节大小必须和当初存储的元素大小一致,否则读到的是半个元素+半个邻居。
6.4 空指针和越界访问
void*接口最大的问题之一是"无法自动知道数组长度"。所以每个操作函数必须显式检查index范围和NULL指针。
c复制if (arr == NULL || index >= arr->size) {
return NULL;
}
有些朋友觉得这太啰嗦,省掉检查,结果线上环境一旦传了非法索引,直接内存越界,数据被改得乱七八糟。写库代码的人,宁可慢一点也要保证健壮性——因为你不知道调用方会在什么奇葩场景下调用你。
7. 实测性能:void*和直接类型访问差多少
空谈理论没意思,我在一台x86-64 Linux机器上跑了1000万次int数组遍历,对比三种方案:直接int数组、通过void*泛型数组+回调、内嵌在结构体内的上下文。结果如下(时间是大约值,仅作参考):
| 方案 | 耗时(ms) | 备注 |
|---|---|---|
| 直接int数组 | 12 | 基线 |
| void* + 回调函数 | 18 | 回调无法内联,多了一次间接跳转 |
| void* + 上下文 + 宏展开 | 14 | 编译器可以部分内联 |
可以看到,泛型方案的性能损失并非不可接受。但如果回调函数本身很重(比如涉及系统调用),那多出来的10%就不是瓶颈所在。真正需要警惕的是在热循环里频繁使用回调,那会让流水线频繁停顿。我在做嵌入式实时系统时也验证过:qsort的性能损失在大多数业务逻辑中几乎可以忽略,除非排序占整个程序运行时间的一大部分。
8. 我的实操习惯:几个真心推荐的模式
这几年我在C项目里几乎离不开这套泛型模式,总结三个让我省心很多的做法:
第一,统一用typedef给回调函数起别名。typedef int (*compare_func)(const void *, const void *);比直接写函数指针参数清晰得多,你的IDE补全也会变好用。
第二,在结构体里配套提供"构造、拷贝、销毁、打印"四个回调。这四个回调组成一个完整生命周期:构造时用传入参数初始化,拷贝时深拷贝,销毁时释放资源,打印时输出可读内容。有这四件套,通用容器、日志系统、序列化模块都可以自由组合。
第三,把泛型API和具体类型的封装拆开。比如我写一个IntArray,会让它内部持有GenericArray,然后对外提供ia_push(IntArray*, int)、ia_get(const IntArray*, size_t)这些类型安全的函数。这样用户层的代码不会看到void*,更安全;而底层反复复用泛型容器,不会重复造轮子。
c复制typedef struct {
GenericArray base;
} IntArray;
void ia_init(IntArray *arr) {
ga_init(&arr->base, sizeof(int), NULL);
}
void ia_push(IntArray *arr, int value) {
ga_push_back(&arr->base, &value);
}
int ia_get(const IntArray *arr, size_t index) {
return *(int *)ga_get(&arr->base, index);
}
这个模式叫"类型安全的薄封装",既得到了泛型的复用,又在接口层保留类型检查,是我最推荐的生产级做法。
