After a day of programming in Zig
A review by a Rust enthusiast
I am a big fan of Rust since it provides great tooling and allows me to write code with lots of confidence that it will work reliably. But sometimes I hate it, too. It takes more time to write code in Rust and some things are pretty difficult to properly implement (looking at you async
).
In the last year, I heard a couple of times about a new low-level programming language called Zig. And now I finally found the time to try it out. In this article, I want to talk about the things I liked and disliked about Zig from the perspective of a Rust developer (and the high standards they are used to 🦀👑).
So what is Zig?
Zig describes itself as "... a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software.". Sounds very generic, eh?
The "unique selling points" are:
No hidden control flow; you control everything
No hidden memory allocation; they are all explicit and allow to use different allocation strategies
No preprocessor, no macros; directly write compile time code in Zig instead
Great interoperability with C/C++; supports cross-compilation; can be used as a drop-in replacement for C.
Zig is a bit similar to Rust since both focus on performance and safety. They don't use garbage collection, use LLVM as the compiler backend, and provide code testing capabilities. Both use a modern syntax and offer programming features like error handling and options.
const std = @import("std");
/// Removes the specified prefix from the given string if it starts with it.
pub fn removePrefix(input: []const u8, prefix: []const u8) []const u8 {
if (std.mem.startsWith(u8, input, prefix)) {
return input[prefix.len..];
}
return input;
}
Even though it simplifies a lot, I like to say that Zig is to C what Rust to C++ is. Others say, that Zig is the modern successor of C. As a rule of thumb, it probably makes sense to use Zig in projects where you would have used C before.
Is it good?
I usually learn new programming languages by simply writing some simple programs from start to finish. In this case, my goal was to write a telnet client (an old network protocol for remote access to terminals). This was quite a journey since telnet is a lot more complex than it seems. But this deserves an article on its own.
It actually took me more than one day to implement this project, but after a full day of working on it, I had the feeling that I understood the basics of Zig. You can find the source code here: https://github.com/michidk/telnet-zig/
What I dislike about Zig
The barrier of liking Zig is not too high, since I really dislike programming in C. So let's first discuss the things I disliked:
The Zig community and ecosystem are rather small, and not many libraries are available. Those that are available are also not very fleshed out yet. This is very different for Rust, where you can find at least one very popular and well-implemented crate for each problem which you might want to outsource to a library.
Zig comes with a standard library that is similarly minimalistic like the Rust standard library. It is yet rather small but carefully designed. The documentation is not very good and many methods are undocumented.
Undocumented code from std.io.Writer
(https://ziglang.org/documentation/master/std/#A;std:io.Writer):
There is no proper pattern matching in Zig. However, switch
statements are quite powerful and when nesting them it is possible to achieve something similar, like one can do with Rust's match
statement.
In Rust, traits (or interfaces in other languages) allow for polymorphism - the ability to write code that can operate on objects of different types. This is a powerful feature for designing flexible and reusable software components. Zig, however, lacks this feature. It relies on other mechanisms like function pointers or comptime polymorphism, which can be less intuitive and more cumbersome for scenarios typically handled by interfaces or traits.
But to be honest, I wouldn't have expected otherwise, since Zig is rather young and only recently gained in popularity.
Why I love Zig
Tooling, Build System & Tests
Even though the language is rather young, the tooling is great! However is not as advanced as the Rust tooling with cargo
and clippy
(yet). Only the most recent version v0.11
delivered us an official package manager, called Zon. It can be used together with the build.zig
file (which is similar to a build.rs
in Rust) to load libraries from GitHub into our project without much hassle (I don't even want to know how much valuable lifetime cmake
and make
have cost me in the past).
$ zig build-exe hello.zig
$ ./hello
Hello, world!
Similarly to Rust, Zig comes with robust testing capabilities built into the language. Tests in Zig are written in special functions, allowing them to reside alongside the code they are validating. Unique to Zig is the ability to leverage its compile-time evaluation features in tests. Additionally, Zig's support for cross-compilation in testing is particularly noteworthy, enabling developers to effortlessly test their code across various target architectures. Some people even use Zig to test their C code!
const std = @import("std");
const parseInt = std.fmt.parseInt;
// Unit testing
test "parse integers" {
const ally = std.testing.allocator;
var list = std.ArrayList(u32).init(ally);
defer list.deinit();
...
The Language
The language itself is well-designed and the syntax is quite similar to Rust. Both have a type system that emphasizes strong, static typing, though the way they handle types and type inference differs.
Error Handling and Optionals
Zig and Rust both promote explicit error handling, however their mechanisms are different. Rust uses Result
enums, while Zig uses a (global) error set type (though similar to an enum) and error propagation. Similarly, Rust uses the Option
enum for optional types, while Zig uses a type modifier (?T
). Both offer modern, syntactic sugar to handle those (call()?
and if let Some(value) = optional {}
in Rust, try call()
and if (optional) |value| {}
in Zig). Since Rust uses the standard library to implement error handling and options, users have the possibility to extend those systems which is quite powerful. However, I like the approach Zig takes in providing those things as language features. While their approach fits well into the C universe, I dislike that there is no pragmatic way to add more context to an error (but well, no allocations). Libraries like clap solve this by implementing a diagnostics mechanism.
// Hello World in Zig
const std = @import("std");
pub fn main() anyerror!void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello, {s}!\n", .{"world"});
}
C Interop
The C interoperability in Zig is world-class. You don't need to write bindings, with Zig you can just use the @cImport
and @cInclude
built-in functions (which parses C header files) to directly use C code.
Comptime
Zig allows us to write Zig code (no special macro syntax like in Rust), which is evaluated during compile time using the comptime
keyword. This can help to optimize the code and allows for reflection on types. However, dynamic memory allocations are not allowed during compile time.
Types
Like in Rust, Zig types are zero-cost abstractions. There are Primitive Types, Arrays, Pointers, Structs (similar to C structs, but can include methods), Enums and Unions. Custom types are implemented through structs and generics are implemented by generating parameterized structs during compile time.
// std.io.Writer is a compile-time function which returns a (generic) struct
pub fn Writer(
comptime Context: type,
comptime WriteError: type,
comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
return struct {
context: Context,
const Self = @This();
pub const Error = WriteError;
pub fn write(self: Self, bytes: []const u8) Error!usize {
return writeFn(self.context, bytes);
}
...
Memory Allocation
Unlike Rust, which employs an automatic borrow-checker to manage memory, Zig opts for manual memory management. This design decision aligns with Zig's philosophy of giving the programmer full control, reducing hidden behaviors and overhead.
At the core of Zig's memory management strategy is the Allocator
interface. This interface allows developers to dictate precisely how memory is allocated and deallocated. Developers can choose from several allocators or implement custom ones tailored to specific needs or optimization goals.
This is great but can be a bit annoying in practice. The allocator is typically created at the beginning of the application code and assigned to a variable. Methods which want to allocate memory, require the allocator as a parameter in their function signature. This makes allocations very visible but also can get a bit annoying because it has to be passed around through multiple functions throughout the whole application (at least to the parts where you allocated memory).
Cross Compilation
Zig, like Rust, has native support for cross-compilation. Its integrated toolchain simplifies compiling for different architectures or operating systems. Setting the target architecture in Zig is as straightforward as passing an argument in the build command:
# Build for Windows on Linux
zig build -Dtarget=x86_64-windows-gnu
In contrast, Rust requires the installation of the target platform's toolchain through rustup
and often necessitates manual linker configuration for the target platform.
Conclusion
I find Zig to be a well-designed, fun, and powerful language. It can be challenging to use because of the small ecosystem and the lack of documentation, which most likely will improve soon with increasing popularity. Zig provides modern syntax, a great type system, complete control over memory allocations and state-of-the-art language features.
Overall, I enjoyed programming in Zig and I think it has a lot of potential to become a popular choice for low-level development. Personally, I think Zig could be a real game changer for embedded systems (in a few years) and I am quite excited to see what the future holds for Zig.
I encourage you to give Zig a try⚡.