I’ve written some practice projects when I learned C before, but I haven’t tested them. there are more unit testing frameworks in C, so I don’t know which one to choose, so I might as well just use Zig to do the testing. I just saw this article Testing and building C projects with Zig, and I feel it is a good choice. I’ve heard about Zig for half a year, and I’m interested in the libc-independent, better C interop, and robust features.

The code involved in this article can be found at this GitHub repository.

Hello World

First, take a look at the examples on the Zig official language site to get a first-hand feel for what the language looks like.

 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
// Importing a standard library
const std = @import("std");
const json = std.json;
// Define a multi-line string
const payload =
    \\{
    \\    "vals": {
    \\        "testing": 1,
    \\        "production": 42
    \\    },
    \\    "uptime": 9999
    \\}
;
// Define a structure type, the type is a first class member like the base types such as u8, and can be used as a value
const Config = struct {
    vals: struct { testing: u8, production: u8 },
    uptime: u64,
};
// Define a global variable, immutable, and a block with label x
// Since config is a const, this block will be executed at compile time
const config = x: {
    // Define a mutable local variable using var
    var stream = json.TokenStream.init(payload);
    const res = json.parse(Config, &stream, .{});
    break :x res catch unreachable;
};

// The return value of the main function is !void, where the specific error type is omitted and automatically deduced by the compiler
pub fn main() !void {
    if (config.vals.production > 50) {
        @compileError("only up to 50 supported");
    }
    std.log.info("up={d}", .{config.uptime});
}

The above code is partially commented as a brief introduction to Zig syntax, and the following section focuses on the syntax of the json parsing part.

  • Signature of the function json.parse.

    1
    
    parse(comptime T: type, tokens: *TokenStream, options: ParseOptions) ParseError(T)!T
    
    • The comptime keyword in the first argument indicates that the argument is executed at compile time, and the type of T is type, indicating a type value.
    • T indicates that the return value may be wrong, similar to the Result type in Rust.
      • ParseError(T) is executed at compile time to derive the specific error type. zig uses comptime to implement the generic type.
  • json.parse(Config, &stream, . {}) in . {} denotes an anonymous struct whose type can be derived by the compiler. The fields are not initialized within the struct because the characters in ParseOptions have default values.

  • break :x means exit block fast, followed by return value.

  • res catch unreachable means take out the value of the normal case, equivalent to res.unwrap() in Rust.

The sample code above shows some of the syntax specific to Zig, most notably comptime, which is the basis for Zig’s implementation of generic types, where types are first-class members that can make function calls or assignments. Take for example the following example.

 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 means Option type, value may be null
            prev: ?*Node,
            next: ?*Node,
            data: T,
        };

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

Here the LinkedList defines a generic linked list, the type of which is determined at compile time. It is used in the following way.

 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 defines a test code fast, which will be executed when zig test main.zig.
test "linked list" {
    // Functions called at compile-time are memoized. This means you can
    // do this:
    // expect may return an error, try means catch |err| return err
    // Similar to result in Rust?
    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.? is the equivalent of option.unwrap() in rust, taking out the value of
    try expect(list2.first.?.data == 1234);
    // instead of try expect(list2.first.?.*.data == 1234);
}

Pointers

The above section took the reader through the basic syntax of Zig. This section introduces another clever design in Zig: pointers.

A pointer is the closest machine abstraction in C. It represents a memory address, which is fine if it is a basic type like int32, where the compiler can determine the length of the pointing, but if it is an array, a pointer alone cannot determine the number of elements and usually requires an additional length field to be saved.

Zig improves on this by defining the following three pointer types.

  • *T A pointer to an element, e.g. *u8
  • [*]T pointers to multiple elements, similar to pointers to arrays in C, with no length information
  • *[N]T pointer to an array of N elements, length information can be obtained from array_ptr.len

In addition to the three pointers mentioned above, one of the most common structures used by Zig is slice, of the form []T. It can be seen as the following structure.

1
2
3
4
const slice = struct {
    ptr: [*]T,
    len: usize,
}

