Lazy compilation causing no compile errors within unused functions is a headache

I’m coming to Zig from Rust and mostly enjoying it (comptime is fantastic), but the one thing that’s been really frustrating is the lazy compiler and lack of errors for unused functions or even just warnings that they’re unused.

If you write a function that contains invalid code and then don’t invoke it anywhere, your program will compile without so much as a warning from the compiler. This is really annoying, since often I write functions before I use them. I’ve been told “only write the functions you need!” but I often know exactly what I need beforehand and want to write all of the methods for interfacing with a particular struct all in one go, not write them here and there as they are needed.

Sure, writing tests for everything is a solution to force them to be evaluated, but if you’re just quickly prototyping something, that’s not really something you’d want to do. Even if you write tests, you’re still left guessing which methods are covered by tests and which ones are going to explode with compile errors that were previously not showing as soon as you try and invoke them.

This wouldn’t even be such a bother if there was a compile warning for unused functions, but as it is, this has caused me a lot of frustration and uncertainty about the robustness of my code. It’s also very surprising considering how otherwise Zig seems to be very hard-line about giving hard compile errors for unused symbols.

Am I missing something?

3 Likes

Welcome @ElnuDev.

Mainly, the challenge is that of conditional compilation. There’s no free lunch here - either a programming language must require explicit annotation of what is and isn’t supposed to be compiled, or the compiler can figure it out automatically. If the latter is chosen, then it has Zig’s lazy analysis properties including not analyzing stuff that you potentially did want it to analyze.

Some languages do wacky things to address this problem. Go, for instance, has special rules about if your source file ends with “_linux.go”. This is a form of explicit annotation.

C/C++ have #ifdef - another form of explicit annotation.

Rust has #[cfg(foo)] - another form of explicit annotation.

Zig embraces lazy analysis which avoids explicit annotation but risks the existence of dead code, as you noticed.

There is nothing planned really to address this. It’s one of the fundamental tradeoffs the language has made.

26 Likes

As a help, std.testing has the functions refAllDecls and refAllDeclsRecursive which helps with that.

It is essentially explicitly annotating to the compiler that you want stuff to be analysed.

3 Likes

Thank you, this seems to be somewhat what I’m looking for. That being said, It doesn’t help with errors showing in my editor, because I don’t think ZLS treats test blocks like entry points?

I tried putting refAllDeclsRecursive in a comptime block, but its first line returns early if it is not in a test, so I got it to work by copying the source and removing that line and it works for getting errors to show up in my binary project. For whatever reason, this doesn’t work in my project that’s just a library without an executable, though.

Thank you for the reply!

From a technical standpoint is it not possible to eagerly analyze everything in tooling, and then only be lazy when actually compiling binaries?

I understand the advantages of lazy compilation from the perspective of compile speed, but in terms of dev experience it seems really weird to me that you can write a function that contains invalid code and then not even get a warning about it until it’s actually invoked, it’s the first time I’ve used a compiled language that works like this. In Rust you get errors in unused functions, etc. but they aren’t actually included in the final binary if they’re unused to my understanding.

I have a quick question to hopefully get some clarity related to this topic;
From a GitHub issue in early 2023, you had stated that.

refAllDecls is a hack that will almost certainly be removed from the standard library…

Is this still slated for chopping block in the foreseeable future, or have things since changed? Upon reading this issue some time ago, I had stopped using it, and just do manual importing within test blocks to achieve the same effect, figuring my future-self would be thankful when it was eventually removed and my code was unaffected by the change.

I ask because I often still see this function being recommended for use (even in this thread), but was unsure if that was actually sound advice based on that statement, or if we should be recommending alternatives?

1 Like

something I didn’t realize until reading this thread that was surprising to me when learning Zig is that the compiler is quite loud about unused variables, and quite silent about unused functions. I think that contributes to the confusion that most people go through when they run into this for the first time

I had a similar impulse; but as someone else pointed out elsewhere, it will always be possible to implement the function, the question is whether it belongs in std. so I continue to use it, and will copy into my codebases if I need to

2 Likes

