别把 ArrayList 当成会自动管理内存的 List:Zig 动态数组从入门到实战

39 阅读12分钟

别把 ArrayList 当成会自动管理内存的 List:Zig 动态数组从入门到实战

处理数量不确定的数据时,固定长度数组很快就会遇到限制:文件有多少行并不确定,接口返回多少条记录也不确定,程序运行过程中产生多少个结果更无法提前写死。

Zig 标准库里的 std.ArrayList(T),就是为这类场景准备的动态数组。它使用一段连续内存保存元素,空间不够时通过 allocator 扩容,访问方式仍然保持数组一样的简洁。

如果熟悉 C#、C++ 或 Rust,可以先这样建立对应关系:

C#       List<T>       Add / Count / RemoveAt
C++      std::vector   push_back / size / erase
Rust     Vec<T>        push / len / remove
Zig      ArrayList(T)  append / items.len / orderedRemove

但 Zig 有一个必须牢记的区别:ArrayList 不替内存分配器做决定,也不替业务代码管理元素内部的资源。分配、释放、所有权和切片生命周期,都需要在代码中写清楚。

本文按 Zig 0.16.0 编写。

一、ArrayList 解决了什么问题?

普通数组的长度属于类型的一部分:

const numbers = [_]i32{ 10, 20, 30 };

它的类型是 [3]i32,只能保存 3 个元素。数组适合长度已知、空间固定的场景,例如:

const rgb = [3]u8{ 255, 128, 0 };
var buffer: [1024]u8 = undefined;

但下面这些数据通常没有固定长度:

  • 读取文件得到的所有行;
  • 命令行参数;
  • 搜索结果;
  • 运行时收集的日志;
  • 解析后的用户记录;
  • 一个任务队列或待办事项列表。

ArrayList 把“当前元素数量”和“已申请空间”分开保存:

len      = 3
capacity = 8

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │    │    │    │    │    │
└────┴────┴────┴────┴────┴────┴────┴────┘
└────── 当前元素 ──────┘└─── 可继续使用 ───┘

len 表示有效元素数量,capacity 表示底层内存可以容纳多少个元素。追加元素时,只要 len 小于 capacity,就能直接放入;空间用完后才需要重新申请更大的内存。

二、Zig 0.16 的正确初始化方式

现代 Zig 中,std.ArrayList(T) 是不保存 allocator 的数组列表。最简单的声明方式是:

var numbers: std.ArrayList(i32) = .empty;

调用可能分配内存的方法时,把 allocator 显式传进去:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var numbers: std.ArrayList(i32) = .empty;
    defer numbers.deinit(allocator);

    try numbers.append(allocator, 10);
    try numbers.append(allocator, 20);
    try numbers.append(allocator, 30);

    std.debug.print("len = {d}\n", .{numbers.items.len});
}

这里有两处容易漏掉:

var numbers: std.ArrayList(i32) = .empty;
defer numbers.deinit(allocator);

.empty 只表示一个还没有分配底层数组的空列表;deinit(allocator) 才负责释放列表申请过的内存。

旧教程中经常出现这样的代码:

var list = std.ArrayList(i32).init(allocator);
try list.append(10);
defer list.deinit();

这属于旧版 API。Zig 0.16 中应改成:

var list: std.ArrayList(i32) = .empty;
try list.append(allocator, 10);
defer list.deinit(allocator);

三、items.len 和 capacity 怎么理解?

ArrayList 的有效数据通过 items 暴露。items 是一个切片,因此:

list.items.len

就是当前元素个数。

完整观察示例:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var list: std.ArrayList(u32) = .empty;
    defer list.deinit(allocator);

    try list.ensureTotalCapacity(allocator, 4);

    std.debug.print("开始: len={d}, capacity={d}\n", .{
        list.items.len,
        list.capacity,
    });

    for ([_]u32{ 10, 20, 30, 40, 50 }) |value| {
        try list.append(allocator, value);
        std.debug.print("追加 {d}: len={d}, capacity={d}\n", .{
            value,
            list.items.len,
            list.capacity,
        });
    }
}

ensureTotalCapacity 会提前准备至少可以容纳指定数量元素的空间。第五次追加时,列表会再次扩容。

容量增长策略属于实现细节,不能把某个具体增长倍数写进业务逻辑。真正稳定的判断只有两个:

items.len       当前有效元素数量
capacity        当前底层空间上限

