【编者按】本文是C语言动态数组系列的第二篇。在上一篇中,我们构建了一个固定容量的数组,它在超出初始大小时直接罢工。今天,我们要解决一个更棘手的问题:如何让数组自动增长,而这背后的核心机制——realloc,可能是C标准库中最容易被误用的函数。 正文: 周三下午,一个C程序员写下了一行看似无害的代码:`arr->data = realloc(arr->data, new_size)`。他不知道的是,这行代码里藏着两个陷阱,任何一个都足以让程序在某个随机时刻崩溃,而且这种崩溃往往发生在代码审查通过很久之后。 此刻他的数组还好好的,但种子已经埋下。 这和上一步我们留下的那个固定容量数组形成鲜明对比。那个数组的问题很清楚:空间用完了,`array_push`返回-1,拒绝合作。用户要么提前知道需要多少空间,要么就得承担反复分配和拷贝的痛苦。这不是一个真正的动态数组,它只是一个带友好API的定长缓冲区。 真正的动态数组必须解决核心矛盾:调用者不知道自己需要存储5个元素还是500万个。 realloc就是为此而生的。它的函数签名简洁到具有欺骗性:`void *realloc(void *ptr, size_t new_size)`——传入一个已分配内存的指针,传回一个至少有新大小字节的内存块指针,旧数据原封不动。 但在这个简单接口背后,藏着两件完全不同的事。 第一种情况:原地扩展。如果在堆上你当前分配块的后面恰好有足够的空闲空间,内存分配器只需要把这个块的边界往后挪一挪。指针不变,没有新分配,没有拷贝。这是最快的路径,也是你永远不能指望的路径。 第二种情况:分配-拷贝-释放。如果当前块后面没有空间,分配器会找一块新的、足够大的内存区域,把旧数据全部拷贝过去,然后释放原来的块。指针会变,旧指针立刻失效。 这就是第一个陷阱的名字:指针失效。在realloc调用之后,任何指向旧缓冲区内部的指针都变成了悬空指针。这不是理论上的担忧,这是C代码库中最常见的use-after-free问题来源之一。当你写下`arr->data = realloc(arr->data, new_size)`时,如果realloc在内部执行了第二种情况,旧的内存已经被归还给操作系统,而你之前在数组里保存的那些指向特定元素的指针——它们现在全指向了已释放的内存。 更危险的是,这个bug可能很长时间都不会暴露。如果碰巧大多数时候realloc都在原地扩展,指针一直有效,测试通过,代码上线。然后在某个生产环境的特殊条件下,堆的布局变了,realloc决定搬家,程序开始随机崩溃。 但还有第二个陷阱,和失败处理有关。 realloc可能失败。当系统内存耗尽时,它会返回NULL。现在回到那行代码:`arr->data = realloc(arr->data, new_size)`。如果realloc失败返回NULL,NULL被赋值给了arr->data,而你原本的数据指针被覆盖了。结果是:旧数据的内存还在那里,但你已经丢了指向它的指针——内存泄漏了,数据也丢了。 解决这两个陷阱的正确方法是"临时指针"模式,它只有三行代码,但三行代码的区别就是:一个数组在内存耗尽时能体面降级,另一个会泄露数据然后崩溃。 这三行的逻辑很简单:先把realloc的结果存到一个临时变量里,检查它是不是NULL,如果不是,再把临时变量赋给arr->data。这样即使分配失败,arr->data仍然指向旧的有效数据,数组可以继续使用或体面地清理。 当你把这个临时指针模式应用到数组的自动扩容逻辑中时,整个机制就变得清晰:当size达到capacity,用realloc把缓冲区翻倍,检查是否成功,更新capacity计数,然后继续push。用户从头到尾都不需要关心capacity这个字段的存在,他们只需要调用push。 但用户不需要关心,不代表你不应该理解。自动增长带来了便利,也带来了代价。每次realloc可能搬家时,都是一次隐藏的拷贝操作。翻倍策略虽然摊销了拷贝的成本,但如果你提前知道需要存储100万个元素,一次性分配好仍然是更高效的选择。 动态数组的关键不在于"无限大",而在于"让容量管理从用户的责任变为数组自己的责任"。realloc是把这个转移变成现实的那一环,但前提是你得知道它的两面性:它可能移动你的数据,你绝不能把它的结果直接赋值给你正在重新分配的那个指针。 当你理解了这两点,大多数教程关于realloc的讲解就变成了明显的反例。你开始看懂那些看似无害的代码里藏着什么,也开始明白为什么C语言动态数组的实现总是围绕这个临时指针展开——不是因为它复杂,而是因为直接赋值的诱惑太自然了。

打开网易新闻 查看精彩图片