背景我是个Rust爱好者,日常主力语言就是Rust,CLI工具、Web服务、数据处理管道都写过,编译器的脾气基本摸清了。Zig是这个月才开始学的,起因很简单——想换个视角看看系统编程还能怎么做。学了之后顺手写了两个小项目练手:一个zvm版本管理工具和一个kvdb。这篇文章不是什么权威评测,就是
我是个Rust爱好者,日常主力语言就是Rust,CLI工具、Web服务、数据处理管道都写过,编译器的脾气基本摸清了。Zig是这个月才开始学的,起因很简单——想换个视角看看系统编程还能怎么做。
学了之后顺手写了两个小项目练手:一个zvm版本管理工具和一个kvdb。
这篇文章不是什么权威评测,就是一个刚学Zig的Rust玩家的真实体感,给正在两个语言之间犹豫的朋友一个参考。
Rust像一个操心的家长,借用检查器时时刻刻盯着你,生怕你犯内存相关的错。Zig像一个信任你的朋友——给你厉害的工具,但默认你知道自己在干什么。
这不是说Zig不安全。它的思路是:
把危险操作显式化,让程序员看得见,而不是用类型系统消灭它。
举个最直观的例子,处理可能为空的值:
// Rust — Option是个枚举,带着一整套生态
fn find_user(id: u32) -> Option<User> { /* ... */ }
match find_user(42) {
Some(user) => process(user),
None => handle_missing(),
}
// Zig — optional是编译器内置的,轻量但够用
fn findUser(id: u32) ?User { /* ... */ }
if (findUser(42)) |user| {
process(user);
} else {
handleMissing();
}
表面看起来差不多。但Rust的Option是标准库里的枚举类型,你可以对它map、and_then、ok_or,整个函数式组合子生态围绕它构建。Zig的?T是编译器直接支持的,没有那些花哨的combinator,但胜在零开销——编译器知道这个东西可能是null,生成的机器码很干净。
这个差异其实贯穿两个语言的整个设计思路:
Rust靠类型系统的丰富性解决问题,Zig靠编译器的聪明解决问题。
如果只看一个点,看这个。
Rust的所有权系统是它的灵魂,也是最大的门槛。你要跟编译器battle借用和生命周期,要搞清楚&、&mut、'a。学习过程确实痛苦,但跨过这道坎之后,数据竞争在编译期就被消灭了,这种安全感是别的语言给不了的。
Zig走了一条完全不同的路——没有所有权系统,没有借用检查,但要求你显式管理每一个内存分配:
const std = @import("std");
pub fn main() !void {
// 显式创建分配器
var gpa = std.heap.DebugAllocator(.{}).init;
defer gpa.deinit();
const allocator = gpa.allocator();
// 每个需要分配的数据结构都要传入allocator
var list = std.ArrayList(u32).init(allocator);
defer list.deinit();
try list.append(42);
try list.append(100);
}
注意几个细节:
DebugAllocator是0.15+的新名字,以前叫GeneralPurposeAllocator(别名还能用)。它在debug模式下会检测内存泄漏、double free、use after free。defer关键字确保函数退出时执行清理,Zig版的RAII。这里有个Zig新手容易踩的坑:0.15之前很多人写.{}初始化容器,现在必须用.empty或.init`:
// 0.15+ 这样写会报错
var list: std.ArrayList(u32) = .{};
// 正确写法
var list: std.ArrayList(u32) = .empty; // 空集合
var gpa = std.heap.DebugAllocator(.{}).init; // 有状态的类型用 .init
作为Rust写习惯了的人,刚切到Zig的时候确实有点慌——没有借用检查器护体了。但写完zvm和kvdb之后发现,显式allocator其实给了你另一种自由:arena分配、内存池、栈上分配,想怎么玩怎么玩,不需要跟借用检查器解释为什么这样做是安全的。
还有一个细节:Zig的defer和errdefer配合使用真的很舒服:
fn processFile(path: []const u8) !void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // 无论如何都会关闭
var buf: [4096]u8 = undefined;
var reader = file.reader(&buf);
const r = &reader.interface;
errdefer {
// 只在出错时执行,做清理或日志
std.log.warn("Failed to process: {s}", .{path});
}
const data = try r.takeStruct(Header, .little);
try processData(&data);
}
Rust的Drop trait能做到类似的事情,但errdefer的"只在出错路径执行"这个语义确实更精确。Rust要实现同样的效果,得用Result配合闭包或者额外的guard类型。
Rust的Result<T, E>加?操作符已经成了一个标杆。Zig错误处理乍一看很像:
// Zig — 错误联合类型
const FileError = error{
NotFound,
PermissionDenied,
DiskFull,
};
fn openFile(path: []const u8) FileError!std.fs.File {
// ...
}
// Rust — Result枚举
#[derive(Debug)]
enum FileError {
NotFound,
PermissionDenied,
DiskFull,
}
fn open_file(path: &str) Result<std::fs::File, FileError> {
// ...
}
关键区别在于:Zig的错误集不是完整的值类型,它更像一组标签。Rust的错误是真正的值,想带什么数据都行。Zig要附带错误信息的话得靠errdefer或者额外的机制。
实际使用中的体感:
| 方面 | Rust | Zig |
|---|---|---|
| 附带数据 | Result<T, E>里E可以是任何类型 |
错误集不能直接带数据 |
| 错误传播 | ?操作符 + From trait自动转换 |
try关键字,简单直接 |
| 性能 | 零开销但枚举本身有size开销 | 错误集编译期确定,代码极简 |
| 生态 | thiserror / anyhow / eyre | 标准库自带,没太多第三方选择 |
Rust的错误处理更丰富,anyhow的context链、thiserror的derive,大型项目里非常有用。Zig的错误处理更快,但也更朴素——你不会花太多时间在错误处理框架上,因为没什么框架可花。
我个人的偏好:写库代码的时候喜欢Rust的严谨,写应用代码的时候喜欢Zig的简单。
这部分可能是两个语言差异最大的地方。
Rust的过程宏(proc macro)功能强大,但写过的都知道——太痛苦了。你要操作AST token流,要用syn解析、用quote生成代码,写出来的东西自己一周后都看不懂。但生态严重依赖它,serde、tokio、clap,全靠宏撑着。
Zig的思路完全不同:让普通代码在编译时执行。
// 编译时生成类型 — 这就是个返回类型的普通函数
fn Matrix(comptime T: type, comptime comptime width: usize, comptime height: usize) type {
return [height][width]T;
}
const Mat4f = Matrix(f32, 4, 4); // 编译期生成一个新类型
// 编译时类型反射
fn printTypeInfo(comptime T: type) void {
comptime {
const info = @typeInfo(T);
std.debug.print("{s}, size: {d}\n", .{ @typeName(T), @sizeOf(T) });
}
}
这就是普通的Zig代码,只不过前面加了comptime关键字,告诉编译器在编译阶段执行它。没有token操作,没有AST变换,没有卫生性问题。
用了comptime之后看Rust的过程宏,感觉像是在用正则表达式解析HTML——能干,但为什么要受这个罪?
但公平地说:
Zig 0.15还加了一些有意思的东西,比如labeled switch做状态机:
var result = state: switch (initial_state) {
.idle => continue :state .running,
.running => {
if (done) break :state .finished;
continue :state .running;
},
.error => return error.Failed,
};
这比Rust用enum + loop + match实现状态机要简洁不少。而且非穷尽枚举的switch也变得更灵活了,可以同时用else(匹配未列出的命名标签)和_(匹配未命名的整数值)。
这块必须单独说,因为Zig 0.15对整个I/O API做了重写,社区管这事叫"Writergate"。
以前写Zig的I/O是这样的:
// 0.14及以前的写法 — 现在会报错
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello\n", .{});
0.15+变成这样:
// 0.15+ 新API:手动提供buffer,要flush
var buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&buf);
const stdout = &stdout_writer.interface;
try stdout.print("Hello\n", .{});
try stdout.flush(); // 必须手动flush!
变化很大。新的Writer和Reader不再用泛型,而是非泛型接口加buffer。好处是编译速度更快、二进制更小,但迁移成本不低——所有I/O相关的代码都得改。
读文件也变了:
var buf: [4096]u8 = undefined;
var file_reader = file.reader(&buf);
const r = &file_reader.interface;
// 按行读取
while (try r.takeDelimiter('\n')) |line| {
// 处理每一行,不包含 '\n'
}
// 读二进制结构体 — 直接从流里取
const header = try r.takeStruct(Header, .little);
const value = try r.takeInt(u32, .big);
对比Rust的std::io::BufRead::lines()或者tokio::io::BufReader,Zig的API更底层但也更透明——你能清楚地看到buffer在哪,数据怎么流动。Rust把很多细节抽象掉了,用起来更舒服,但出了问题排查起来有时候也要多绕几步。
如果说有一个特性让我选Zig而不是Rust,那就是交叉编译。
# Zig交叉编译,就这一行
zig build -Dtarget=aarch64-linux-gnu
Rust也能交叉编译,但你要配target、装linker、搞sysroot,一套搞下来半天没了。Zig把所有主流平台的工具链都内置了,连C代码的交叉编译都能用Zig当toolchain来做。
对于嵌入式和跨平台CLI工具来说,这个优势太大了。写zvm的时候要支持多平台,Zig这边改一个target参数就行,Rust那边光是配各个target的交叉编译环境就得多折腾不少。
说到build系统,0.15的build.zig也有变化:
// 0.15+ 新写法 — root_source_file 被移除了
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
// 添加依赖模块也变了
exe.root_module.addImport("helper", helper_mod);
以前root_source_file直接放在addExecutable的参数里,现在要先创建Module。多了一层嵌套,但逻辑更清晰——未来Zig要支持多模块编译,这个结构扩展性更好。
对比Cargo.toml?说实话,日常使用Cargo的体验还是更好。cargo add、cargo test、cargo run一条龙。Zig的build.zig更灵活——它是真正的Zig代码,不是声明式配置文件,你能做任何编译期计算。但灵活的代价是你得写更多代码。
这个差距比较明显。
cargo test / cargo bench / cargo doc一条龙zig build能用但不如Cargo方便不过Zig有个Rust没有的优势:Zig自包含。一个二进制文件就包含了编译器、构建系统、C/C++编译器、格式化工具。不需要单独装rustup、cargo、rustfmt。拷贝一个文件就能用,这在CI环境和容器里特别方便。
说了这么多,给个主观建议。
学了一个月Zig之后,我对Rust有了新的理解。
以前我觉得所有权系统是天经地义的——内存安全不就应该这么做吗?用了Zig之后我发现,显式内存管理加上编译器辅助检测,也是一种可行方案。不是所有场景都需要编译时100%的安全保证。有时候你需要的是对内存布局的绝对控制,而不是编译器帮你管着。
反过来,学了Zig之后我更欣赏Rust的trait系统和泛型约束。Zig没有trait,泛型用的是duck typing + comptime。写zvm和kvdb这种中小项目的时候很爽,但如果项目再大一些,你可能会怀念Rust的where T: Display + Clone + 'static——那种在函数签名里就能看到约束的感觉。
两个语言都很有前途。Rust生态更成熟,找工作更容易,社区更大。Zig更年轻更灵活,在嵌入式、系统工具、游戏引擎底层这些领域有独特优势。
说句掏心窝的话:
别因为某个语言是你的主力就拒绝了解另一个。
我从Rust学到的类型驱动设计影响了我写所有语言的方式,这一个月从Zig学到的显式资源管理也让我写Rust的时候思路更清晰。语言的终极价值不只是写代码的工具,更是看问题的方式。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!