从 Rust 转过来,用 Zig 是什么感觉
写了 7 年 Rust 的作者用 Zig 重做了一个 JSONPath 库:没有 IDE 支持、结构被迫扁平、函数式范式用不上、allocator 无处不在。四种内存泄漏与重复释放的形状逐一与 Rust 对照,最后说 Zig 有成为 C 真正继承者的潜力。
中文
复制
引言
我在过去 7 年里一直是 Rust 开发者,主要在开源项目上工作,我自认对这门语言和它的生态已经练出了相当扎实的手感。我偏爱 Rust 里偏函数式的那一面,比如干净的函数、有表达力的类型,诸如此类。但我对其他语言一直很好奇,而 Zig 在我的关注列表上待了一段时间,它是 C 继承者的候选之一:更底层、更轻量,并且正在稳步进入人们认真对待的语言之列。我早年的职业生涯里用了一段时间 C,所以这个比较一直显得值得一做。
有一点需要先说清楚:我对 Zig 的经验是从这个项目开始的。有些观察,对每天用 Zig 的人来说会显得天真和显而易见;我在过程中做的一些决定,也几乎肯定不是最优的,它们更多是受从 Rust 带过来的习惯驱动,而不是出自深厚的 Zig 惯用法。这没关系,谁都得从某处开始,在此期间我就靠着这些年攒下的跨语言直觉,好也罢坏也罢。
为了让比较公平,我决定重新实现一个我已经用 Rust 写过的东西:不是玩具,但也不是一个大到没边的项目,最好是社区真的用得上的东西。我选定了 JSONPath:一种用于 JSON 的查询语言,在 RFC 9535 里做了规范。Rust 版已经存在(jsonpath-rust),目标是把同样的东西带到 Zig 上:zig-jsonpath。
IDE 支持
第一件让我措手不及的事,说真的,谁能想到这会是整件事里最让人记得的部分,是 IDE 支持,或者说它几乎完全不存在。我给 Rust 用 RustRover,其他语言用 JetBrains 的各种版本,相比之下,Zig 除了语法高亮和基础补全之外几乎什么都没有。这并不算意外,但它确实把我逼回了基本功:学着主要靠在命令行里和这门语言打交道。一开始像是缺点,后来却成了这段经历里比较有意思的部分之一。原来我只是忘了,光靠命令行工具就能干活有多直接。
这里第一个真正的教训是 build.zig,它把这件事处理得意外地轻松。我最后定下来的是这样一套:
zig build test # run all tests
zig build test -Dfilter="filter match function basic" # run one test
zig build test -Ddebug-query=true # all tests with debug
zig build compliance # compliance suite
zig build check # unit tests + compliance
一旦你接受了它的那套约定,用起来是真的清爽。
兜了个圈子说,我还得感谢 Zig 触发了一条更大的连锁反应,也就是我从完整的 IDE 转向 helix + alacritty + zellij 这套配置。
扁平结构
用 Rust,以及大多数其他语言时,我总是花不少时间来回纠结,去找文件大小与目录深度之间的那个平衡点。你可以随意拆分文件,把目录层级堆到任意深。结果发现,Zig 对此也不反对,但它不知怎么的并不鼓励你这么做(像 C 一样,对一门底层系统语言来说这并不意外)。你想嵌套文件和目录也行,但这样做会带来一点 import 上的摩擦,而真正的问题就变成了:何必呢?把东西拆到更多文件和目录里,你在可读性上究竟得到了什么?理论上是更好的可读性。实践中,当你把相关的东西收进一个大文件里,你可以直接切分它、一节一节地浏览,而且把所有东西放在一处确实有实打实的好处。多数情况下,Zig 会把你往扁平的方向推。如果某个东西需要一个配套的 model 文件,我就在它旁边建一个 model_ 文件,然后继续往下走。
我不认为这种做法能扩展到大型项目,也就是说到了某个规模,你终究需要真正的层级结构,但需要它的门槛在 Zig 里比我预期的要高得多。在 Rust 里,我倾向于很早就要目录结构,几乎是默认动作。在 Zig 里,我一再往后拖,到这个项目结束,我根本没用上。
这个对比的意义超出了 Zig 本身,因为它让我重新想了一遍:即便是在其他语言里,我组织文件是因为项目真的需要,还是出于习惯?这也是一个相当诚实的办法,用来衡量一个项目到底有多大:如果你在第一天就忍不住要建目录,也许它比感觉上要小。
下面是实际的差别,并排来看。
Rust(src/):
src/
├── lib.rs
├── parser.rs
├── parser/
│ ├── errors.rs
│ ├── macros.rs
│ ├── model.rs
│ ├── tests.rs
│ └── grammar/
│ └── json_path_9535.pest
├── query.rs
└── query/
├── atom.rs
├── comparable.rs
├── comparison.rs
├── filter.rs
├── jp_query.rs
├── queryable.rs
├── segment.rs
├── selector.rs
├── state.rs
├── test.rs
└── test_function.rs
Zig(src/):
src/
├── root.zig
├── parser.zig
├── model.zig
├── model_query.zig
└── query.zig
测试
先把 rfc9535 一致性套件放到一边,只谈语言本身:
在 Rust 里,我通常固定用两种测试方式:
- 内联单元测试,和被测代码放在同一个文件或同一个目录里。这是方便的默认做法,永远在那儿,不需要额外配置。
- 集成测试,放在主源码树之外的独立目录(比如
tests)。这是例外而非常态,有时干脆没有。
我原以为 Zig 也大致是这么分的。纸面上看确实相似:你可以直接在同一个文件里写测试。问题,至少对我来说,是啰嗦。考虑到我已经落定的扁平结构,我只有两个选择:要么为每个 model 建一个单独的 model_test 文件,要么把测试直接内联进 model 文件本身。两种做法最后都会让东西变乱:要么是各个文件变乱,要么是整个主目录变乱。
我选了第二种,这意味着要在 build.zig 里显式配置它。不过一旦接好,它就跑得很好,而且保持得挺干净。
所以总的来说:在 Rust 里写和管理测试,我感觉更省事。但在 Zig 这边,多出来的那些摩擦大部分是语言特有的,问题出在 Zig 的手工内存管理,而不是测试设施本身。
没有函数式范式
Rust 严格来说是一门命令式语言,但它大量借鉴了函数式的概念:零成本迭代器、惰性求值、ADT、模式匹配、单子式类型、trait、闭包,等等。我另外还用过一段时间的 Haskell 和 Erlang,所以比较偏向函数式风格,这一点在这个库里看得很清楚。它大量依赖 FP 惯用法:
- 通过
Queryable及相关类型这样的组合子做单子式错误控制 - 像
Data这样带map、flat_map、reduce等方法的单子式数据类型 - 纯的、不可变的变换
- 用迭代器组合子代替循环
- 用闭包做局部抽象
- 把声明式宏当作一个小型的嵌入式 DSL
- 和类型与积类型
我一开始就知道,这些东西我不可能全都带到 Zig 里,但我希望至少能保住核心概念。实践中,在 Rust 依靠不可变性和组合子的地方,Zig 把我推向了原地修改,以及命令式世界里最本色的那套写法。
两边靠得很近的地方是和类型。
在 Rust 里纯粹而直接:
pub trait Query {
fn process<'a, T: Queryable>(&self, state: State<'a, T>) -> State<'a, T>;
}
impl Query for Segment {
fn process<'a, T: Queryable>(&self, step: State<'a, T>) -> State<'a, T> {
match self {
Segment::Descendant(segment) => segment.process(step.flat_map(process_descendant)),
Segment::Selector(selector) => selector.process(step),
Segment::Selectors(selectors) => process_selectors(step, selectors),
}
}
}
在 Zig 里则是鸭子类型:
pub fn query(node: anytype, iteration: *JsonPathIter) !void {
const T = switch (@typeInfo(@TypeOf(node))) {
.pointer => |p| p.child,
else => @TypeOf(node),
};
if (!@hasDecl(T, "query")) {
return; // no compile-time trait; just checks the method exists
}
try node.query(iteration);
}
递归在两边也都站得住。
Rust:
fn process_descendant<T: Queryable>(data: Pointer<T>) -> Data<T> {
if let Some(array) = data.inner.as_array() {
Data::Ref(data.clone()).reduce(
Data::new_refs(/* children */).flat_map(process_descendant)
)
} else { Data::Nothing }
}
Zig:
fn collectDescendants(allocator, value: *std.json.Value, path, out) !void {
try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });
switch (value.*) {
.array => |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),
else => {},
}
}
但这门语言很快就逼着你偏离函数式风格,主要是因为你得直接跟 allocator 打交道,而一个真正纯函数式的做法意味着不断构造新结构。这在内存上要么昂贵,要么为了避开它而在手工记账上昂贵。
核心差别是「修改」对「不可变单子」。
Rust 做的是直截了当的单子变换:
pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
match self {
Data::Ref(data) => f(data), // returns a *new* Data
Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
_ => Data::Nothing,
}
}
Zig 转向了修改:
pub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {
while (i < iteration.cursors.items.len) {
if (obj.getPtr(name)) |val| {
iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // in-place overwrite
} else iteration.remove(i); // mutate list directly
}
}
Reduce 对 Fork。
Rust:
selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)
Zig:
var lhs_branch = try iter.fork(); // deep copy of cursor state
defer lhs_branch.deinit(); // then discarded
组合子对循环。
Rust:
items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()
Zig:
while (i < cursors.len) {
if (actual_index < arr.items.len) { cursors[i] = .{...}; i += 1; }
else iteration.remove(i);
}
总的来说,这反映了两门语言各自的设计目标和目标领域,也是合理的取舍,但主观上,我觉得由此产生的 Zig 代码不如对应的 Rust 代码易读。
Allocator
Allocator 无处不在。几乎每个函数都要接收一个,每个结构体都要持有一个。它是显式的,而一旦你把它当作入门的代价接受下来,跟起来还算直接。这差不多就是这门语言的标志性特征,所以不能说没人提醒过我。
但在实践中,这个过程很枯燥。你必须一丝不苟地遵守 init/deinit 的约定,而一旦调用栈变长,这份纪律就开始松。相比 C 里无声的段错误或内存损坏,这当然是明显的进步,但从 Rust 过来,你仍然是那个靠手来执行规则的人:分配一块内存,处理失败路径,决定谁负责 deinit,每一次都得如此。
所幸 Zig 的 TestAllocator 在这里救了场。它不会自动抓住所有问题,你仍然得写出那些会走到失败路径的测试用例,但一旦写了,它相当可靠。而这正是陷阱所在:这一切在纸面上看着都很显然,直到代码变复杂,那些 bug 就互相缠在一起躲起来。
下面是最扎手的几个例子,每一个都和 Rust 处理同样形状的方式作对比:
内存泄漏:忘了 deinit
var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']");
// BUG: no iter.deinit()
抓法:MemoryLeakDetected,指向 append 内部的 dupe 调用。
修法:在 init 之后立刻 defer iter.deinit();。
Rust:Drop 在作用域结束时自动运行,所以这个具体的 bug 根本不存在。不过严格说,Rust 里仍然可能泄漏,比如 Rc 引用环,或者显式调用 Box::leak,所以「永不泄漏」不是硬保证,只是你得特意去触发才会中招。
内存泄漏:错误路径上跳过了 deinit
fn build(json: *Value, a: Allocator) !q.JsonPathIter {
var iter = q.JsonPathIter.init(json, a);
try iter.append(json, "$['a']"); // ok
try iter.append(json, "$['b']"); // fails -> iter leaked
return iter;
}
抓法:FailingAllocator{ .fail_index = 1 },它迫使第二次 append 落入 MemoryLeakDetected。
修法:在 init 之后立刻 errdefer iter.deinit();。
Rust:这个问题被彻底消除。Drop::drop 在任何作用域退出时都会无条件触发,包括 ? 造成的提前返回。
内存损坏:deinit 被调用了两次
fn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {
var iter = q.JsonPathIter.init(json, a);
errdefer iter.deinit();
try q.query(qstr, &iter);
return iter.toResult(parsed); // ownership moves to caller
}
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
try cache.append(result); // cache now holds a (shallow) copy of result's pointers
defer result.deinit(); // BUG: frees the same heap data cache.items still points to
printResults(&result);
}
fn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {
var cache = std.ArrayList(q.JsonPathResult).init(a);
defer {
for (cache.items) |*r| r.deinit(); // frees the SAME memory Layer 2 already freed
cache.deinit();
}
for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}
抓法:在 std.testing.allocator 下运行,它会在 processAll 的清理阶段对第二次查询的 cache.items[0].deinit() 报错,DoubleFree 同时指向两处 free 的位置,确认这是一个跨函数的归属 bug,而不是一行笔误。
修法:只能有一层拥有这个值。既然 cache 比 cacheAndLog 活得久,归属就该属于第三层;第二层在把它交出去之后就不能再 defer deinit:
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,
cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
printResults(&result); // use it first
try cache.append(result); // then hand off ownership — no defer after this
}
Rust:这个形状根本编译不过。cache.push(result) 会移动 result,那一行之后,result 不再是一个可用的绑定,所以不可能在之后误调 drop(result)。
内存损坏:移入结构体失败时留下的孤儿分配
pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
const duped = try self.allocator.dupe(u8, path);
// BUG: no errdefer
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}
抓法:FailingAllocator{ .fail_index = 1 } 让数组增长(第二次分配)失败,从而让 duped(第一次分配)成为孤儿。
这种泄漏和前一种不同:iter.deinit() 本身跑得好好的,它只是永远看不到这个字符串。
修法:
const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped); // only fires if append below fails
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
Rust:从构造上就是对的。Vec::push(item) 把 item 移进去,要么成功、要么在 OOM 时中止,而标准 API 里没有一个会失败的 push 会把一个「已分配但未链接」的值交回给你,让你不小心丢掉。errdefer 在这里填的那个缺口,从一开始就不存在。
库与核心 API
生态还很年轻。库真的稀缺,连正则这样基础的东西都不完全成熟,比如 Zig 里能用的正则引擎 mvzr 不支持 Unicode 属性转义(\p{...}),这在实现 RFC 9535 的 filter 函数时直接暴露成一个缺口。除此之外,这门语言自己的标准库还会一个版本一个版本地改 API。这些我一开始就知道,但值得记上一笔。
总体印象
这门语言和 Rust 不同(谁知道呢,对吧),但它留下的印象是真好。它直接、现代,而且快得离谱。我相信它真有潜力成为 C 的真正继承者。另一方面,它还很年轻,而且看得出来:语言本身的形状在有些地方仍显得没做完,我猜随着它成熟,会吸收更多更好用的生活质量特性和语法糖。
至于我,我想继续为这个生态做贡献,只要有值得做的项目,我就会做。
链接
免责声明:本文全篇的代码风格与错误处理都是借助 AI 整理过的。