Zig vs Rust:一个Rust爱好者学了一个月Zig之后的真实感受

  • King
  • 发布于 12小时前
  • 阅读 28

背景我是个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是标准库里的枚举类型,你可以对它mapand_thenok_or,整个函数式组合子生态围绕它构建。Zig的?T是编译器直接支持的,没有那些花哨的combinator,但胜在零开销——编译器知道这个东西可能是null,生成的机器码很干净。

这个差异其实贯穿两个语言的整个设计思路:

Rust靠类型系统的丰富性解决问题,Zig靠编译器的聪明解决问题

内存管理:两个完全不同的方向

如果只看一个点,看这个。

Rust:所有权系统

Rust的所有权系统是它的灵魂,也是最大的门槛。你要跟编译器battle借用和生命周期,要搞清楚&&mut'a。学习过程确实痛苦,但跨过这道坎之后,数据竞争在编译期就被消灭了,这种安全感是别的语言给不了的。

Zig:显式分配器

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);
}

注意几个细节:

  1. allocator是显式传参的,不是全局状态。Zig没有隐藏的内存分配,每个需要堆内存的操作都要你明确指定从哪分配。
  2. DebugAllocator是0.15+的新名字,以前叫GeneralPurposeAllocator(别名还能用)。它在debug模式下会检测内存泄漏、double free、use after free。
  3. 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的defererrdefer配合使用真的很舒服:

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的简单。

comptime vs 宏:这是最有意思的对比

这部分可能是两个语言差异最大的地方。

Rust的过程宏:强大但痛苦

Rust的过程宏(proc macro)功能强大,但写过的都知道——太痛苦了。你要操作AST token流,要用syn解析、用quote生成代码,写出来的东西自己一周后都看不懂。但生态严重依赖它,serde、tokio、clap,全靠宏撑着。

Zig的comptime:编译时执行代码

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——能干,但为什么要受这个罪?

但公平地说:

  1. Rust的derive宏生态已经非常成熟,serde基本是必用的,体验其实很好(作为使用者,不是宏的作者)
  2. comptime目前文档少,社区还在摸索最佳实践
  3. comptime的编译错误有时候不太直观,调试体验跟Rust宏半斤八两

0.15+的新玩意

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(匹配未列出的命名标签)和_(匹配未命名的整数值)。

I/O:Zig 0.15的大地震

这块必须单独说,因为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的杀手锏

如果说有一个特性让我选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 addcargo testcargo run一条龙。Zig的build.zig更灵活——它是真正的Zig代码,不是声明式配置文件,你能做任何编译期计算。但灵活的代价是你得写更多代码。

工具链对比

这个差距比较明显。

Rust生态已经成熟了:

  • Cargo包管理丝滑,crates.io上什么都有
  • rust-analyzer的补全、跳转、重构体验一流
  • clippy抓代码坏味道,rustfmt统一风格
  • cargo test / cargo bench / cargo doc一条龙

Zig还在建设中:

  • zig build能用但不如Cargo方便
  • 包管理有了但还在早期
  • zls(Zig Language Server)能用但稳定性不如rust-analyzer
  • 标准库还在快速迭代,升级版本经常要改代码

不过Zig有个Rust没有的优势:Zig自包含。一个二进制文件就包含了编译器、构建系统、C/C++编译器、格式化工具。不需要单独装rustup、cargo、rustfmt。拷贝一个文件就能用,这在CI环境和容器里特别方便。

什么时候选哪个

说了这么多,给个主观建议。

选Rust如果:

  • 做Web后端 / 微服务 —— axum、actix生态成熟
  • 大型项目需要类型系统帮你管理复杂度
  • 团队协作 —— 严格性减少了"你那儿能跑我这里不行"的问题
  • 异步编程是核心需求(tokio生态完善)
  • 你喜欢"编译过了基本没问题"的安全感

选Zig如果:

  • 做嵌入式、OS内核、驱动这类底层东西
  • 跟C代码库大量交互 —— Zig可以无缝include C头文件
  • 需要极致的交叉编译体验
  • 嫌Rust太啰嗦,想要更接近金属的控制感
  • 项目不大,不需要重型类型系统

两个都学如果:

  • 你想深入理解内存管理和系统编程
  • 想看看不同语言怎么用不同思路解决相同的问题
  • 有时间 —— 两个语言的细节都不少

最后的碎碎念

学了一个月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的时候思路更清晰。语言的终极价值不只是写代码的工具,更是看问题的方式。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发