别把 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 模拟一个常见需求:
- 接收一批分数;
- 保存到 ArrayList(u32);
- 计算总分和平均分;
- 删除一个异常数据;
- 生成格式化后的结果字符串。
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;
- 列表不会自动释放元素内部持有的字符串、数组或其他资源。