更好的 C 语言:Zig 初体验

Menu

久闻 Zig 语言大名,作为一众底层语言的有力竞争者,Zig 被常常认为是 better C。Zig 自己也说,“Zig 与 C 竞争,而不是依赖于它”。前天用 Zig 写了一个简单的命令行跨平台贪吃蛇游戏,也算是体验了一下 Zig 的有趣功能。

Zig 核心

照例先来写个 hello world 然后说一下 Zig 官方的简介:

const std = @import("std");

pub fn main() !void {
    std.debug.print("hello, world\n", .{});
}

Zig 是一种简单的语言。
专注于调试你的应用程序,而不是调试你的编程语言知识。

  • 没有隐式控制流。
  • 没有隐式内存分配。
  • 没有预处理器,没有宏。

编译期代码执行
基于编译期代码执行和惰性求值的全新元编程方法。

  • 编译期调用任意函数。
  • 在没有运行时开销的情况下,将类型作为值进行操作。
  • 编译期模拟目标架构。

实际上在我的体验来看,Zig 这几个特色确实非常突出,可以说是这个语言最重要的特色甚至是基石了。

没有隐式控制流

其实我觉得这没什么特别大的必要,毕竟重载运算符还是一个很方便的语言特性的,但是 Zig 排除了它。

Zig 抛弃隐式控制流是为了可读性和可维护性。不过我觉得这更可能是一种 C with classes[1] 心理阴影(?

一个不负责任的 C++C with classes 程序员有可能会写出这样的代码——

void foo() {
    auto some_resource = bar();
    baz(&some_resource);
    some_resource.close();
}

这看上去一点问题都没有,对吧。创建资源,使用资源,释放资源。这如果写成等价的的 C 语言是一点问题都没有的,可惜这是 C++。C++是有异常的,你在 baz 的时候说不定在调用栈的深处从哪抛出了一个异常,于是你的资源就没有释放——最常见的是内存泄漏,更坏一些的可能是网络资源或者文件资源,于是你的程序直接崩溃了,甚至丢失用户数据。

拿 C with classes 的思路,抛弃 RAII[2],不让人用异常却忽视 STL 中有大量异常。隐式控制流用好了非常好用,但是用得烂的话就是对可读性和可维护性的损失。而 Zig 是一门追求简洁的语言,于是它抛弃了它。

如果 Zig 代码看起来不像是在调用一个函数,那么它就不是。这意味着你可以确定下面的代码只会先调用 foo(),然后一定会[3]调用 bar(),不需要知道任何元素的类型,这一点也是可以保证的:

var a = b + c.d;
foo();
bar();

没有隐式内存分配

真是为嵌入式着想啊(赞美)

对于许多桌面应用程序——也就是 Rust, C++, Go, Java 发光发热的地方,操作系统肯定会提供好的内存分配器,没有隐式内存分配完全就是增加复杂性,所以这个设计完全是为了 zig 一次编写到处运行的宏大理想而量身定制

zig 语言完全没有任何隐式内存分配。包括标准库——除了那些本身就是用来做堆分配器的 API。所以只要你不用堆分配器,你的代码就不会有堆分配。这意味着你完全可以在裸金属环境下使用几乎全部标准库,而 C++ 和 Rust 都要排除掉依赖内存分配器的实用标准库。

举个例子,当你想要一个双向链表的时候,Zig 提供的是这样的 API:

pub fn append(list: *Self, new_node: *Node) void
// Insert a new node at the end of the list.

pub fn pop(list: *Self) ?*Node
// Remove and return the last node in the list.

pub fn popFirst(list: *Self) ?*Node
// Remove and return the first node in the list.

pub fn prepend(list: *Self, new_node: *Node) void
// Insert a new node at the beginning of the list.

注意到全部需要插入 *Node 类型!也就是说,Zig 把对 Node 进行内存分配的任务交给了你,你可以自由安排将它分配在什么位置,无论是堆上还是 buffer 上。Zig 给了程序员最大的自由来调配内存分配——这在底层开发、嵌入式开发中非常重要。

当然这使得 Zig 程序需要到处传递 Allocator,使得在比较偏应用层的代码里比较难绷,不过 Zig 本来就不是设计出来干这种事情的——你也不会用 C 语言去写应用吧,自讨苦吃的事情还是少干(

超强的 C 交互性

说到嵌入式,许多嵌入式的机器,厂商只给了 C 库,如果是别的语言(除了 C++ )的话,就不得不通过 C ABI 专门定义自己的函数来互操作了,到时候还得交叉编译,非常麻烦。所以就算 Rust 非常安全,也没什么人拿来用作嵌入式,大家还是老老实实写 C。

而 Zig 就是破局者,因为 Zig 直接内置了 C 语言支持。和 C++ 不同的是,Zig 的 C 语言支持堪称十分明智,它提供了某种自动重写,你可以直接使用 @cImport 导入 C 语言的头文件,直接使用 C 语言的函数,而且还有完美的类型支持。

比如我写的贪吃小游戏,为了直接读取键盘操作,需要用到操作系统给的库。Zig 没有对应的 std 支持怎么办?没关系,直接导入 windows.h

const std = @import("std");
const c_windows = @cImport({
    @cInclude("windows.h");
});

pub const GetchError = error{
    CannotGetStdHandle,
    ReadNotOk,
};

pub fn getch_win() GetchError!i32 {
    const stdin = std.os.windows.GetStdHandle(std.os.windows.STD_INPUT_HANDLE) catch {
        return GetchError.CannotGetStdHandle;
    };
    var input_record: c_windows.INPUT_RECORD = undefined;
    var _num_events_read: std.os.windows.DWORD = undefined;
    while (true) {
        const ok = c_windows.ReadConsoleInputW(stdin, &input_record, 1, &_num_events_read);
        if (ok == 0) {
            return GetchError.ReadNotOk;
        }
        if (input_record.EventType != c_windows.KEY_EVENT) {
            continue;
        }
        if (input_record.Event.KeyEvent.bKeyDown != 0) {
            continue;
        }
        return input_record.Event.KeyEvent.wVirtualKeyCode;
    }
}

是的,只需要一个 @cImportwindows.h 里的所有函数就能为 zig 所用。 Zig 会自动将导入的 C 库重写成 Zig 的语法来提供合适的类型定义和语法高亮,完全和你用 zig 原生库一样简单——除了需要 C 风格的错误处理和字符串。真是应了深入了解 ⚡ Zig 编程语言那句话,Zig 比 C 更擅长使用 C 库。

相比于 C++ 直接在语法上兼容 C 最后被 C 的历史遗留问题坑死,Zig 的做法简直明智多了,直接通过原生支持交叉编译互相导入来解决问题,一点历史包袱不负同时还兼顾了兼容性和迁移能力。

另外,zig 编译器还可以直接编译和运行 C/C++代码。这更是为 zig 提供了逐步替换 C 项目的可能。想想看,想要为你的 C 项目引入 Zig 只需要把编译器换成 Zig,然后添加新的 Zig 文件愉快写代码就可以了,那有什么理由不试试呢?

$ zig run prime_fast_cpp.cpp --library c++
start calculating
1227 ms
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541,

$ zig run prime_fast.c --library c
start calculating
1411 ms
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541,

$ zig run prime_fast.zig
start calculating
done, executed in 1274900500 nanoseconds
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541,
上述代码是我用 C,C++,Zig 分别写的快速筛素数。算法相同。

更好的错误处理

和 Rust 一样,zig 抛弃了 null 的空处理和 exception 的错误处理。Null 的事情不用我说,大家应该都知道 Null References: The Billion Dollar Mistake

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

Zig 没有对 Rust 那样 enum 的原生支持(大概是因为它是 better C 而不是 C++),而是引入了两种类型,?TError!T 分别代表 Option<T>Result<T, Error>

与 C 完全不一样的是,Zig 的普通指针 *T 不能为 null,只有 optional pointer ?*T 可以为 null。这样以来,人们就不需要担心一个 *T 被意外的传入 null 而不停地编写重复的 if (xxx == null) return;

Zig 的错误处理也非常有意思。Zig 的错误类型能且仅能是一个 enum,我对此只能说有好有坏吧,坏处是没法返回 struct 来得到更详细的错误信息,好处大概是其实它就是 C 语言错误码的对应——毕竟 Zig 一心设计为一个更好的 C 而不是 C++(不然就和 Rust 生态位重了)我们最常见到 C 语言中使用

int foo(Foo* res, Args args);

来表示一个可能出错的函数,然后查表每个错误码代表着什么。然后又有 Linux 风格和 Windows 风格,有的返回 0 代表成功,有的返回 1 代表成功,还有的返回一串 bit,每一位代表哪一个参数成功了……而 Zig 就不需要这一堆混乱的东西,错误类型已经在 enum 的名字里告诉你了。

pub const GetchError = error{
    CannotGetStdHandle,
    ReadNotOk,
};

pub fn getch_win() GetchError!i32;

更好的是,Zig 强制你处理这些错误。拿上面这段代码为例,你只有三种选择:

_ = getch_win(); // 只执行,我管你出错不出错,当然也没法使用结果了
const v = try getch_win(); // 如果出错,就继续往上抛
const v = getch_win() catch |err| {
	// handle error ...
	// 处理错误
};

和 Rust 一样,哪里使用就在哪里处理错误,而不是 C++ 风格的 expection,会打穿整个调用栈,而 try ... catch ... 还可能抓到不属于自己想要的错误。

另外,Zig 还原生自带 anyerror 功能。使用 Rust 的人应该都有印象,有时候其实根本不关心发生了什么错误,上头一个日志就完事了,但是还是得乖乖写 Result<T, E> 的类型,于是催生了 anyhow 库,用 anyhow::Result<T> 来表示随便什么错误我不关心。但是 Zig 就很好的解决了这个问题。同样的,如果你只是单纯的懒,也可以直接用 anyerror 的特性不定义 error 的 enum 就直接返回一个错误的名字。

defer 关键字

你以为只有 go 语言有 defer?NO,NO,NO,zig 也有。

C++ 和 Rust 选择用 RAII 来解决哪怕是经验老到的 C 程序员也可能忘记释放内存的问题。而 Zig 就不一样,它选择了和 Go 一样的做法: defer (和 errdefer

Zig 的思路是:在哪里分配就在哪里释放。这也是 Allocator 到处传的原因,在哪里创建资源,就在那里 defer destory 资源。就像这样(来自官方的示例)

pub fn main() !void {
    const soundio = c.soundio_create();
    defer c.soundio_destroy(soundio);

    try sio_err(c.soundio_connect(soundio));

    c.soundio_flush_events(soundio);

    const default_output_index = c.soundio_default_output_device_index(soundio);
    if (default_output_index < 0) return error.NoOutputDeviceFound;

    const device = c.soundio_get_output_device(soundio, default_output_index) orelse return error.OutOfMemory;
    defer c.soundio_device_unref(device);

    std.debug.print("Output device: {s}\n", .{device.*.name});

    const outstream = c.soundio_outstream_create(device) orelse return error.OutOfMemory;
    defer c.soundio_outstream_destroy(outstream);

    outstream.*.format = c.SoundIoFormatFloat32NE;
    outstream.*.write_callback = write_callback;

    try sio_err(c.soundio_outstream_open(outstream));

    try sio_err(c.soundio_outstream_start(outstream));

    while (true) c.soundio_wait_events(soundio);
}

注意到,defer 总是和资源的创建成对出现!zig 通过 defer 的设计让资源的释放变得一目了然,更不容易忘记释放。对比之下,C 经常需要使用大量的 goto 和宏,或者手动压栈来完成这样的操作,由于语法糖过于的少,导致语言丑陋不堪。

当然,这也是不使用隐式控制流的思想的体现。这大概也是为什么 zig 没有选择 RAII

编译期执行任意代码,超酷的

如果说 Zig 给我留下的最大的最大的印象一定是编译期执行任意代码了。甚至 zig 的泛型(伪)都是用 compiletime 搞的

举个例子,让我们看看 zig 的标准库链表如何构造:

pub fn DoublyLinkedList(comptime T: type) type {
    return struct {
        const Self = @This();

        /// Node inside the linked list wrapping the actual data.
        pub const Node = struct {
            prev: ?*Node = null,
            next: ?*Node = null,
            data: T,
        };

        first: ?*Node = null,
        last: ?*Node = null,
        len: usize = 0,

        ... // 后面是定义方法了

是的,这个链表直接以一个类型为参数,传入了一个函数里面,然后捏皮球一样捏出了对应类型的链表——神奇吧。

这样的泛型当然已经算是大家都有的东西了。但是 zig 的编译期执行可不止这点。举个例子,为了实现跨平台的贪吃蛇,为了捕获键盘按下事件,不同的系统需要引入不同的库,做不同的预处理。如果是 C 的话,那就得定义一个编译期符号,然后用 #ifdef 来选择性编译代码,显得非常突出,非常丑陋。而 Zig,我是这样做的:

pub fn main() anyerror!void {
    switch (builtin.os.tag) {
        .windows => {
            // set utf-8
            _ = std.os.windows.kernel32.SetConsoleOutputCP(65001);
        },
        .linux => {
            const c = @cImport(@cInclude("ncurses.h"));
            const screen = c.initscr();
            defer _ = c.endwin();
            _ = c.raw();
            _ = c.keypad(screen, true);
            _ = c.noecho();
        },
        else => {
            std.log.err("Unsupported platform", .{});
            return;
        },
    }

	// ... 程序代码
}

std.os.windows.kernel32.SetConsoleOutputCP 这个函数在 Linux 上根本不存在? ncurses.h 在 Windows 上没有?完全没关系!因为 builtin.os.tag 是编译期被 build.zig 填充的 compiletime struct,所以对应的 switch 语句自然也是 compiletime 的,编译器直接走到系统对应的分支,编译,链接,一点都不需要在乎其它分支。zig 将编译期处理的代码和运行时的代码统一了起来,完全使用相同的格式,这使得会了 zig 也就同时会了 compiletime zig,而不像 C++ 一样编译期模板和普通的 C++代码完全是两个天地。

这使得 Zig 开发跨平台应用极其自然,非常简单,编译命令也保持完全一致,相比于虽然号称“一次编写处处运行”,实而脱离了平台的库以后寸步难行的 C 代码反而更容易写出跨平台的应用。

当然这也有个意外的好处,zig 可以作为一个编译工具而使用,这在 zig 自己编译 zig 项目的时候尤其好用,build.zig 和你的 zig 代码是一个语法,一点也不用担心额外学一门标记法(目移 看向 CMake)

当然,还有需要打磨的地方

如果说 zig 有什么急需打磨的地方,或许是它到现在都还没有第一个正式版吧。zig 的标准库接口在过去几年里变了不少,以至于GPT 没法生成复制粘贴就能跑的代码。同时令人担忧的是,zig 语言的作者非常固执己见,比如他希望替换 LLVM 后端,就花了很长时间去弄它,哪怕社区很多人劝说也没用。zig 到现在拖了很久都是 0.13 版本,或许也只有勇于尝试的项目敢用它了。

zig 还有一个让我很不满的地方是它不支持语法糖来创建闭包。当然你能用匿名结构体来创建闭包,但是主要是作者一人的意见——他不喜欢函数式的语法——而否决了闭包的提案。

尾声

如果说 C 语言是一坨汇编的封装,那 Zig 才是我理想中的,应该在 C 语言位置上的语言。简洁、底层、现代,既能一目了然地看出对应的汇编,又不至于和 C 一样过于简陋缺少语法糖而必须写出非常丑陋的代码。

它或许不太适合应用开发,毕竟太偏向底层,但它一定能带来比 C 更好的体验。如果下次遇到需要用 C 的项目,不妨试试 zig 吧。


本文以 CC BY-NC-SA 4.0 发布。


  1. 这里指代不好好学 C++ 拿 C 语言的经验写代码的傻瓜 ↩︎

  2. 不要问我为什么要抛弃 RAII 这样的好东西。这就是活跃于编程话题下 C with classes 人,他们坚信 C with classes 就能满足 99% 的需求 ↩︎

  3. 当然 foo() 有可能死循环或者在某处被你使用 panic 或者 std.process.exit(1) 之类的强制结束进程,导致 bar() 无法被执行。但是任何一个图灵完备的编程语言都没办法完全预防前者,这是停机问题。 ↩︎

Previous  Next

Loading...