别把 ArrayList 当成会自动管理内存的 List:Zig 动态数组从入门到实战
处理数量不确定的数据时,固定长度数组很快就会遇到限制:文件有多少行并不确定,接口返回多少条记录也不确定,程序运行过程中产生多少个结果更无法提前写死。
Zig 标准库里的 std.ArrayList(T),就是为这类场景准备的动态数组。它使用一段连续内存保存元素,空间不够时通过 allocator 扩容,访问方式仍然保持数组一样的简洁。
如果熟悉 C#、C++ 或 Rust,可以先这样建立对应关系:
text
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 解决了什么问题?
普通数组的长度属于类型的一部分:
zig
const numbers = [_]i32{ 10, 20, 30 };
它的类型是 3i32,只能保存 3 个元素。数组适合长度已知、空间固定的场景,例如:
zig
const rgb = [3]u8{ 255, 128, 0 };
var buffer: [1024]u8 = undefined;
但下面这些数据通常没有固定长度:
- 读取文件得到的所有行;
- 命令行参数;
- 搜索结果;
- 运行时收集的日志;
- 解析后的用户记录;
- 一个任务队列或待办事项列表。
ArrayList 把"当前元素数量"和"已申请空间"分开保存:
text
len = 3
capacity = 8
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
└────── 当前元素 ──────┘└─── 可继续使用 ───┘
len 表示有效元素数量,capacity 表示底层内存可以容纳多少个元素。追加元素时,只要 len 小于 capacity,就能直接放入;空间用完后才需要重新申请更大的内存。
二、Zig 0.16 的正确初始化方式
现代 Zig 中,std.ArrayList(T) 是不保存 allocator 的数组列表。最简单的声明方式是:
zig
var numbers: std.ArrayList(i32) = .empty;
调用可能分配内存的方法时,把 allocator 显式传进去:
zig
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});
}
这里有两处容易漏掉:
zig
var numbers: std.ArrayList(i32) = .empty;
defer numbers.deinit(allocator);
.empty 只表示一个还没有分配底层数组的空列表;deinit(allocator) 才负责释放列表申请过的内存。
旧教程中经常出现这样的代码:
zig
var list = std.ArrayList(i32).init(allocator);
try list.append(10);
defer list.deinit();
这属于旧版 API。Zig 0.16 中应改成:
zig
var list: std.ArrayList(i32) = .empty;
try list.append(allocator, 10);
defer list.deinit(allocator);
三、items.len 和 capacity 怎么理解?
ArrayList 的有效数据通过 items 暴露。items 是一个切片,因此:
zig
list.items.len
就是当前元素个数。
完整观察示例:
zig
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 会提前准备至少可以容纳指定数量元素的空间。第五次追加时,列表会再次扩容。
容量增长策略属于实现细节,不能把某个具体增长倍数写进业务逻辑。真正稳定的判断只有两个:
text
items.len 当前有效元素数量
capacity 当前底层空间上限
四、添加元素:append、appendSlice 和预分配
最常用的 API 是 append:
zig
try list.append(allocator, value);
它可能申请新内存,所以返回 Allocator.Error!void,调用处通常需要 try 或 catch。
一次添加多个元素,可以使用 appendSlice:
zig
try list.appendSlice(allocator, &[_]i32{ 1, 2, 3 });
try list.appendSlice(allocator, &[_]i32{ 4, 5 });
也可以重复添加同一个值:
zig
try list.appendNTimes(allocator, 0, 5);
如果已经提前确认容量足够,可以使用不检查分配的版本:
zig
try list.ensureTotalCapacity(allocator, 10);
list.appendAssumeCapacity(100);
list.appendSliceAssumeCapacity(&[_]i32{ 200, 300 });
AssumeCapacity 的意思是"调用方保证容量足够"。容量不足时会触发断言或产生非法行为,因此不能把它当成更快的普通 append 随便替换。
当数据量可以估算时,先预留空间通常更合适:
zig
try list.ensureTotalCapacity(allocator, expected_count);
for (values) |value| {
list.appendAssumeCapacity(value);
}
这样可以减少扩容次数,但 expected_count 只是性能优化,不影响正确性。
五、遍历、读取和修改元素
items 是普通切片,数组切片能做的事情,ArrayList.items 基本都能做:
zig
for (list.items, 0..) |value, index| {
std.debug.print("[{d}] = {d}\n", .{ index, value });
}
按下标读取和修改:
zig
const first = list.items[0];
list.items[0] = 999;
范围切片:
zig
const first_three = list.items[0..3];
items 只包含有效元素,不包含 capacity - len 那部分未使用空间。未使用区域的内容是 undefined,不能读取。
如果需要直接写入尚未计入 len 的空间,可以使用 addOne 或 addManyAsSlice:
zig
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:保持原顺序
zig
try list.insert(allocator, 1, 99);
原列表:
text
10 20 30 40
插入下标 1 后:
text
10 99 20 30 40
后面的元素需要整体向右移动,因此时间复杂度是 O(N)。
orderedRemove:保持原顺序删除
zig
const removed = list.orderedRemove(1);
如果列表是 10、99、20、30,删除下标 1 后变成 10、20、30。后面的元素会向左移动,同样是 O(N)。
swapRemove:不保证顺序,O(1) 删除
zig
const removed = list.swapRemove(1);
列表 10、20、30、40 删除下标 1 后,最后一个元素补到空位:
text
10 40 30
顺序发生变化,但不需要移动中间的一大片数据。如果场景只关心删除这个元素,不关心剩余元素顺序,swapRemove 更合适。
pop:删除最后一个元素
zig
const last: ?i32 = list.pop();
列表为空时返回 null,不需要先手动检查 items.len。
七、清空列表和回收容量
清空有两种常用方式:
zig
list.clearRetainingCapacity();
list.clearAndFree(allocator);
区别如下:
text
clearRetainingCapacity()
len 变为 0
保留底层 capacity
适合列表还会继续使用的场景
clearAndFree(allocator)
len 变为 0
释放底层内存
适合暂时不再使用,或需要归还内存的场景
示例:
zig
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 收集数据,返回时不必继续返回列表对象,而是返回一段拥有明确所有权的切片。这时可以使用:
zig
const result = try list.toOwnedSlice(allocator);
调用后:
- 返回切片的内存归调用方所有;
- ArrayList 变为空;
- 原列表不应该再负责释放这段内存;
- 使用完毕后必须调用 allocator.free(result)。
完整示例:
zig
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 扩容时,底层内存可能搬到新地址。扩容前取得的元素指针或切片,可能因此失效:
zig
const first = &list.items[0];
try list.append(allocator, 1000);
// first 可能已经指向旧内存
即使没有扩容,插入和删除也会改变元素位置。安全做法是:
zig
try list.append(allocator, 1000);
const first = &list.items[0];
或者先预留足够容量:
zig
try list.ensureTotalCapacity(allocator, expected_count);
const first = &list.items[0];
list.appendAssumeCapacity(1000);
不过,插入和删除仍可能移动元素,预留容量不能保证所有指针永远有效。原则可以概括成一句话:
text
只要 ArrayList 发生可能改变布局的操作,就重新取得 items、切片和元素指针。
十、存储结构体时,释放的是列表内存,不是字段内部资源
存储普通结构体很直接:
zig
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。
如果结构体内部拥有动态内存,应让结构体自己管理释放:
zig
const User = struct {
id: u32,
name: []u8,
fn deinit(self: User, allocator: std.mem.Allocator) void {
allocator.free(self.name);
}
};
删除或清空列表前,需要先遍历释放每个 User.name,再释放列表本身:
zig
for (users.items) |user| {
user.deinit(allocator);
}
users.clearAndFree(allocator);
若字符串来自字面量、静态数组或其他长期有效内存,则不应该对它调用 allocator.free。这就是 Zig 中"容器负责自己的数组内存,元素负责自己的内部资源"的边界。
十一、实战 Demo:读取分数并生成统计结果
下面的 Demo 模拟一个常见需求:
- 接收一批分数;
- 保存到 ArrayList(u32);
- 计算总分和平均分;
- 删除一个异常数据;
- 生成格式化后的结果字符串。
src/main.zig
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
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);
}
创建目录并执行:
bash
mkdir -p arraylist-demo/src
cd arraylist-demo
zig build run
zig build test
zig build -Doptimize=ReleaseSafe
运行结果类似:
text
移除异常分数: 76
有效分数: { 88, 92, 100, 85 }, 平均分: 91.25
这个 Demo 展示了几个组合点:
- ArrayList(u32) 保存动态数量的数值;
- ensureTotalCapacity 配合 appendAssumeCapacity 避免循环中的重复扩容;
- orderedRemove 删除数据并保持顺序;
- ArrayList(u8) 可以作为动态字符串缓冲区;
- writer(allocator) 可以直接向字节列表格式化写入;
- 测试代码与业务函数放在同一个源文件中。
十二、ArrayList 和其他容器怎么选?
长度固定:普通数组
元素数量从编译期就确定时,普通数组更简单:
zig
const weekdays = [_][]const u8{
"Mon", "Tue", "Wed", "Thu", "Fri",
};
长度运行时确定,但不需要增长:allocator.alloc
如果最终长度已经知道,可以直接申请切片:
zig
const values = try allocator.alloc(u32, count);
defer allocator.free(values);
这种方式少一层容器管理,适合"一次申请,填满后只读或一次性处理"。
不允许堆分配:固定缓冲区分配器
数据有明确上限时,可以使用栈上的缓冲区:
zig
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,不能直接按旧用法理解。
当前代码最重要的习惯是:
zig
var list: std.ArrayList(Item) = .empty;
try list.append(allocator, item);
defer list.deinit(allocator);
如果项目需要进一步控制底层表示,可以阅读标准库中的 array_list 实现和 unmanaged 相关类型;普通业务代码优先使用 std.ArrayList(T),接口更直观,也更容易维护。
十四、常用 API 速查
text
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 本质上是一段可增长的连续数组。真正需要记住的不是某个方法名,而是下面这套使用逻辑:
text
.empty
↓
append(allocator, item)
↓
items 读取有效元素
↓
扩容可能让旧指针和切片失效
↓
toOwnedSlice 转移所有权,或 deinit 释放内存
日常开发中可以遵循几条简单规则:
- Zig 0.16 使用 .empty 初始化;
- 所有可能分配内存的操作显式传入 allocator;
- 列表用完调用 deinit(allocator);
- 已知大致数量时提前 ensureTotalCapacity;
- 保持顺序用 orderedRemove,追求 O(1) 删除用 swapRemove;
- 扩容、插入、删除后重新取得切片和元素指针;
- toOwnedSlice 返回的内存由接收方负责 free;
- 列表不会自动释放元素内部持有的字符串、数组或其他资源。