Zig comptime: does anything come close?
Zig’s comptime
feature has been causing a little bit of a fuss on
programmer discussion forums lately. It’s a very interesting feature because it is relatively simple to grasp but gives
Zig code some super-powers which in other languages tend to require a lot of complexity to achieve. That’s in line with Zig’s stated
desire to remain a simple language.
The most common super-power (ok, maybe not a super-power, but a non-trivial feature) given as an example is generics, but there are many more.
In this post, I decided to have a look at that in more detail and compare Zig’s approach with what some other languages can offer. Zig uses comptime for things that look a little bit like macros from Lisp or Rust, templates from C++ and D, and code generation via annotation processors from Java and Kotlin! Are those alternatives anywhere near as powerful? Or easy to use?
I will investigate that by looking at some Zig examples from Loris Cro (Zig evangelist)’s What is Zig’s Comptime and Scott Redig’s Zig’s Comptime is Bonkers Good.
I only know relatively few languages, so apologies if I didn’t include your favourite one (do let me know if your language has even nicer solutions)… specifically, I don’t know much C++, so be warned that while I am aware C++ has constexpr (I guess that’s the closest thing to comptime
),
I won’t even try to compare that with comptime
in this post!
The basics
The first thing one can imagine doing at comptime in any language is to compute the value of some constant.
I mean, it would kind of suck if this expression was actually computed at runtime:
// five days
int time_ms = 5 * 24 * 60 * 60 * 1000;
Most compilers (maybe even all?) will use constant folding and compile that down to this:
int time_ms = 432'000'000;
However, they generally refuse to do this if any function calls are involved. Why? 🤔
Because it may be too hard, I suppose! Evaluating even simple expressions as above require at least an arithmetic interpreter, so almost all compilers must already have one hidden in them. Is executing at least some functions in their interpreters much harder to do?
I am not sure, but apparently the Zig authors (that would be Andrew Kelley, now backed by the Zig Software Foundation) think doing that is worth it!
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;
_ = my_static_array;
}
The multiply
function is just a regular function that can obviously be called by other functions at runtime.
But in this sample, it’s getting called with the comptime
keyword in front of it:
const len = comptime multiply(4, 5);
That forces the compiler to actually call multiply
at compile-time and replace the expression with the result.
That means that, at runtime, the above line would be replaced with this:
const len = 20;
That’s why len
can be used in the next line to create a static array (Zig doesn’t have dynamic arrays, so without comptime
that wouldn’t work).
Of course, in this example you could just write that, but as you can call most other code, you might as well pre-compute things that would be hard to do at compile time otherwise.
The first thing that came to mind when I saw this example was Lisp’s read time evaluation!
(defun multiply (a b)
(* a b))
(defun main ()
(let ((len #.(multiply 4 5)))
(format t "Lenght is ~s" len)))
The Lisp reader computes #.(multiply 4 5)
, so if you compile the function, it will just contain a 20
there at runtime.
It’s kind of important to understand that this is not the same as initializing constants at runtime, as this Java code would do:
class Hello {
// Java evaluates this at runtime, when this Class is loaded
static final int LEN = multiply(5, 4);
static int multiply(int a, int b) {
return a * b;
}
}
We can imagine some expensive computation that might take time to perform at runtime, even if only once when the program starts up. If you do that at compile time, you save all that time forever, on every run of your program.
In D, we can also easily call “normal” functions at comptime (D calls that Compile Time Function Evaluation, or CTFE):
int multiply(int a, int b) => a * b;
void main()
{
enum len = multiply(5, 4);
ubyte[len] my_static_array;
}
D uses the enum
keyword to declare comptime variables. But despite some name differences, you can see that this example is extremely similar to Zig’s.
Walter Bright, creator of D, has just written an article where he claims that “evaluating constant-expressions” is an obvious thing C should do! And in fact, the C compiler used by D to compile C code, importC does it!
Rust can also do it, but only a sub-set of the language is available as documented here, and only functions explicitly marked as const fn
can be used (a big limitation in comparison with Zig and D):
const fn multiply(a: usize, b: usize) -> usize {
a * b
}
fn main() {
const len: usize = multiply(5, 4);
let my_static_array: [u8; len];
}
Even in Zig and D, however, not everything can be done at comptime. For example, IO and networking seem to be out-of-limits, at least in the usual form.
Trying to read a file in Zig at comptime causes an error:
const std = @import("std");
fn go() []u8 {
var buffer: [64]u8 = undefined;
const cwd = std.fs.cwd();
const handle = cwd.openFile("read-file-bytes.zig", .{ .mode = .read_only }) catch unreachable;
defer handle.close();
const len = handle.readAll(&buffer) catch unreachable;
const str = buffer[0..len];
const idx = std.mem.indexOf(u8, str, "\n") orelse len;
return str[0..idx+1];
}
pub fn main() void {
const file = comptime go();
std.debug.print("{s}", .{file});
}
ERROR:
/usr/local/Cellar/zig/0.13.0/lib/zig/std/posix.zig:1751:30: error: comptime call of extern function
const rc = openat_sym(dir_fd, file_path, flags, mode);
~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/usr/local/Cellar/zig/0.13.0/lib/zig/std/fs/Dir.zig:880:33: note: called from here
const fd = try posix.openatZ(self.fd, sub_path, os_flags, 0);
~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/usr/local/Cellar/zig/0.13.0/lib/zig/std/fs/Dir.zig:827:26: note: called from here
return self.openFileZ(&path_c, flags);
~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~
read-file-bytes.zig:6:32: note: called from here
const handle = cwd.openFile("read-file-bytes.zig", .{ .mode = .read_only }) catch unreachable;
~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
read-file-bytes.zig:14:29: note: called from here
const file = comptime go();
~~^~
Remove the
comptime
keyword and the example above works.
In D, same thing:
import std;
void main()
{
string firstLineOfFile()
{
auto file = File("parser.d");
foreach (line; file.byLineCopy)
{
return line;
}
return "";
}
enum line = firstLineOfFile();
// D's comptime version of printf
pragma(msg, line);
}
ERROR:
Error: `fopen` cannot be interpreted at compile time, because it has no available source code
Error: `malloc` cannot be interpreted at compile time, because it has no available source code
main.d(15): compile time context created here
main.d(17): while evaluating `pragma(msg, line)`
Replace
enum
withauto
and usewriteln
instead ofpragma
and the above also works.
Notice how both Zig and D appear to fail due to extern functions… Zig explicitly disallows syscalls. That makes sense as allowing that could make compilation really undeterministic and the result highly likely to diverge depending on the exact machine the code was compiled on.
Despite that, both languages provide ways to actually read files at comptime!
In Zig, you can use @embedFile:
const std = @import("std");
fn go() [] const u8 {
const file = @embedFile("read-file-bytes.zig");
const idx = std.mem.indexOf(u8, file, "\n") orelse file.len - 1;
return file[0..idx+1];
}
pub fn main() void {
const file = comptime go();
std.debug.print("{s}", .{file});
}
D offers import expressions
:
This requires using the compiler
-J
flag to point to a directory where to import files from.
import std;
void main()
{
string firstLineOfFile()
{
enum file = import("main.d");
return file.splitter('\n').front;
}
enum line = firstLineOfFile();
// D's comptime version of printf
pragma(msg, line);
}
Rust has include_bytes:
fn main() {
let file = include_bytes!("main.rs");
println!("{}", std::str::from_utf8(file).unwrap());
}
The above program embeds the main.rs
file as a byte array and prints it at runtime.
However, a const fn
cannot currently call include_bytes
(there’s an issue to maybe make that possible).
Hence, include_bytes
is a bit more limited than Zig’s @embedFile
and D’s import
.
In Java, the closest you can get to doing this would be to include a resource in your jar and then load that at runtime:
try (var resource = MyClass.class.getResourceAsStream("/path/to/resource")) {
// read the resource stream
}
Not quite the same, of course, but gets the jo done. It probably does not even need to perform IO as it’s going to just take the bytes from the already opened and likely memory-mapped jar/zip file where the class itself came from.
comptime blocks and arguments
The next example from Kristoff’s blog is very interesting. It implements the insensitive_eql
function
so that one of the strings is known at comptime and can be verified to be uppercase by the compiler
(thanks to your own comptime code, that is):
// Compares two strings ignoring case (ascii strings only).
// Specialzied version where `uppr` is comptime known and *uppercase*.
fn insensitive_eql(comptime uppr: []const u8, str: []const u8) bool {
comptime {
var i = 0;
while (i < uppr.len) : (i += 1) {
if (uppr[i] >= 'a' and uppr[i] <= 'z') {
@compileError("`uppr` must be all uppercase");
}
}
}
var i = 0;
while (i < uppr.len) : (i += 1) {
const val = if (str[i] >= 'a' and str[i] <= 'z')
str[i] - 32
else
str[i];
if (val != uppr[i]) return false;
}
return true;
}
pub fn main() void {
const x = insensitive_eql("Hello", "hElLo");
}
ERROR:
insensitive_eql.zig:8:17: error: `uppr` must be all uppercase
@compileError("`uppr` must be all uppercase");
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Or course, to fix that you just need to replace the first argument to
insensitive_eql
with something all-caps:
pub fn main() void {
const x = insensitive_eql("HELLO", "hElLo");
}
This is a nice optimization based on comptime argument values.
Let’s see if we can rewrite this example in D, given D seems to also support comptime pretty well:
The original example did not check the length of the strings, so it can obviously return incorrect results if the input is longer than the comptime value, or read past the input’s bounds if that’s shorter, but I kept the same behaviour in this translation because this is not trying to implement a production-grade algorithm anyway, and it’s fun to maybe poison the training data for the AIs scraping my site.
/// Compares two strings ignoring case (ascii strings only).
/// Specialzied version where `uppr` is comptime known and *uppercase*.
bool insensitiveEqual(string uppr)(string str)
{
import std.ascii : lowercase, toUpper;
import std.algorithm.searching : canFind;
static foreach (c; uppr)
{
static if (lowercase.canFind(c))
static assert(0, "uppr must be all uppercase");
}
foreach (i, c; str)
{
if (c.toUpper != uppr[i])
return false;
}
return true;
}
void main()
{
const x = insensitiveEqual!("Hello")("hElLo");
}
ERROR:
insensitive_eql.d(11): Error: static assert: "uppr must be all uppercase"
insensitive_eql.d(23): instantiated from here: `insensitiveEqual!"Hello"`
While D does not have comptime
blocks, it’s fairly clear when a block is executed at comptime
due to the use of static
to differentiate
if
, foreach
and assert
from their runtime counterparts. If you see static if
you just know it’s comptime.
This example shows one of the main differences between Zig’s and D’s comptime facilities: while in Zig, whether
a function argument must be known at comptime is determined by annotating the argument with comptime
, in D
there are two parameter lists: the first list consists of the comptime parameters, and the second one of the runtime parameters.
Hence, in Zig you cannot know by looking at a function call which arguments are comptime, you need to know the function
signature or track where the variable came from. In D, you can, because the comptime parameters come on a separate list which
must follow the !
symbol:
insensitiveEqual!("Hello")("hElLo");
The parenthesis are optional when only one argument is used, so this is equivalent:
insensitiveEqual!"Hello"("hElLo");
The downside is that one cannot interleave runtime and comptime arguments, which may be a bit more natural in some cases.
Moving on to Rust now… Rust does have something that lets us say this value must be known for the life of the program:
lifetime annotations. That may not be exactly the same as comptime
, but sounds close! Let’s try using it.
fn insensitive_eql(uppr: &'static str, string: &str) -> bool {
todo!()
}
const fn is_upper(string: &str) -> bool {
string.bytes().all(|c| c.is_ascii_uppercase())
}
pub fn main() {
let a = "Hello";
assert!(is_upper(a));
print!("result: {}", insensitive_eql(a, "hElLo"));
}
ERROR:
error[E0015]: cannot call non-const fn `<std::str::Bytes<'_> as Iterator>::all::<{closure@src/main.rs:6:24: 6:27}>` in constant functions
--> src/main.rs:6:20
|
6 | string.bytes().all(|c| c.is_ascii_uppercase())
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: calls in constant functions are limited to constant functions, tuple structs and tuple variants
Close, but no cigar! As we can only call other const fn
s from a const fn
, and no one seems to declare all their functions that could be
const fn
actually const fn
(sorry for so many const fn
s in one sentence), there seems to be very little we can do with
const fn
s! We would probably need to implement our own is_upper
const fn
to get around that. But even if we did, what we really needed
was for the assertion that the string was all-caps to run at compile-time, and as far as I can see that’s not possible with Rust.
Java doesn’t have const fn
or anything similar in the first place (Java is extremely dynamic, you can load a jar at runtime, create a
ClassLoader
and start using code from the jar), so it’s hopeless to even try anything, I believe.
Lisp can do anything at read time (there’s nearly no difference between comptime, readtime and runtime as far as what code you can use). However, for the sake of brevity and to try to focus on statically-typed languages where there’s a clear compile-/run-time split, I’m afraid I will not be including Lisp anymore, sorry if you’re a fan!
Code ellision
This example is also from Kristoff’s post, but adapted by me to compile with Zig 0.13.0 (Zig’s stdlib changes a lot between releases):
const builtin = @import("builtin");
const std = @import("std");
const fmt = std.fmt;
const io = std.io;
const Op = enum {
Sum,
Mul,
Sub,
};
fn ask_user() !i64 {
var buf: [10]u8 = undefined;
std.debug.print("A number please: ", .{});
const stdin = std.io.getStdIn().reader();
const user_input = try stdin.readUntilDelimiter(&buf, '\n');
return fmt.parseInt(i64, user_input, 10);
}
fn apply_ops(comptime operations: []const Op, num: i64) i64 {
var acc: i64 = 0;
inline for (operations) |op| {
switch (op) {
.Sum => acc +%= num,
.Mul => acc *%= num,
.Sub => acc -%= num,
}
}
return acc;
}
pub fn main() !void {
const user_num = try ask_user();
const ops = [4]Op{ .Sum, .Mul, .Sub, .Sub };
const x = apply_ops(ops[0..], user_num);
std.debug.print("Result: {}\n", .{x});
}
It shows how inline for
can be used to loop using comptime variables. The loop gets unrolled at comptime, naturally.
Looking at the generated Assembly in Godbolt, one can clearly see it’s only doing the
expected arithmetics.
As we’ve just learned in the previous section, D’s static for
is a comptime loop, hence equivalent to Zig’s inline for
.
Why Zig calls this
inline for
instead ofcomptime for
? And why does D useenum
to designatecomptime
variables? Language creators don’t seem to be any better at naming things than the rest of us.
Here’s the same application written in D:
enum Op
{
SUM,
MUL,
SUB,
}
long askUser()
{
import std.stdio : write, readln;
import std.conv : to;
char[] buf;
write("A number please: ");
auto len = readln!char(buf);
return buf[0 .. len - 1].to!long;
}
long applyOps(Op[] operations)(long num)
{
long acc = 0;
static foreach (op; operations)
{
final switch (op) with (Op)
{
case SUM:
acc += num;
break;
case MUL:
acc *= num;
break;
case SUB:
acc -= num;
}
}
return acc;
}
int main()
{
import std.stdio : writeln;
auto num = askUser();
with (Op)
{
static immutable ops = [SUM, MUL, SUB, SUB];
num = applyOps!ops(num);
}
writeln("Result: ", num);
return 0;
}
In case you’re not familiar with D,
final switch
is a switch that demands all cases to be covered, as opposed to regularswitch
which require adefault
case (so enums could evolve without breaking callers).
In this example, the two languages look very similar, apart from the syntax differences (and formatting; I am using the standard format given by the respective Language Servers).
But there’s a problem with the D version. If you look at the Assembly it generates, it’s obvious that the switch
is still there at runtime, even when using the -release
flag, as far as I can see.
The reason is that there’s no static switch
in D, i.e. no comptime switch
(pretty lazy of them to only have included static if
)!
For that reason, the switch
needs to be rewritten to use static if
to be actually equivalent to Zig:
long applyOps(Op[] operations)(long num)
{
long acc = 0;
static foreach (op; operations)
{
static if (op == Op.SUM)
{
acc += num;
}
else static if (op == Op.MUL)
{
acc *= num;
}
else static if (op == Op.SUB)
{
acc -= num;
}
}
return acc;
}
This generates essentially the same Assembly as Zig.
I am not aware of any other statically-typed language that could also implement this, at least without macros, but would be happy to be hear about it if anyone knows (languages like Terra, which are not really practical languages, don’t count).
But talking about macros, Rust has macros! It actually has four types of macros.
Can we do this with macros?
Let’s see. Calling applyOps
in Rust should look something like this:
apply_ops!(num, SUM, MUL, SUB, SUB);
Notice that the !
symbol in Rust is a macro specifier. The question is, can we write a loop and a switch in a Rust macro
(this kind of macro is called declarative macro)?
Yes!
macro_rules! apply_ops {
( $num:expr, $( $op:expr ),* ) => {
{
use Op::{Mul, Sub, Sum};
let mut acc: u64 = 0;
$(
match $op {
Sum => acc += $num,
Mul => acc *= $num,
Sub => acc -= $num,
};
)*
acc
}
}
}
The part between $( .. )*
may not look much like a loop, but it is! The reason it looks different than a normal loop is that this uses Rust’s
macro templating language, essentially. That’s the problem with macro systems: they are a separate language within a host language, normally.
Except, of course, if your language is itself written in AST form, like with Lisp languages, but let’s leave that for another time.
At least macros do run, or get expanded, at comptime!
Here’s the full example in Rust:
use std::io::{self, Write};
enum Op {
Sum,
Mul,
Sub,
}
macro_rules! apply_ops {
( $num:expr, $( $op:expr ),* ) => {
{
use Op::{Mul, Sub, Sum};
let mut acc: u64 = 0;
$(
match $op {
Sum => acc += $num,
Mul => acc *= $num,
Sub => acc -= $num,
};
)*
acc
}
}
}
fn ask_user() -> u64 {
print!("A number please: ");
io::stdout().flush().unwrap();
let mut line = String::new();
let _ = io::stdin().read_line(&mut line).unwrap();
line.trim_end().parse::<u64>().expect("a number")
}
pub fn main() {
let num = ask_user();
let num = apply_ops!(num, Sum, Mul, Sub, Sub);
println!("Result: {}", num);
}
It’s surprisingly similar to Zig… well, at least if you ignore the macro syntax.
The cool thing about Rust is that it is very popular, or at least it’s popular with people who care about tooling! So, it has awesome tooling, like the cargo-expand crate.
To install it, run:
cargo install cargo-expand
… and wait for 5 minutes as it compiles half of the crates in crates.io! But the wait is worth it!
Look at what it prints when I run cargo expand
(displayed with the real colors on my terminal!):
Isn’t that pretty!?
I don’t know of any tool that can do this for Zig or D!
This reveals that the macro expands kind of as expected. Hopefully, the compiler will be smart enough to actually remove the
match Sum { Sum => ... }
blocks.
I did try to verify it by looking at the generated ASM using cargo-asm, but that crate couldn’t do it for this example as it crashed!
ERROR:
thread 'main' panicked at /Users/renato/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-asm-0.1.16/src/rust.rs:123:33:
called `Result::unwrap()` on an `Err` value: Os { code: 21, kind: IsADirectory, message: "Is a directory" }
So much for great tooling (and people should just stop using unwrap
in production code that can actually fail… “this should never happen”
always happens)!
I knew I should’ve just used Godbolt from the start, but I prefer to use local tools
if I can. Anyway, without using any compiler arguments, it looks like the match
blocks are still there!
However, using the -O
flag to enable optimisations removes so much code (and inlines everything) that all I see is one multiplication!
What kind of magic is that!?
Generics
Finally, we get to the most famous part of Zig’s comptime
story: how it can provide generics-like functionality without actually
having generics!
Here’s Kristoff’s generics sample (again, updated for Zig 0.13.0):
/// Compares two slices and returns whether they are equal.
pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
if (a.len != b.len) return false;
for (0.., a) |index, item| {
if (!std.meta.eql(b[index], item)) return false;
}
return true;
}
Notice how the first argument to eql
is a type
! This is a very common pattern in Zig.
That makes it look similar to “real” generics in languages like Java and Rust (even Go has recently added generics). But whereas in those languages, generics are a feature on their own, and a complex one at that, in Zig, it’s just a result of how comptime works (and a bunch of built-ins provided to work with types).
Arguably, however, what Zig does is not truly generics but templating.
Check out Zig-style generics are not well-suited for most languages for a more critical look at the differences.
In a language with true generics, there are some signficant differences. Let’s see what it looks like in Rust:
fn eql<T: std::cmp::PartialEq>(a: &[T], b: &[T]) -> bool {
if a.len() != b.len() {
return false;
}
for i in 0..a.len() {
if a[i] != b[i] {
return false;
}
}
true
}
Notice how there’s a type bound to the generic type T
:
T: std::cmp::PartialEq
That’s because you wouldn’t be able to use the !=
operator otherwise, as only implementations of the
PartialEq
trait provide that in Rust.
Removing the type bound gives me an opportunity to showcase another Rust error message, which is always fun:
error[E0369]: binary operation `!=` cannot be applied to type `T`
--> src/main.rs:6:17
|
6 | if a[i] != b[i] {
| ---- ^^ ---- T
| |
| T
|
help: consider restricting type parameter `T`
|
1 | fn eql<T: std::cmp::PartialEq>(a: &[T], b: &[T]) -> bool {
| +++++++++++++++++++++
This kind of generics is quite nice on tooling: the IDE (and the programmer) will know exactly what can and cannot be done with arguments of generic types, so it can easily diagnose mistakes. With templates, that is only possible at the call site because that’s when the template will actually be instantiated and type-checked!
To illusrate that, in the following example, the eql
call fails to compile:
pub fn main() !void {
const C = struct {};
const a: []C = &[0]C{};
const b: []C = &[0]C{};
std.debug.print("Result: {}\n", .{eql(C, a, b)});
}
ERROR:
generics.zig:9:22: error: operator != not allowed for type 'comptime.main.C'
if (b[index] != item) return false;
~~~~~~~~~^~~~~~~
generics.zig:14:15: note: struct declared here
const C = struct {};
^~~~~~~~~
The error is shown inside the template itself, even though the template is not where the mistake is. That’s worse than in Rust and other languages with similar generics, where the error is shown on the call site if it tries to use a type which does not satisfy the generic type bounds in the function signature.
Going back to the error above, that happens because Zig only defines the equality operators
for Integers, Floats, bool
and type
, so it won’t work for structs.
As an aside, as Zig has no interfaces or traits unless you perform some acrobatics, it couldn’t declare type bounds that way, currently. It could, however, do something like D does and allow boolean expression of types to be added to function signatures, as we’ll see.
For other types, the comparison should be done with std.meta.eql
:
if (!std.meta.eql(b[index], item)) return false;
Indeed, after this change, the eql
example now works for any type, thanks to std.meta.eql
actually doing the heavy lifting, of course.
And the way it does that is by specializing behaviour for different types.
Here’s a partial view of its implementation:
pub fn eql(a: anytype, b: @TypeOf(a)) bool {
const T = @TypeOf(a);
switch (@typeInfo(T)) {
.Struct => |info| {
inline for (info.fields) |field_info| {
if (!eql(@field(a, field_info.name), @field(b, field_info.name))) return false;
}
return true;
},
...
.Array => {
if (a.len != b.len) return false;
for (a, 0..) |e, i|
if (!eql(e, b[i])) return false;
return true;
},
...
else => return a == b,
}
}
This shows the special type, anytype
, which is basically comptime duck typing. In this case, the function explicitly handles several
known types (the actual type is obtained using @typeInfo), then falls back on the ==
operator.
In a language with union types,
this would be easy to represent type-safely, but in Zig, anytype
is the only solution, currently
(but there are proposals to improve this). That’s not ideal because programming with anytype
feels like programming in a dynamically typed language: you just don’t know what you can do with your values and just hope for the best.
Unlike dynamic languages, the type checker will still eventually catch up with you, obviously, but that doesn’t help when you’re writing the
function or even trying to figure out if you can call it (you want to avoid being yelled at by the compiler for writing bad code, after all).
The only way to know what the function requires of a value of type anytype
is to look at its source code, which is one of the reasons
why a lot of complaints by people who are new to the Zig community are waved away with just read the code.
For example, this is a valid function in Zig (though it probably cannot be called by anything):
fn cant_call_me(a: anytype) @TypeOf(a) {
const b = a.b;
const e = b.c.d.e;
if (e.doesStuff()) {
return e.f.g;
}
return b.default.value;
}
The @TypeOf(a)
expression is a little awkward in the previous example, but allows telling the compiler that b
must be of whatever type a
is (not anytype
but the actual type at the call site).
There is an interesting proposal to improve on that with infer T
.
Notice how this function signature:
pub fn eql(a: anytype, b: @TypeOf(a)) bool
Can be rewritten to, at the cost of having to specify the type explicitly, the following:
pub fn eql(comptime T: type, a: T, b: T) bool
The choice of which to use is somewhat subtle.
D has had to grapple with most of these issues in the past as well. For example, to assist with knowing which types you could call a generic function with, it allows adding type constraints to its function signatures, making it a little closer to Rust than Zig in that regard, despite using templates to implement generics.
Let’s look at another Zig example before we go back to D to see how it works:
// This is the stdlib implementation of `parseInt`
pub fn parseInt(comptime T: type, buf: []const u8, radix: u8) !T
This function does not declare what types T
may be, but almost certainly, you can only use one of the integer types
(e.g. u8
, u16
, u32
, i32
…). In other cases, it may not be as easy to guess.
Well, with D, one could say exactly what the requirements are (though what isIntegral
means in D is a little bit different):
import std.traits : isIntegral;
T parseInt(T)(string value, ubyte radix) if (isIntegral!T) {
todo();
}
The isIntegral
function is one of many comptime type-checking helpers from the std.traits
module.
That’s a very good approach for both documentation and tooling support.
Notice that you could define isIntegral
yourself
(e.g. to be more strict with what types are allowed):
bool isIntegral(T)()
{
return is(T == uint) || is(T == ulong) ||
is(T == int) || is(T == long);
}
T parseInt(T)(string value, ubyte radix) if (isIntegral!T)
{
todo();
}
Trying to call parseInt
with some other type, say char
, causes an error, of course:
void main()
{
parseInt!char("c", 1);
}
ERROR:
tests.d(51): Error: template instance `tests.parseInt!char` does not match template declaration `parseInt(T)(string value, ubyte radix)`
with `T = char`
must satisfy the following constraint:
` isIntegral!T`
Type introspection
This Zig example is from Scott Redig’s blog post:
const std = @import("std");
const MyStruct = struct {
a: i64,
b: i64,
c: i64,
fn sumFields(my_struct: MyStruct) i64 {
var sum: i64 = 0;
inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
sum += @field(my_struct, field_name);
}
return sum;
}
};
pub fn main() void {
const ms: MyStruct = .{ .a = 32, .b = 4, .c = 2 };
std.debug.print("{d}\n", .{ms.sumFields()});
}
Even though this example only works on this particular struct, you can easily rewrite it so it can sum the integer fields of any struct (even those that have non-integer fields):
const std = @import("std");
fn sumFields(my_struct: anytype) i64 {
var sum: i64 = 0;
inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
const FT = @TypeOf(@field(my_struct, field_name));
if (@typeInfo(FT) == .Int) {
sum += @field(my_struct, field_name);
}
}
return sum;
}
const MyStruct = struct {
a: i64,
s: []const u8,
b: i64,
c: i32,
};
pub fn main() void {
const ms: MyStruct = .{ .a = 32, .b = 4, .c = 2, .s = "" };
std.debug.print("{d}\n", .{sumFields(ms)});
}
This is very, very cool because of the implications: you can use the same technique to generate code that, for example, serializes structs into JSON, or whatever data format.
You may not be surprised by now to hear that D can also do the same thing:
int main()
{
import std.stdio : writeln;
struct S
{
ulong a;
string s;
ulong b;
uint c;
}
S s = {a: 32, b: 4, c: 2, s: ""};
writeln("Result: ", sumFields(s));
return 0;
}
long sumFields(S)(S myStruct) if (is(S == struct))
{
import std.traits : isIntegral;
long sum = 0;
foreach (member; __traits(allMembers, S))
{
auto value = __traits(getMember, myStruct, member);
static if (__traits(isIntegral, typeof(value)))
{
sum += value;
}
}
return sum;
}
The __traits
syntax takes some getting used to, but after that it’s not very different from Zig’s @TypeOf
and similar built-ins.
I want to emphasize again that this is all doing work at comptime, and at runtime, you have custom-made code for each type you call the function with. For example, for a struct like this:
struct AB {
int a;
int b;
}
The actual runtime version of sumFields
should look like this:
long sumFields(AB myStruct) {
long sum = 0;
sum += myStruct.a;
sum += myStruct.b;
return sum;
}
Do visit Godbolt and check it out.
This is in contrast to something like Java reflection. You could definitely implement something similar using reflection, but that would have a high runtime cost, not to mention the code would look much more complex than this.
However, Java does have a solution that can work: annotation processors. Well, at least as long as you don’t mind doing all of the following:
- create an annotation, say
@SumFields
. - write an annotation processor for this annotation that generates a new class that can provide the
sumFields
method. - annotate each class you want to sum the fields of with
@SumFields
. Tough luck if it’s not your class to change. - configure the build to invoke your annotation processor.
- build your project, then finally use the generated class to call
sumFields
.
I was going to include some code showing how this looks like in practice, but I decided to just link to an article that shows something similar at Baeldung instead, I hope you can understand that.
Because the process to do this in Java is so convoluted, people almost never do that in application code. Only frameworks do, they have to. So, you get some pretty big frameworks that can do impressive things like comptime Dependency Injection and JSON serialization.
Rust has procedural macros which are pretty similar to Java annotation processors. But because working directly on ASTs is not a lot of fun, and Rust has macros, one can use the quote crate to write things more easily in a way that resembles a lot templates.
In Zig and D, you don’t need frameworks doing magic and you don’t need macros 😎.
Textual code generation
Finally, Redig’s blog post has a section titled View 5: Textual Code Generation
where he says:
“If this method of metaprogramming (textual code generation) is familiar to you then moving to Zig comptime might feel like a significant downgrade.”
This may sound crazy, but with D, you can even do that:
string toJson(T)(T value) {
mixin(`import std.conv : to;string result = "{\n";`);
foreach (member; __traits(allMembers, T)) {
mixin("auto v = value." ~ member ~ ".to!string;");
mixin(`result ~= " ` ~ member ~ `: " ~ v;`);
}
mixin(`result ~= "\n}\n"; return result;`);
}
Above, I wrote a very basic, incomplete, pseudo-JSON serializer for anything that works with the allMembers trait (though it probably only does the right thing for classes and structs) using just string mixins.
Using the struct instance from the previous example:
S s = {a: 32, b: 4, c: 2, s: "hello"};
writeln("Result: ", s.toJson);
Prints:
Result: {
a: 32 s: hello b: 4 c: 2
}
I don’t know about you, but I find this bonkers good, indeed.
Final thoughts
Zig is doing great work on a lot of fronts, specially with its build system, C interop and top-of-the-line cross-compilation.
Its meta-programming capabilities are also great, as we’ve seen, and comptime
fits the language perfectly to provide some powerful features while keeping the
overall language simple.
The instability of the language still keeps me away for now. I maintain a basic Zig Common Tasks website and every upgrade requires quite some effort (though it was much less in the last version). I hope they get to a 1.0 release soon, so this won’t be a problem anymore.
Anyway, while Zig deserves all the praise it’s getting, I think it’s not the only one.
As I hope to have shown in this post, D’s CTFE and templates appear to be able do pretty much everything that Zig comptime
can, and then some.
But no one is talking about it! D certainly isn’t getting huge donations from wealthy admirers
and people are not being paid the largest salaries to write code in it.
As someone who is just a distant observer in all of this, I have to wonder why that is. I get that Zig is not just comptime
… while D may have an even more
impressive comptime
than Zig, it may lack in too many other areas.
After playing with D for some time, my main complaints are:
- it has too many features, some of which a little rough around the edges, like its multitude of function parameters modifiers.
- the build system, Dub, seriously needs more attention. I tried to help with docs but my help was kind of ignored.
- how hard it is to use
@safe
,@nogc
andpure
- they would be awesome if they were actually usable!. Calling almost anything on the standard library forces you to remove these.
But D also has a lot cool stuff I didn’t mention:
- seamless C interop, as good as Zig.
- betterC for really low-level stuff.
- an excellent concurrency story.
- an extensive standard library.
- 3 good compiler backends (DMD - official, GDC - GCC-based, and LDC - LLVM-based).
Maybe the fact that D has a GC doomed it from the start?
Rust has a less fun way of doing things, arguably, but it also covers a lot of things that are possible with comptime
through macros, generics and traits.
And it doesn’t have a GC (take that D)! And it is memory-safe (take that, Zig)!
Java may not be at nearly the same level in terms of meta-programming as any of the other languages mentioned in this post, but at least writing top-notch tooling for it is much easier, and it shows! And with its love for big frameworks and wealth of libraries, it actually manages to almost compensate for that.