Zig and Liveness of Code

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.
43 Likes

Excellent article, thanks @mnemnion for the effort!

I am on the camp that would like to be able to temporarily disable the liveness checks, but I promised myself I would not bring this up ever again (or participate in any discussions about the topic) because most of the discussion happens in terms of “I am right to want it this way, you are wrong for not wanting it”. Your article firmly avoids that, and for this I thank you.

No matter what, I still love Zig.

9 Likes

Good article!

One thing that I would add is that you can also discard a parameter at declaration site by giving it the name _:

pub fn newMethod(_: @This(), a: usize, b: f64, _: u8) f64 {
     return someFunction(a, b);
}

This is less flexible and useful than the assignment style _ = foo when in the process of writing code, but for completed functions, a parameter declared as _: T more clearly conveys that the parameter is there to fullfill some sort of contract/interface, perhaps because the function will be passed around as a callback or to avoid breaking backward compatibility. _ = foo on the other hand suggests that the implementation of the function is still a work in progress and not yet completed.

5 Likes

I have taken to always having the following function somewhere in my code (ig is short for ignore):

pub fn ig(args: anytype) void {
    _ = args;
}

Then ig(&somevar) wipes out the warnings permanently.

I can then rearrange whatever is inside the ig function depending upon whatever idiom becomes unacceptable to Zig this week.

It’s irritating. It’s infuriating. It leaves bird droppings all over my code. I hate it.

But I can get on with debugging my code without having to fight the damn compiler.

Great Article @mnemnion. These comments left me with a few questions:

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.

This is officially discouraged, because the compiler can’t identify the discard as “pointless”. In other words, this compiles:

I’m not sure I understand what is meant by pointless discards. Are you saying that it is expected in the future that patterns like _ = notneeded; will be done away with?

It seems like that would be terrible. It would make it impossible to implement the interface of a function where you don’t need all the parameters.

I’ve been using Go as my primary language in my day job for over a decade, and I’m still very new to Zig.

Go chooses to use compiler errors for unused variables (in function scope but not in “package” (like top-level) scope)-- the temporary workaround while still developing is similar, _ = x to make the variable “used”.

It’s a similarly common complaint in the Go ecosystem, so it has come up in their FAQ under the heading “Can I stop these complaints about my unused variable/import?”, and people file issues about it on occasion.

Go similarly disallows unused loop labels, but actually does not complain about unused constants – Go constants are a bit different from Zig constants in that you can only basically only declare numeric and string constants. It is slightly surprising that these two functions are treated differently.

func bad() {
  x := 1 // <-- compiler error about unused variable
  fmt.Println("hello")
}

func fine() {
  const x = 1 // No compiler error.
  fmt.Println("hello again")
}

Go does not complain about unused function arguments, though. Zig’s approach to unused arguments feels like it would have been a good fit in Go, IMO.

I’m just pointing this out that there is a pretty mature ecosystem over there in Go, where people have quickly learned to deal with some flavor of “unused values are illegal”, and they largely don’t complain much about it. (Personally, I do find it useful and not annoying.)


This is very sage advice for any programming language. When I am debugging something in one of the innermost layers of a large piece of software – and often while I have a nontrivial change already in progress – I will still put my first couple print calls in where it seems like it will help, but after the fourth or fifth one, they all get deleted and I put const debugging = true somewhere in an upper scope, exactly so I can trivially remove only that debugging code. Or if I’m juggling a couple bugs at once, I might use a little more detail in the names like debugLocalFile, debuggingNilFoo, etc.

When we finally have all proper tests in place, we make sure the tests still pass when all the debugging values are set to false, and then we can either search and replace for references to those variable names, or we can just delete the constants to let the compiler point us to the debugging code we can delete.

This approach is a significantly better experience than poring over your changes to filter out what is debug code to be removed and what needs to actually be included in the commit.

1 Like

“Pointless discard” is a compiler error:

