Things I like about Zig as a Go Programmer
Zig is a relatively new comer to the programming language landscape. Created by Andrew Kelly, Zig got first introduced to the world back in 2016. The Zig community describes it as as “a general purpose programming language for maintaining robust, optimal, and reusable software”.
Hidden in this simple proclamation are some very lofty ambitions. For instance, Zig is also described as a programming environment that competes with C language. Additionally, Zig is also a compiler toolchain that can be used as a drop-in replacement for existing C compilers.
As a someone who uses Go, I find the propositions put forward by Zig and its toolchain compelling. As I researched Zig, it became apparent that both languages (Zig and Go) share some common traditions. In this blog post I will highlight the features that I find interesting about Zig as a Go programmer.
Simplicity
Both languages have adopted a design philosophy of simplicity to remove the language out of the way and lets you be productive quickly. There is no magic or hidden surprises in the flow of execution. Zig has no support for macros, preprocessors, or operator overloading.
Go is a managed memory language and and has runtime magic to handle memory allocation/deallocation. Zig however, stays true to its “no hidden control flow” mantra and does not have automatic memory management and instead provides APIs that let the programmer manage memory manually via its standard library.
Strongly typed
As a language designed for system programming, Zig offers a large set of features around its type system centered around safety and C ABI compatibility. While I cannot adequately cover all of it, here are some highlights you may find interesting:
- Signed/unsigned integers (preset sizes from 8- through 128-bit)
- Arbitrary size signed/unsigned integers (i.e.
i7
for a 7-bit int) - Floating points (from 16 through 128 bits precision)
- Slices and arrays (i.e.
[]u8{ ‘h’, ‘i’, ‘!’}
or[4]i32{ 1, 2, 3, 4 }
) - UTF-8 encoded string literals, stored as null-terminated byte arrays
- Feature-rich struct types with C ABI compatibility capabilities
- Enums with implicit/explicit ordinal values and support for methods
- Unions for storing a value from multiple choices of types
- Support for parallel operations using vectors
- A traditional pointer and multi-item pointer with slice expressions
Handling error
Error handling works beautifully in Zig. It’s a cross between try-catch-exception semantic and Go’s error value. Let’s see how it works.
First, all Zig errors are values that must be assigned and handled (or will cause compile time errors). A Zig error is declared as a set of values (think enums) using the error
keyword:
const DigitError = error{
TooLarge,
};
Now, this is where things get interesting. Zig error values can be combined with the value of normal type, using a binary operator !
, to form a Union type that can be returned by a function. For instance, the following function can return a value of either type error
or u32
, denoted by return type !u32
(or explicitly DigitError!u32
):
fn addSingleDigits(a: u32, b: u32) !u32 {
if (a > 9) return error.DigitTooLarge;
if (b > 9) return error.DigitTooLarge;
return a + b;
}
But wait, there’s more!
Zig has an interesting construct to help with error handling. Similar to exception handling in other languages, Zig uses the catch
keyword to append an error-handling code block to the function call which gets executed if the error value is returned as shown below:
pub fn main() void {
const result = addSingleDigits(4, 5) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("Result: {}\n", .{result});
}
Zig also supports a mechanism for propagating an error up the call stack using the try
keyword. For instance, function addAll
below would return the error if one is returned or continue to do other things.
fn addAll(a: u32, b:u32) !void {
try addSingleDigits(a,b);
// do other things if no previous error
}
Lastly, Zig can use an if-else-switch
block to filter and handle errors results more precisely:
pub fn main() void {
if (addSingleDigits(4, 5)) |result| {
std.debug.print("Result: {}\n", .{result});
}else |err| switch (err){
error.TooLarge ==> {
std.debug.print("Digits too large: {}\n", .{err});
},
else => {
// Do something else
}
}
}
Zig test
In Zig, source code testing is a first-class constituent with its own dedicated test
keyword in the language. Tests are declared similar to top-level functions using the test keyword followed by a description and a code block:
test "test that 1 + 1 equals 2" {
const result = 1 + 1;
assert(result == 2);
}
As with go test
, the toolchain comes with the zig test
command to exercise the tests in the source code:
zig test mysource.zig
Zig run
Similar to go run
, Zig provides a convenient zig run
command that combines the steps of compiling and running your Zig source code:
zig run my_program.zig
Defer
Similar to Go, Zig facilitates resource management using the defer
concept to execute cleanup operations, such as releasing resources, when the currently executing scope block ends.
const print = @import("std").debug.print;
fn addSingleDigits(a: u32, b: u32) !u32 {
defer print("this is deferred!")
if (a > 9) return error.DigitTooLarge;
if (b > 9) return error.DigitTooLarge;
return a + b;
}
Comptime
comptime
is another interesting concept in the language that is not found in most other languages. Zig does not have a separate meta-language or macro system. Instead, Zig provides a clever solution that extends programmability of its source code into the compilation phase using the notion of comptime (or compile-time).
With comptime
, Zig make several features possible at compile-time:
- Variables and expressions that are resolved at compile time
- Functions that behave based on compile-time values
comptime
code blocks that are selectively executed during compilation- Meta-programming executed at compile time
Generics
Naturally, one thing that resulted from the compile-time programmability of Zig is the implementation of generic types and data structures. In Zig, comptime
provides access to type values that can be stored and passed as regular data values.
This makes it possible to create functions that take type parameters as follows:
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
test "max with different types" {
const condition = false;
const result = max(if (condition) f32 else u64, 1234, 5678);
_ = result;
}
Because comptime
type values are treated as any other types, Zig allows you to build generic data structures using them. For instance, MakeList
uses comptime
type information to return a struct built at compile-time:
const std = @import("std");
fn MakeList(comptime T: type, comptime size: usize) type {
return struct {
items: [size]T,
};
}
pub fn main() void {
var list = MakeList(i32, 3){
.items = [3]i32{1,2,3},
};
std.debug.print("List {}\n", .{list});
}
Zig as a C (cross-)compiler
The Zig toolchain has a full-feature C compiler, which means you can use Zig as a replacement of your current C compiler toolchain. Given the following hello.c
source code file:
#include <stdio.h>
int main(int argc, char **argv) {
printf("Hello world\n");
return 0;
}
Zig can compile the source into an executable binary with the following command:
zig build-exe hello.c --library c
Zig and C cross-compilation
Zig makes it easy to cross compile your code (whether C or Zig). None of the messy “you bring your own cross toolchain” business! Zig assembles all necessary tools and libraries to ensure you can target any of its supported architectures.
For instance, Zig can cross-compile the previous C source code into a static binary targeting linux (using musl
):
zig build-exe hello.c --library c -target x86_64-linux-musl
Zig and CGo cross-compilation
Zig’s C cross-compilation support turns out to be extremely useful for cross-compiling CGo-enabled Go source code. For instance, given the following C function add
in add.c
:
#include <stdint.h>
int32_t add(int32_t a, int32_t b) {
return a + b;
}
Let’s use Go to call it:
package main
/*
#include "add.c"
*/
import "C"
import (
"fmt"
)
func main() {
a, b := int32(3), int32(4)
result := C.add(a, b)
fmt.Printf("%d + %d = %d\n", a, b, result)
}
Assuming we’re building the code from MacOS, we can use command zig cc
to use Zig’s C compiler to cross-compile the C code into object files that are linked to Go object files to build a static binary for Linux running on x86 architectures:
CGO_ENABLED=1 GOOS=linux CC="zig cc -target x86_64-linux-musl" go build .
All you need for this to work is to have the Zig toolchain installed on your workstation and no other dependencies required!
While this may not seem like a big deal, keep in mind that cross-compiling a CGo-enabled static binary is more involved (when not using Zig). It often requires several steps to prepare the build environment with the required software packages needed to cross-compile for the target platform (see here).
Conclusion
This blog provides a tiny taste of Zig. Zig’s unique blend of simplicity, power, safety, and C compatibility makes it an exciting choice for developers. Whether you’re looking for a language for a new project or, like me, just interested in expanding your programming repertoire, Zig offers a compelling and innovative option worth exploring.