30、综合实战:实现一个动态数组库(Vector)

终于到了最后一章实战课。说实话,前面讲了那么多内存分配的原理、陷阱、调试技巧,如果不亲手写一个能用的库,总觉得少了点什么。

今天我们就来实现一个动态数组库——我习惯叫它 Vector。为什么选这个?因为动态数组是 C 语言里最常用的数据结构之一,而且它完美地融合了 malloc、realloc、free 以及内存管理的各种细节。你想想看,一个项目里如果连动态数组都写不好,那内存泄漏基本是板上钉钉的事。

设计目标

我们要实现一个轻量级的 Vector,支持以下操作:

  • 初始化:创建一个空数组,预留初始容量
  • 插入:在尾部追加元素
  • 删除:移除尾部元素
  • 扩容:容量不足时自动翻倍
  • 销毁:释放所有内存

嗯,这里要注意:我们不追求 STL 那种花哨的泛型,而是用 void* 配合元素大小来实现通用性。说白了,就是存任意类型的数据。

数据结构定义

typedef struct {
    void* data;        // 数据指针
    size_t elem_size;  // 每个元素的大小(字节)
    size_t capacity;   // 当前容量(元素个数)
    size_t size;       // 实际元素个数
} Vector;

我个人习惯把 capacitysize 分开存。为什么?因为这样扩容逻辑才清晰。容量是已经分配好的空间,大小是实际用到的空间。两者相等时,就该扩容了。

初始化函数

int vector_init(Vector* vec, size_t elem_size, size_t initial_capacity) {
    vec->data = malloc(elem_size * initial_capacity);
    if (!vec->data) return -1;  // 分配失败

    vec->elem_size = elem_size;
    vec->capacity = initial_capacity;
    vec->size = 0;
    return 0;
}

这里有个小细节:initial_capacity 不能为 0。我在项目中遇到过有人传 0 进来,结果 malloc(0) 的行为是未定义的。所以最好加个断言或者默认给个最小值,比如 4。

扩容机制

int vector_reserve(Vector* vec, size_t new_capacity) {
    if (new_capacity <= vec->capacity) return 0;

    void* new_data = realloc(vec->data, vec->elem_size * new_capacity);
    if (!new_data) return -1;  // 扩容失败

    vec->data = new_data;
    vec->capacity = new_capacity;
    return 0;
}

static int vector_grow(Vector* vec) {
    size_t new_capacity = vec->capacity * 2;
    if (new_capacity == 0) new_capacity = 4;  // 防止初始容量为0
    return vector_reserve(vec, new_capacity);
}

扩容策略我选的是翻倍。为什么不是加固定大小?你想想看,如果每次只加 10 个元素的空间,那插入 1000 个元素就要 realloc 100 次,性能太差了。翻倍的话,realloc 的次数是 O(log n),而且均摊下来每次插入的时间复杂度是 O(1)。

小技巧: 如果你知道数据量大概有多大,可以在初始化时直接指定一个较大的容量,避免频繁扩容。比如你知道要存 1000 个元素,那就直接 vector_init(&vec, sizeof(int), 1024)

插入与删除

int vector_push_back(Vector* vec, const void* elem) {
    if (vec->size >= vec->capacity) {
        if (vector_grow(vec) != 0) return -1;
    }

    // 计算目标地址,拷贝元素
    void* target = (char*)vec->data + vec->size * vec->elem_size;
    memcpy(target, elem, vec->elem_size);
    vec->size++;
    return 0;
}

int vector_pop_back(Vector* vec) {
    if (vec->size == 0) return -1;  // 空数组不能删除
    vec->size--;
    return 0;
}

注意看 target 的计算:(char*)vec->data + vec->size * vec->elem_size。这里把 void* 转成 char* 是为了做指针运算,因为 C 标准不允许对 void* 做算术操作。嗯,这个坑我踩过,当时编译报错我还愣了半天。

删除操作其实只是把 size 减一,并没有真正释放内存。为什么?因为释放内存是 vector_destroy 的事。而且如果删了又插,频繁 realloc 反而更慢。

销毁函数

void vector_destroy(Vector* vec) {
    free(vec->data);
    vec->data = NULL;
    vec->capacity = 0;
    vec->size = 0;
}

我曾经见过有人只 free 不置 NULL,结果后面不小心又用了这个指针,导致野指针崩溃。所以我的习惯是:free 之后立刻置 NULL,这样即使误用也会立刻 crash,而不是随机崩溃。

完整使用示例

#include <stdio.h>
#include "vector.h"

int main() {
    Vector vec;
    vector_init(&vec, sizeof(int), 4);

    for (int i = 0; i < 100; i++) {
        vector_push_back(&vec, &i);
    }

    printf("size = %zu, capacity = %zu\n", vec.size, vec.capacity);

    // 打印前10个元素
    for (size_t i = 0; i < 10; i++) {
        int* p = (int*)vec.data + i;
        printf("%d ", *p);
    }
    printf("\n");

    vector_destroy(&vec);
    return 0;
}
警告: 上面的打印方式只是为了演示。实际项目中,你应该提供 vector_getvector_set 这样的接口函数,而不是直接访问 vec.data。封装性很重要,不然别人用你的库时很容易搞出野指针。

核心流程一览

下面这张图展示了 Vector 从初始化到销毁的完整生命周期:

Vector 动态数组生命周期 vector_init vector_push_back 容量不足? vector_grow vector_pop_back vector_destroy 分配初始内存 拷贝元素,size++ realloc 翻倍 size--,不释放内存 free(data),重置所有字段

避坑指南

  • 我曾经realloc 失败后直接覆盖了原指针,结果原数据也丢了。正确的做法是先用临时变量接收 realloc 的返回值,检查成功后再赋值。
  • 我曾经忘记在 vector_destroy 里把 sizecapacity 清零,结果调试时看到非零值以为还有数据,浪费了半天时间。
  • 注意:如果你的元素类型包含指针(比如字符串数组),那么 vector_destroy 之前需要先遍历释放每个元素内部的资源。这个库只管理内存本身,不管理元素内部的资源。

性能对比

操作 时间复杂度 备注
尾部插入(均摊) O(1) 扩容时 O(n),但均摊后是常数
尾部删除 O(1) 只减 size,不释放内存
随机访问 O(1) 通过指针偏移直接访问
扩容 O(n) 需要拷贝所有元素
销毁 O(1) 只释放一次内存

核心要点: 动态数组的优势在于随机访问和尾部操作。如果你需要频繁在中间插入或删除,那应该考虑链表。没有万能的数据结构,只有合适的选择。

好了,这个 Vector 库虽然简单,但五脏俱全。你可以在它的基础上扩展出更多功能,比如 vector_insertvector_erasevector_clear 等。记住,写库代码时,接口设计比实现更重要——好的接口让使用者不容易犯错,差的接口让所有人头疼。

动手试试吧,把代码跑起来,然后试着改一改,加一加功能。只有亲手写过,才能真正理解动态内存管理的精髓。