Lecture 10: Error Handling
\( \newcommand\bigO{\mathrm{O}} \)
In this lecture, we will cover three mechanisms for handling errors:
- Stopping the program (so, not handling the error much)
- Letting the caller know through:
- Exceptions: by specially-handled values that anyone in the chain has to check.
- Optionals: by returning a special wrapper that keeps track of whether the returned value makes sense or not.
1 Stopping the program
Suppose, we are implementing our own math library, and implemented the square root function like this (the implementation does not matter much to us):
double Sqrt(double a) {
// 20 iterations are enough for doubles
double x = 1;
for (int i = 0; i < 20; ++i) {
// x_{i+1} = x_i - f(x_i)/f'(x_i)
//
// where f(x) = x^2 - a, so the roots are +/- sqrt(a)
// f'(x) = 2x
x = (x + (a / x) ) / 2;
}
return x;
}
Then, we can evaluate, say, Sqrt(4)
and get 2
. That looks
sensible. However, the square root function is not defined for negative
numbers. So, we need to report an error. The simplest option we have is to
print an error and exit the program:
double Sqrt(double a) {
if (a < 0) {
std::cout << "tried to compute square root of negative number " << a << "\n";
std::exit(1);
}
// 20 iterations are enough for doubles
double x = 1;
for (int i = 0; i < 20; ++i) {
// x_{i+1} = x_i - f(x_i)/f'(x_i)
//
// where f(x) = x^2 - a, so the roots are +/- sqrt(a)
// f'(x) = 2x
x = (x + (a / x) ) / 2;
}
return x;
}
So far, everything is nice. Our square root program always returns a correct
value (or it does not return and stops the program). However, the programmer
now needs to be careful when calling Sqrt
: they also need to make the check
the input every single time, or the program may randomly crash. Can we do
better? More specifically, can we help the programmer by giving them a
mechanism to handle the error of calling Sqrt
with an invalid input. The
answer is yes. Enter exceptions.
2 Exceptions
C++ has a construct called "exceptions" to handle, well, exceptional cases. You can read more about them at cppreference.com or in PS 16. Normally, a function runs and returns a value. However, when an exceptional case happens, the function may instead throw an exception, which means that the function stops executing and instead of returning a value, it passes an exception value to the caller (or whichever context is set up to catch that exception). For example, say we are trying to read a positive number from the input:
int main() { int value; try { cout << "Enter a positive number: "; cin >> value; if (value < 0) throw value; cout << "The number entered is: " << value << endl; } catch (int e) { cout << "The number entered is not positive:" << e << endl; } cout << "Statement after try/catch block" << endl; return 0; }
Here, everything executes within the try block if execution is normal. Every try block must be accompanied by one or more catch blocks.
- Only when
value < 0
would we throw something in the example above. - Statements after throwing an exception within the try block is ignored, and code within the catch block of the exception type is executed. You can think of it as the execution "jumping" to the catch block.
- Regardless if an exception was thrown or not, execution resumes after the try/catch block.
Note that the value we caught in the catch
statement is same as the value
thrown by the throw
statement. We can now change our square root function
to throw an exception, so the caller can catch it:
double Sqrt(double a) {
if (a < 0) {
throw "passed negative value to sqrt";
}
// 20 iterations are enough for doubles
double x = 1;
for (int i = 0; i < 20; ++i) {
// x_{i+1} = x_i - f(x_i)/f'(x_i)
//
// where f(x) = x^2 - a, so the roots are +/- sqrt(a)
// f'(x) = 2x
x = (x + (a / x) ) / 2;
}
return x;
}
Now, the caller can catch the exception if they are not sure if the input is
non-negative. They can also call Sqrt
a bunch of times, and catch the first
exception that would occur, like so:
try {
Sqrt(2);
Sqrt(-4);
Sqrt(3);
Sqrt(0);
Sqrt(-3);
} catch (const char * message) {
cout << "caught error message: " << message << "\n";
}
Here, we are catching a const char *
because Sqrt
is throwing a string
literal, which is of that type. This is inconvenient! Because, we cannot tell
between other things that would throw an exception versus the things Sqrt
throws. To solve this, we can define special classes so we can throw objects
of those types:
struct SqrtError {};
// later, in Sqrt
double Sqrt(double a) {
if (a < 0) {
throw SqrtError{};
}
// ...
}
// now, we can catch only the errors from Sqrt (assuming nothing else throws
// SqrtError):
try {
Sqrt(x);
} catch (SqrtError& err) {
cout << "passed negative value to sqrt, carrying on\n";
}
Here, we are catching the exception by reference because exceptions are often in a class hierarchy with virtual methods.
2.1 An exception hierarchy
Let's extend our math library by adding a method to compute quadratic equations. We will be using the classical way of solving \( a x^2 + b x + c = 0 \) by finding the roots \( \tfrac{-b \pm \sqrt{b^2 - 4ac}}{2a} \). There are two things that can go wrong here:
- There are no roots ( \(b^2 -4ac < 0 \) )
- The equation is not truly quadratic ( \( a = 0 \) )
Let's add exception objects for each of these cases, so we can handle them:
struct QuadraticError {
virtual string what() {
return "generic quadratic eqn error";
}
};
struct NonQuadraticEqn : public QuadraticError {
string what() override {
return "non-quadratic equation";
}
};
struct NoRoots : public QuadraticError {
string what() override {
return "equation has no roots";
}
};
// Find the roots of given quadratic equation
pair<double, double> SolveQuadraticEqn(double a, double b, double c) {
if (a == 0) {
throw NonQuadraticEqn{};
}
// square root of discriminant
try {
double d = Sqrt(b * b - 4 * a * c);
return { (-b + d) / (2 * a), (-b - d) / (2 * a) };
} catch (SqrtError&) {
throw NoRoots{};
}
}
We now have a hierarchy of errors: the user can catch the two specific cases,
or generally any error with quadratic equation solving. For example, this
wrapper function prints the solution or an error message if the equation
cannot be solved by SolveQuadraticEqn
:
void SolveAndPrint(double a, double b, double c) {
try {
auto [ root1, root2 ] = SolveQuadraticEqn(a, b, c);
cout << "roots of " << a << "*x² + " << b << "*x + " << c << " = 0 are "
<< root1 << " and " << root2 << "\n";
} catch (NonQuadraticEqn&) {
if (b != 0) {
auto root = - c / b;
cout << "the root of " << a << "*x² + " << b << "*x + " << c << " = 0 is "
<< root << "\n";
} else {
cout << "eqn doesn't have roots\n";
}
} catch (NoRoots&) {
cout << "could not solve the equation "
<< a << "*x² + " << b << "*x + " << c << " = 0\n";
}
cout << "solver done\n";
}
As a side note: the notation auto [ root1, root2 ] = SolveQuadraticEqn(a, b, c);
tells the compiler to break down the result of SolveQuadraticEqn
(which is
a struct, specifically a pair), and assign root1
to the first field and
root2
to the second field.
And, here is a small program that catches the general QuadraticError
and
uses the what()
virtual method to print the specific error message, all
thanks to dynamic dispatch:
int main(int argc, char * argv[]) {
if (argc == 4) {
double a = stod(argv[1]);
double b = stod(argv[2]);
double c = stod(argv[3]);
try {
auto [ root1, root2 ] = SolveQuadraticEqn(a, b, c);
cout << "x1 = " << root1 << ", x2 = " << root2 << "\n";
} catch (QuadraticError &err) {
cout << "could not solve the eqn: " << err.what() << "\n";
}
}
}
This setup is what the standard library has: It has a hierarchy of exceptions
inheriting from std::exception, and each exception implements the what()
virtual function to print the appropriate message. The exceptions represent
cases like a general logic_error
, or more specific out_of_range
error,
or invalid_argument
.
Before we wrap up, what happens if there is no catcher? For example, try running the following program with the arguments 1 0 4 (that is \( x^2 + 4 = 0 \) ):
int main(int argc, char * argv[]) {
if (argc == 4) {
double a = stod(argv[1]);
double b = stod(argv[2]);
double c = stod(argv[3]);
auto [ root1, root2 ] = SolveQuadraticEqn(a, b, c);
cout << "x1 = " << root1 << ", x2 = " << root2 << "\n";
}
}
The program would crash with an uncaught exception error. So, exceptions can get tricky to use because they can still crash the program if not handled correctly.
3 Optionals
An alternative to exceptions is returning the value in a wrapper that says
"maybe there is a value". For example, we could define a class like this and
return SqrtResult
from Sqrt
:
struct SqrtResult {
bool is_valid;
// valid only if is_valid is true
double value;
};
Then, Sqrt
can return a valid value only when the input is not negative:
SqrtResult Sqrt(double a) noexcept {
if (a < 0) {
// return an invalid value
return SqrtResult(false, 0);
}
// 20 iterations are enough for doubles
double x = 1;
for (int i = 0; i < 20; ++i) {
// x_{i+1} = x_i - f(x_i)/f'(x_i)
//
// where f(x) = x^2 - a, so the roots are +/- sqrt(a)
// f'(x) = 2x
x = (x + (a / x) ) / 2;
}
return SqrtResult(true, x);
}
Callers of Sqrt
would need to check the is_valid
field before using the
value now. Defining a result type for everything would be tedious, we can make
SqrtResult
generic to hold a value to get around that. Luckily, the standard
library comes with a type like that: std::optional
. std::optional
works
like this:
template<class T>
class optional {
bool is_valid;
T value;
public:
T& operator * () { return value; }
bool operator bool () { return is_valid; }
// other stuff ...
};
So, if we have an optional value, we can check if it is valid like checking if a pointer is valid:
int * p = nullptr;
if (p) { // or p != nullptr
// p evaluates to false, so we don't enter here
}
p = new int(5);
if (p) {
// p evaluates to true, so we enter here
}
std::optional<int> o = std::nullopt;
if (o) { // or o != nullopt
// o evaluates to false, so we don't enter here
}
o = 5; // note that we don't need new
if (o) {
// o evaluates to true, so we enter here
}
We can also access the value inside, like a pointer:
optional<int> o = 42;
cout << *o << endl; // prints "42"
However, unlike a pointer, optionals carry the associated data with them (rather than pointing to them), so they destroy the value when they go out of scope.
Now, let's rewrite Sqrt
using optional
and use it in SolveQuadraticEqn
:
// Compute square root using Newton's method
optional<double> Sqrt(double a) noexcept {
if (a < 0) {
return nullopt;
}
// 20 iterations are enough for doubles
double x = 1;
for (int i = 0; i < 20; ++i) {
// x_{i+1} = x_i - f(x_i)/f'(x_i)
//
// where f(x) = x^2 - a, so the roots are +/- sqrt(a)
// f'(x) = 2x
x = (x + (a / x) ) / 2;
}
return x;
}
// auto x = Sqrt(5); // x = double OR x = an error value
// Find the roots of given quadratic equation
pair<double, double> SolveQuadraticEqn(double a, double b, double c) {
if (a == 0) {
throw NonQuadraticEqn{};
}
// square root of discriminant
if (optional<double> r = Sqrt(b * b - 4 * a * c)) {
double d = r.value(); // we can also use *r
return { (-b + d) / (2 * a), (-b - d) / (2 * a) };
} else {
throw NoRoots{};
}
}
I used r.value()
above, which throws an exception if r
is empty (that is,
it is equal to nullopt
. We could've used *r
instead in this case. If r
is empty, then *r
is undefined behavior.
Also, if you are not familiar with the way I wrote the second conditional
there, if (optional<double> r = Sqrt(b * b - 4 * a * c))
is roughly same as:
optional<double> r = Sqrt(b * b - 4 * a * c);
if (r)
So, the guard of if
uses the assigned value r
. The benefit of that if
statement is that r
is not available in the else
block because it is
restricted to only the true branch. This allows us to use the compiler to get
an error if we mistakenly use r
in the false branch.