Should `fn std.debug.expect(bool) !void` be created?

Hi,

std.debug.assert(bool) void immediately crashes the code when it encounters an error.

While that is useful in debugging certain cases, it would be safer if instead of crashing, we had something like std.testing.expect(bool) !void within std.debug module such that it returns an !void error union, because we can try it and recover from the error.

We can use std.testing.expect() directly but the documentation recommends to only use it in tests.

Why would we need expect in std.debug module?

Because assert directly crashes the code and we would want some safer way to run runtime safety checks within our code, such that anyone who uses our code can recover from errors. expect which returns a tagged error union, allows the user to try the result and propagate the error up the call stack.

As of now, I’m directly using std.testing.expect (by ignoring the recommendation to only use it within tests).

Thus I think it would be better if expect was added to std.debug.

Thanks.

 // Hot loop: Brute force search for right cell.
 // Iterate through the left and right node indices until they match.
 // Although this is O(N*M), N and M are small values (N, M in [0,10])
 for (cells.row(icr)) |inr| {
     for (node_index_to_find) |inl| {
         try expect(inl < std.math.maxInt(u32)); // < SAFETY CHECKS
         try expect(inr < std.math.maxInt(u32)); // < SAFETY CHECKS
         if (inl == inr) {
             num_nodes_found += 1;
         }
    }
}

Why not just write it as an if in the code directly and provide a more descriptive error type?

if(inl >= std.math.maxInt(u32)) return error.OutOfBounds;
// Instead of
try expect(inl < std.math.maxInt(u32)); // returns a nameless error.UnexpectedResult

Sure you need to type a few more characters, but you also gain nicer error messages in release builds without traces.

7 Likes

This is a nice idea and I might use it to provide more descriptive errors.

However often time we just want to run some safety tests as quickly as possible and returning !void seems good enough.

So having std.debug.expect would help, since it’s simpler to use.

Thanks.

I can see that it would be more convenient, but you will find that a lot of Zig’s design favors readability over being able to write it quickly, since code is more often read than written.

And during the debugging process, which I assume is the place where you want to quickly sanity check something, I don’t see why you wouldn’t just use assertions here, which are even faster to type. Or do you have another use-case in mind?

1 Like

assert isn’t really meant to catch recoverable errors, it’s a tool to check for invalid state caused by programming errors. To quote zig zen:

* Runtime crashes are better than bugs.

assert is exactly this, instead of allowing a program to keep running when invalid state, or in other words a bug, is encountered, it just crashes the entire thing instead and you get a nice stack trace to help you with debugging.

I feel like expect would lend itself to misuse because it would promote making bugs just another type of error a function can return which the caller has to try to handle.

This is maybe a bit of a stupid example, but imagine you’re writing Minecraft and you messed up some item collection logic, so now when you collect a diamond and you already have a stack of them in your inventory, instead of starting a new stack the item counter overflows and you now only have a single diamond. You’ve been a careful programmer so you check the total amount of diamonds in the player’s inventory with expect(count == prev_count + 1) afterwards.
Oh no, the expectation wasn’t met! You return error.UnexpectedResult.
What’s the caller supposed to do with this information? The player still only has a single diamond in his inventory, all of his precious diamonds are gone!
If you now just continue running the program you’ve just stolen an entire stack of diamonds from an unsuspecting player. Better to let the program crash and make it immediately obvious to the programmer that something’s gone seriously wrong. Otherwise the player might not even notice that his diamonds have gone missing if he hasn’t been paying close attention and he’ll just continue playing. Maybe he’ll notice in a week because of something completely unrelated, and then he will be seriously confused!

All to say, it’s probably better to just let your program crash if you’ve entered invalid state. And if you know that you can recover, then just do that or use explicit errors to communicate exactly what’s gone wrong until you’ve bubbled it up to some component that can actually perform the recovery.

6 Likes

You seem to be asking to put test within the main body of your program. In principle, this shouldn’t be done; the main body of your program should only contain assertions, and expect, as you mentioned, which is only used for debugging, shouldn’t appear in the final main body of your program. Unit tests should be independent of the main body of your program, not mixed in with it. However, if it’s only for temporary use and won’t end up in the main body of your program, using std.testing.expect temporarily might be acceptable.

It should not.

Often times libraries in C/C++ are plagued with assertions and make it difficult for others to use the libraries safely, because any function we call can cause a runtime crash, due to assertions.

Instead Rust provides a better solution where we can return an Option or Result that allows us to recover from potential errors due to calling some external library function. That is also what all Zig code should try to do.

If any particular Zig library crashes instead of returning a recoverable error/option, most people will eventually stop using it.

There are two kinds of errors in the code world: the first is a program’s own logical errors that lead to unreachable paths; the second is a predictable error caused by external input.
For the first, the correct approach is to “fail fast” to expose the problem as quickly as possible, rather than pretending to handle it even after a clear logic error has occurred. In Rust, the correct response in this situation is panic!, not throwing an error.
For the second, you should clearly understand the external input that may have caused the error and throw a clear error such as error.InvalidArgument.

5 Likes

The solution that @IntegratedQuantum gave already solves this: Just invert the condition and return an error if the failure condition is met. Rust’s error system is quite similar to Zigs. Asserts should be used to verify programmer errors. Rust also panics when using assert! with a false condition:

Asserts that a boolean expression is true at runtime.
This will invoke the panic! macro if the provided expression cannot be evaluated to true at runtime.

1 Like

Yes, assert is not a tool for catching recoverable errors, but no, it’s not a tool for catching invalid state. @panic is the tool for catching invalid state.

assert and, more specifically unreachable (and other builtins like @intCast) are about providing the compiler with extra knowledge about things that are not possible.

