Zig's 0.14 release inches the project toward stability
The Zig project has announced the release of the 0.14 version of the language, including changes from more than 250 contributors. Zig is a low-level, memory-unsafe programming language that aims to compete with C instead of depending on it. Even though the language has not yet had a stable release, there are a number of projects using it as an alternative to C with better metaprogramming. While the project's release schedule has been a bit inconsistent, with the release of version 0.14 being delayed several times, the release contains a number of new convenience features, broader architecture support, and the next steps toward removing Zig's dependency on LLVM.
More targets
Zig tracks the different architectures it supports using a tier system to describe different levels of support. This release looks as though it demotes all tier-1 targets except for x86_64 on Linux to tier-2, but that's actually an artifact of changing how the project defines its tiers. tier-1 targets used to be the ones with working code generation, a fully implemented standard library, regular continuous-integration testing, all language features known to work, and a few other conditions. The 0.14 release adds a requirement that the compiler can generate machine code for the target without relying on LLVM for it to be considered tier-1.
Zig has been trying to drop its dependency on LLVM for some time. The first step of that process was switching to the self-hosting compiler; once that was complete, the project started focusing on adding native code generation. This release includes a mostly-working x86_64 code generator, although LLVM remains the default. If it proves stable, the Zig code generator may become the default for debug builds (where performance impacts from a less-mature optimizer are less important) in the next release. The release also includes an (experimental) incremental-compilation mode. When combined with the Zig code generator, the new mode makes debug builds quite fast.
Besides the code generation changes, Zig's list of supported targets has significantly expanded in this release, largely due to additional support in the standard library for different platforms. The release notes make this bold claim:
The full list of target-specific fixes is too long to list here, but in short, if you've ever tried to target arm/thumb, mips/mips64, powerpc/powerpc64, riscv32/riscv64, or s390x and run into toolchain problems, missing standard library support, or seemingly nonsensical crashes, then there's a good chance you'll find that things will Just Work with Zig 0.14.0.
Pages and allocation
The 0.14 release also saw a major change to the way the language handles different page sizes. It used to be that each architecture was expected to have a single, static page size. This doesn't match how some architectures (notably Apple Silicon) work — the page size can vary at run time. The new Zig release switches to having two compile-time constants: a minimum and maximum size. At run time, the program can detect what the actual page size is.
This change required rewriting Zig's general-purpose memory allocator, because it assumed that the page size was fixed. Andrew Kelley, Zig's founder, wrote about the improvements that he was able to make in rewriting the allocator. In short, the old general-purpose allocator is now Zig's debug allocator, and a new multi-threaded allocator should be Zig users' first choice.
Kelley has some benchmarks showing that the multi-threaded allocator actually outperforms the GNU C library's (glibc's) memory allocator — although, as is always the case with benchmarks, simple tests can be misleading. The multi-threaded allocator is about 10% faster when using the compiler to compile itself, but Zig's compiler has a somewhat unusual approach to memory use, so whether the gains translate to other programs remains to be seen. One potential complication is memory fragmentation; allocators can trade decreased time for increased fragmentation or vice versa. Zig programmers can experiment with the multi-threaded allocator, the debug allocator, glibc's allocator, or others in order to find what works best for their programs.
Labeled switches
The biggest change to the language itself is the addition of labeled switch statements. In Zig, continue statements require specifying the label of the loop that is being continued to. This helps avoid confusion when there are nested loops in a program. Now, switch statements may be given optional labels, so that continue statements can target them. This turns switch statements into implicit loops, since control flow can return to the start of the statement. The intended use case is writing more efficient byte-code interpreters (a topic that LWN discussed in a recent article), although the feature also makes writing some finite-state automatons more straightforward.
state: switch (State.start) {
.start => switch (self.buffer[self.index]) {
'0'...'9' => {
result.tag = .number;
continue :state .number;
},
else => continue :state .invalid,
},
.number => { ... },
.invalid => { ... },
}
When the values given to the continue statements are known at compile time, the compiler produces direct jumps. When they are only known at run time, the compiler produces a separate indirect jump for each case, which helps the CPU's branch predictor learn which cases are likely to follow which other cases. The first implementation of the feature ran into problems with an LLVM optimization pass that combined the jumps into one, but the version present in the release doesn't have that problem.
Another change that makes writing Zig programs more pleasant has to do with how the language interprets enumeration literals. Zig supports a limited form of type inference in the form of "Result Location Semantics"; when the compiler knows what type the result of an expression should have, that information can be used to automatically perform certain casts, or provide a shorter syntax. For example, when the compiler knows that the result of an expression is a value of an enumeration type, the programmer can leave the name of the type out — writing .number in place of State.number, as in the example above.
The 0.14 release extends this shorthand to things that aren't enumerations. Now, when the compiler sees the expression ".name", it will look for an associated constant with that name on the inferred type of the expression; if one exists, the value of that constant will be used. Besides being a useful shorthand, the new syntax lets developers rename an enum variant in-place without needing to update all the callers by defining the old name as an alias for the new one. In combination with improvements to packed structures — allowing them to be compared for equality and operated on atomically — Zig programmers have a good deal of freedom to create types that seem like simple enumerations while containing other information as well.
A practical example of this use in the standard library comes from Zig's support for different calling conventions. Zig functions can optionally be given a callconv() annotation in order to set their calling convention:
pub fn callable_from_c() callconv(.C) void { ... }
Previously, this annotation took an enumeration type specifying one of a handful of alternatives. Now, the standard library has changed it to a tagged union, so that different calling conventions can add additional configuration options. Most of them permit setting the required stack alignment, for example. The previous versions have become constants defined for the type, so the new literal syntax ensures existing code doesn't need to change.
The future
Zig's development community remains active, with 350 commits by 58 authors over the past month — a speed that's fairly typical for the project. Despite this, the 1.0 release of the language remains several years away. Kelley has already made a strong commitment to backward compatibility once Zig reaches version 1.0, and consequently has a long list of experimental features and deficiencies in the current implementation that should be solved before then.
The number of actual changes to the language itself in each release seems to be slowing down, with this release boasting many small changes, as opposed to the wide-reaching differences of previous releases. That stability seems to have afforded the project the chance to spend time improving the tooling instead, as demonstrated by the expanded number of supported architectures and improvements to the compiler's backend. Despite that improvement, there are still a large number of known problems with Zig's tooling, not least of all because of how expansive that tooling is. Zig includes a lot of tools that other languages do not — a translator for C code, a cross-platform C compiler, a build system, and (new in this release) a built-in fuzzer — and it seems likely that the development cycle for the next release, expected in about six months, will be spent improving them.
