Handling Errors
We finished the last lesson with a question: what should the stoi() and stod() functions do when given invalid input? The C++ 17 version, from the standard library, does one thing, while the version we wrote does something else entirely when given the invalid input "UB-40".
To answer this question, first consider what should happen when you try to:
- Print the square root of -2?
- Open a file that doesn't exist? Read data from that file?
- Convert a string that doesn't contain a number to a number?
Each of these is handled in a different way. None of them are syntax or linker errors. Instead, they are runtime errors. The compiler and linker produced an executable, but when it runs, an error occurs.
Let's examine a few ways to handle such errors.
The Terminator
One option is to write a function like die(), which you saw in earlier lessons, and which prints an error message and then terminates. This is not really a good solution for runtime errors unless they are very severe, since it doesn't give the user a chance to recover. Imagine if your Web browser shut down every time you typed a URL incorrectly or clicked on a dead link.
Using a function like die() does prevent the program from continuing with garbage values, (which is good!), but it is simply too drastic and too inflexible to be a good universal approach.
However, there is one time when a "terminator" is the correct way to handle errors:
- When you are developing your code and …
- When the error is a programming problem that you can fix.
In fact, the C++ library has a built-in macro which does this, called assert().
Using assert
An assertion is a statement about a condition which must be true when encountered. If the condition is not true, then assert(), (declared in <cassert>), causes the program to immediately fail, printing an error message.
Programmers use assertions to reason about logical correctness. Assertions can be used to check preconditions (what must be true before the program runs correctly), and postconditions (what must be true after a calculation completes).
Here is an (admittedly silly) example using assert().
cout << "Making sure that 2 + 2 is 5?" << endl;
assert(2 + 2 == 5); // false
The programmer assumed (wrongly) that the expression 2 + 2 should produce 5. The assertion causes the program to stop and print an error message, so the programmer can fix the mistake. The message will depend on the toolchain. Here is g++ on Unix.
a.out: main.cpp:10: int main():
Assertion `2 + 2 == 5' failed.
Aborted (core dumped)
The message includes the executable name (a.out), the source file (main.cpp), the line number (10), the function name and the assertion which failed, so you can immediately open your editor and fix the code.
More Assertions
Assertions are not designed to handle runtime errors. They are designed to point out bugs in your code. Steve Maguire, one of the original developers of Excel, wrote a classic book named Writing Solid Code, which contains a chapter on assertions in C. Here are the points he makes:
- Assertions are shorthand way to write debugging checks
- Use assertions to check for illegal conditions, not error conditions
- Use assertions to validate function arguments under your control
- Use assertions to validate any assumptions you have made
If you want your code to help you find your bugs, make liberal use of assert().
Since assertions are only needed while you are developing your code, you can remove them from your production build by compiling with the -D NDEBUG compiler switch, or by adding #define NDEBUG before including <cassert>.
assert() is not actually a function, but a preprocessor macro, so defining NDEBUG allows the preprocessor to remove all assert() statements before your code is compiled. Becuase of this, you need to make sure that an assert() never has a side effect, which could change the way your program runs when it is removed.
Static Asserts
C++ 11 also introduced the static_assert() declaration which may be used to double-check your assumptions about the platform you are developing on. For instance, if your code assumes that the int type is a 32-bit signed number, you can check that with:
static_assert(sizeof(int) == 4, "int must be 32 bits.");
Unlike regular assertions, static_assert is checked when you compile; it does not check for runtime errors. You can only check on compile-time constants and the error message must be a string literal; you cannot include variables. (In C++17 you may omit the error message.)
Completion Codes
A second error-reporting option is the "tried-and-true" traditional completion code technique used for years in C, Pascal and FORTRAN. Have your function return a special value meaning that “the function failed to execute correctly.”
In a way, this is what sqrt() does; it returns the "special" not-a-number value when its answer cannot be converted to a valid double. You can test for this value using the isnan() function in the header <cmath>. You could use the "error code" like this:
if (isnan(answer = sqrt(-1))) { /* error */}
The isnan() function was added to C++ 11. Before that, sqrt() set the global variable errno, defined in <cerrno>, which was used like this.
double answer = sqrt(-1.0); // invalid
if (errno == EDOM) { /* invalid DOMain */}
Error Flags
With the advent of object-oriented programming, a variation on completion codes was birthed—error state which is encapsulated in objects. Of course, you've already encountered this with the input stream classes.
Here's an example. What happens if the user enters twelve?
cout << "Enter an integer: ";
int n;
cin >> n; // Error state is set here
Each stream object has an internal data member that contains an individual error code, or error flag. These flags are given names like badbit, goodbit and failbit. If the user enters twelve, then the failbit is "set". If the keyboard isn't working, the badbit is set.
In the C-style of programming, you use bitwise logical operators (something we won't cover in this class, but you'll probably encounter in Computer Architecture) to read or set each of these error codes. In C++, however, you have member functions:
cin >> n; // Error state may be set here
if (cin.fail()) // Check if failbit is set
{
cin.clear(); // clear all of the error flags
// empty the input stream and try again
}
The big problem with completion codes and with error states, is that you can ignore the return value without encountering any warnings. Research has shown that programmers almost never check them. To better handle these kinds of problems, C++ introduced exception handling. If an error occurs inside a function, rather than returning a value, you report the problem and jump to the proper error-handling code.