Assert vs comptime if

i’m building the same source code for two very different target architectures, and i’m currently using the following pattern to isolate certain functions that contain (say) target-specific asm statements…

const arch = ... ;  // current target architecture

fn archIndependent() void {
    // compilable for any architecture
}

fn archSpecific() void {
    if (arch != some_specific_arch) return;
    ...
    asm volatile ("...");
    ...
}

without this comptime guard, i’ll receive errors about illegal asm instructions… but apparently this pattern is sufficient to keep the compiler from generating code for the rest of this function…

i was reading up on std.debug.assert, which apparently can provide some level of “semantic hints” to the optimizer… to that end, i tried the following:

fn archSpecific() void {
    std.debug.assert(arch == some_specific_arch);
    ...
    asm volatile ("...");
    ....
}

but now, the compiler did attempt to generate code for archSpecific and failed… FWIW, some_specific_arch is a resource-constrained MCU where the program is built in ReleaseSmall mode… just like the eariler if(...) return statement, the assert(...) doesn’t show up in the generated machine code (as i’d expect)…

what’s a little disappointing is that i wasn’t able to convey my intent using the assert… in the spirit of “design-by-contract”, i have lots of other “semantic information” which i’d like to share with the compiler (eg., numeric ranges)…

could someone please explain just how much assert COULD do in theory, SHOULD do in practice, and actually DOES do at present…

in the meanwhile, can i safely assume that my if(comptime_test) idiom will “always work” – giving me the zig equivalent of the C #if directive???

Use comptime inside the if to guarantee the check will happen at comptime:

const b = true;
if(comptime b){
    // If b is false, this will be ignored 
    // by the compiler, guaranteed.
}

Similarly, you can do comptime std.debug.assert(b). This is equivalent to C++'s static_assert. Compilation fails if the assertion fails.

I think what you want is this:

const arch = ... ;  // current target architecture

fn archIndependent() void {
    // compilable for any architecture
}

fn archSpecific() void {
    comptime std.debug.assert(arch == some_specific_arch);
    ...
    asm volatile ("...");
    ...
}

fn dispatch() void{
  switch(comptime arch){
    .specific => archSpecific(),
    else => archIndependent(),
  }
}

Off-topic, but this is proposed.

1 Like

it clearly happens “earlier” when i do a comptime assert… when i compile my source for the “other” architecture, the compiler fails because of unreachable code!!!

and putting comptime in front of my original if(...) statement fails the compiler, since a “runtime” callable function can’t return a value at comptime!!!

so right now, my original solution – which relies on behavior a little further downstream in the compiler pipeline – is my best option???

I don’t think that would work. The problem here is that assert() returns void. Unless the compiler magically changes the function such that in that instance it returns noreturn, compilation would continue.

What we want is something like this:

const arch = .intel;

fn assertCT(comptime arg: bool) if (arg) void else noreturn {
    if (!arg) unreachable;
}

export fn armSpecific() void {
    assertCT(arch == .arm);
    @compileLog("ARM");
}

export fn intelSpecific() void {
    assertCT(arch == .intel);
    @compileLog("Intel");
}

test "nothing" {}

If you test the code, only “Intel” would appear in the log. assertCT() returns noreturn when arg is false so the call to compileLog() gets skipped.

EDIT: replaced stupid code

1 Like

The difference is that in your original the function is effectively a no-op on other targets, whereas the comptime assert strategy makes calling the function at all on those other targets a compile error.
If you hit the assert it means the compiler cannot prove that the function isn’t called on those other targets.

1 Like

I think in many cases I would instead create multiple modules that have the same functions, but with different implementations and then use build logic to switch which of the modules gets actually added as an import.

That way your code just gets to call the specific functions without having to use guard logic everywhere and you have one place where you can switch on the architecture to pick the right implementation.

2 Likes
const std = @import("std");

const Arch = enum{
    specific,
    nonSpecific,
};
const arch: Arch = .nonSpecific;

fn archIndependent() void {}

fn archSpecific() void {
    comptime std.debug.assert(arch == .specific);
}

fn dispatch() void{
  switch(comptime arch){
    .specific => archSpecific(),
    else => archIndependent(),
  }
}

pub fn main() void{
    dispatch();
}

Godbolt.

This works even when you change arch.
The comptime switch is equivalent to a comptime if, it prevents compilation of the non taken branch.

The architecture should be comptime-known. We would have to see the code, but there’s probably a missing comptime somewhere. The compiler will treat things as comptime when it can, but sometimes it fails to notice that something is indeed comptime, and you need the keyword to enforce it. The assertion is doing its job here. If you remove the assertion and the code compiles, there’s a good chance that function will end up in the binary. Not only is this wasted space, but there could be a code path that leads to this function when it shouldn’t.

3 Likes