之前学 C 的时候写了一个 oh-my-github 的项目,一直没有测试,C 里面的单元测试框架比较多,不知道选哪个好,不如直接用 Zig 来做测试,正好也看到这篇文章 Testing and building C projects with Zig,感觉是个不错的选择。其实我听说 Zig 语言有半年了,不依赖 libc、 better C interop、robust 这几个特性我都挺感兴趣的。这篇文章就带领读者一窥 Zig 的真实面貌。

本文涉及代码可在这个 GitHub 仓库找到。

在和社区小伙伴共同努力下,尝试搭建了 Zig 语言中文社区,便于 Zig 中文用户交流分享心得:

Why Zig

Zig 官网上有一篇文章:有了C++、D和Rust,为什么还需要Zig?,这里面介绍了 Zig 与其他类似语言的区别,核心的一点就是:简单。

没有隐式控制流、没有隐式内存分配、统一的构建工具,用尽可能少的特例(没有宏与元编程),来支持编写复杂程序。

Hello World

这一小节使用 Zig 语言官网上的例子,来介绍 Zig 语言的基本语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 引入标准库
const std = @import("std");
const json = std.json;
// 定义多行的字符串
const payload =
    \\{
    \\    "vals": {
    \\        "testing": 1,
    \\        "production": 42
    \\    },
    \\    "uptime": 9999
    \\}
;
// 定义一个结构体类型,类型和 u8 等基础类型一样是一等成员,可以作为值来使用
const Config = struct {
    vals: struct { testing: u8, production: u8 },
    uptime: u64,
};
// 定义一个全局变量,不可变,同时定义了一个 block,label 为 x
// 由于 config 是个 const,因此该 block 会在编译期执行
const config = x: {
    // 使用 var 定义一个可变的局部变量
    var stream = json.TokenStream.init(payload);
    const res = json.parse(Config, &stream, .{});
    break :x res catch unreachable;
};

// main 函数的返回值为 !void,这里省略了具体错误类型,由编译器自动推导
pub fn main() !void {
    if (config.vals.production > 50) {
        @compileError("only up to 50 supported");
    }
    std.log.info("up={d}", .{config.uptime});
}

上面代码加了部分注释,算是对 Zig 语法的简单介绍,下面重点介绍 json 解析部分的语法:

  • json.parse 的函数签名是

    parse(comptime T: type, tokens: *TokenStream, options: ParseOptions) ParseError(T)!T
    
    • 第一个参数中的 comptime 关键字表示该参数是在编译期时执行,T 的类型是 type,表示类型值

    • 返回值 ParseError(T)!T 表示返回值可能出错,类似 Rust 中的 Result 类型

      • ParseError(T) 会在编译期执行,得出具体错误类型。Zig 采用 comptime 来实现泛型

  • json.parse(Config, &stream, .{}) 中的 .{} 表示匿名结构体,类型可以由编译器推导出来,结构体内没有对字段进行初始化,是由于 ParseOptions 中的字符都有默认值

  • break :x 表示退出 block 快,后面跟着返回值

  • res catch unreachable 表示取出正常情况下的值,相当于 Rust 中的 res.unwrap()

上面这个示例代码展示了 Zig 中一些特有的语法,最主要的是 comptime,它是 Zig 实现泛型的基础,类型是一等成员,可以进行函数调用或赋值。比如下面这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn LinkedList(comptime T: type) type {
    return struct {
        pub const Node = struct {
            // ?T 表示 Option 类型,值可能为 null
            prev: ?*Node,
            next: ?*Node,
            data: T,
        };

        first: ?*Node,
        last:  ?*Node,
        len:   usize,
    };
}

这里的 LinkedList 定义了一个泛型链表,类型在编译期确定。使用方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// test 定义了一个测试代码快,zig test main.zig 时会执行这里面的代码
test "linked list" {
    // Functions called at compile-time are memoized. This means you can
    // do this:
    // expect 可能返回错误,try 表示 catch |err| return err
    // 类似于 Rust 中的 result?
    try expect(LinkedList(i32) == LinkedList(i32));

    var list = LinkedList(i32) {
        .first = null,
        .last = null,
        .len = 0,
    };
    try expect(list.len == 0);

    // Since types are first class values you can instantiate the type
    // by assigning it to a variable:
    const ListOfInts = LinkedList(i32);
    try expect(ListOfInts == LinkedList(i32));

    var node = ListOfInts.Node {
        .prev = null,
        .next = null,
        .data = 1234,
    };
    var list2 = LinkedList(i32) {
        .first = &node,
        .last = &node,
        .len = 1,
    };

    // When using a pointer to a struct, fields can be accessed directly,
    // without explicitly dereferencing the pointer.
    // So you can do
    // option.? 相当于 rust 中的 option.unwrap(),取出其中的值
    try expect(list2.first.?.data == 1234);
    // instead of try expect(list2.first.?.*.data == 1234);
}

指针

上面小节带领读者领略了 Zig 的基本语法,这一小节介绍下 Zig 里面的另一个巧妙设计:指针。

