A core decision made for the Zig language is that code will always be live. This was made early, and is tracked by issue #335.
This is among the most controversial of those decisions, as a read of that issue will show.
What It Means
In concrete terms, status-quo Zig requires certain things to be coherent, or it won’t compile. If a variable is created, it must be referenced, if a function takes parameters, those must all be referenced, and if a variable is declared mutable with var
, then the function must mutate it.
The intention expressed in #335 is to extend this to more aspects of Zig code, but this document will focus on how Zig works now.
Why This is Controversial
This decision doesn’t sit right with everyone. The reasons break down into two related categories. The first is that when we’re writing new code, it’s a hard guarantee that the text will frequently be in an invalid state.
Let’s say you’re writing a new function, and you’ve gotten exactly this far:
pub fn somethingNew(self: @This(), msg: []const u8) void {
|
}
Where the |
is the cursor. Note that it’s inevitable that this status will be reached in the course of writing a function!
Zig will not compile this. ZLS will immediately complain about the unused parameters. This is a process which will repeat every time you create a new const
or var
in the function body: it will be identified as erroneous until it’s referenced or mutated.
It’s understandable that this rubs some people the wrong way. It feels like being told to wash your dishes while you’re still eating dinner. There have been many proposals, pleas even, a fork even, about creating a “draft” mode where this doesn’t happen.
The other problem arises when debugging or refactoring. It’s natural to want to be able to knock out code to see if the change that causes gives any insight into the problem.
However, if commenting code out eliminates the last reference to a constant/parameter, or the place where a variable is mutated, the code will not compile. Debugging is not a time when people want to be thinking about the overall integrity of the code, they’re concentrating on solving the problem, and this feels like an imposition.
So if you don’t like this aspect of Zig development, you’re not alone.
Why This Won’t Change
Andrew Kelly and the core compiler team are firmly committed to this behavior, and it won’t change.
@andrewrk explains why in #335:
Zig is designed primarily for the use case of modifying existing Zig code and secondarily for the use case of writing new Zig code from scratch. This is why it accepts the tradeoff of the annoyance of compile errors for unused things, in exchange for helping to rework existing code without introducing bugs.
What do we get in return for this sometimes-annoying busywork while writing code? We get a guarantee that any Zig code we’re reading is following through on its premises. When you see parameters in a function, they either get used, or they’re explicitly discarded like this:
_ = param;
// Or (this is better style for a deliberate discard):
fn parameterNotUsed(self: @This(), _: bool) void { ... }
When you see a constant, it will be used. When you see a variable, it’s not a constant in disguise, it will in fact be mutated in some way. When you see a label, there will be a break
or continue
which references that label.
If you discard an identifier with _ = something;
and then later use something
, this, too will not compile: the compiler will return error: pointless discard
.
This provides a baseline level of coherence to any Zig code you read. Violating these rules is not an option, period, so it isn’t something you have to think about, check to see if builtin.is_draft
is being set in the build script, none of that. These rules operate on the same level as type checks, you can count on them.
Most experienced Zig authors come to appreciate the benefits of this contract. Code should mean something. If you’ve ever lost an afternoon of your life trying to debug a function full of things which don’t make sense: variables which don’t vary, parameters which just disappear, you’ll know that’s one afternoon too many. If you haven’t, count yourself lucky.
But the issues which arise when writing new code, and debugging, remain.
How To Work With It
Here are a few tips on how to write new code, and debug old code, while working with the live code requirement.
Something which has to be said is that the problem is currently exacerbated by our tooling. ZLS is great work, carried on by volunteers, who are doing a good job. But for technical reasons which are hard to address, using the language server can force an urgency in fixing the temporary state of dead code, which doesn’t have to be there.
I’m not talking about the little red squiggles telling you that parameters aren’t being used and so on. That’s useful information, and it’s harmless on its own. But ZLS often becomes unable to provide things like completions, sometimes it can’t even format code, until those things are “fixed”. That’s in scare quotes because partially written code isn’t broken, it’s just not complete.
That is in fact excessive, because it prompts a need to add and then remove an extraneous line or three just so that the language tools will work while you fill things out. But again, it’s not easy to fix this behavior, and it’s good to remember that a) ZLS isn’t Zig, and b) it’s still early days on all these things, and patience will be rewarded. I’m sure that the ZLS team would love some assistance in giving a better experience here, if you’re of a mind to chip in.
ZLS also has an autofix
mode which can mitigate this problem. But fundamentally: you will need to compile your code eventually, and if it isn’t in a live state, that won’t happen.
Writing New Code
Let’s say you’re working on a function, and that function needs a to call a method which doesn’t exist yet. So you want to write the signature of that method, keep working, and go back to fill it in.
An easy way to get that looks like this:
pub fn newMethod(self: @This(), a: usize, b: f64, c: u8) f64 {
_ = .{self, a, b, c};
}
There’s currently a ‘loophole’ in the compiler, where it doesn’t consider this a pointless discard, meaning that as you reference self
, a
, b
, and c
, the tuple discard remaing legal.
That’s a mixed bag, and I wouldn’t expect it to last forever. It does mean that you can leave a discard tuple like this in your code indefinitely, meaning you don’t have to remove fields as they get referenced in the function body.
On a personal note, I prefer this tuple-discard style because it’s nicely compact, but I would like it more if the compiler considered any field in a tuple discard which is later referenced to be a pointless discard. I don’t like the risk that I’ll just leave it there when I’m done working.
If you have ZLS autofix turned on, an empty method with the above signature becomes this:
pub fn newMethod(self: @This(), a: usize, b: f64, c: u8) f64 {
_ = self; // autofix
_ = a; // autofix
_ = b; // autofix
_ = c; // autofix
}
And autofix will also remove these discards when they’re no longer valid. Some people really like this, some don’t.
Another thing to know is that discarding a var
requires a slightly different approach:
var new_var: usize = 0;
_ = &new_var;
For somewhat out-of-scope reasons, taking the address of a var
counts as a mutating use as far as the compiler is concerned.
This is officially discouraged, because the compiler can’t identify the discard as “pointless”. In other words, this compiles:
test "undetected discard" {
var i: usize = 5;
_ = &i;
i += 1;
try std.testing.expectEqual(6, i);
}
The fact remains that, while this may be a dirty hack, it does work, and it’s currently the only way to settle down ZLS and the Zig compiler about an unused var
for long enough to get to the lines where you use it. Employ cautiously.
For completeness, and to introduce a technique used in the next section, you can discard an unused label this way:
if (false) break :label;
This is not recommended. It’s cleaner to write whatever label-able block you’re working on, and then add the label when you’re ready to use it. But if you want to write the label up front, this does work. It has the same risk as a discard tuple or a pointer discard of a variable does, because it remains legal code after you do use the label.
While there’s room for improvement in the tooling, these are things which quickly become a habit, and when they do it will no longer distract you from the task at hand.
If you do get frustrated, remember that you can turn the language server off and on whenever you’d like, and that tooling will only improve over time.
Debugging is a different story. Zig’s liveness criterion is actually a strength here, but it calls for a modified technique from what you may be used to in other languages.
Debugging Zig Code
Here there’s a different issue which arises.
First off, let’s stress how nice it is to not have to think about whether code is dead when you’re debugging it. The liveness rule really is there to make your life easier when you need to read and debug code, whether someone else wrote it, or whether that someone else is you in the past.
One of the natural things to do when debugging is to knock out or stub some of the code. Classically this is done by commenting it out. That’s actually a bad habit, because it leads to blocks of commented code getting left in the source, and usually with no additional commentary on what the zombie code is doing there in the first place. The accident could either be not uncommenting it, or not deleting it, no way to tell. This is what live code is meant to prevent.
Zig has a much better solution to this problem. What you can do is add this line somewhere in a file you’re debugging, in the container (aka “global”) scope:
const XXX = false;
Now we have an all-purpose code knockout device.
Need to knock out a while loop?
if (XXX) while (thing < other_thin) {
// lots of other code
}
Gone. Works for a line, works for an existing block, if you need to knock out several lines within a block you wrap it in braces and use if (XXX)
.
Since the value of XXX
is compile-time known, that code doesn’t exist as far as the compiler is concerned. It’s just gone for the duration. But everything in the code counts as “live” for these purposes, so you never have to deal with an additional problem where you comment out the only reference to a constant and now the constant is invalid.
If you determine that the code should be removed, and you remove it, then something like a now-unreferenced constant becomes invalid, and you have to remove that too. This is why the rule is good! It’s very easy to leave little stranded bits in a function under these circumstances, and then someone else, possibly you in the future, would have to try and figure out what the spurious variable is doing. That’s the fate the liveness rule spares us from enduring.
Commenting out code is a bad habit to get into. Everyone indulges a bit when trying to bring up a new piece of code, but even then it’s a better habit to use an if (false)
. It’s difficult to grep specifically for commented out code, but if (false)
and if (XXX)
are both trivial to search for.
For debugging it’s better to use a named variable à la XXX
, which obviously doesn’t belong in the program, because when you’re done, you can just delete it. If there’s any knocked-out code left, the compiler will alert you, and you have a chance to decide between removing it and removing the conditional which knocks it out. Either way, you won’t be left with dead code.
A relative of this technique works for the related task of stubbing in an early return. Zig also disallows unreachable code, on the same basic premise, so you can’t unconditionally have code following a return expression.
Let’s say you have some tests which are invalidated, and you don’t want to fix them right away. You can skip them like so:
test "several things" {
if (true) return error.SkipZigTest;
try std.testing.expect(thing.whichDoesNotWorkNow());
// ...
}
The compiler counts the rest of the code as reachable, even though a literal true
branch will always execute, so this compiles.
When debugging, sometimes you need to mock a pre-determined return value into a function, so you can add if (true) return some_value;
at the top of the function. If you’re using XXX
already, you may as well spell that if (!XXX)
, because again, you get the benefit that when you remove the dummy boolean, everything which uses it becomes invalid.
It’s reasonably common for projects to have a commit hook which prevents the string “XXX” from ever being merged with the trunk branch, and this technique cooperates with that convention.
Afterword: a Moment of Zen
The liveness rules exist for a reason, and they solve real problems. Especially when you’re learning the language, it might not feel that way, because writing code comes before debugging and refactoring it, so the friction appears before the benefit is apparent.
These rules are an integral part of the philosophy of Zig, directly addressing a few points from The Zen:
- Communicate intent precisely.
- Favor reading code over writing code.
- Reduce the amount one must remember.
The reward for doing a bit more work up front is durable, because it means that you can trust what you see. Tooling can be improved, but if the Zig compiler were to relax the constraints, we’d lose that promise.
We
- Avoid local maximums
And focus on
- Incremental improvements
So that
- Together we serve the users.