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.
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:
if
statement is trying to handle a bug or a legitimate scenario that can arise during normal program execution.if
statement in the previous example, reduces performance.In this chapter, we will cover better ways of handling errors and exceptions.
We can broadly consider errors we’ll encounter as being of one of three types:
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:
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.
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.
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.
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);
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
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'
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'
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
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.
#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)
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
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.
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:
static_assert
for compile-time assertions, ensuring conditions are met before code execution.assert
to check run-time conditions and prevent the execution of code with invalid states.Learn how we can ensure that our application is in a valid state using compile-time and run-time assertions.
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.