The difference between slice []T and array [N]T is that the length of the former is determined at runtime, while the latter is determined at compile time.

In addition, to facilitate interaction with C, Sentinel-Terminated Pointers is defined in Zig with the syntax [*:x] T, which means that it ends with x. Zig string literals are of type *const [N:0]u8.

1
2
3
test "string literal" {
    try expect(@TypeOf("hello") == *const [5:0]u8);
}

[*:0]u8 and [*:0]const u8 can be equated to string in C.

These types of pointers can be converted to each other, and this is where beginners tend to get confused when they first start writing code, and the following examples give the common conversions.

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

The Zig command line tool integrates with clang, so zig can be used to compile not only Zig code, but also C/C++ code, and zig cc is very popular in the community, as in the following article.

Zig Makes Rust Cross-compilation Just Work · Um, actually…

Thanks to the libc-independent nature of zig, zig can be used to cross-compile very easily.

In addition to command-line tools, zig also provides good support for C at the language level, allowing direct reference to C code.

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 also integrates pkg-config to facilitate linking to the rich library in the C community, which can be imported directly in the code with @cImport.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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);
    }
}

raylib demo diagram

In addition, Zig provides the zig translate-c command to convert C code to Zig code, which can be referred to when you encounter difficulties in calling C libraries. For pointers in C, Zig uses [*c]T, which is called C pointer, and supports the same operation as pointers in Zig, which generally translates C pointer to Optional Pointer ? *T to use, null means that the C pointer is null.

In order for C to be able to call Zig, one thing needs to be noted.

struct/enum/union has no memory structure guarantees by default and needs to be declared to conform to the C ABI memory format using the extern or packet keywords.

The main reason for not having memory structure guarantees by default is to facilitate Zig to perform optimizations such as

In Rust structs are also not guaranteed memory structures by default, for reasons similar to zig. Here’s a small example from 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));
// On a 64-bit 8-byte aligned machine will output 16, 24 in that order

Here is an example of exporting Zig as a lib for C to call (the current version of Zig does not export header files, you need to write them by hand, see #6753).

1
2
3
4
5
6
// cat mathtest.zig
export fn add(a: i32, b: i32) i32 {
  return a + b;
}
// Compile as a static library, or add the -dynamic option to compile as a dynamic library
// 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;
}

Finally, compile it with the following build script.

 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

Memory Control

Rust uses strict ownership support to ensure memory safety, how does Zig solve this problem? The answer is: Allocator, first look at an example.

 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 is one of the memory allocators provided by Zig. You can see that the usage is similar to that in C. After using the allocated memory, you need to free manually, so how does Zig ensure memory safety? The answer is GeneralPurposeAllocator, this allocator will prevent double-free, use-after-free, leaks, etc., for example.

 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 is a balance between C and Rust by providing a variety of allocators to meet the needs of different scenarios, which is neither as dangerous as C nor as strict as Rust. For more information on how to choose an allocator, see: Chapter 2 - Standard Patterns | ziglearn.org

Peripheral tools

Build

The command line tools in zig are sufficient, no need for additional tools like make

  • zig build build, description file is build.zig
  • zig init-exe initializes the application project
  • zig init-lib initializes the class library project

But there is no package manager yet, there are currently two in the community, gyro and zigmod. More can be found at https://ziglearn.org/chapter-3/#packages

Development

Currently there is zls, an officially supported language server, supported by all major editors, and zig fmt for code formatting.

Documentation

There is a serious lack of documentation, and you need to refer to the library source code to write code. Fortunately std is of high quality, relatively easy to read, and zls can jump automatically.

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.

Summary

This article is a preliminary introduction to the Zig language, not designed to multi-threading, async and other content, which is relatively advanced, so we will introduce it later when we have more experience.

Zig expects 2025 Release 1.0

As a modern language, many features of Zig are designed from the disadvantages of C, so it avoids many problems, which is very valuable. According to the current latest roadmap, 1.0 will be released in 2025 at the earliest, which may be a long process, but that doesn’t stop enthusiasts from trying! First step, start by joining the community!