Hi everyone! Recently, PR #19414 was merged, which more-or-less rewrote the compiler’s internal implementation of comptime var
. This rewrite comes with some language changes, so I thought I’d explain the user-facing impact of this PR here so that people know what’s going on when they inevitably get errors
TL;DR
A pointer to a comptime var
is never allowed to become runtime-known, to be contained within the resolved value of a declaration, or to be otherwise referenced by analysis of any declaration other than the one which created it.
The most likely way for this to manifest is in a function computing a slice at comptime by filling an array. To fix the error, copy your finalized array to a const
before taking a pointer.
If you used the previous semantics to create global mutable comptime state, your code is broken. This use case is not and will not be supported by Zig – please represent your state locally.
If that all made sense, there you go – you can go and spend your time on something better! Otherwise, read on and I’ll explain this in more depth.
The Long Version
Zig supports the concept of a comptime var
: a mutable, comptime-known, local variable. These can be created with explicit comptime var
syntax, or, if a function is evaluated at comptime, all var
s within it are implicitly comptime
.
As of commit 4055022
, there are some restrictions in place on the usage of comptime var
.
Firstly, a pointer to a comptime var
is never allowed to become runtime-known. For instance, consider this code:
test "runtime-known comptime var pointer" {
comptime var x: u32 = 123;
// `var` makes `ptr` runtime-known
var ptr: *const u32 = undefined;
ptr = &x;
if (ptr.* != 123) return error.TestFailed;
}
This code previously worked as you might expect. Now, it is a compile error, because the assignment to ptr
makes the value &x
– which is a pointer to a comptime var
– runtime-known.
Such pointers can also become runtime-known by, for instance, being passed to a function:
fn load(ptr: *const u32) u32 {
return ptr.*
}
test "comptime var pointer as runtime parameter" {
comptime var x: u32 = 123;
if (load(&x) != 123) return error.TestFailed;
}
This test also emits a compile error. The call to load
occurs at runtime, and its ptr
argument is not marked comptime
, so ptr
is runtime-known within the body of load
. This means that the call to load
makes the pointer &x
runtime-known, hence the compile error.
The second rule is that a pointer to a comptime var
cannot be contained within the resolved value of a container-level const
or var
declaration. Intuitively, the comptime var
is not allowed to “leak out” of the declaration that created it. Here is an example which violates this rule:
const ptr: *const u32 = ptr: {
var x: u32 = 123;
break :ptr &x;
};
comptime {
_ = ptr;
}
Attempting to compile this code will emit a compile error, because the global value ptr
contains a pointer to the comptime var
named x
. This can manifest in much subtler ways, such as nested pointers and/or datastructured:
const S = struct { counter: *const u32 };
const ptr: *const S = ptr: {
var x: u32 = 123;
const s: S = .{ .counter = &x };
break :ptr &s;
};
comptime {
_ = ptr;
}
The same error occurs here: the value of the global ptr
contains the value &s
, from which we can access s
, which contains the value &x
, from which we can access x
. Note that it doesn’t matter that there is a const
in the mix, nor that all pointers involved are marked const
: if there is any accessible reference to a comptime var
, the value is invalid.
This rule is the most common way this error will manifest. It comes up often in comptime
code constructing slices:
pub const my_name: []const u8 = name: {
var buf: [5]u8 = undefined;
// In practice there'd be some complex logic here to set up `buf`!
@memcpy(&buf, "mlugg");
break :name &buf;
};
Analysis of this declaration fails just like the above, for the exact same reason: the slice value, even though marked const
, contains a reference to the comptime var
named buf
. The solution here is to copy the finalized data into a const
, to avoid any references to a comptime var
:
pub const my_name: []const u8 = name: {
var buf: [5]u8 = undefined;
@memcpy(&buf, "mlugg");
const final = buf;
break :name &final;
};
There’s one more context in which errors can now be raised, but it’s fairly esoteric, so I won’t go into detail: but if a container type captures a pointer to a comptime var
from an outer scope, the error will also be emitted. For instance:
pub const x: u32 = blk: {
var unused = "hello";
_ = struct {
comptime {
// This reference causes this `struct` to capture
// a pointer to `unused`.
_ = &unused;
}
};
break :blk 123;
};
Despite the fact that unused
is not in any way referenced by the final value of x
, and that the struct
type is never even used, its creation here raises a compile error by capturing a pointer to a comptime var
. If you do happen to hit this, chances are you don’t need to capture by pointer – try just accessing the variable directly!
What about my global mutable comptime state?
Previously, you could get a global pointer to comptime memory and use it to make, for instance, a global counter:
const counter: *u32 = c: {
var x: u32 = 0;
break :c &x;
};
This code is not correct. It will not work, and Zig does not and will not have any method for global comptime-mutable state. Please represent your state locally.
Why?
At first glance, these changes seem a little arbitrary and unjustified: however, they are important for reasons relating to both language soundness and compiler design.
Firstly, language soundness. Before this change, it was possible for a pointer to a comptime var
to be known at runtime. If you’re just loading from the pointer, that isn’t necessarily catastrophic: although it’s still not great, because you’ll only be able to read the “final” value of the comptime var
. But even worse, what if you try to store to the pointer? Well, I can tell you: the answer is that shit breaks. Here’s one example of an old issue where someone completely overlooked this issue – completely understandably, because within Zig’s type system the invalid code was considered fine. These recent changes mean that it’s impossible for a pointer to a comptime var
to be used at runtime, avoiding this issue entirely.
Secondly, compiler design. It’s no secret that I’ve been doing a lot of work on incremental compilation lately: this is likely to be one of the most significant features, not necessarily of Zig as a language, but of its compiler. Incremental compilation relies on the fact that global declarations are largely independent of one another, and their interdependencies can be easily modeled (for instance, one global declaration might depend on the resolved value of another). We also depend on the fact that the order of analysis of different declarations is (aside from some awkward edge cases, which eventually will either be ironed out or defined as implementation-defined behavior) unobservable. Unfortunately, global comptime state breaks both of these promises. Loads and stores of this state depend entirely on analysis order, and re-analyzing a declaration may mutate global state in ways which would not happen without incremental compilation. In short, the previous behavior was completely incompatible with incremental compilation.
As a nice bonus, this change has given way to some huge cleanups of internal compiler datastructures. This will almost certainly lead to user-facing improvements: bugfixes, performance improvements, and eventually, incremental compilation.
The road to incremental is long and winding, but we’re marching it nonetheless