先说清楚要做什么
做一个命令行 TODO 工具:add 记一条、list 列出、done 打勾、rm 删除,数据落在当前目录的 todo.txt 里,单文件 Zig 源码,编译后是一个无依赖的可执行文件。这个项目小,但”没有 GC”这条约束会让每一行涉及数据的代码都变重——每次分配都要回答两个问题:内存在哪来、谁负责还;每个可能失败的调用都要回答:失败走哪条路。Zig 恰好把这两个问题从”以后再说”变成”当场回答”,所以它是练分配器与错误处理的最小合适载体。工具链与语法前置见语法要点篇,条目随手查参考字典。本篇代码按 0.14.1 口径编写(版本说明见文末)。
| 验收项 | 目标 | 怎么测 |
|---|---|---|
| add / list | 新增条目可见,id 递增不复用 | add 两条后 list 应出现两行,id 为 1、2 |
| done | 状态翻转并落盘 | done 1 后看 todo.txt,对应行第二列变为 1 |
| rm | 该行消失 | rm 2 后 list 不再出现,且文件少一行 |
| 坏行容错 | 报 BadRow、退出码非 0,不崩 | 手工把某行的 Tab 改成空格再 list |
| 首次使用 | 无文件当空列表,不报错 | 删掉 todo.txt 后 list 应安静退出 |
整体设计
单文件三层,没有框架没有依赖:
args(命令行参数) → run(命令分发)
│ 解析/增删改的是内存里的 ArrayList(Todo)
▼
load / save(读写 todo.txt,纯文本 TSV:id \t done \t text)
两个选型的”为什么不”。存储不用 SQLite 不用 JSON:单用户、几十条、人要能直接打开看——TSV 文本文件够了,还顺带制造了”坏行如何处理”这个真实错误处理课题;不用 JSON 的额外理由是 std.json 的 API 在 0.14/0.15 之间也在变,文本解析反而稳定且教学价值更高。进程内不搞缓存层:CLI 一命一跑,load 完改完就 save,中间任何复杂化都是过度设计——工具程序的第一美德是肉眼可审计。
难点一:分配器策略,为什么是 Arena
本程序全程只用一个分配器:ArenaAllocator 包着 page_allocator。理由有三:CLI 是”活一轮、清一次”的形状, arena 整批结账正好;所有解析出来的文本都是指向文件缓冲区或参数缓冲区的切片,arena 让这些切片活到进程退出,省掉逐项所有权的记账;代码里几乎没有 free,可读性显著提高。四种内置分配器的取舍如下:
| 分配器 | 适合什么 | 本项目为什么不用它 |
|---|---|---|
| ArenaAllocator | 请求式/命令式一轮一清的负载 | 选中:进程退出统一归还,切片借活期安全 |
| DebugAllocator | 开发期查泄漏(0.14 前叫 GPA) | 单次运行泄漏无从观察,留到服务类项目 |
| page_allocator | 大块整页、无簿记的底层 | 小对象拿它分配至少一页,浪费;只当 arena 底座 |
| smp_allocator | 0.14+ 多线程服务的发布档 | 单线程 CLI 用不上 |
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // 退出时一次性归还 arena 全部内存,包括所有解析产物
const a = arena.allocator();
run(a) catch |err| switch (err) {
error.Usage => {
try std.io.getStdOut().writer().print(
"用法: todo add 文本 | todo list | todo done id | todo rm id\n", .{});
std.process.exit(2);
},
error.BadId => { std.debug.print("id 必须是数字\n", .{}); std.process.exit(2); },
error.UnknownId => { std.debug.print("没有这个 id\n", .{}); std.process.exit(2); },
else => { std.debug.print("出错了: {s}\n", .{@errorName(err)}); std.process.exit(1); },
};
}
main 里把错误翻译成”人话加退出码”而不是直接 return err——运行时默认打印的 error: Xxx 对脚本调用方够用(非 0 退出码),对人不够友好,这层翻译属于 UI 不属于逻辑。反模式点名:在循环里每次用 page_allocator.alloc 而不释放,命令行程序侥幸不崩,同样的代码搬进常驻服务就是慢性内存泄漏;以及拿 catch unreachable 应付用户输入,见下一节。
难点二:错误处理,错误集分层与边界转换
错误处理的设计重点不是语法(try/catch 的分工见语法篇),是”错误从哪来、到哪去”。本项目两个来源:存储层的坏行(文件是持久化的,内容随时可能被人改坏)与用户层的误用(id 不存在、参数缺失)。对应的错误集分层声明:
const TodoError = error{
Usage, // 命令行用法错误:缺参数、未知命令
BadRow, // todo.txt 某行不符合 id\tdone\ttext 格式
BadId, // 用户给的 id 不是合法数字
UnknownId, // id 不存在
};
解析一行是防御的核心。文件内容按”不可信输入”对待——Tab 数量、数字合法性、标志位取值,一项项验,验不过返回 BadRow 而不是 panic:
const Todo = struct {
id: u32,
done: bool,
text: []const u8, // 指向文件缓冲区的切片,随 arena 一起活
};
fn parseRow(line: []const u8) TodoError!?Todo {
const row = std.mem.trim(u8, line, " \t\r");
if (row.len == 0) return null; // 文件末尾的空行是常态,不算坏
const t1 = std.mem.indexOfScalar(u8, row, '\t') orelse return error.BadRow;
const rest = row[t1 + 1 ..];
const t2 = std.mem.indexOfScalar(u8, rest, '\t') orelse return error.BadRow;
const id = std.fmt.parseInt(u32, row[0..t1], 10) catch return error.BadRow;
const flag = rest[0..t2];
const done: bool = if (std.mem.eql(u8, flag, "1"))
true
else if (std.mem.eql(u8, flag, "0"))
false
else
return error.BadRow;
return Todo{ .id = id, .done = done, .text = rest[t2 + 1 ..] };
}
fn nextId(todos: []const Todo) u32 {
var max: u32 = 0;
for (todos) |t| { if (t.id > max) max = t.id; }
return max + 1; // rm 后不复用旧号:删 3 再加出现"新 3"会扰乱肌肉记忆
}
反模式点名:把 parseInt 的失败写成 catch unreachable。文件里存什么不由代码决定,用户敲什么也不由代码决定——这两处 unreachable 等于把”坏行”和”手滑”都升级成进程崩溃,恰好把 Zig 用错误集换来的确定性又亲手扔掉了。逃生门只允许出现在逻辑不变量为真的位置。
第二个设计点:错误语义在边界转换。error.FileNotFound 从系统层看是错误,从产品语义看是”第一次使用”——在 load 里转成空列表而不是让用户第一次跑就见报错:
fn load(a: std.mem.Allocator) !std.ArrayList(Todo) {
var todos = std.ArrayList(Todo).init(a);
const text = std.fs.cwd().readFileAlloc(a, "todo.txt", 1 << 20) catch |err| {
if (err == error.FileNotFound) return todos; // 首次使用:空列表是正常路径
return err; // 其他错误(权限等)如实上抛
};
// 不 free(text):Todo.text 是指向它的切片,arena 会在进程退出统一归还
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |line| {
if (try parseRow(line)) |t| try todos.append(t);
}
return todos;
}
注意那句”不 free(text)”——在普通分配器下这是悬垂切片 bug,在 arena 下它是正确的省略,且注释把前提写明。这正是 arena 的教育意义:所有权复杂度没有被消灭,是被集中到了一处并写进了注释。若将来迁到常驻服务,改成 dupe 每个 text 加 free 原缓冲区即可。
难点三:读改写落盘与并发边界
每次增删改都是”内存改完、整文件覆盖写回”。覆盖写(createFile 默认 truncate)而不是追加写:done 要能把 0 改成 1,追加写只能堆新行、旧状态永远留在那里——追加式存储适合日志,不适合状态:
fn save(a: std.mem.Allocator, todos: []const Todo) !void {
var out = std.ArrayList(u8).init(a); // 先拼完整内容,再一次写入
const w = out.writer();
for (todos) |t| {
try w.print("{d}\t{d}\t{s}\n", .{ t.id, @as(u8, if (t.done) 1 else 0), t.text });
}
const f = try std.fs.cwd().createFile("todo.txt", .{ .truncate = true });
defer f.close();
try f.writeAll(out.items); // 一次写入全部内容;原子性如何,见下文诚实边界
}
诚实边界要写清楚三点。第一,”一次 writeAll”不等于原子写:进程在 writeAll 中途被杀,文件仍可能只剩半截——真要防这个得写临时文件再 rename(原子替换),本工具规模下没做,是明示的取舍。第二,没有锁:两个终端同时跑 add,后写的整文件覆盖先写的,数据丢失且无提示——单用户场景可接受,多人共享目录请上文件锁或直接换 SQLite,本篇不假装这个边界不存在。第三,写回的顺序就是内存顺序,不保证 id 排序,list 展示按文件序——要排序在 list 分支排内存数据,不动存储。
命令分发与改动落盘的完整逻辑:
fn run(a: std.mem.Allocator) !void {
const args = try std.process.argsAlloc(a); // 0.14 取参数要分配器,0.15 签名有变
if (args.len < 2) return error.Usage;
var todos = try load(a);
const out = std.io.getStdOut().writer();
if (std.mem.eql(u8, args[1], "add")) {
if (args.len < 3) return error.Usage;
const id = nextId(todos.items);
try todos.append(.{ .id = id, .done = false, .text = args[2] });
try save(a, todos.items);
try out.print("added #{d}\n", .{id});
} else if (std.mem.eql(u8, args[1], "list")) {
for (todos.items) |t| {
try out.print("[{c}] #{d} {s}\n", .{ if (t.done) @as(u8, 'x') else ' ', t.id, t.text });
}
} else if (std.mem.eql(u8, args[1], "done") or std.mem.eql(u8, args[1], "rm")) {
if (args.len < 3) return error.Usage;
const id = std.fmt.parseInt(u32, args[2], 10) catch return error.BadId;
const marking = std.mem.eql(u8, args[1], "done");
for (todos.items, 0..) |*t, i| {
if (t.id != id) continue;
if (marking) t.done = true else _ = todos.orderedRemove(i);
try save(a, todos.items); // 只有真的改到东西才写盘
return;
}
return error.UnknownId; // 遍历完没命中:显式失败而不是静默结束
} else return error.Usage;
}
验证
zig run todo.zig -- add 买牛奶 # zig run 传参要用 -- 分隔
zig run todo.zig -- add 写周报
zig run todo.zig -- list
1. [ ] #1 买牛奶
1. [ ] #2 写周报
zig run todo.zig -- done 1
cat todo.txt
1. 1 1 买牛奶
1. 2 0 写周报
zig run todo.zig -- rm 2
zig run todo.zig # 缺命令:打印用法,退出码 2
| 现象 | 原因 | 怎么处理 |
|---|---|---|
| 首次 list 无输出且退出码 0 | FileNotFound 已按”空列表”语义转换 | 预期行为,验收表里的首用项 |
| list 报 BadRow | 有人手工改文件把 Tab 敲成空格 | 按提示行号修复,或 rm 整行重加 |
| add 后偶尔丢刚写的数据 | 两个终端同时跑,覆盖写互踩 | 单用户约定;要并发上文件锁或换 SQLite |
| Windows 终端中文乱码 | 控制台代码页不是 UTF-8 | chcp 65001 或改用 Windows Terminal |
| 编译报 ArrayList 方法不存在 | 0.15 工具链跑 0.14 写法 | 换 0.14.1 工具链,或按 0.15.1 notes 改 unmanaged |
关于性能的诚实说明
本篇代码未实测,运行数据不提供——对一个读改写一个几百字节文本文件的 CLI,性能不是这个项目的问题域,编造毫秒数毫无意义。定性判断:单文件读入上限 1 MB、全程一个 arena、无系统级开销,量级在”敲下回车到出结果”的感知阈值以内;拿它当练手工程即可,别拿它当基准测试对象。0.15.1 迁移提示:本篇按 0.14.1 口径编写,涉及 0.15 破坏性改动的点为 ArrayList 默认 unmanaged、stdout 获取方式(Writergate)、std.process.args 去掉分配器参数——迁移以官方 0.15.1 release notes 为准,核心的解析与错误处理逻辑不受影响。
总结
这个 150 行的小工具练到了三件 Zig 的核心功夫:分配器当参数传,策略(arena 一轮一清)集中声明一次,其余代码不再碰内存细节;错误集分层(存储层 BadRow、用户层 BadId/UnknownId/Usage),让”失败方式”成为 API 契约的一部分,main 统一翻译成人话与退出码;边界转换语义(FileNotFound 到空列表)与显式不装(无锁、非原子、不支持多人,全写在注释与本文里)。什么时候不该用这套方案:数据要多人并发写、要断电安全、要查询统计——任何一个条件出现,TSV 加覆盖写就该换成真数据库;这个 TODO 的价值在训练,不在产品。
系列剩下的路:开山篇回答值不值得学,本篇之后怎么走见Zig 学习路线。
版本核实(以官方文档为准):0.14.1 发布于 2025-05-23,本篇代码按该版口径编写(ArrayList managed 写法、std.io.getStdOut、argsAlloc 分配器参数均为 0.14 已发布行为);0.15.1 发布于 2025-08-20,其 ArrayList 默认 unmanaged、std.Io 读写接口重构(Writergate)与 std.process.args 签名变更均为该版公开的破坏性改动,文中已逐点标注;ArenaAllocator、DebugAllocator(0.14.0 起)、smp_allocator(0.14.0 起)、std.debug.print、文件读写与测试基础设施在 0.14/0.15 均可用。本文未实测性能,未引用尚未发布版本的行为。