Errors and Assertions

Learn how we can ensure that our application is in a valid state using compile-time and run-time assertions.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

Whenever we write a function that accepts arguments, we generally have some assumptions about those arguments. For example, if we have a function that takes a pointer, we might assume that the pointer is not a nullptr.

These are sometimes called "preconditions" - things that our function assumes to be true before it starts performing its task.

Error Handling

In the beginner courses, the main tool we had to check our assumptions was the basic if statement. Below, we prevent our LogName() function from crashing if it is provided with a nullptr:

#include <iostream>

class Character {
public:
  std::string GetName() {
    return "Legolas";
  };
};

void LogName(Character* Player) {
  if (!Player) return;
  std::cout << Player->GetName();
}

int main() {
  LogName(nullptr);
}

However, as unintuitive as it might seem, crashing and generating errors is not necessarily a bad thing.

If we never expect Player to be a nullptr, but it is sometimes a nullptr, that means our assumptions are wrong. In this scenario, crashing is often desirable.

Testing our assumptions at run time, and then trying to recover from bad assumptions, has a few problems:

  • It can hide bugs. If something has gone wrong and one of our preconditions isn’t holding, we’d rather know about it so we can fix it.
  • It can move, delay, or multiply bugs. Having a function "work around" an unexpected state is often just going to cause the invalid state to generate bugs elsewhere. We want bugs to manifest as close as possible to where they were caused, as it makes debugging easier
  • It can obfuscate what our preconditions and assumptions are. It may not always be clear if an if statement is trying to handle a bug or a legitimate scenario that can arise during normal program execution.
  • Testing assumptions at run time, like the if statement in the previous example, reduces performance.

In this chapter, we will cover better ways of handling errors and exceptions.

The Three Types of Errors

We can broadly consider errors we’ll encounter as being of one of three types:

1. Compile-Time Errors

No doubt we’ve already seen plenty of compile-time errors at this point. Any time we build our software, the compiler checks for and alerts us of any errors it can find. When our software is going to have an error, we’d prefer it happen at compile time. This is because:

  • Checking for them has no performance cost
  • We are guaranteed to notice them
  • They show up quickly - we don’t need to build and run our program only to notice it has an error

A tenet of good software design is striving to write software where, if errors occur, they are more likely to occur at the compilation stage.

This is one of the strengths of strongly typed languages. They move entire categories of errors (eg, functions or variables receiving data of an unexpected type) to the compilation stage, preventing them from becoming bugs.

2. Run-Time Errors

Run time errors are less preferable to compile time errors, as they occur later in the process. More importantly, they can also be missed, risking that we ship bugs.

For example, if our program has a run-time error that occurs if a user performs a specific action, we won’t notice it unless we also perform that same action.

Checking for errors at runtime also has a performance cost. A common convention for dealing with this is to have these checks enabled or disabled based on a preprocessor definition.

So, during the development process, these checks are running and helping us detect bugs. Then, when we’re compiling our software for the public, the preprocessor removes all these checks to maximize performance.

3. Run Time Exceptions

The final situation we can have is exceptions. These are scenarios that disrupt the normal flow of our program, even once it is released to users.

We have access to an elaborate suite of options to generate, detect, and react to exceptions. Because of this, exceptions are sometimes not errors at all - we might intentionally create one because we know some other part of our program will detect and recover from it.

This lesson will cover compile-time and run-time errors, whilst the rest of the chapter will focus on working with exceptions.

Compile Time Assertions with static_assert()

We can make assertions at compile time, using static_assert(). Static assert does not require any headers to be included - it is part of the language by default.

Naturally, static_assert() can only be used to assert things that are static, that is, known at compile time.

To use static_assert(), we simply provide it with a boolean expression. The compiler will generate an error if that expression isn’t true.

Below, we use it to check the version of some external library our project might be using:

namespace SomeLibrary {
  constexpr int Version{1};
}
#include <SomeLibrary>

static_assert(SomeLibrary::Version >= 2);

C++14 Note

When working on projects that target C++14 and earlier, we need to provide a second argument to static_assert. This can be an empty string:

static_assert(SomeLibrary::Version >= 2, "");

This string allows us to provide a custom error message, which we explain below.

This code yields a compilation error that will give us the file name and line number where a static assertion failed:

main.cpp(3): error: static assertion failed

Custom Error Messages with static_assert()

We can, and generally should, provide a string as the second argument to static_assert(). This lets us provide a descriptive explanation of the problem:

static_assert(SomeLibrary::Version >= 2,
  "Version 2 or later of SomeLibrary is "
  "required - please upgrade");
main.cpp(3): static_assert failed: 'Version 2 or later of SomeLibrary is required - please upgrade'

Asserting Type Traits with static_assert()

Typically, static_assert() is used to investigate types. For example, we might want to ensure that our data types have enough memory on the platform the code is being compiled for.

If we’re using the int type, and we’re assuming it has at least 32 bits (4 bytes), we can statically assert that:

