Newshot
Account

Why Zig over C for Systems Programming

Original text(via Hacker News)

After I wrote Why Zig Over Rust for Systems Programming, the obvious next question was C. The Rust question was about safety and how much ceremony I was willing to accept for it. The C question is different, because Zig and C want the same things. Both are small languages without a Garbage Collector and both stay close to what the machine does. So this post is not about leaving C for something completely different. It is about the parts of C that cost me time and how Zig deals with them. All the code was compiled on a Mac with Zig 0.17 and clang.

What C gets right Link to heading

C is the language of UNIX. The kernels, the C libraries and most of the system libraries that every other language calls in the end are written in C and the man pages describe them with C prototypes and C examples. A C program from the 1990s still compiles today and there is a C compiler for almost every CPU that was ever made. The language is small enough that one book, The C Programming Language by Kernighan and Ritchie, covers all of it. Zig itself does not try to replace any of that. It talks to C libraries directly and it uses the C ABI when it needs to. I am not writing this post because I think C is bad; C is my favorite programming language! I am writing it because I do not want to write new code in C any more.

Cleanup without goto Link to heading

The C code that I like the least is a function that acquires several resources and has to release them in reverse order when something fails halfway. The usual answer, which is also the one the Linux kernel uses, is goto:


        struct user *user_new(const char *name, size_t n_groups)
{
    struct user *u = malloc(sizeof *u);
    if (u == NULL)
        goto fail;
    u->name = strdup(name);
    if (u->name == NULL)
        goto fail_user;
    u->groups = calloc(n_groups, sizeof *u->groups);
    if (u->groups == NULL)
        goto fail_name;
    return u;

fail_name:
    free(u->name);
fail_user:
    free(u);
fail:
    return NULL;
}
      

It works, but the cleanup is far away from the allocation and the order of the labels has to match the order of the allocations by hand. The same function in Zig looks like this:


        fn newUser(allocator: std.mem.Allocator, name: []const u8, n_groups: usize) !*User {
    const u = try allocator.create(User);
    errdefer allocator.destroy(u);
    u.name = try allocator.dupe(u8, name);
    errdefer allocator.free(u.name);
    u.groups = try allocator.alloc(u32, n_groups);
    return u;
}
      

Each errdefer sits right below the allocation it undoes and runs only if the function returns an error. Zig runs them in reverse order, so there are no labels to keep in order. What I like even more is that I can test every failure path. The std.testing.FailingAllocator type fails on the first, second or third allocation, depending on the value of fail_index. The testing allocator below it reports any memory that is not freed:


        test "newUser does not leak when an allocation fails" {
    for (0..3) |i| {
        var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = i });
        try std.testing.expectError(error.OutOfMemory, newUser(failing.allocator(), "mihalis", 4));
    }
}
      

        $ zig test user.zig
1/1 user.test.newUser does not leak when an allocation fails...OK
All 1 tests passed.
      

Testing the same three paths in the C version needs a wrapper around malloc() or an external tool.

A slice knows its length Link to heading

In C an array that goes to a function becomes a pointer and the length travels separately, if at all. The following C function has an off-by-one error, <= instead of <:


        int sum(const int *a, size_t n)
{
    int total = 0;
    for (size_t i = 0; i <= n; i++)
        total += a[i];
    return total;
}
      

With an array of {1, 2, 3} it prints sum = 7 on my Mac and exits with 0. The extra 1 came from whatever was in memory after the array. Neither clang with -Wall -Wextra nor the program itself complains. In Zig a slice carries its length, so the same mistake stops the program in the debug and safe modes:


        fn sum(a: []const i32) i32 {
    var total: i32 = 0;
    for (0..a.len + 1) |i| total += a[i];
    return total;
}
      

        $ zig run sum.zig
thread 1278459 panic: index out of bounds: index 3, len 3
sum.zig:5:38: 0x1023c5b4b in sum (sum)
    for (0..a.len + 1) |i| total += a[i];
                                     ^
      

In real Zig code you would write for (a) |x| and the mistake cannot happen at all. Strings work the same way. A Zig string is a slice of bytes with a length, so there is no strlen() call that walks the memory looking for a 0 byte and no buffer that is one byte short because somebody forgot the terminator.

Errors you cannot ignore Link to heading

In C a function reports an error by returning -1 or NULL and setting errno, or by returning an error code, or in some other way that the man page describes. Nothing forces you to check. A lot of C code calls close() and fclose() without looking at the return value and the compiler is fine with it. In Zig an error is part of the return type and the compiler refuses to compile a call whose error you ignore. You have to use try, catch or if. The failure path is in the source code, right next to the call, which is also what I liked about Go.

comptime instead of the preprocessor Link to heading

The C preprocessor works with text and knows nothing about C. The classic #define MAX(a, b) ((a) > (b) ? (a) : (b)) evaluates one of its arguments twice and a big C project usually has a lot of #ifdef lines for every Operating System it supports. In Zig, code that runs at compile time is ordinary Zig code. Instead of #ifdef __linux__ I write if (builtin.os.tag == .linux) and the compiler drops the branch that does not apply. Generic data structures are functions that take a type and return a type. The compiler checks all of it like the rest of the program. I wrote about this in Zig comptime.

Fewer undefined surprises Link to heading

In C the overflow of a signed integer is undefined behavior. The following C program adds 1 to INT_MAX:


        int add(int a, int b)
{
    return a + b;
}

int main(void)
{
    printf("%d\n", add(INT_MAX, 1));
    return 0;
}
      

Compiled with clang it prints -2147483648 and exits with 0, but the C standard allows the compiler to do anything, including removing code that it decides can never run. In Zig the overflow of any integer, signed or unsigned, is checked in the debug and safe modes and the program stops with panic: integer overflow and the line where it happened. When I want wrapping, I write +% and when I want the result to stop at the maximum value, I write +|.

Zig is also a C compiler Link to heading

The zig cc command is a C compiler that you can use instead of gcc or clang. It also cross compiles out of the box. On my Mac, zig cc -target x86_64-linux-musl -o sum sum.c creates a static Linux executable without installing anything else. In the debug mode zig cc also turns on the undefined behavior sanitizer, so the add() program above compiled with zig cc stops with panic: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'. A build.zig file can compile C files next to Zig files, so you can move a C project to Zig one file at a time and keep using the same build. If your Zig code needs C header files, Zig 0.17 removed @cImport() and you use zig translate-c instead. I wrote about that in @cImport() Is gone in Zig 0.17.

The honest tradeoff Link to heading

Zig is not C with all the problems solved. It does not stop you from using memory after you free it or from keeping a pointer to a stack variable that no longer exists. In the fast and small modes most safety checks are turned off and you are back to undefined behavior, just as in C. C is also stable in a way that Zig is not yet. When Zig 0.17 came out, I had to change 35 of the 152 Zig files of my book, while C code from decades ago still compiles. C has more tools, more books, more people who know it and compilers for chips that Zig does not support. If you write code for a microcontroller that only has a vendor C compiler, the choice is made for you.

Conclusion Link to heading

C is not going away and I still read a lot of it, because the UNIX source code and most system libraries are written in C and their man pages document them with C prototypes. But for new code I choose Zig. It lets me write the same kind of code as C, close to the machine and with no runtime, while the compiler and the debug mode catch many of the mistakes that C lets through without a word.

Happy coding in Zig!

Want to learn Zig? Look at my book Systems Programming with Zig.

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by Hacker News
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