What do you mean by tooling? I can’t comment to the feasibility of this approach, but i would note that other than a few integrated tools (fmt, testing) zig is mostly a compiler. ZLS is an unofficial tool (though widely used). I think Andrews question answers about feasibility. It is feasible to do so, as shown by other languages that do it. But there are trade-offs. Compilation speed is one, and one that Andrew and team have worked hard to make very fast.

That being said, It is jarring coming from other languages where all code must be valid before you can compile it, even if it is unused. However, I’ve found that to be less jarring over time. Indeed, I think it has helped me reorder the way I code. I now code from the Main function and build out what I need as I go along, rather than building out parts I think I need and then trying to get them to work together.

When I do find there are things that must be written before integration, I like to write either tests for them or mini-programs that test the desired functionality.

That’s a good point. I have a bad habit of conflating standard library features with language ones in my mind, when we could easily just copy the source code if it were ever removed.

1 Like

you can have zls run any build command on save, it’s a configuration setting. if you name a step check then it will be run automatically without any configuration needed.
https://zigtools.org/zls/guides/build-on-save/

To learn how to define a step:
https://ziglang.org/learn/build-system/#run-step
https://www.youtube.com/watch?v=jy7w_7JZYyw

3 Likes

It may not be related to the OP’s related issues, but if the compiler has such relevant content, it is possible to develop distributed enums, which in turn may allow the source library’s scalable polymorphism to be implemented based on tagged union without the need for virtual tables, while only the extensible polymorphism of binary libraries requires virtual tables.
But I admit that this is difficult to implement due to a huge conflict with the current compiler implementation

Indeed! I stumbled over this too. I’d really like to see errors for unused imports and unused non-exported functions.

I’ve become used to this from JS/TS linters, and even C compilers warn about unused static functions these days :slight_smile:

I’ve stumbled over this, too. But it can also be an advantage sometimes.

1 Like

We implemented this check for TigreBeetle, there’s a relatively straightforward approximation: any non-pub symbol must be mentioned twice in its declaring file:

I think stuffing something like that into ASTGen in the Zig compiler itself might be a good idea!

But in general, yeah, the fundamental grain of the language is that only the used code is type-checked. My mental model for Zig is that itis just an interpreted language, which runs your pub fn main when you invoke zig build-exe, with the caveat that if something is not yet known (e.g., values of runtime-known CLI arguments), the interpreter evaluates Zig code not to a value like 92 but to a snippet of machine code like add eax, ebx. Fighting this generally won’t lead to happiness, you need to work with unique character of every language, rather than against it.

There are both the benefits and the drawbacks to the lazy approach, you want to embrace the benefits and minimize the drawbacks! See, e.g., how you can leverage comptime reflection for tests: Swarm Testing Data Structures.

5 Likes

Would an off-the-shelf build step/compiler flag that errors (helpfully) if dead code is detected, be possible and useful?

Detection for unused non-pub decls is an already accepted proposal.
Pub decls can’t be subject to this same filter though (because they might be meant to be users by consumers of the library). Although to be more precise it would have to be pub decls that are reachable from the root file. Any pub decl that is not reachable by the root file and that is not used anywhere in the codebase is dead code.

5 Likes

Would an off-the-shelf build step/compiler flag that errors (helpfully) if dead code is detected, be possible and useful?

Thinking about this, some sort of ‘build coverage visualization’ in that new --webui thingie would be really cool :wink:

PS: the problem with ‘dead code’ being fully evaluated is that any code that uses conditional compilation on std.builtin or comptime build options would fail, for instance even my simple Dear ImGui sample would fail because the user can select between the docking and non-docking flavour of Dear ImGui (check all usages of the use_docking boolean - the Dear ImGui flag ig.ImGuiConfigFlags_DockingEnable doesn’t even exist in the non-docking version):

I think Zig’s behaviour that the code is “parsed but not compiled” is okay, and at least a bit better than C/C++ #ifdef/#endif which completely skips inactive conditional compilation blocks.

I see your point. I wonder how the accepted proposal to detect dead decls will handle the case of a decl that is only used comptime-conditionally. Will the programmer be required to restructure the code so that the decl only exists if the conditions in which it will later be used apply?

Separately - even though conditional compilation is a thing, so is cross-compilation, which means that you theoretically could detect code that is dead on all supported build configurations. But this is starting to feel like a much heavier-duty thing.

Related:

2 Likes