test "pointless discard" {
    const tru = true;
    _ = tru;
    try std.testing.expect(tru);
}

This doesn’t compile, you get error: pointless discard of local constant.

I don’t think that the pointless discard error is going anywhere, but the tuple-discard pattern doesn’t trigger it and for obscure reasons the address-discard doesn’t trigger it either, and probably can’t (or not without great effort). The tuple discard probably should, however.

It’s late where I am, but I’ll take some time tomorrow to make that part clearer.

1 Like

No worries, I see now I had it backwards. I was thinking you were saying that discarding was going to be eliminated as valid.

1 Like

Very nice article! :saluting_face:

1 Like

Thank you for pointing it out! It means I didn’t communicate that clearly, so I’ve added an introduction to the term “pointless discard”, such that it doesn’t come out of nowhere.

Similarly:

Is a good point as well. I wanted to introduce “draft friendly” discards, but I wouldn’t want to encourage poor style, so I’ve added this to the code block as well.

Also: anyone should feel free to give feedback on the article, and I’ll do my best to incorporate it. Alternately, the article is a Wiki link, so if you see something to improve, and want to, you can do that directly.

Love this article. Worth a section of the docs or a site full of articles about:

Living with Zig – Learning to love the software you love.

1 Like

What about vars being written to not being read?

Assume I have
std.debug.print("x={d}\n", .{object.as_int()});
and I want to add one to the object’s int in display. I add a var obj_int = object.as_int();. Zig complains about me introducing that var, but never writing to it. Fine, let’s write. obj_int += 1;

Let’s revisit the whole code after my changes:

var obj_int = object.as_int();
obj_int += 1;
std.debug.print("x={d}\n", .{object.as_int()});
// should be: std.debug.print("x={d}\n", .{obj_int});

The compiler could complain about my lack of use of obj_int here, couldn’t it? Shouldn’t it?

After having updated zig code this way and stumbling over (a more complicated) update, and if I were to successfully embrace the liveness of code idea fully (I’m struggling, but let’s assume I’m trying), I guess I would’ve expected a compile error after all here.

(Yes, it would’ve complained had I written const obj_int = object.as_int() + 1; here. This works in this example, but maybe not in my actual use.)

2 Likes

Together we serve the users. But zig seems to serve it’s own needs first and foremost because the plenty of developers don’t like the forced errors for unused variables.

1 Like

I always read that as users of the software that gets produced through the influence that Zig has on products and the software industry, so I read it as users of the resulting products, not primarily developers using Zig. I think it is more focused on the people who end up running the software and how the software landscape ends up serving people in general.

Zig doesn’t only focus on the developer who first writes it, but also those who later have to maintain it, or use the resulting software or use Zig as a toolchain.

These broader goals mean some short term developer comfort gets sacrificed (compared to if the development experience was the only priority), for hopefully better code and maintainability long term and making it easier for people to work well with another.

8 Likes

Maybe, but probably not. A language with pointers makes it fairly difficult to detect this kind of lack-of-liveness:

var obj_int = object.as_int();
const obj_int_ptr = &obj_int;
obj_int += 1;
return obj_int_ptr.*;

This is also one of the reasons why taking a pointer to a var qualifies as mutation.

The specific example here looks easy enough to catch, but that doesn’t generalize, and I suspect that an algorithm which does generalize is superlinear, and compilers can’t afford many of those.

The language needs to strike a balance here, and I think “variable is mutated but the result is never used” is impractical to provide. I’m sure that time will bring more advanced tools for validating Zig code properties, possibly with a bit of language-level support, and that will be the proper tool for catching bugs of this nature.

As long as we retain some way to ‘park’ a var while working on code, the way _ = &thing; functions now, I wouldn’t object to “variable is mutated but the result is never used” being a compile error. I suspect that it’s surprisingly difficult and expensive to catch all cases of it, but it does fit the bill for “Compile errors for unused things”, so if it can be accomplished on a practical level I’d be happy for the compiler to catch a bug like that.