四、添加元素:append、appendSlice 和预分配

最常用的 API 是 append:

try list.append(allocator, value);

它可能申请新内存,所以返回 Allocator.Error!void,调用处通常需要 try 或 catch。

一次添加多个元素,可以使用 appendSlice:

try list.appendSlice(allocator, &[_]i32{ 1, 2, 3 });
try list.appendSlice(allocator, &[_]i32{ 4, 5 });

也可以重复添加同一个值:

try list.appendNTimes(allocator, 0, 5);

如果已经提前确认容量足够,可以使用不检查分配的版本:

try list.ensureTotalCapacity(allocator, 10);
list.appendAssumeCapacity(100);
list.appendSliceAssumeCapacity(&[_]i32{ 200, 300 });

AssumeCapacity 的意思是“调用方保证容量足够”。容量不足时会触发断言或产生非法行为,因此不能把它当成更快的普通 append 随便替换。

当数据量可以估算时,先预留空间通常更合适:

try list.ensureTotalCapacity(allocator, expected_count);
for (values) |value| {
    list.appendAssumeCapacity(value);
}

这样可以减少扩容次数,但 expected_count 只是性能优化,不影响正确性。

五、遍历、读取和修改元素

items 是普通切片,数组切片能做的事情,ArrayList.items 基本都能做:

for (list.items, 0..) |value, index| {
    std.debug.print("[{d}] = {d}\n", .{ index, value });
}

按下标读取和修改:

const first = list.items[0];
list.items[0] = 999;

范围切片:

const first_three = list.items[0..3];

items 只包含有效元素,不包含 capacity - len 那部分未使用空间。未使用区域的内容是 undefined,不能读取。

如果需要直接写入尚未计入 len 的空间,可以使用 addOne 或 addManyAsSlice:

const item = try list.addOne(allocator);
item.* = 42;

const items = try list.addManyAsSlice(allocator, 3);
items[0] = 10;
items[1] = 20;
items[2] = 30;

这些元素已经属于列表。直接操作 allocatedSlice() 则要更加谨慎,它包含未初始化的额外容量,不能把整个结果当成有效数据。

六、插入和删除:顺序换性能

insert:保持原顺序

try list.insert(allocator, 1, 99);

原列表:

10 20 30 40

插入下标 1 后:

10 99 20 30 40

后面的元素需要整体向右移动,因此时间复杂度是 O(N)。

orderedRemove:保持原顺序删除

const removed = list.orderedRemove(1);

如果列表是 10、99、20、30,删除下标 1 后变成 10、20、30。后面的元素会向左移动,同样是 O(N)。

swapRemove:不保证顺序,O(1) 删除

const removed = list.swapRemove(1);

列表 10、20、30、40 删除下标 1 后,最后一个元素补到空位:

10 40 30

顺序发生变化,但不需要移动中间的一大片数据。如果场景只关心删除这个元素,不关心剩余元素顺序,swapRemove 更合适。

pop:删除最后一个元素

const last: ?i32 = list.pop();

列表为空时返回 null,不需要先手动检查 items.len。

七、清空列表和回收容量

清空有两种常用方式:

list.clearRetainingCapacity();
list.clearAndFree(allocator);

区别如下:

clearRetainingCapacity()
    len 变为 0
    保留底层 capacity
    适合列表还会继续使用的场景

clearAndFree(allocator)
    len 变为 0
    释放底层内存
    适合暂时不再使用,或需要归还内存的场景

示例:

try list.appendSlice(allocator, &[_]u8{ 1, 2, 3, 4 });
const old_capacity = list.capacity;

list.clearRetainingCapacity();
try list.append(allocator, 5);

std.debug.print("capacity still = {d}, old = {d}\n", .{
    list.capacity,
    old_capacity,
});

list.clearAndFree(allocator);

clearRetainingCapacity 只修改长度,不会擦除原有内存中的字节。之后重新追加时,只有新 len 范围内的数据才是有效元素。

八、toOwnedSlice:把列表变成独立切片

有时函数内部使用 ArrayList 收集数据,返回时不必继续返回列表对象,而是返回一段拥有明确所有权的切片。这时可以使用:

const result = try list.toOwnedSlice(allocator);

调用后:

  • 返回切片的内存归调用方所有;
  • ArrayList 变为空;
  • 原列表不应该再负责释放这段内存;
  • 使用完毕后必须调用 allocator.free(result)。