指针是 C 语言最贴近机器的抽象,表示一个内存地址,如果是像 int32 这样的基本类型还好,编译器能够确定指向的长度,但如果指向的是个数组,单有一个指针是不能确定元素个数的,一般都需要额外保存一个 length 字段。Zig 改进了这一点,定义了以下三种指针类型:

  • *T 指向一个元素的指针,比如 *u8

  • [*]T 指向多个元素的指针,与 C 中指向数组的指针类似,没有长度信息

  • *[N]T 指向 N 个元素数组的指针,长度信息可以通过 array_ptr.len 获取。在需要时,这种指针可以自动转成上面的 [*]T

除了上述三种指针外,Zig 最常用的一种结构是 slice,形式为 []T 。可以看作是下面的结构体:

1
2
3
4
const slice = struct {
    ptr: [*]T,
    len: usize,
}
  • slice []T 与数组 [N]T 的区别在于前者的长度是在运行时确定,后再是在编译时确定

此外,为了方便与 C 交互,Zig 中还定义了 Sentinel-Terminated Pointers,语法为 [*:x]T ,表示最后以 x 结尾。Zig 字符串字面量就是 *const [N:0]u8 类型:

1
2
3
test "string literal" {
    try expect(@TypeOf("hello") == *const [5:0]u8);
}
  • [*:0]u8[*:0]const u8 分别等价于 C 中的 char *const char *

这几种类型的指针可以相互转化,这也是初学者容易刚开始写代码时,容易混淆的地方,下面示例给出了常用的转化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const std = @import("std");
const os = std.os;
const expect = std.testing.expect;

pub fn main() !void {
    // [N]T --> *[N]T or []T
    // *[N]T --> []T
    {
        var array = [_]u8{ 1, 2, 3 };
        try expect(@TypeOf(array) == [3]u8);

        var ptr_to_array = array[0..];
        try expect(@TypeOf(ptr_to_array) == *[3]u8);
        var slice: []u8 = array[0..];
        try expect(@TypeOf(slice) == []u8);

        var slice2: []u8 = ptr_to_array[0..];
        try expect(@TypeOf(slice2) == []u8);

        const str: [*:0]const u8 = "hello";
        const str2: [*]const u8 = str;
        _ = str2;
    }
    // [N]T --> [:x]T
    // [:x]T --> []T
    {
        var array = [_]u8{ 3, 2, 1, 0, 3, 2, 1, 0 };

        var runtime_length: usize = 3;
        const terminated_slice = array[0..runtime_length :0];
        try expect(@TypeOf(terminated_slice) == [:0]u8);

        // thread 11571310 panic: sentinel mismatch
        // const slice2 = array[0..runtime_length :1];
        // _ = slice2;

        // error: expected type 'void', found '[:1]u8'
        // _ = array[0..runtime_length :1];

        const slice = array[0..terminated_slice.len];
        try expect(@TypeOf(slice) == []u8);
    }
}

C interop

Zig 命令行工具集成了 clang,所以 zig 不仅仅可以用来编译 Zig 代码,还可以用来编译 C/C++ 代码, zig cc 在社区内就非常流行,比如下面这篇文章:

得益于 zig 不依赖 libc 的特性,zig 可以非常方便的用来进行交叉编译。

除了命令行工具,zig 在语言层面也对 C 提供了很好的支持,可以直接引用 C 的代码:

1
2
3
4
5
6
7
8
const std = @import("std");

pub extern "c" fn printf(format: [*:0]const u8, ...) c_int;

pub fn main() !void {
    _ = printf("Hello, World!\n");
    _ = std.c.printf("Hello, Zig!\n");
}

Zig 还集成了 pkg-config,方便链接 C 社区内丰富的类库,在代码中直接用 @cImport 导入即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

// zig build-exe cimport.zig -lc $(pkg-config --libs raylib) $(pkc-config --cflags raylib)
const ray = @cImport({
    @cInclude("raylib.h");
});

pub fn main() void {
    const screenWidth = 800;
    const screenHeight = 450;

    ray.InitWindow(screenWidth, screenHeight, "raylib [core] example - basic window");
    defer ray.CloseWindow();

    ray.SetTargetFPS(60);

    while (!ray.WindowShouldClose()) {
        ray.BeginDrawing();
        defer ray.EndDrawing();

        ray.ClearBackground(ray.RAYWHITE);
        ray.DrawText("Hello, World!", 190, 200, 20, ray.LIGHTGRAY);
    }
}
https://img.alicdn.com/imgextra/i2/581166664/O1CN01GjRZeQ1z6A9NEn5o7_!!581166664.png
raylib demo 运行示意图

此外,Zig 提供了 zig translate-c 命令来将 C 代码转成 Zig 代码,遇到调用 C 库困难时,可以参考这个命令。对于 C 中的指针 Zig 用 [*c]T 表示,称为 C 指针,与 Zig 中的指针支持同样的操作,一般会把 C 指针转成 Optional Pointer ?*T 来用, null 就表示 C 指针为空。

为了让 C 能够调用 Zig,有一点需要注意:

struct/enum/union 默认没有内存结构保证,需要用 externpacket 关键字声明成符合 C ABI 内存格式的,

默认没有内存结构保证的主要原因是方便 Zig 进行优化,比如:

