Lecture 10: Error Handling

\( \newcommand\bigO{\mathrm{O}} \)

In this lecture, we will cover three mechanisms for handling errors:

  1. Stopping the program (so, not handling the error much)
  2. Letting the caller know through:
    1. Exceptions: by specially-handled values that anyone in the chain has to check.
    2. 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.

Author: Mehmet Emre

Created:

The material for this class is based on Prof. Richert Wang's material for CS 32