从Rust转向Zig的感受
摘要
一位经验丰富的Rust开发者分享了通过重新实现JSONPath工具来学习Zig的旅程,重点介绍了IDE支持和代码组织方面的差异。
<p><a href="https://lobste.rs/s/oaybfe/what_zig_felt_like_coming_from_rust">评论</a></p>
查看缓存全文
缓存时间: 2026/08/20 10:41
# 从Rust转向Zig的体验
来源:https://besok.github.io/posts/what-zig-felt-like-coming-from-rust/
## 引言
过去7年我一直是Rust开发者,主要参与开源项目。自认为已对语言及其生态系统建立了扎实的理解。我偏爱Rust的函数式风格——简洁的函数、富有表现力的类型等。但我始终对其他语言充满好奇,而Zig作为C的潜在继任者,长期列入我的关注列表:更底层、更轻量,并逐渐在严肃语言圈站稳脚跟。早年我也曾使用C开发,因此这种对比必然引人深思。
需要提前说明:我对Zig的接触始于本项目。其中一些观察在Zig日常开发者眼中可能显得幼稚且显而易见,某些决策也未必最优——更多是受Rust习惯影响,而非深谙Zig惯用法。这没关系,万事皆有开端。无论如何,我正凭借多年积累的跨语言直觉(无论好坏)进行探索。
为确保公平比较,我选择用Zig重新实现已在Rust中完成的项目——既非玩具级应用,也非庞然大物,最好是社区能实际使用的工具。最终选定JSONPath:一种符合RFC 9535规范的JSON查询语言。Rust版本已存在(jsonpath-rust),目标是将其移植到Zig:zig-jsonpath。
## IDE支持
首先令我意外的是——老实说,谁能想到这会成为难忘的部分——IDE支持几乎不存在。我习惯为Rust使用RustRover,其他语言则搭配各种JetBrains IDE,相比之下Zig仅提供语法高亮和基础自动补全。这虽不意外,却迫使我回归本质:学习通过命令行驾驭语言。
起初的劣势逐渐转变为有趣的体验。我发现自己早已遗忘纯命令行工具的简洁高效。第一课来自`build.zig`,它以惊人简洁处理构建流程。最终形成如下配置:
```
zig build test # 运行所有测试
zig build test -Dfilter="filter match function basic" # 运行单个测试
zig build test -Ddebug-query=true # 带调试信息运行所有测试
zig build compliance # 合规性测试套件
zig build check # 单元测试+合规性测试
```
一旦接受这种模式,工作体验异常清爽。感谢Zig间接促成了更大转变:从依赖完整IDE转向helix + alacritty + zellij(https://github.com/besok/dotfiles)的极简环境。
## 扁平化结构
在Rust及多数语言中,我常花费大量时间在文件大小与目录深度间权衡。理论上可自由拆分文件、无限延伸目录层级。Zig虽同样允许,却不鼓励这种做法(符合底层系统语言的特性,如C)。虽然可以嵌套文件和文件夹,但会带来导入摩擦。核心问题是:这样做真的能提升可读性吗?
理论上拆分能增强可读性,实践中将相关代码整合到单个大文件后,通过分段浏览反而更高效。Zig倾向引导扁平化组织。若模型需配套文件,我直接创建相邻的`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
```
## 测试
暂不讨论RFC 9535合规性套件,聚焦语言本身:
在Rust中,我通常采用两种测试方式:
- **内联单元测试**:与被测代码同文件/同目录,便捷的默认选项,无需额外配置
- **集成测试**:位于主源码树外的独立目录(如`tests`),作为特例存在
我曾预期Zig有类似划分。表面上相似:可在同文件中编写测试。但问题在于冗余度——鉴于已采用扁平结构,我面临两种选择:为每个模型创建独立`model_test`文件,或直接内联到模型文件。两种方式都会导致混乱(无论是分散文件还是整体目录)。我选择后者,并在`build.zig`中显式配置。连接成功后,测试运行流畅且整洁。
总体而言:我认为Rust编写和管理测试更简便。但Zig的额外摩擦主要源于语言特性,更接近命令式世界的原生模式,而非测试基础设施本身。
## 无函数式范式
Rust虽是命令式语言,却大量借鉴函数式概念:零成本迭代器、惰性求值、代数数据类型、模式匹配、单子类型、trait、闭包等。受Haskell和Erlang影响,我已深度倾向函数式风格,这在本库中尤为明显。
关键特征包括:
- 通过`Queryable`等组合子实现单子式错误控制
- 支持`map`/`flat_map`/`reduce`的单子式数据类型`Data`
- 纯粹的不可变转换
- 组合子替代循环
- 闭包实现局部抽象
- 声明式宏作为微型DSL
- 和类型与积类型
我明知无法将全部特性移植到Zig,但仍期望保留核心理念。实践中Rust依赖不可变性和组合子时,Zig引导我采用就地变更和命令式原生模式。
**两者接近处:和类型**
Rust中简洁直接:
```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中采用鸭子类型:
```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; // 无编译期trait,仅检查方法是否存在
}
try node.query(iteration);
}
```
**递归模式两者相似**
Rust示例:
```rust
fn process_descendant(data: Pointer) -> Data {
if let Some(array) = data.inner.as_array() {
Data::Ref(data.clone()).reduce(
Data::new_refs(/* 子元素 */).flat_map(process_descendant)
)
} else {
Data::Nothing
}
}
```
Zig示例:
```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 => {},
}
}
```
但Zig很快迫使你偏离函数式风格,主要因为需直接处理内存分配器,而真正的纯函数式意味着不断构造新结构——要么内存消耗巨大,要么需大量手工簿记避免开销。
**核心差异:变更 vs. 不可变单子**
Rust的简单单子变换:
```rust
pub fn flat_map(self, f: F) -> Data<'a, T> {
match self {
Data::Ref(data) => f(data),
Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
_ => Data::Nothing,
}
}
```
Zig转向变更:
```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 };
} else iteration.remove(i);
}
}
```
**归约 vs. 分支**
Rust:
```rust
selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)
```
Zig:
```zig
var lhs_branch = try iter.fork();
defer lhs_branch.deinit();
```
**组合子 vs. 循环**
Rust:
```rust
items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()
```
Zig:
```zig
while (i < cursors.len) {
if (actual_index < arr.items.len) {
cursors[i] = .{...};
i += 1;
} else iteration.remove(i);
}
```
总体而言,这反映了各自的设计目标和目标领域,是合理的权衡。但主观上,我认为Zig生成的代码可读性低于Rust对应版本。
## 内存分配器
内存分配器无处不在:几乎每个函数都接受一个;每个结构体持有一个。这种显式性一旦被接受,遵循规则相对直接。这近乎语言的标志性特征,故无人未被警告。
实践中,过程却繁琐:必须严格遵循初始化/反初始化规范,且调用栈变长时纪律容易松动。这比C中无声段错误或内存损坏有明确改进,但来自Rust的开发者仍需手动执行规则——每次分配资源、处理失败路径、决定反初始化责任方。
幸运的是,Zig的`TestAllocator`可挽救局面。它不会自动捕获所有问题,仍需编写测试用例覆盖失败路径——但一旦完成,可靠性较高。陷阱在于:纸面上看似简单,直到代码复杂化,这些错误便纠缠隐藏。
以下是最典型的案例,对比Rust处理方式:
### 内存泄漏:遗忘反初始化
```zig
var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']"); // 错误:未调用iter.deinit()
```
**捕获方式**:`MemoryLeakDetected`,指向`append`中的`dupe`调用
**修复方法**:初始化后立即添加`defer iter.deinit();`
**Rust对应**:`Drop`在作用域结束时自动运行,故此特定错误不存在(注:技术上仍可能泄漏,如`Rc`循环引用或`Box::leak`,但需刻意触发)
### 内存泄漏:错误路径跳过反初始化
```zig
fn build(json: *Value, a: Allocator) !q.JsonPathIter {
var iter = q.JsonPathIter.init(json, a);
try iter.append(json, "$['a']"); // 正常
try iter.append(json, "$['b']"); // 失败 -> iter泄漏
return iter;
}
```
**捕获方式**:`FailingAllocator{ .fail_index = 1 }`迫使第二次append触发`MemoryLeakDetected`
**修复方法**:初始化后添加`errdefer iter.deinit();`
**Rust对应**:彻底解决。`Drop::drop`在任何作用域退出(包括`?`提前返回)时无条件执行
### 内存损坏:重复调用反初始化
```zig
fn runQuery(...) !q.JsonPathResult {
var iter = q.JsonPathIter.init(json, a);
errdefer iter.deinit();
try q.query(qstr, &iter);
return iter.toResult(parsed); // 所有权转移给调用者
}
fn cacheAndLog(...) !void {
var result = try runQuery(json, qstr, a);
try cache.append(result); // cache持有result指针的浅拷贝
defer result.deinit(); // 错误:释放cache仍指向的堆数据
printResults(&result);
}
fn processAll(...) !void {
var cache = std.ArrayList(q.JsonPathResult).init(a);
defer {
for (cache.items) |*r| r.deinit(); // 释放cacheAndLog层已释放的内存
cache.deinit();
}
for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}
```
**捕获方式**:使用`std.testing.allocator`运行时,在第二次查询的`cache.items[0].deinit()`(`processAll`清理阶段)失败,`DoubleFree`指向两个释放位置,确认这是跨函数所有权错误而非单行拼写错误
**修复方法**:仅单一层级可拥有值所有权。因`cache`生存期长于`cacheAndLog`,所有权属第三层;第二层移交后不得`defer deinit`:
```zig
fn cacheAndLog(...) !void {
var result = try runQuery(json, qstr, a);
printResults(&result); // 先使用
try cache.append(result); // 再移交所有权——此后无defer
}
```
**Rust对应**:此结构无法编译。`cache.push(result)`移动`result`后,`result`作为有效绑定不再存在,故无法意外调用`drop(result)`
### 内存损坏:结构体移动失败时的孤岛分配
```zig
pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
const duped = try self.allocator.dupe(u8, path); // 错误:无errdefer
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}
```
**捕获方式**:`FailingAllocator{ .fail_index = 1 }`使数组扩容失败(第二次分配),导致`duped`(首次分配)孤立。此泄漏方式不同于案例一:`iter.deinit()`正常运行,但永不包含此特定字符串
**修复方法**:
```zig
const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped); // 仅当下方append失败时触发
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
```
**Rust对应**:构造上保证安全。`Vec::push(item)`移动`item`入列,要么成功要么OOM中止。标准库无可能返回"已分配但未链接"值的fallible push,故根本不存在`errdefer`需填补的缺口
## 库与核心API
生态系统仍处于早期阶段。库资源稀缺,即使是正则表达式这类基础库也未完全成熟(例如`mvzr`)...
相似文章
重返Zig
作者描述了从Zig到Rust再回到Zig的历程,探讨了编程语言中稳定性与表达力之间的权衡。
2026 年的 Zig 与 Rust
本文在 2026 年的背景下对比了 Zig 和 Rust,认为编程代理通过自动化生成 Rust 代码,削弱了 Zig 在人机交互体验上的优势。
以Zig风格构建你的项目
作者详细介绍了构建一个名为bygge-zig的工具,该工具使用Zig构建系统来编译Rust项目,用更少的代码行复制了Cargo的功能,并突出了其中的差异和挑战。
我们的Rust到Zig重写进展如何
Roc编译器团队已将他们30万行Rust代码库重写为Zig,经过18个月实现了功能对等,生成了更小的WebAssembly二进制文件并提高了性能。
Zig 示例教程
通过带注释的示例,对 Zig 编程语言进行实践性介绍,涵盖从基础到高级的主题。灵感来源于 Go by Example。