In safe modes assertions will not be trusted and so will be lowered to panics (because panic is the tool for catching programming errors), but in ReleaseFast and ReleaseSmall, assertions will be used to perform optimizations, resulting in wrong behavior if the assertion is wrong.

As an example:


const Case = enum { one, two, three };

pub fn handleCase(c: Case) usize {
   if (case == .one) return doOne();
   
   // some other logic 

   switch (case) {
      .one => unreachable, // handled above
      .two => return 2,
      .three => return 3,
   }  
}

When compiled in ReleaseFast or ReleaseSmall, the switch case will only contain machine code for handling .two and .three because it is being asserted that it’s impossible for .one to reach that point of the program.

In Debug and ReleaseSafe machine code for branching to .one will be generated and it will lead to a panic.

In conclusion:

  • Zig errors: help you rewind program state when you hit an unhappy path (but not exclusively, you can use errors to rewind program state even if it’s not the unhappy path).
  • Panic: interrupts program execution, used to guard from programming errors.
  • Assertions / casts / unreachable: provides information to the compiler about impossible states, which is lowered to panics in safe modes and exploited for performance in unsafe release modes.
5 Likes

Thanks for the reply, I agree to an extent.

Maybe ‘invalid state’ isn’t the right word, but I think that exactly the fact that assert basically ‘disappears’ in unsafe release modes (or even helps with optimization) makes it an excellent tool for catching programming errors.
You pay no extra cost for the checks in unsafe release builds and still get them in debug builds. And if you want to get the best of both worlds, you can still make every failed assertion a @panic by compiling in ReleaseSafe mode.

Also I was under the impression that this is exactly how assertions are used all across the Zig stdlib and compiler source code. Most usages of @panic there are either OOM or TODO, these aren’t exactly programming errors.

2 Likes

For me as a non-native speaker this is a very interesting discussion. I always understood assert as “enforce”. For example “enforce this function is not called with this invalid parameter value”.

Now I learned in zig you can understand it more like “promise” to the compiler! Which other languages understand it like that? Is the the c macro used for optimization for example? Python for sure does not understand it like that :slight_smile:

Edit: or maybe even better is, that one can understand it as one of both, depending on the current build type

No but there are libraries that do similar things, like libasserts ASSUME_VAL which is defined as if(!(expr)) { __builtin_unreachable(); }, basically the same as Zig (though Zig is way more useful in debug builds)

1 Like

If you want to dive deeper into this have a look at Key semantics of std.debug.assert it discusses in great detail what exactly assert/unreachable does and doesn’t do

1 Like

As an example of how things can go wrong in release fast/small when you mess up an assertion, guess what this slightly modified version of the program above prints:

const std = @import("std");
const Case = enum { one, two, three };

pub fn handleCase(c: Case) usize {
   var case = c;
   if (case == .one) return 1;
   
   case = .one; // whoops

   switch (case) {
      .one => unreachable, // handled above
      .two => {
        std.debug.print("two\n",.{});
        return 2;
      },
      .three => {
        std.debug.print("three\n",.{});      
        return 3;
      },
   }  
}

pub fn main() void {
    std.debug.print("{}\n", .{handleCase(.three)});
}

Spoiler: On godbolt it prints “two” and then “2”, but could do something different on your machine.
Gotbolt: https://godbolt.org/z/95dKK3Gqd

1 Like

I’m certainly not denying that @panic will catch errors in unsafe release modes that unreachable won’t. My conclusion looks something like this though:

  • Zig errors: help you rewind program state when you hit an unhappy path (but not exclusively, you can use errors to rewind program state even if it’s not the unhappy path).
  • Panic: interrupts program execution, used to bail from unrecoverable situations. This situation could occur because of a programming error, but it could also be something like OOM in a one-shot program.
  • Assertions / casts / unreachable: provides information to the compiler about impossible states, which is lowered to panics in safe modes to protect against programming errors and exploited for performance in unsafe release modes.

Sure, in the end it’s always a panic that aborts the program. But the panic is only there in safe build modes because
a) the programmer explicitly used unreachable/something similiar like a cast builtin or
b) the compiler inserted a safety check, e.g. safety tags for bare unions.

Of course I’m exaggerating a bit here, but to truly protect against all programming errors of this kind in ReleaseFast and ReleaseSmall, one would have to replace every single

a += b;

with

a, const overflow = @addWithOverflow(a, b);
if (overflow != 0) @panic("integer overflow");

Or you could just compile in ReleaseSafe mode and get the check for free. My impression is that the latter is what Zig’s safety system is built around.

This makes it sound as if there are only 2 options, I think Zig actually gives you at least 4 options (are there more?) and you can pick and choose between them based on your circumstances (and for different program parts):

  • replace even basic operations with explicit code
    (your @addWithOverflow example)
  • compile using Debug or ReleaseSafe
  • mark some part of your program as running with runtime safety or without it @setRuntimeSafety()
  • have asserts, tests, fuzzing, simulation testing etc. so that you are confident, that having safety checks disabled results in identical behavior to having them enabled

I think the last one is potentially the one that can require the most work, but also can lead to the best result, both high safety and performance.

I think the others are useful for various stages during development and also to tradeoff effort vs getting results more quickly.

For me the impression is that Zig wants to give you the tools to basically make those nuanced tradeoffs and allow you to gradually move between them.

For example you start writing your program completely in debug mode, then over time through more effort you can add more and more assertions and testing and can make it safe to run in ReleaseFast or ReleaseSmall, but maybe you realize that you aren’t completely done with that work, so you can use ReleaseSafe in the mean time, but then you realize some part is actually safe and done and well tested so you can turn off the safety checks for it, to gain some more performance…

2 Likes