DEV Community

Paul J. Lucas
Paul J. Lucas

Posted on • Updated on

Using assert() for Less Buggy Code

Introduction

The assert() macro, part of the C standard library defined in assert.h, has been around since the early days of C as a mechanism to aid in writing less buggy code. It allows you to “assert” that some condition must be true in order to continue. If the condition is not met, the program will print an error message (including the assertion that was not met and the source file and line of the assert()), calls abort() that terminates the program, and typically provides a core dump as a debugging aid. Conditions include preconditions, postconditions, and invariants for implementing design by contract.

Examples

Assertions are used most to check that function arguments are valid (preconditions), for example:

void c_ast_op_params_min_max( c_ast_t const *ast,
                              unsigned *params_min,
                              unsigned *params_max ) {
  assert( ast != NULL );
  assert( ast->kind == K_OPERATOR );
  assert( params_min != NULL );
  assert( params_max != NULL );
  // ...
Enter fullscreen mode Exit fullscreen mode

checks that no parameter is NULL and the “kind” of ast is K_OPERATOR.

Assertions are also good to check a pointer for NULL before assigning to it (a precondition), for example:

c_ast_t *const param_ast = param_node->data;
assert( param_ast->param_of_ast == NULL );
param_ast->param_of_ast = func_ast;
Enter fullscreen mode Exit fullscreen mode

ensures that you’re not overwriting a non-NULL pointer causing a memory leak of the object to which the pointer already points.

Assertions can also be used to check invariants, for example:

c_ast_t const* c_ast_untypedef( c_ast_t const *ast ) {
  for (;;) {
    assert( ast != NULL );
    if ( ast->kind != K_TYPEDEF )
      return ast;
    ast = ast->tdef.for_ast;
  }
}
Enter fullscreen mode Exit fullscreen mode

not only checks that ast is not NULL initially, but that every for_ast pointer in the linked list is not NULL.

In case you’re wondering, these code snippets are from cdecl.

Sample Implementation

To demystify assert(), here’s a basic implementation (spread over several lines for clarity):

// assert.h
#ifndef NDEBUG
#define assert(EXPR) (                        \
  (EXPR) ? (void)0 : (                        \
    printf("%s:%d: failed assertion '%s'\n",  \
      __FILE__, __LINE__, #EXPR               \
    ),                                        \
    abort()                                   \
  ))
#else
#define assert(EXPR)  ((void)0)
#endif /* NDEBUG */
Enter fullscreen mode Exit fullscreen mode

The first thing to notice is that assert() is defined only if NDEBUG is not defined. The consequence is that if you #include <assert.h> in your program and do nothing else, assertions will be enabled, that is they will actually assert what you are asserting.

However, if you define NDEBUG (the value doesn’t matter), then assert() is defined to a do-nothing expression and assertions will be disabled (“No Debug”), that is they will not do anything at all. You might want to do this for production code — more later.

The assert() macro itself is fairly straightforward: if the assertion expression evaluates to non-zero (true), it does nothing; otherwise it prints an error message and calls abort().

In order to call two statements in an expression, the comma operator is used.

While this sample implementation uses a macro, any implementation invariably will since it needs to obtain the file and line number of the assert() as well as the “stringification” of the expression — things only the preprocessor can give. While some implementations use a function to do the printf() and abort() instead, they call a function only if the check fails; the check itself needs to be fast via inlining.

Assertions vs. Errors & Exceptions

Errors, exceptions, and assertions are related in that they’re all used to catch and report an invalid state; but they’re for different purposes:

  • Errors are for “conceivable” invalid states, but there may be a way to recover and continue.
  • Exceptions are for errors that are “exceptional” (in the “unlikely” sense, not in the “outstanding” sense), but there still may be a way to recover and continue.

In C++ that has exceptions, the line between errors and exceptions can sometimes get blurry. For example, if you attempt to open a file that doesn’t exist, should that be an error or exception? It’s debatable. (Fortunately, this article is about assertions, so that debate can be deferred to another time.)

Assertions, however, are only for an invalid state that should “never” happen (inconceivable!) — but if it does anyway, it means there’s a bug and either:

  1. While there may be a way to recover and continue, it’s better to crash and produce a core dump as an aid to debug and fix the bug; or:
  2. There’s no way to recover, so you have no choice but to crash.

Case 2 typically happens in a function that’s at the bottom of the call stack and there’s no way to report the error, to return the error all the way up the call stack, or, even if you could, the caller wouldn’t know how to handle the error.

In such a case, it’s better to crash in a controlled way rather than have the program limp along possibly doing irreparable damage, e.g., overwriting data in a file with garbage, before possibly crashing anyway and possibly far from where the assertion was not met making debugging harder.

Disabling Assertions in Production Code

As mentioned, if NDEBUG is defined, then assertions are disabled — and you might want to do this for production code.

