Jose Storopoli, PhD
Zig comptime is such a powerful built-in feature
I have been following the development of Zig for a while now. I like the idea of a systems programming language that is simple to learn and use, yet powerful and expressive.
With Rust, I can get the powerful and expressive part, but the learning curve is a bit steep, and some parts of the language are complex and hard to understand. For example async
/await
(specially if you have to deal with Stream
s and Pin
s); and the macro domain-specific language (DSL).
Now back to Zig. The language can be learned in one lazy morning. That’s exactly what I did and then played around migrating some C code to Zig from an old course that I used to teach about algorithmic complexity. The code is now simpler and more readable than the original C code. I have hugely benefited from Zig’s built-in features such as:
- Optionals
- Error handling
comptime
(more on that, since it is the main topic of this post)- safe integer/floating-point arithmetic
- deferring memory deallocation to the end of the scope
struct
s with functions (methods)- BYOA (Bring Your Own Allocator)[^BYOA]
[^BYOA]: Zig doesn’t have a built-in allocator. And I don’t know it the acronym BYOA is a thing. I just made it up. But it makes sense, right?
Writing in Zig, comparing to C, is such a joy and you are always sure that you won’t spend hours debugging because Zig has:
- No hidden control flow
- No hidden memory allocations
- No preprocessor, no macros
I highly recommend you to give Zig a try. It is the ultimate C killer. In fact, you can compile and interop C and Zig code with Zig. So you can start migrating your C codebase to Zig incrementally. As an additional caveat, Zig can be faster than C.
Zig’s comptime
I’ve been wanting to write about Zig for a while now. But I had no topic in mind. Then, I was inspired by this talk by @matklad on modern systems programming comparing Rust and Zig interwoven with his professional journey from Rust to Zig, and the amazing work he’s doing at TigerBeetle:
The talk goes on to compare Rust and Zig in terms of systems programming. Then, at the middle of the talk, he starts to talk about Zig. One of the Zig’s features that he covers is Zig’s comptime
feature which allows to run code and evaluate expressions at compile-time without the need for meta-programming/macros or code generation.
Let me explain now what is comptime
in Zig. You can do comptime
in Zig in different places, such as:
- Parameters of functions
- Variables
- Expressions
Here are some examples thanks to Loris Cro and Zig’s documentation.
Parameters of functions
The first Zig code example is about using comptime
to decide the length of a statically-allocated array:
fn multiply(a: i64, b: i64) i64 {
return a * b;
}
pub fn main() void {
const len = comptime multiply(4, 5);
const my_static_array: [len]u8 = undefined;
}
Variables
The second example is about using comptime
to define a variable. This Zig code evaluates a Fibonacci number at compile-time:
const expect = @import("std").testing.expect;
fn fibonacci(index: u32) u32 {
if (index < 2) return index;
return fibonacci(index - 1) + fibonacci(index - 2);
}
test "fibonacci" {
// test fibonacci at run-time
try expect(fibonacci(7) == 13);
// test fibonacci at compile-time
try comptime expect(fibonacci(7) == 13);
}
Expressions
The final example is about using comptime
to evaluate an expression. The following Zig code evaluates a for
-loop at compile-time.
const max = 10;
comptime var total = 0;
comptime {
for (1..max) |i| {
total += i;
}
}
Bonus example: Zig’s Generics
Now you can see how impressive comptime
is. In fact, Zig’s generics are implemented using comptime
. Check the Zig code below that creates a generic List
data structure:
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
};
}
// The generic List data structure can be instantiated by passing in a type:
var buffer: [10]i32 = undefined;
var list = List(i32){
.items = &buffer,
.len = 0,
};
The caveats of comptime
comptime
has some caveats. comptime
expressions must be known at compile-time. That means:
- At the callsite, the value must be known at compile-time, or it is a compile error.
- In the function definition, the value is known at compile-time.
If you guarantee that the value is known at compile-time, you can use comptime
to evaluate anything at compile-time.
The motivating example
The motivating example for this blog post is an explanation that matklad gives in his talk about how they use comptime
at TigerBeetle to make sure that a struct
that represents a header has no padding in its fields.
Then, he said that to have the same checks at compile-time in Rust, you would need to bring a lot of complexity with proc-macro
s. That made me curious about how hard it would be to do the same in Rust.
So here’s a toy problem that I came up with to compare Zig and Rust. Instead of checking for padding in fields of a struct
, I decided to simplify and check for zero-padding in a string.
Zero padding check in Zig
In Zig, strings are arrays of bytes, and you can iterate over them at compile-time:
const std = @import("std");
fn checkZeroPadding(comptime s: []const u8) void {
for (s) |c| {
if (c == '0') {
@compileError("String contains zero-padding");
}
}
}
pub fn main() void {
comptime {
const str1 = "12345";
const str2 = "01234";
checkZeroPadding(str1); // This will compile
checkZeroPadding(str2); // This will cause a compile-time error
}
}
The code above uses comptime
to check if a string has zero-padding.
Zero padding check in Rust
Compare this to how to do the same thing in Rust. We need to use a procedural macro to achieve the same result. Note that macros in Rust have their own domain-specific language (DSL) that is not Rust itself. Hence, you need to learn a new language to write a macro in Rust.
First create a library crate named zero-padding-checker
that exports a proc-macro
and add a procedural macro to check for zero-padding in a string:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn check_zero_padding(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as LitStr);
let value = input.value();
if value.starts_with('0') {
return quote! {
compile_error!("String contains zero-padding");
}
.into();
}
quote! {
#input
}
.into()
}
Then you call the macro in your code:
use zero_padding_checker::check_zero_padding;
fn main() {
check_zero_padding!("12345"); // This will compile
check_zero_padding!("01234"); // This will cause a compile-time error
}
The Rust code above is far more complex than the Zig code. First, it requires dependency on syn
and quote
crates (note that the proc-macro
crate is provided by Rust’s compiler). Second, despite the fact that this is a simple example, procedural macros in Rust have their own domain-specific language (DSL) and the complexity can grow quickly as the problem becomes more complex.
Conclusion
Yeah, Zig is a great language and has a bright future ahead. comptime
is a powerful feature that allows you to run almost any code at compile-time. It is built into the language and you don’t need to learn a new language to use it or bring external dependencies to make it work.
If you like to learn more about Zig, I recommend reading the learn section of Zig’s documentation. Also, to learn more about Zig’s comptime
feature, check Zig’s documentation on comptime
.