从 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 这样带 mapflat_mapreduce 等方法的单子式数据类型
  • 纯的、不可变的变换
  • 用迭代器组合子代替循环
  • 用闭包做局部抽象
  • 把声明式宏当作一个小型的嵌入式 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();

RustDrop 在作用域结束时自动运行,所以这个具体的 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,而不是一行笔误。

修法:只能有一层拥有这个值。既然 cachecacheAndLog 活得久,归属就该属于第三层;第二层在把它交出去之后就不能再 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 整理过的。

来源: besok← 返回首页