// Ensure integers have at least 4 bytes of memory
// on the platform we're compiling for
static_assert(sizeof(int) >= 4);

The most common application of this is within template code, where we want to examine the types that the compiler is trying to instantiate our template with.

For example, we can use functions provided within the <type_traits> header to help us. Below, we ensure our Square function template is only instantiated with numbers:

#include <type_traits>

template <typename T>
T Square(T x) {
  static_assert(std::is_floating_point_v<T>,
    "Square requires a floating-point type");
  return x * x;
}

int main() {
  Square("Hello"); 
}
main.cpp(5): error C2338: static_assert failed: 'Square requires a floating-point type'

Compile Time Checks using Concepts

As we covered in the templates chapter, C++20 concepts are another form of compile-time checking. Below, we ensure our function template is only called with floating point numbers:

#include <concepts>

auto Square(std::floating_point auto x) {
  return x * x;
}

int main() {
  Square(2.0);
  Square(2);
}
main.cpp(9,3): 'Square': no matching overloaded function found
the associated constraints are not satisfied
the concept 'std::floating_point<int>' evaluated to false

Run Time Assertions Using assert()

Instead of using an if statement to detect run time issues, we can instead use the assert() macro. This is available after including <cassert>:

#include <cassert>

Within our code, we then simply pass a boolean expression to assert(). Our program will abort if the condition isn’t true:

#include <iostream>
#include <cassert>

class Character {
public:
  std::string GetName() {
    return "Legolas";
  };
};

void LogName(Character* Player) {
  assert(Player);  
  std::cout << Player->GetName();
}

int main() {
  LogName(nullptr);
}

If we build and run our program in debug mode, it will now crash as expected.

Assertion failed: Player, file main.cpp, line 12
cpp.exe (process 8640) exited with code 3.

Disabling Assertions using #define NDEBUG

Calls to assert() have a performance impact, so we’ll typically want to disable them when we release our program to users.

To do this, we #define a macro called NDEBUG. This can be done prior to the directive that includes <cassert>:

#include <iostream>
#define NDEBUG
#include <cassert>

int main() {
  assert(false);
  std::cout << "Program ran successfully";
}
Program ran successfully

More commonly, we’d do it within our tools, so that the macro is defined globally based on our release configuration.

In Visual Studio, we can do this under Properties > C/C++ > Preprocessor > Preprocessor Definitions

In CMake, we can add definitions using the add_compile_definitions() function:

add_compile_definitions(NDEBUG)

Custom Error Messages with assert()

We can pass any boolean expression into assert(). If it is not true, our program will terminate:

#include <cassert>

int Divide(int x, int y) {
  assert(y != 0);
  return x / y;
}

int main() { Divide(2, 0); }

However, the error messages can be quite cryptic. When our program gets large, it can be quite difficult to understand what went wrong when an assertion like this failed:

Assertion failed: y != 0, file main.cpp, line 4

The assert() macro doesn’t give us the option of providing a second argument to explain the problem. We can work around this in a few ways:

int Divide(int x, int y) {
  assert(("Cannot divide by zero", y != 0));
  return x/y;
}
Assertion failed: ("Cannot divide by zero", y != 0), file main.cpp, line 4

Or alternatively:

int Divide(int x, int y) {
  assert(y != 0 && "Cannot divide by zero");
  return x/y;
}
Assertion failed: y != 0 && "Cannot divide by zero", file main.cpp, line 4

Third-Party Assert Libraries

Companies tend to implement their own assert() macros to provide more features or clearer syntax compared to the standard assert(), or they use assertion libraries released by third parties.

For example the Boost.Assert library uses the second argument convention:

BOOST_ASSERT_MSG(y != 0, "Cannot divide by zero");

The CHECK macros of Google’s logging library use the << syntax:

CHECK(y != 0) << "Cannot divide by zero";

The specific implementation of these assertion utilities may change from code base to code base. However, that syntax can be learned very quickly. Understanding the benefits of assertions, and where to use them, is what’s important.

Summary

In this lesson, we explored the crucial role of assertions, focusing on compile-time and run-time errors. We learned how to use static_assert and assert to enforce preconditions. Key topics included:

  • Understanding the difference between compile-time and run-time errors and how assertions can help in identifying them.
  • Usage of static_assert for compile-time assertions, ensuring conditions are met before code execution.
  • Implementing assert to check run-time conditions and prevent the execution of code with invalid states.
  • Exploring the use of type traits and C++20 concepts for more precise compile-time checks.
  • Learning about third-party assert libraries for enhanced assertion capabilities and custom error messages.

Was this lesson useful?

Next Lesson

Exceptions: throw, try and catch

This lesson provides an introduction to exceptions, detailing the use of throw, try, and catch.
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Exceptions and Error Handling
Next Lesson

Exceptions: throw, try and catch

This lesson provides an introduction to exceptions, detailing the use of throw, try, and catch.
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved