做C/C++这些年,要说哪个关键字最让人又爱又恨,我第一个提名void*。很多新手看到它就觉得玄乎,觉得就是个“万能兜底”,什么都敢往里塞,但真到了项目里,又不太敢用,怕出事。而老手恰恰相反,看到void就两眼放光——它意味着接口可以做到极致的通用和松弛。今天这篇不聊教科书上那几句干巴巴的定义,就聊聊我在实际工程里怎么把void玩出花来的,以及那些只有踩过坑才能总结出来的经验和禁忌。
这篇文章适合那些已经写过一阵子C或C++、但对void*一直停留在“好像用过但没想明白”阶段的同学。如果你正在做一个需要高度抽象、跨模块复用的项目,或者被各种“类型写死”的接口堵得难受,那这篇文章应该能给你不少启发。
1. void*的底层逻辑:为什么它能“装”下任何类型
1.1 指针变量的本质是什么
要说清楚void*为什么“万能”,得先回到指针的本质上去。我们声明一个int *p,它存储的不是int本身,而是某个int类型变量在内存里的地址编号。地址就是一个数字,在64位系统上固定是8个字节。所以不管你是char*、int*、double*还是struct MyBigStruct*,这些指针变量在机器层面的本质完全相同——都是一个8字节的数字。
那为什么我们平时用int*和char*觉得它们不是一回事?因为编译器在用“文明”的方式管理这些数字。它知道你声明的是int*,所以在做加减运算时按sizeof(int)来步进,解引用时按int的宽度去读取内存,赋值时也做类型检查。
而void*等于跟编译器说:“别管我,我手里就是一个地址数字,至于它背后是什么类型、怎么解释这段内存,我不告诉你,也不需要你替我做检查。”这就是“万能”二字的来源——不是它真的能存储所有类型的数据,而是它能存储所有类型数据的地址,并且放弃了解释这段内存的权力。
1.2 “无类型”带来的自由与代价
放弃类型检查,意味着获得两层自由。
第一层:接口层面。你可以写一个函数,说“给我个地址,我帮你做点什么”,这个“什么”可以是排序、拷贝、遍历、比较,不关心具体类型。C标准库里的qsort和bsearch就是最典型的例子,它们通吃的秘诀就是void*。
第二层:数据层面。你想做一个支持任意类型元素的容器,比如动态数组、链表、栈。结构体成员只有固定类型的话,这容器就只能服务一种类型。但如果你在结构体里存一个void*去指向元素,那这个容器立刻变成了“万能容器”,装int可以,装字符串可以,装自定义结构体也可以。
代价也很明确:编译器不再帮你把关。你有责任记住这段内存原本是什么类型,并在使用时把它转回去。如果记错了,代码不会在编译期报错,而是在运行期给你一个莫名其妙的结果,或者直接崩溃。所以使用void*的第一原则是:类型信息可以被隐藏,但不能被丢失。谁创建了这段内存,谁就要在合适的时机把类型信息告诉我,或者让使用方有办法知道。
2. 核心场景拆解:void*在工程里的四大高频用法
2.1 通用数据结构的基石:手写动态数组
先看一个我在做某个跨平台系统时写过的动态数组骨架。系统的底层模块需要在不知道上层数据长什么样的情况下,先把存储框架搭起来:
typedef struct { void* elements; // 指向一块连续内存 size_t element_size; // 单个元素占多少字节 size_t capacity; // 当前容量 size_t length; // 实际元素个数 } GenericArray;这个结构体里所有关键信息都在了。注意,我用void*指向内存起点,用element_size记录每个元素的字节数。这个设计能成立,依赖的就是“任何指针都是地址数字”的本质——不管放什么类型的数组,它们都是一段连续内存,操作方式完全一样。
往里面追加元素的核心代码长这样:
void GenericArray_Append(GenericArray* arr, const void* newElem) { if (arr->length >= arr->capacity) { // 扩容:新容量翻倍 size_t newCap = arr->capacity ? arr->capacity * 2 : 4; arr->elements = realloc(arr->elements, newCap * arr->element_size); arr->capacity = newCap; } // 计算新元素应该存放的内存位置 char* base = (char*)arr->elements; char* dest = base + arr->length * arr->element_size; // 逐字节拷贝,元素多大就拷贝多大 memcpy(dest, newElem, arr->element_size); arr->length++; }这里有三个细节非常关键。
细节一:为什么把elements强转成char*?因为char的宽度就是1字节,只有转成char*,做地址偏移时加1才等同于加1个字节。如果我直接拿void*做加法,很多编译器会直接报错,因为“对不完整类型做算术运算”没有任何意义。而void*在GNU C里虽然被允许按1字节步进做偏移,但这是扩展语法,为了可移植性,建议统一走char*这个路子。
细节二:为什么用memcpy而不是直接dest = newElem?因为newElem是const void*,它的地址背后是用户的数据,我并不知道它的具体类型。直接赋值只是让dest指向了那块内存,但如果后续数组扩容导致内存搬迁,原来的指向就失效了。memcpy是实打实把数据复制到数组自己的内存里,之后再怎么扩容,数据都是自持的,跟外部源数据无关。这一点在写容器时是生死攸关的——你存的是“数据实体”,不是“指向别人数据的别名”。
细节三:扩容为什么要翻倍而不是每次加1?翻倍扩容的均摊复杂度是O(1),每次加1则是O(n)。当数据量上来之后,这个差别是质的差距。我实测过,插入10万条记录时,翻倍扩容几乎瞬间完成,每次加1的方式慢到像卡死。
2.2 通用算法接口:把“操作权”交给调用方
第二个高频场景是算法函数。你写一个通用排序,但排序的规则因数据类型而异——整数可以直接比大小,字符串要按字典序,自定义结构体可能要按某个字段排序。这时把比较逻辑交给调用方,让调用方通过函数指针传进来,就是标准做法。
我在某图像处理Demo里就写过这样一个通用遍历器:
typedef int (*CompareFunc)(const void* a, const void* b); void GenericSort(void* base, size_t count, size_t elemSize, CompareFunc cmp) { // 简单起见,用冒泡排序演示接口设计 for (size_t i = 0; i < count - 1; i++) { for (size_t j = 0; j < count - 1 - i; j++) { char* elemA = (char*)base + j * elemSize; char* elemB = (char*)base + (j + 1) * elemSize; if (cmp(elemA, elemB) > 0) { // 交换 char tmp[64]; // 临时缓冲区,需要足够大 memcpy(tmp, elemA, elemSize); memcpy(elemA, elemB, elemSize); memcpy(elemB, tmp, elemSize); } } } }调用方需要做的,就是提供一个比较函数:
int CompareInt(const void* a, const void* b) { int ia = *(const int*)a; int ib = *(const int*)b; return (ia > ib) - (ia < ib); }这个写法有几个值得品的地方。
第一,为什么比较函数参数是const void*?因为比较操作不应该修改元素本身,声明成const相当于一个契约,编译器帮你盯着,防止误写。
第二,return (ia > ib) - (ia < ib)这种写法是为了返回规范的-1/0/1三态,而不是直接return ia - ib。直接相减在int溢出时会给出错误的比较结果,这是我在实际中踩过的坑——当时排出来的序列永远有个别元素错位,查了很久才发现是减法溢出。
第三,临时交换缓冲区用了char tmp[64]。这在elemSize已知不超过64字节时是有效的,但一旦元素变大就崩了。更稳妥的做法是malloc(elemSize),或者把元素个数限制在一个合理范围内并做静态断言。
所以,void*在这个场景里的真正价值是:让算法代码与数据类型彻底解耦。算法只关心“如何移动内存块”“何时比较两个内存块”,这两个问题都通过参数解决了——前者用elemSize,后者用回调函数。剩下的,全是调用方自己的事。
2.3 回调函数的上下文传递:Thread与Timer的通用魔法
回调函数是void的另一个经典舞台。很多库函数允许你注册一个“当某事发生时调用我”的函数,但回调函数内部往往还需要访问一些外部数据。如果没有void,这个“外部数据”就只能靠全局变量,非常丑陋且不可重入。
通用的回调签名长这样:
typedef void (*CallbackFunc)(void* userData); void RegisterCallback(CallbackFunc cb, void* userData);调用方可以这样用:
typedef struct { int count; char name[64]; } TaskInfo; void OnTimerFired(void* userData) { TaskInfo* info = (TaskInfo*)userData; printf("任务%s执行次数: %d\n", info->name, info->count++); } TaskInfo info = {0, "critical"}; RegisterCallback(OnTimerFired, &info);这里的关键在于,回调函数虽然签名是统一的void*,但调用方知道这里的userData本质上是TaskInfo*,在回调内部自行转回来。这就把“类型解释权”完全留给了调用方,库本身完全不感知。
我也用这个模式做过一个模拟项目的定时器调度器。多个定时器同时挂载,每个定时器注册的回调函数不同、上下文数据也不同。如果不用void*,就必须为每个回调类型单独写一个注册接口,接口膨胀不说,还极难维护。统一用void*之后,新增一种任务类型只需要新增一个结构体和对应的回调实现,注册接口一行不用改。这就是可扩展性的真正体现——不是写一堆代码去覆盖所有场景,而是写一个足够抽象的接口去消化所有场景。
2.4 跨模块解耦:动态链接库的“盲人摸象”
最后这个场景可能接触的人少一些,但更沉淀。如果做过插件系统或者动态库接口设计,你会发现所有对外导出的API都想做成void*风格,因为动态库的发布方和调用方往往不是一个团队,甚至不是同一种语言。
我参与过某中间层组件库的设计,对外暴露的接口清一色是void*:
typedef struct OpaqueEngine OpaqueEngine; OpaqueEngine* Engine_Create(int type); void Engine_Destroy(OpaqueEngine* engine); int Engine_Process(OpaqueEngine* engine, const void* input, void* output);你看OpaqueEngine*,这个写法是void*的一个变体——用“不完整结构体”隐藏内部实现,调用方只知道它是一个引擎,但完全不知道里面是什么。这不是为了“万能”,而是为了“封装”,让别人永远无法从头部直接访问内部字段,只能通过我们提供的函数来操作。
Engine_Process的const void* input和void* output则更彻底地表达了“我不关心你输入输出的是什么格式,反正我按协议解析”。这种设计在跨平台、跨语言的底层库中极其常见。
3. 深入源码背后:为什么void*不能随便解引用
3.1 解引用void*为什么在编译期就过不去
很多人第一次写*p(p为void*)被编译器拒绝时,一脸茫然。我们来拆解一下这个“拒绝”背后的运行逻辑。
解引用一个指针,本质上是说:“从指针指向的地址开始,按照指针所指向的类型去解释这段内存。”如果指针是int*,编译器知道读4个字节并解释为int;如果是double*,编译器知道读8个字节并解释为double。
但如果指针是void*,编译器对这背后是什么完全一无所知。读4字节还是8字节?按有符号还是无符号解释?是浮点还是整数?全是未知。与其猜错,不如直接拒绝。
这就像你收到一个快递盒子,盒子上没写里面是什么。快递员递给你说“签收吧”,你问“里面是什么”,他摇头。你不会轻易拆开或者转卖,因为你不知道它到底值多少钱、是易碎品还是液体。大得多的情况是,你把它原封不动地拿回家,等真正需要时再看箱子上贴的说明。void*就是那个盒子,真正的内容物信息,要由你自己去维护。
3.2 void*与函数指针:一个必须要绕开的坑
再提醒一个特别容易翻车的点:void*不能和函数指针互相转换。这在C标准里是未定义行为,在某些平台上虽然“碰巧能用”,但换一个平台或编译器就会出诡异故障。
为什么?数据指针(指向变量、数组、结构体的指针)和函数指针在部分体系结构上有着完全不同的表示形态。有些CPU架构的函数指针不只是地址,还包含段选择符、模式位之类的额外信息。强行把函数指针塞进void*再转回来,极可能丢失这些信息,导致调用时跳转失败、程序直接崩溃。
那真到了需要把“某种可调用对象”作为通用数据来传递的时候怎么办?有两条路。
第一条路:把函数指针包装进一个结构体,再把结构体指针放进void*。
typedef struct { void (*func)(void*); } FuncWrapper; void* wrapper = malloc(sizeof(FuncWrapper)); ((FuncWrapper*)wrapper)->func = MyFunction;第二条路:用函数指针本身做传递,但不去碰void*,接口里直接用函数指针类型。C标准库就是这么干的——qsort的比较参数直接用int (*)(const void*, const void*),而不是void*。
我自己的经验是:能不用void*去装函数指针就尽量别用,因为这意味着你的设计已经绕了远路——你为了“统一”而抹掉了类型信息,但类型信息恰恰在调用时是必须的,等于给自己找麻烦。
3.3 对齐问题:一个隐蔽的内存杀手
还有一个隐蔽的坑必须提,就是内存对齐。当void*指向一个通过char*偏移得到的地址时,这个地址可能没有按照目标类型的对齐要求对齐。
举个例子:你在一个char*数组里塞了一个double的值,按sizeof(double)把数据memcpy进去。之后用double* d = (double*)ptr去读取,如果ptr的数值恰好不是8的倍数,在某些ARM平台上,这一读就直接触发总线错误,程序当场崩掉。
x86平台上这种问题不容易暴露,因为x86允许非对齐访问,只是性能略低。但C标准明确说这是未定义行为,而且一旦迁移到嵌入式或移动平台,就会变成“为什么我的程序一到客户现场就崩”的经典悬案。
规避方案很简单:如果要让void*指向一段可以承载各种类型的内存,就用union或max_align_t来声明缓冲区,保证对齐等级足够。标准写法是:
typedef union { void* p; long double ld; long long ll; double d; void (*fn)(void); } MaxAlign; char buffer[sizeof(MaxAlign) * N];这样buffer的对齐级别和MaxAlign一致,就能安全承载绝大多数类型。
4. 一个完整的实战手记:通用键值对存储系统
4.1 需求场景与设计取舍
某模拟项目需要一个轻量级的键值存储模块,既要支持int、double、字符串,还要支持任意自定义结构体,而且要求存储模块本身不依赖任何具体业务类型。
一开始团队里有人提议直接用C++的模板和std::map,但项目情况特殊:这是个跨模块底层库,对外接口必须用C风格暴露,不暴露模板,因为上层可能是其他语言通过FFI调用。
于是决定做一个基于void*的键值模型。设计上做两个关键取舍:
第一,键的类型限制为字符串或int枚举,避免键的复杂度影响核心逻辑。
第二,值的类型不做限制,存储时统一转成void*,同时记录一个dataType枚举,便于运行时检查和序列化。
这个模块的结构设计是:
typedef enum { TYPE_INT = 0, TYPE_DOUBLE, TYPE_STRING, TYPE_CUSTOM } DataType; typedef struct Node { char key[64]; // 简化:键是一个定长字符串 void* value; size_t valueSize; DataType type; struct Node* next; } Node; typedef struct { Node* head; size_t count; } KVStore;头插法最简单,查找时遍历链表即可。数据量确实不大(最多几百条),所以O(n)查找完全扛得住,不需要做哈希表。这里再次体现一个工程原则:不要过早优化,在数据规模明确可控时,优先选最简单的实现,把复杂度留给真正存在瓶颈的地方。
4.2 数据存取的核心实现
写入接口长这样:
void KVStore_Set(KVStore* store, const char* key, const void* value, size_t size, DataType type) { // 先查是否已存在,已存在就更新 Node* node = KVStore_Find(store, key); if (node) { free(node->value); } else { node = malloc(sizeof(Node)); strcpy(node->key, key); node->next = store->head; store->head = node; store->count++; } node->value = malloc(size); memcpy(node->value, value, size); node->valueSize = size; node->type = type; }这段代码里有几个值得停下来琢磨的点。
第一,为什么这里要memcpy而不是直接node->value = (void*)value?因为value可能指向调用方的栈上局部变量,函数返回后那块内存就失效了。存“地址”不存“实体”,就是典型的悬垂引用。而memcpy一份,存储模块拥有了完整的数据所有权,调用方之后怎么改原数据都不影响已存储的值。
第二,为什么还要存valueSize?因为老节点更新时要free,而free只知道要释放的地址,不需要大小。但如果你想把整个存储序列化到文件或者网络发送出去,没有大小就不知道要读多少字节。所以这个字段不是为了内存管理,而是为了“数据出门”用的。
第三,为什么用malloc(size)而不是malloc(node->valueSize)去拷贝?因为新数据可能和旧数据尺寸不同,先free旧的再按新尺寸分配,才能避免内存不够或浪费。这里如果忘了free旧内存,更新几次之后就会造成内存泄漏。我经常跟团队里的小朋友说:“每写一个malloc,就要在同一屏范围内看见对应的free,否则这个代码将来一定出问题。”
读取接口反向操作,把存储块复制到调用方提供的缓冲区:
bool KVStore_Get(const KVStore* store, const char* key, void* outBuf, size_t bufSize) { const Node* node = KVStore_Find(store, key); if (!node) return false; if (bufSize < node->valueSize) return false; memcpy(outBuf, node->value, node->valueSize); return true; }这里设计成“调用方提供缓冲区”,而不是“返回内部指针”,是有原因的。如果直接把node->value返回给调用方,调用方就能透过这个void*直接改写存储内部的数据,破坏了封装性,也很难排查是谁改动了状态。而拷贝一份,就形成了一道隔离墙。拷贝当然有开销,但对于这个量级的模块,性能影响完全可以忽略。
4.3 类型安全策略:用枚举给void*上保险
只存void*而不记录类型,等于埋雷。所以我设计里强行加入DataType type字段。这样一来,取出数据时可以做一层检查:
bool KVStore_GetInt(const KVStore* store, const char* key, int* out) { const Node* node = KVStore_Find(store, key); if (!node || node->type != TYPE_INT) return false; memcpy(out, node->value, sizeof(int)); return true; }这条检查的价值是:你能在运行时快速发现“存的时候用错API了”这类低级错误,比一崩到底强得多。很多纯void*接口不做这层防护,结果出现问题时,你得花好几个小时把整个调用链捋一遍才能定位到是哪个环节的类型转错了。加了type标记,错误信息立刻就清晰了:“我在取int,但它存的是double”,一眼就知道是谁的锅。
4.4 资源管理的细节:析构时怎么安全释放所有内存
最后一个环节是释放。因为每个Node的value都是malloc出来的,析构函数要把它们全部释放干净:
void KVStore_Destroy(KVStore* store) { Node* cur = store->head; while (cur) { Node* next = cur->next; free(cur->value); free(cur); cur = next; } store->head = NULL; store->count = 0; }这里有个连环坑是:如果你是先free(cur)再去拿cur->next,就访问了已释放内存,这是未定义行为。在调试构建里可能还能跑,在发布构建里可能头几个元素遍历正常、后面就断链了。所以一定先用Node* next = cur->next记住下一个节点,再释放当前节点。
另外一个更隐蔽的问题:如果value本身指向的是嵌套的、需要单独释放的结构体——比如value是一个包含动态数组的结构体——那这里的free就只释放了外层结构体,内部动态数组的内存全泄漏了。解决办法是提供一个自定义析构回调,让用户负责释放“结构体内部的资源”,然后再释放结构体本身。这也是void*体系绕不开的一个通用话题:资源管理责任要跟着类型走,框架能做的是释放“皮”,深入内部的释放必须由知情者完成。
5. 常见问题与排查技巧实录
5.1 崩溃、乱码、内存泄漏:三大问题速查表
我自己在项目里、以及帮同事排查过的问题,列一个常见问题速查表:
| 问题现象 | 常见原因 | 排查思路 | 修复方案 |
|---|---|---|---|
| 解引用void*编译不过 | 对void*直接使用*p运算 | 检查是否在编译期就把“未知类型”当已知类型用 | 先强转目标类型再解引用 |
| 数据全部是乱码 | 用错类型强转,取出的字节数或解释方式不对 | 打印内存hex dump,对比原始数据分布 | 核对类型和字节长度 |
| 偶发崩溃,调试时好时坏 | 对齐问题或访问已释放内存 | 用AddressSanitizer跑一遍,看崩溃调用栈 | 用union对齐缓冲区,先保存next再释放 |
| 更新后内存持续上涨 | 替换数据时忘了free旧值 | 用Valgrind或Sanitizer查泄漏 | 在覆盖前先free旧内存 |
| 跨模块传函数指针后调用崩溃 | 函数指针与void*转换,架构上表示不同 | 排查是否有(void*)func写法 | 用结构体包裹函数指针 |
| 清理时访问野指针 | free当前节点后访问了next字段 | 检查释放顺序 | 先缓存next再free当前节点 |
| 在不同编译器下行为不一致 | 依赖了GNU扩展的void*指针运算 | 换标准编译器测一次 | 统一转char*做偏移 |
5.2 一次耗时数小时的“离奇崩溃”复盘
分享一个具体的排查过程,很有代表性。某跨平台系统在ARM板子上跑,每次运行到数据量超过某个阈值时就随机崩溃,偶发性很强,x86上完全复现不了。
一开始我以为是内存不够导致malloc失败,但检查代码发现malloc的结果没有判空——第一次注意到就修掉了,但崩溃依旧。
后来用AddressSanitizer和GCC的-fsanitize=undefined重新编译,日志里明晃晃地写着“runtime error: load of misaligned address”。定位到代码里,发现是从KVStore取一个double值时,目标缓冲区是char buffer[32],地址在栈上没有按8字节对齐。强转成double*后取值,在ARM上直接触发对齐异常。
修复方案很简单:把局部缓冲区声明成union对齐容器。
这是void*体系里最值得警惕的坑之一:类型转换本身不报错,但平台一旦较真,就炸给你看。建议没有跨平台需求的人也多跑几次Sanitizer,成本极低,收益极大。
5.3 长期维护中的类型安全底线
用void还有一个长期维护层面的隐患:时间一长,没人记得某个void背后到底是什么类型。特别是多人协作的项目里,一条数据从模块A传到模块B再传回A,中间如果经历了两层void*包装,类型信息早就丢了。
我见过的最离谱的现场是:一个字段在结构体里被声明为int,某个同事为了“统一处理”把它存进void*,取出时强转成了long long*,在64位系统上碰巧前4字节对得上,没炸;换到32位系统后,数据被错位读取,所有相关数值全部失真。
要防御这类问题,一个实用的习惯是在结构体定义旁边加注释,写明“value指向的必须是XXX类型,由YYY模块保证”。另一个更系统性的做法是在调试构建里加一层类型ID校验:让每个类型注册一个唯一magic number,存储时一并带上,取出时校验。
typedef struct { uint32_t magic; void* data; } TypedValue; #define MAGIC_INT 0x494E5401 // 'INT' #define MAGIC_STR 0x53545201 // 'STR'这一招可以让误用类型的问题从“运行期崩溃”提前到“断言失败”,定位时间节省一大半。虽然常规项目里没见过这么极端的做法,但当你的模块要被很多团队复用时,这层防御确实值得加。
6. 什么时候该用void*,什么时候别用
6.1 void*的优势清单
- 接口通用性拉满:一个函数通吃任意类型,减少重复代码。
- 调用方自由度最大化:数据是什么、怎么解释,交给调用方决定。
- 跨边界友好:适合用作动态库、跨语言调用、插件系统的稳定边界。
- 类型隔离:通过不透明指针隐藏内部实现,比暴露struct字段安全得多。
6.2 void*的劣势清单
- 编译期类型检查失效:传错类型、转错类型,编译期没有任何提示。
- 可读性下降:阅读代码时缺少类型信息,理解成本明显上升。
- 调试困难:类型错误暴露延迟,问题定位成本高。
- 操作受限:不能直接解引用、不能做指针算术(严格意义上),必须转换。
- 资源管理模糊:不知道指向的堆内存归谁释放、怎么释放。
6.3 工程判断标准:我的一套取舍原则
总结这些年写下来,我给自己定了三条判断线。
第一,如果类型集合是已知且有限,优先用联合体或泛型。C++里优先模板和std::variant,C里优先union加类型枚举。只有在类型集合“开放”时——即将来可能会增加未知类型——才值得上用void*。
第二,如果类型信息在接口边界处必须消失,用void但同时记录元数据。存储模块虽然只认识void,但一定要记住“这堆字节来自哪里、是什么类型、多长”。元数据可以在调试时救命,在序列化时也是必需品。
第三,如果调用方和使用方都是自己团队的人,而且代码规模不大,尽量少用void*,用具体类型写起来更直白。但如果是写公共库、底层库、给外部用的接口,那void*几乎是唯一正解——因为你无法预知外部会传来什么类型。
7. 写在最后的个人体会
聊到这儿,你可能发现了:void*本身并不神秘,它就是把地址的数字本质赤裸裸地暴露出来,把“解释内存”的权力完全交给人。它不会犯错误,容易犯错误的是用它的人。
我在实际项目中养成了一个习惯:每次要把某个东西转成void*时,都先问自己一句——“这段类型信息,我丢掉了,但等会儿我真的还能找回来吗?”如果能,放手用;如果不能,那就是在自己给自己埋雷。
还有个小技巧分享给你:写void*相关代码时,尽量让“强转类型”这件事集中在一个很小的范围内发生。换句话说,数据在“进入容器”时强转一次、在“离开容器”时强转一次,中间绝不反复横跳。这种集中化的设计,能最大程度减少转换出错的概率,让异常行为在局部可见、可查。
用void*做得越久,越会觉得它像一把“万能钥匙”:能打开很多锁,但你也得清清楚楚知道每把锁背后的门里是什么,不然一开门可能就是深渊。希望这篇分享能帮你把钥匙用得更好,也少踩几个我当年踩过的坑。