在 Rust 中 struct 默认也是不保证内存结构的,原因和 zig 类似。这里用 C 中的一小段代码来举例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct {
  int64_t a;  // 8
  int16_t b;  // 2
  char c;     // 1
  // padding 5
} a = {.a=1,.b=2,.c='a'};
printf("a %lu\n", sizeof(a));

struct {
  int16_t b; // 2
  // padding 6
  int64_t a; // 8
  char c;    // 1
  // padding 7
} b = { .a=1, .b=2, .c='a' };
printf("b %lu\n", sizeof(b));
// 在 64 位 8 字节对齐的机器上会依次输出 16,24

下面给出一示例,将 Zig 导出成 lib,供 C 调用(Zig 目前的版本不会输出头文件,需要自己手写,参考 #6753):

1
2
3
4
5
6
// cat mathtest.zig
export fn add(a: i32, b: i32) i32 {
  return a + b;
}
// 编译成静态库,或加 -dynamic 选项编译成动态库
// zig build-lib mathtest.zig
1
2
3
4
5
6
7
8
#include "mathtest.h"
#include <stdio.h>

int main(int argc, char **argv) {
  int32_t result = add(42, 1337);
  printf("%d\n", result);
  return 0;
}

最后用下面的构建脚本来编译:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const Builder = @import("std").build.Builder;

pub fn build(b: *Builder) void {
  const lib = b.addSharedLibrary("mathtest", "mathtest.zig", b.version(1, 0, 0));

  const exe = b.addExecutable("test", null);
  exe.addCSourceFile("test.c", &[_][]const u8{"-std=c99"});
  exe.linkLibrary(lib);
  exe.linkSystemLibrary("c");

  b.default_step.dependOn(&exe.step);

  const run_cmd = exe.run();

  const test_step = b.step("test", "Test the program");
  test_step.dependOn(&run_cmd.step);
}

// $ zig build test
// 1379

内存控制

Rust 采用严格的所有权支持保证内存安全,Zig 如果解决这个问题呢?答案是:Allocator,首先看一示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const std = @import("std");
const expect = std.testing.expect;

test "allocation" {
    const allocator = std.heap.page_allocator;

    const memory = try allocator.alloc(u8, 100);
    defer allocator.free(memory);

    try expect(memory.len == 100);
    try expect(@TypeOf(memory) == []u8);
}

page_allocator 是 Zig 提供的内存分配器之一,可以看到用法与 C 中类似,使用完分配的内存后,需要手动 free ,那么 Zig 如何保证内存安全呢?答案是 GeneralPurposeAllocator ,这种分配器会阻止 double-free、use-after-free、leaks 等的发生,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
test "GPA" {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer {
        const leaked = gpa.deinit();
        if (leaked) expect(false) catch @panic("TEST FAIL"); //fail test; can't try in defer as defer is executed after we return
    }

    const bytes = try allocator.alloc(u8, 100);
    defer allocator.free(bytes);
}

Zig 就是通过提供多种分配器来满足不同场景的需求,既没有 C 那么危险,也没有 Rust 的那种严格,算是在 C 与 Rust 中取了一个平衡。关于如何选择分配器,可以参考:

周边工具

构建

zig 自带里命令行工具就够用了,不需要 make 之类的额外工具

  • zig build 构建,描述文件是 build.zig

  • zig init-exe 初始化应用项目

  • zig init-lib 初始化类库项目

但是还没有包管理器,社区内目前有 gyrozigmod 这两个。更多可参考:

开发

目前有 zls 这个官方支持的 language server,主流编辑器都支持,补全、定义跳转都支持;此外,有 zig fmt 进行代码格式化。

文档

严重不足,写代码需要参考类库源码。幸运的是 std 的质量比较高,容易相对阅读。不过由于现在 Zig 还在快速开放中,std 中的 API 时不时会有 breaking change,需要定期更新到 master 上的最新版。

1
2
3
./main.zig:24:9: error: no member named 'read' in struct 'std.fs.file.File.OpenFlags'
        .read = true,
        ^

上面的错误即表示新版本去掉了这个字段。

Zen

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ zig zen

 * Communicate intent precisely.
 * Edge cases matter.
 * Favor reading code over writing code.
 * Only one obvious way to do things.
 * Runtime crashes are better than bugs.
 * Compile errors are better than runtime crashes.
 * Incremental improvements.
 * Avoid local maximums.
 * Reduce the amount one must remember.
 * Focus on code rather than style.
 * Resource allocation may fail; resource deallocation must succeed.
 * Memory is a resource.
 * Together we serve the users.

总结

本文对 Zig 语言进行了初步介绍,没有设计到多线程、async 等内容,这些内容相对高级,后面等有更多经验时再来介绍吧。

Zig 作为一门现代语言,很多特性都是从 C 的弊端出发设计的,因此规避了很多问题,光是这一点就很有价值;基于 comptime 的泛型实现更是业界首创。根据目前最新的 roadmap 来看,最早会在 2025 年发布 1.0,这个过程可能有些漫长,但这并不妨碍爱好者进行尝试!第一步,先从加入社区开始吧!