漫步 zig

本文只是浅谈个人对于目前的 zig 的观点,zig 的具体相关使用可以查看上一篇文章

目前的 zig

目前的 zig 仍然处于开发阶段,但是基于 zig 团队对于 proposal 的态度,zig 将来的发展也可以窥见一斑。目前来说,zig 团队的人数较少,也没有 rust 那样混乱的社区,zig 的发展应该类似于 go,由少数人主导的团队往往能带来编程语言的一致性,不过这也会导致一言堂的情况,因此 zig 的语言发展大体上不会被社区左右,而是逐步发展成创作者的理想语言。

但是目前来说,zig 的实现仍然有些简陋,且还有很多逆天的 bug,例如:

pub fn main() void {
    var bar: Bar = undefined;
    bar.foo[0] = 0;
    no(bar, &bar);
}

const Bar = struct {
    foo: [10]u32,
};

fn no(a: Bar, b: *Bar) void {
    b.*.foo[0] = 5;
    std.debug.print("a.foo[0]: {}\n", .{a.foo[0]});
}

正如你所想的,最终打印的结果竟然是 a.foo[0]: 5a 变量理应被传值,但是却莫名奇妙地被传引用了,据官方文档所说:

Primitive types such as Integers and Floats passed as parameters are copied, and then the copy is available in the function body. This is called "passing by value". Copying a primitive type is essentially free and typically involves nothing more than setting a register.

Structs, unions, and arrays can sometimes be more efficiently passed as a reference, since a copy could be arbitrarily expensive depending on the size. When these types are passed as parameters, Zig may choose to copy and pass by value, or pass by reference, whichever way Zig decides will be faster. This is made possible, in part, by the fact that parameters are immutable.

可以看出,这里的 Bar.foo 被认为了是一个 immutable 的,造成了程序的不可预料的行为。

再者,在编写 zig 的过程中,我常常感觉 zig 能提供的抽象能力实在是过少。comptime 往往解决不了复杂情况的处理,这还导致我通常无法抽象出能对缓冲区和分配器的模型,导致这些往往需要手动处理,这也导致了 zig 的代码往往是冗长的,而且很难抽象出通用的模型。

最后,zig 不支持函数字面量,这无疑是非常痛苦的。可以说我在写 zig 的业务过程中绝大部分时间都浪费在了写一些非常无聊的控制流语句。

comptime

最后想稍微探讨以下 zig 最大的特性 comptime。我十分怀疑 zig 团队没有针对性的做出泛型、接口、多态之类的设计,是因为他们偶然间发现 comptime 可以或多或少的实现这些特性,因此就让 comptime 来负责这些事情了。过度的 OOP 和泛型滥用确实非常可怕。但是已经有 C++ 在排坑,且 Rust 和 Go 都各自给出了非常不错的解决方案,即使两者的目标不同。而 zig 就真的类似 C 语言,对于底层而言,确实不太常用各种抽象,往往一个 Struct 就足够,但是这并不代表没有这样的需求。即使 linux 的代码里也用 C 语言的 Struct 通过一些手段实现了部分 OOP 的特性,这是我无法理解的。

此外,comptime 的能力很强大,这是完全不容置疑的。但是,他只给你提供了 compile-time 的强大,对于 run-time,zig 仍旧是一个非常弱的语言,而且 zig 的 compile time 也并没有 Nim 那样 arbitrary。这就将 zig 的能力卡在了很微妙的地方。

再比如,zig 声称 macro 是万恶之源,他们不能忍受。但是 comptime 就何尝不是一种 macro ?在写 comptime 的时候,没有任何 IDE 和 LSP 可以帮助你,hover_document?抱歉,lsp does not support,即使使用 std.meta 限制了类型,我也需十分小心的处理类型,有人说把 run-time error 变为 compile-time error 还不知足吗?拜托,正常来说,就根本不会出现这种错误。我就是因为如此逃离的 rust,现在 zig 也要如此。既然 zig 是 duck-typing 的,那么我是否能说 comptime 写起来像 macro,用起来像 macro,那么它就是 macro 呢?

同理的,既然选择了 macro 式的处理,那么类型推断也瞬间变得毫无用处,编译器即使知道错误,也知道了这是 compile-time error,他又有什么帮助呢?在简单的代码中我可以知道错误的具体情况,但是对于复杂情况,已经可以预想到,既然 C 无法处理,那么 zig 怎么可能处理呢?

最后,zig 的 comptime 缺少了太多可知的信息,例如:

pub fn main() !void {
    const a = comptime foo();   // Ok
    const b = comptime bar();   // Ok
    const c = comptime bar2();  // Error
    std.debug.print("a: {}, b: {}, c: {}\n", .{ a, b, c });
}

fn foo() u8 {
    return 2;
}
fn bar() u8 {
    var buf: [1]u8 = undefined;
    buf[0] = foo();
    return buf[0];
}
fn bar2() u8 {
    var buf: [1]u8 = undefined;
    std.os.getrandom(buf[0..]) catch return 0;
    return buf[0];
}

foobarbar2 三者的函数签名一模一样,为什么后者就不能在编译期运行?再者说,为什么 zig 仅将 comptime 附着在函数调用上,而不是函数定义上?为什么 zig 不能做到编译期就察觉到函数定义中的错误,而是必须进行函数调用后才会报错?原因都在与,若极端点说, zig 的 comptime 比起说是一种新特性,不如说这就是 C++ Template 的另一种形式罢了(当然,两者的具体实现是截然不同的,zig 团队显然不傻)。C++ 的如下代码就是完全合情合理的:

int f(int a) { return a; }
constexpr int g(int a) { return f(a); } // warning on g++

总结

想到最开始,我是因为看到了别人说”如果你写起来像 rust,那么不如直接使用 rust”,我开始尝试 rust,但是在写 rust 的时候我发现我喜欢的是仅仅是能够很好的支持函数式编程,编译型的静态语言罢了。我不喜欢 rust 中层层包裹的指针,也不喜欢 rust 中满天飞的关键字,我写起来也根本不像 rust。于是我转而学习 zig 这门语言,但是在实际体验过后,现在我对 zig 的想法也 rust 相似。既然 rust 引入的生命周期与所有权大大提高了程序的复杂性和噪音,但是仍有许多人因为安全性而选择了 rust,那么 zig 引入 comptime 并且有人喜欢它,也不是什么稀奇古怪的事。

总的来说,zig 仍是一门写起来很舒服的语言,有着许多现代语法,zig 的社区也十分友好(可以说是我见过的最友好的一个社区),zig 团队虽然一意孤行,但是对于良好的 proposal 还是有着宽容性并且修复 bug 的速度也足够快,zig 这款语言还是很值得期待的。

Zig-style generics are not well-suited for most languages
Documentation - The Zig Programming Language
Zig May Pass Anything By Reference | Lobsters
Last modification:December 28, 2023