完整示例:

const std = @import("std");

fn makeMessage(allocator: std.mem.Allocator) ![]u8 {
    var list: std.ArrayList(u8) = .empty;
    errdefer list.deinit(allocator);

    try list.appendSlice(allocator, "hello, ");
    try list.appendSlice(allocator, "ArrayList");

    return list.toOwnedSlice(allocator);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const message = try makeMessage(allocator);
    defer allocator.free(message);

    std.debug.print("{s}\n", .{message});
}

这里使用 errdefer 很重要:如果中途某次追加失败,列表已经申请的内存仍然会被释放;成功返回后,所有权通过 toOwnedSlice 转移给 message。

如果只需要一个临时只读视图,可以直接返回 list.items,但前提是列表在切片使用期间保持存活,并且不能发生可能导致重新分配的操作。

九、最容易踩到的坑:切片和指针可能失效

ArrayList 扩容时,底层内存可能搬到新地址。扩容前取得的元素指针或切片,可能因此失效:

const first = &list.items[0];

try list.append(allocator, 1000);

// first 可能已经指向旧内存

即使没有扩容,插入和删除也会改变元素位置。安全做法是:

try list.append(allocator, 1000);
const first = &list.items[0];

或者先预留足够容量:

try list.ensureTotalCapacity(allocator, expected_count);

const first = &list.items[0];
list.appendAssumeCapacity(1000);

不过,插入和删除仍可能移动元素,预留容量不能保证所有指针永远有效。原则可以概括成一句话:

只要 ArrayList 发生可能改变布局的操作,就重新取得 items、切片和元素指针。

十、存储结构体时,释放的是列表内存,不是字段内部资源

存储普通结构体很直接:

const User = struct {
    id: u32,
    name: []const u8,
};

var users: std.ArrayList(User) = .empty;
defer users.deinit(allocator);

try users.append(allocator, .{
    .id = 1,
    .name = "Alice",
});

但 name 只是一个切片,ArrayList(User) 不知道这段字符串是否由 allocator 分配,也不会自动调用 free。

如果结构体内部拥有动态内存,应让结构体自己管理释放:

const User = struct {
    id: u32,
    name: []u8,

    fn deinit(self: User, allocator: std.mem.Allocator) void {
        allocator.free(self.name);
    }
};

删除或清空列表前,需要先遍历释放每个 User.name,再释放列表本身:

for (users.items) |user| {
    user.deinit(allocator);
}
users.clearAndFree(allocator);

若字符串来自字面量、静态数组或其他长期有效内存,则不应该对它调用 allocator.free。这就是 Zig 中“容器负责自己的数组内存,元素负责自己的内部资源”的边界。

十一、实战 Demo:读取分数并生成统计结果

下面的 Demo 模拟一个常见需求:

  1. 接收一批分数;
  2. 保存到 ArrayList(u32);
  3. 计算总分和平均分;
  4. 删除一个异常数据;
  5. 生成格式化后的结果字符串。

src/main.zig

const std = @import("std");

fn average(values: []const u32) f64 {
    if (values.len == 0) return 0;

    var total: u64 = 0;
    for (values) |value| {
        total += value;
    }

    return @as(f64, @floatFromInt(total)) /
        @as(f64, @floatFromInt(values.len));
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var scores: std.ArrayList(u32) = .empty;
    defer scores.deinit(allocator);

    try scores.ensureTotalCapacity(allocator, 5);
    for ([_]u32{ 88, 92, 76, 100, 85 }) |score| {
        scores.appendAssumeCapacity(score);
    }

    const invalid = scores.orderedRemove(2);
    std.debug.print("移除异常分数: {d}\n", .{invalid});

    var report: std.ArrayList(u8) = .empty;
    defer report.deinit(allocator);

    try report.writer(allocator).print(
        "有效分数: {any}, 平均分: {d:.2}\n",
        .{ scores.items, average(scores.items) },
    );

    std.debug.print("{s}", .{report.items});
}

test "average scores" {
    try std.testing.expectEqual(@as(f64, 88.75), average(&[_]u32{ 88, 92, 76, 100 }));
    try std.testing.expectEqual(@as(f64, 0), average(&[_]u32{}));
}

build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "score-demo",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    const run_step = b.step("run", "Run score demo");
    run_step.dependOn(&run_cmd.step);

    const tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const run_tests = b.addRunArtifact(tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_tests.step);
}