The arguments for disabling assertions include:

  1. They take a small, but non-zero amount of time to perform the checks that can add up, especially in hot code.
  2. Assertion failures are typically caught by unit tests.
  3. Having your program crash generally results in unhappy customers.
  4. An attacker has found a way to put your program into an invalid state failing an assertion and causing it to crash that can result in a denial-of-service attack.

The arguments against disabling assertions include:

  1. Since the time to perform the checks is small, it’s likely small enough to be negligible. The way to know for sure is to profile your program. If your assert() checks are inconsequential, then you can leave them enabled.
  2. While assertion failures are typically caught by unit tests, passing a test suite can never prove that no bugs exist, so better to leave assertions enabled to detect invalid states.
  3. Having your program not crash but yield incorrect results (that customers may not even realize are incorrect) or cause lasting damage also generally results in unhappy customers and can be even worse for your reputation.
  4. You should never use assertions to validate any input anyway — more later.

Personally, I think assertions should be left enabled. If you want to disable assertions only in hot code, you can use assert() there and define NDEBUG, but then define your own variant macro similar to the sample implementation presented earlier that is unaffected by NDEBUG:

#define always_assert(EXPR)  /* ... */
Enter fullscreen mode Exit fullscreen mode

and use it everywhere else. You could go even farther and define three macros:

#define precondition(EXPR)   /* ... */
#define invariant(EXPR)      /* ... */
#define postcondition(EXPR)  /* ... */
Enter fullscreen mode Exit fullscreen mode

Then you could, say, disable only invariant() (since it’s more likely to be used in loops) and postcondition() yet leave precondition() enabled.

Still: disabling any assertions means you’re running a higher risk of either uncaught bugs or damage.

Best Practices

There are a few best practices for using assertions.

No Side-Effects

Assertions must not cause side-effects.

assert( --count > 0 );         // No!
Enter fullscreen mode Exit fullscreen mode

Or, if they do, there should be a comment that the side-effect is OK.

Why not? Because if assertions are disabled, the statement doesn’t happen at all. In some cases, you might need to add a variable just to assert on:

bool const colors_parsed = colors_parse();
assert( colors_parsed );
Enter fullscreen mode Exit fullscreen mode

Doing so, however, can cause “unused variable” warnings when assertions are disabled. To fix that, add the maybe_unused attribute:

// C23 or C++17
[[maybe_unused]] bool const colors_parsed = colors_parse();
assert( colors_parsed );
Enter fullscreen mode Exit fullscreen mode

If you’re using C before C23 or C++ before C++17, you can instead simply cast the variable to void:

// C < C23 or C++ < C++17
bool const colors_parsed = colors_parse();
assert( colors_parsed );
(void)colors_parsed;
Enter fullscreen mode Exit fullscreen mode

No Input Validation

Assertions must not be used to validate any input, either from a human (e.g., via keyboard), machine (e.g., via socket), or file — even trusted humans, machines, or files. Your program should not crash because a human made a typo or you received or read corrupted or otherwise unexpected data.

No && for Unrelated Conditions

Assertions should not use && to conjoin unrelated conditions:

assert( n > 0 && n < N_MAX );  // OK
assert( n > 0 && p != NULL );  // Meh
Enter fullscreen mode Exit fullscreen mode

Why not? Because for the second assert(), you won’t know which assertion failed: was n ≤ 0 or p == NULL? Separate assertions should be used instead:

assert( n > 0 );               // Better
assert( p != NULL );
Enter fullscreen mode Exit fullscreen mode

Adding a Message to assert()

Sometimes, you might want to include an additional string in the error message produced by assert(). Unfortunately the assert() macro can not optionally take a string to add to the error message, but there is a commonly used trick to work around this:

assert( ("message goes here", ok) );
Enter fullscreen mode Exit fullscreen mode

That is put the string before a comma operator: the string literal will be discarded (in terms of the value of the expression, but still included in the error message) and the result will be the value of ok.

The extra () are necessary so what’s between them is passed as a single argument to assert() rather that two arguments separated by a comma.

While that trick works, some compilers will give you a “left operand of comma operator has no effect” warning. To silence that, you can cast it to void:

assert( ((void)"message goes here", ok) );
Enter fullscreen mode Exit fullscreen mode

At this point, however, employing the trick is a bit baroque. To fix that, you can simply define your own variant of the assert() macro:

#define assert_msg(MSG,EXPR) \
  assert( ((void)(MSG ""), (EXPR)) )
Enter fullscreen mode Exit fullscreen mode

and use that whenever you want an additional string included in the error message.

The use of "" will ensure that MSG is a string literal (which it must be) rather than a char* variable since the preprocessor will concatenate two adjacent string literals but anything else will be an error.

Conclusion

Following a few simple best-practices, use of the assert() macro is a good way to help produce less buggy code.

Top comments (0)