创建目录并执行:

mkdir -p arraylist-demo/src
cd arraylist-demo

zig build run
zig build test
zig build -Doptimize=ReleaseSafe

运行结果类似:

移除异常分数: 76
有效分数: { 88, 92, 100, 85 }, 平均分: 91.25

这个 Demo 展示了几个组合点:

  • ArrayList(u32) 保存动态数量的数值;
  • ensureTotalCapacity 配合 appendAssumeCapacity 避免循环中的重复扩容;
  • orderedRemove 删除数据并保持顺序;
  • ArrayList(u8) 可以作为动态字符串缓冲区;
  • writer(allocator) 可以直接向字节列表格式化写入;
  • 测试代码与业务函数放在同一个源文件中。

十二、ArrayList 和其他容器怎么选?

长度固定:普通数组

元素数量从编译期就确定时,普通数组更简单:

const weekdays = [_][]const u8{
    "Mon", "Tue", "Wed", "Thu", "Fri",
};

长度运行时确定,但不需要增长:allocator.alloc

如果最终长度已经知道,可以直接申请切片:

const values = try allocator.alloc(u32, count);
defer allocator.free(values);

这种方式少一层容器管理,适合“一次申请,填满后只读或一次性处理”。

不允许堆分配:固定缓冲区分配器

数据有明确上限时,可以使用栈上的缓冲区:

var storage: [1024]u8 = undefined;
var fixed = std.heap.FixedBufferAllocator.init(&storage);

var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(fixed.allocator());

try bytes.appendSlice(fixed.allocator(), "small buffer");

空间超过 1024 字节时会返回 error.OutOfMemory,不会偷偷向堆申请内存。

需要键值查询:AutoHashMap

ArrayList 适合按下标访问和顺序遍历;需要通过 key 快速查找时,应考虑 std.AutoHashMap 等哈希表容器。

十三、ArrayListUnmanaged 还需要单独学习吗?

Zig 0.16 的 std.ArrayList(T) 已经采用“不在列表中保存 allocator”的设计,因此很多旧版本文章中提到的 ArrayListUnmanaged,不能直接按旧用法理解。

当前代码最重要的习惯是:

var list: std.ArrayList(Item) = .empty;

try list.append(allocator, item);
defer list.deinit(allocator);

如果项目需要进一步控制底层表示,可以阅读标准库中的 array_list 实现和 unmanaged 相关类型;普通业务代码优先使用 std.ArrayList(T),接口更直观,也更容易维护。

十四、常用 API 速查

std.ArrayList(T)                         创建指定元素类型的列表
.empty                                   空列表初始值
items                                    有效元素切片
items.len                                当前元素数量
capacity                                当前容量
append(allocator, item)                  追加一个元素
appendSlice(allocator, slice)            追加一段切片
appendNTimes(allocator, value, n)        重复追加
appendAssumeCapacity(item)               容量足够时追加
ensureTotalCapacity(allocator, n)        预留至少 n 个元素的容量
addOne(allocator)                        增加一个未初始化元素并返回指针
insert(allocator, index, item)           插入并保持顺序
orderedRemove(index)                     删除并保持顺序
swapRemove(index)                        删除但不保证顺序
pop()                                    删除最后一个元素
clearRetainingCapacity()                清空但保留容量
clearAndFree(allocator)                  清空并释放容量
toOwnedSlice(allocator)                  转移底层内存所有权
deinit(allocator)                        释放列表底层内存

总结

ArrayList 本质上是一段可增长的连续数组。真正需要记住的不是某个方法名,而是下面这套使用逻辑:

.empty
  ↓
append(allocator, item)
  ↓
items 读取有效元素
  ↓
扩容可能让旧指针和切片失效
  ↓
toOwnedSlice 转移所有权,或 deinit 释放内存

日常开发中可以遵循几条简单规则:

  • Zig 0.16 使用 .empty 初始化;
  • 所有可能分配内存的操作显式传入 allocator;
  • 列表用完调用 deinit(allocator);
  • 已知大致数量时提前 ensureTotalCapacity;
  • 保持顺序用 orderedRemove,追求 O(1) 删除用 swapRemove;
  • 扩容、插入、删除后重新取得切片和元素指针;
  • toOwnedSlice 返回的内存由接收方负责 free;
  • 列表不会自动释放元素内部持有的字符串、数组或其他资源。