Error Handling and Exceptions

Thus far, we have largely ignored the problem of handling errors in a program. We have included REQUIRES clauses in many of our functions, specifying that the behavior is undefined when the requirements are violated. However, for functions that interact with data external to the program such as user input, files, or other forms of I/O, it is unrealistic to assume that the data will always be available and valid. Instead, we need to specify well-defined behavior for what happens when the requirements for such a function are violated.

In writing a complex program, we subdivide it into individual functions for each task. Each function only knows how to handle its own job, and it returns results to its caller. As a result, it generally is not the case that the function that detects an error is equipped to handle it. Consider the following example:

// EFFECTS: Opens the file with the given filename and returns the
//          contents as a string.
string read_file_contents(const string &filename) {
  ifstream fin(filename);
  if (!fin.is_open()) {
    ???                      // file did not open; now what?
  }
  ...
}

The job of read_file_contents() is to read data from a file. It doesn’t know anything further about the program, so it isn’t in a position to determine what should be done in case of an error. Depending on the program, the right course of action may be to just print an error message and quit, prompt the user for a new filename, ignore the given file and keep going, and so on. Furthermore, the read_file_contents() function may actually be used in several different contexts, each of which requires a different form of error recovery. The only thing that read_file_contents() should be responsible for is detecting and conveying that an error occurred, and a different function further up in the call stack should do what is required to handle the error.

Thus, we need a mechanism that separates error detection from error handling, as well as a means for notifying a caller that an error occurred. We will look at several different strategies for doing so.

Global Error Codes

A common strategy in the C language is to set the value of a global variable to an error code, which provides information about the type of error that occurred. For example, many implementations of C and C++ functions use the errno global variable to signal errors. The following is an example that calls the strtod() standard-library function to read a double from a C-style string:

#include <cerrno>
#include <cstdlib>
#include <iostream>

using std::cout;
using std::endl;

int main(int argc, char **argv) {
  char *end;
  errno = 0;                              // reset errno before call
  double number = std::strtod(argv[1], &end);
  if (errno == ERANGE) {                   // check errno after call
    cout << "range error" << endl;
  } else {
    cout << number << endl;
  }
}

When given the representation of a number that is out of range of the double type, strtod() sets errno to ERANGE:

$ ./main.exe 1e1000
range error

The strategy of setting a global error code can be (pardon the pun) error-prone. For example, errno must be set to zero before calling a standard-library function that uses it, and the caller must also remember to check its value after the function call returns. Otherwise, it may get overwritten by some other error down the line, masking the original error.

Object Error States

Another strategy used by C++ code is to set an error state on an object. This avoids the negatives of a global variable, since each object has its own error state rather than sharing the same global variable. C++ streams use this strategy:

int main(int argc, char **argv) {
  std::ifstream input(argv[1]);
  if (!input) {
    cout << "error opening file" << endl;
  }
}

As with a global variable, the user must remember to check the error state after performing an operation that may set it.

Return Error Codes

Return error codes are another strategy used in C and C++ code to signal the occurrence of an error. In this pattern, a value from a function’s return type is reserved to indicate that the function encountered an error. The following is an example of a factorial() function that uses this strategy:

// EFFECTS: Computes the factorial of the given number. Returns -1
//          if n is negative.
int factorial(int n) {
  if (n < 0) {
    return -1;                     // error
  } else if (n == 0) {
    return 1;
  } else {
    return n * factorial(n - 1);
  }
}

As with a global error code or an object state, it is the responsibility of the caller to check the code to determine whether an error occurred. Furthermore, a return error code only works if there is a value that can be reserved to indicate an error. For a function such as atoi(), which converts a C-style string to an int, there is no such value. In fact, atoi() returns 0 for both the string "0" and "hello", and the caller cannot tell whether or not the input was erroneous.

Exceptions

All three strategies above have the problem that the caller of a function must remember to check for an error; neither the compiler nor the runtime detect when the caller fails to do so. Another common issue is that error checking is interleaved with the regular control flow of the caller, as in the following:

int main(int argc, char **argv) {
  double number = std::stod(argv[1]);
  string input;
  cin >> input;
  if (!input) {
    cout << "couldn't read input" << endl;
  } else if (input == "sqrt") {
    cout << std::sqrt(number) << endl;
  } else if (input == "log") {
    cout << std::log(number) << endl;
  }
  ...
}

The control flow for the error case is mixed in with that of non-error cases, making the code less readable and harder to maintain. Furthermore, the code above fails to check for errors in sqrt() or log(), which is not immediately clear due to the interleaving of control flow.

What we want is a mechanism for error handling that:

  • Separates error detection from error handling, providing a general means for signaling that an error occurred.

  • Separates the control flow for error handling from that of non-erroneous cases.

  • Detects when an error is not handled, which by default should cause the program to exit with a meaningful message.

The mechanism provided by C++ and other modern languages is exceptions.

The following is an example of code that uses exceptions:

// Represents an exception in computing a factorial.
class FactorialError {};

// EFFECTS: Computes the factorial of the given number. Throws a
//          FactorialError if n is negative.
int factorial(int n) {
  if (n < 0) {                      // error case
    throw FactorialError();         // throw an exception
  }
  if (n == 0) {                     // non-error case
    return 1;
  } else {
    return n * factorial(n - 1);
  }
}

int main(int argc, char **argv) {
  try {
    int n = std::stoi(argv[1]);
    int result = factorial(n);
    cout << n << "! = " << result << endl;
  } catch (const std::invalid_argument &error) {
    cout << "Error converting " << argv[1] << " to an int: "
         << error.what() << endl;
  } catch (const FactorialError &error) {
    cout << "Error: cannot compute factorial on negative number"
         << endl;
  }
}

The individual components of the exception mechanism used above are:

  • The FactorialError class is an exception type, and objects of that type are used to signal an error.

  • When the factorial() function detects an error, it throws an exception object using the throw keyword. In the example above, the function throws a default-constructed FactorialError. The standard-library stoi() function throws a std::invalid_argument object when given a string that does not represent an integer.

  • The compiler arranges for the exception to propagate outward until it is handled by a try/catch block. In the example above, the first catch block is executed when a std::invalid_argument is thrown, while the second is run when a FactorialError is thrown.

When an exception is thrown, execution pauses and can only resume at a catch block. The code that is between the throw and the catch that handles the exception is entirely skipped. This is a good thing; if the rest of the code in factorial() were to run, the function would try to compute the factorial of a negative number, which would recurse indefinitely (or at least until either the program runs out of stack space, or n reaches the most negative int, at which point the subtraction n - 1 would produce undefined behavior).

The result of running this program on different inputs is as follows:

$ ./main.exe 3
3! = 6
$ ./main.exe -1
Error: cannot compute factorial on negative number
$ ./main.exe hello
Error converting hello to an int: stoi: no conversion

The error message in the last example, specifically the part returned by error.what(), is implementation-dependent.

Exception Objects

C++ allows an object of any type to be thrown as an exception. However, a proper exception carries information about what caused the error, and using exceptions of different types allows a program to distinguish between different kinds of errors. More specifically, the example above illustrates that a program can perform different actions in response to each exception type.

There are several exceptions defined by the standard library, such as invalid_argument or out_of_range. The standard-library exceptions all derive from std::exception, and it is common for user-defined exceptions to do so as well:

class EmailError : public std::exception {
public:
  EmailError(const string &msg_in) : msg(msg_in) {}

  const char * what() const override {
    return msg.c_str();
  }

private:
  string msg;
};

A user-defined exception is a class type, so it can carry any information necessary to pass between the function that detects an error and the one that handles it. For EmailError above, the constructor takes a string and stores it as a member variable, allowing any message to be specified when creating an EmailError object:

throw EmailError("Error sending email to: " + address);

The what() member function is a virtual function defined by std::exception, and EmailError overrides it to return the message passed to the constructor. We can call it when catching an exception to obtain the message:

try {
  ...
} catch (const EmailError &error) {
  cout << error.what() << endl;
}

Exception types are often defined in an inheritance hierarchy, with a derived class representing a more specific kind of error. Deriving from std::exception is an example of this, but we can also define exception types that derive from EmailError:

class InvalidAddressError : public EmailError {
  ...
};

class SendFailedError : public EmailError {
  ...
};

We will shortly see how exception hierarchies interact with try/catch blocks.

Try/Catch Blocks

A try/catch block consists of a try block followed by one or more catch blocks:

try {
  int n = std::stoi(argv[1]);
  int result = factorial(n);
  cout << n << "! = " << result << endl;
} catch (const std::invalid_argument &error) {
  cout << "Error converting " << argv[1] << " to an int: "
       << error.what() << endl;
} catch (const FactorialError &error) {
  cout << "Error: cannot compute factorial on negative number"
       << endl;
}

The try/catch can only handle exceptions that occur within the try part of the block, including in functions called by code in the try. When a thrown exception propagates to a try block, the compiler checks the corresponding catch blocks in order to see if any of them can handle an exception of the type thrown. Execution is immediately transferred to the first applicable catch block:

  • The catch parameter is initialized from the exception object. Declaring the parameter as a reference to const avoids making a copy.

  • The body of the catch block is run.

  • Assuming the catch block completes normally, execution proceeds past the entire try/catch. The remaining code in the try or in the other catch blocks is skipped.

In matching an exception object to a catch block, C++ takes into account subtype polymorphism – if the dynamic type of the object is derived from the type of a catch parameter, the catch is able handle that object, so the program uses that catch block for the exception. The following is an example:

void grade_submissions() {
  vector<string> students = load_roster();
  for (const string &name : students) {
    try {
      auto sub = load_submission(name);
      double result = grade(sub);
      email_student(name, result);
    } catch (const FileError &error) {
      cout << "Can't grade: " << name << endl;
    } catch (const EmailError &error) {
      cout << "Error emailing: " << name << endl;
    }
  }
}

The first catch block above handles objects of any type derived from FileError, while the second handles objects of any type derived from EmailError.

C++ also has a “catch-all” that can handle any exception type:

try {
  // some code here
} catch (...) {
  cout << "Caught an unknown error" << endl;
}

The ... as a catch parameter enables the catch block to handle any type of object. However, this should in general be avoided – instead, a particular try/catch should only catch the specific kinds of errors that it is able to recover from. For instance, consider the following implementation of the load_roster() function:

vector<string> load_roster() {
  try {
    csvstream csvin("280roster.csv");      // may throw an exception
    // Use the stream to load the roster...
  } catch (const csvstream_exception &e) {
    cout << e.what() << endl;
    // return ???
  }
}

The code uses the csvstream library to read a spreadsheet in CSV format. The library may throw a csvstream_exception object when opening the file, such as if the file doesn’t exist. However, the load_roster() function is not the best place to handle this exception; as discussed previously, the appropriate error recovery depends on the application, and it is the caller of load_roster() that knows what to do in case of an error. Thus, load_roster() should avoid catching the exception, letting it propagate to its caller.

Exception Propagation

When an exception is thrown, if the current function is not within a try block, the exception propagates to the function’s caller. Similarly, if the current function is within a try block but there is no associated catch block that can handle an object of the exception’s type, the exception also propagates to the caller.

To handle an exception, the program immediately pauses execution, then looks for an exception handler as follows. The process starts at the statement that throws the exception:

  • Determine if the current statement is within the try part of a try/catch block in the current function. If not, the function call is terminated, and the process repeats in the caller.

  • If the execution is within a try, examine the catch blocks in order to find the first one that can handle an exception of the given type. If no matching catch is found, the function call is terminated, and the process repeats in the caller.

  • If a matching catch block is found, the catch parameter is initialized with the exception object, and the body of the catch immediately runs. Assuming it completes successfully, execution proceeds past the entire try/catch.

As described above, if the code is not within a try block that can handle the exception object, execution returns immediately to the caller, and the program looks for a handler there. The exception propagates outward until a viable handler is found.

If the exception proceeds outward past main(), the default behavior is to terminate the program. This ensures that either the exception is handled, or the program crashes, informing the user that an error occurred.

Exception Examples

The following is a DriveThru class that keeps track of menu items and their prices:

class InvalidOrderException : public std::exception {};

class DriveThru {
public:
  // EFFECTS: Adds the given item to the menu.
  void add_item(const string &item, double price) {
    menu[item] = price;
  }

  // EFFECTS: Returns the price for the given item. If the item
  //          doesn't exist, throws an InvalidOrderException.
  double get_price(const string &item) const {
    auto it = menu.find(item);
    if (it != menu.end()) {
      return it->second;
    }
    throw InvalidOrderException();
  }

private:
  // A map from item names to corresponding prices
  map<string, double> menu;
};

The get_price() member function looks up the price of an item, returning it if it is on the menu. If not, it throws an exception of type InvalidOrderException. We use the find() member function of map to avoid inserting a value-initialized price into the map when an item does not exist.

The following is a program that reads order items from standard input, computing the total price:

int main() {
  DriveThru eats280;
  ... // add items to eats280

  double total = 0;
  string item;
  while (cin >> item && item != "done") {
    try {
      total += eats280.get_price(item);
    } catch (const InvalidOrderException &e) {
      cout << "Sorry, we don't have: " << item << endl;
    }
  }
  cout << "Your total cost is: " << total << endl;
}

The program ignores items that aren’t on the menu, printing a message indicating that it is not available. In such a case, get_price() throws an InvalidOrderException. Since the throw statement is not within a try in get_price(), the exception propagates outward to main(). The call to get_price() is within a try, and there is an associated catch that matches the type of the exception. Execution proceeds to that catch, which prints an error message. Then execution continues after the full try/catch, ending an iteration of the loop and reading in a new item to start the next iteration

As another example, we write a subset of a program that loads and grades student submissions for an assignment. We use the following classes:

// An exception signifying an error when reading a file.
class FileError : std::exception {
  ...
};

// An exception signifying an error when sending an email.
class EmailError : std::exception {
  ...
};

// A student submission.
class Submission {
  ...
public:
  // EFFECTS: Grades this submission and returns the result.
  double grade();
};

FileError and EmailError are exceptions that the program will use. We saw the definition of EmailError previously, and FileError is similarly defined. A Submission object represents a student submission, and we elide the definition here.

The following functions perform individual tasks in the grading program:

// EFFECTS: Emails the given student the given grade. Throws
//          EmailError if sending the email fails.
void email_student(const string &name, double grade);

// EFFECTS: Loads the roster from 280roster.csv. Throws
//          csvstream_exception if the file cannot be read.
vector<string> load_roster() {
  csvstream csvin("280roster.csv"); // may throw an exception

  vector<string> roster;
  // Use the stream to load the roster...

  return roster;
}

// EFFECTS: Loads the submission for the given student. Throws
//          FileError if the submission cannot be loaded.
Submission load_submission(const string &name) {
  std::ifstream input(name);

  if (!input) {
    throw FileError();
  }

  return Submission(input);
}

All three functions throw exceptions when they are unable to perform their task. The email_student() function throws an EmailError if sending fails. The load_roster() function throws a csvstream_exception if reading the roster file fails; in actuality, the exception will be thrown by the csvstream constructor, but since load_roster() allows the exception to propagate outward, it documents that such an exception may be thrown. Finally, load_submissions() throws a FileError if a submission file fails to open.

The function that directs the grading process is as follows:

// EFFECTS: Loads and grades all submissions, sending email to each
//          student with their result. Throws csvstream_exception
//          if the roster cannot be loaded.
void grade_submissions() {
  vector<string> students = load_roster();
  for (const string &name : students) {
    try {
      auto sub = load_submission(name);
      double result = sub.grade();
      email_student(name, result);
    } catch (const FileError &e) {
      cout << "Can't grade: " << s << endl;
    } catch (const EmailError &e) {
      cout << e.what() << endl;
    }
  }
}

This function handles FileError and EmailError exceptions by just printing a message to standard out. The try/catch is within the for loop so that failure for one student does not prevent grading of other students. If a csvstream_exception is thrown by load_roster(), it gets propagated to the caller of grade_submissions().

Lastly, we have the main() function:

int main() {
  try {
    grade_submissions();
    cout << "Grading done!" << endl;
  } catch (const csvstream_exception &e) {
    cout << "Grading failed! << endl;
    cout << e.what() << endl;
    return EXIT_FAILURE;
  }
}

This function handles a csvstream_exception by printing a message to standard out and then returning with a nonzero exit code, indicating a failure.

We consider a few more small examples to better understand how exceptions are propagated and handled. The examples use the following exception types:

class GoodbyeError {};

class HelloError {};

class Error {
public:
  Error(const string &s) : msg(s) {}

  const string & get_msg() {
    return msg;
  }

private:
  string msg;
};

Objects of GoodbyeError will generally be thrown by a goodbye() function, while HelloError objects will be thrown by hello().

The first example is as follows:

void goodbye() {
  cout << "goodbye called" << endl;
  throw GoodbyeError();
  cout << "goodbye returns" << endl;
}

void hello() {
  cout << "hello called" << endl;
  goodbye();
  cout << "hello returns" << endl;
}

int main() {
  try {
    hello();
    cout << "done" << endl;
  } catch (const HelloError &he) {
    cout << "caught hello" << endl;
  } catch (const GoodbyeError &ge) {
    cout << "caught goodbye" << endl;
  }
  cout << "main returns" << endl;
}

In this example, hello() is called from within a try in main(). So if an exception is thrown and propagates to main(), the associated catch blocks will attempt to handle the exception. Within hello(), the message hello called is printed, followed by a call to goodbye(). The latter prints out goodbye called and then throws a GoodbyeError object. Execution immediately pauses, and the program checks if it is within a try in goodbye(). It is not, so it then checks the caller to see if it is currently in a try there. There isn’t one in hello(), so the program then checks for a try in main(). The execution state is indeed within a try in main(), so the program checks the catch blocks, in order, to see if there is one that can handle a GoodbyeError. The second catch can do so, and its code is run, printing caught goodbye. Execution then proceeds past the try/catch, so the print of main returns executes.

Observe that the remaining code in goodbye(), hello(), and the try block in main() were skipped when handling the exception. The full output is as follows:

hello called
goodbye called
caught goodbye
main returns

Consider another example:

void goodbye() {
  cout << "goodbye called" << endl;
  throw GoodbyeError();
  cout << "goodbye returns" << endl;
}

void hello() {
  cout << "hello called" << endl;
  try {
    goodbye();
  } catch (const GoodbyeError &ge) {
    throw HelloError();
  } catch (const HelloError &he) {
    cout << "caught hello" << endl;
  }
  cout << "hello returns" << endl;
}

int main() {
  try {
    hello();
    cout << "done" << endl;
  } catch (const HelloError &he) {
    cout << "caught hello" << endl;
  } catch (const GoodbyeError &ge) {
    cout << "caught goodbye" << endl;
  }
  cout << "main returns" << endl;
}

This is the same as the first example, except now the call to goodbye() is within a try in hello(). When the GoodbyeError object is thrown, the program determines that it is not within a try in goodbye(), so it checks whether it is in a try in hello(). It is, so the program checks whether there is a catch block that can handle a GoodbyeError. The first catch block can do so, so its code is run. This throws a HelloError, so the program checks whether execution is within a try in hello(). It is not – execution is within a catch block, not within a try. So the program proceeds to the caller, checking whether execution is in a try in main(). The call to hello() is indeed within a try, so the program examines the catch blocks. The first one handles a HelloError, so its body is executed, printing caught hello. Then execution proceeds past the try/catch to the print of main returns.

Observe that a try/catch can only handle exceptions that are thrown within the try block; it does not deal with exceptions that are thrown from one of its catch blocks, so an outer try/catch must handle such exceptions instead.

hello called
goodbye called
caught hello
main returns

The following example uses the Error class defined above, which has a constructor that takes a string:

void goodbye() {
  cout << "goodbye called" << endl;
  throw Error("bye");
  cout << "goodbye returns" << endl;
}

void hello() {
  cout << "hello called" << endl;
  try {
    goodbye();
  } catch (const Error &e) {
    throw Error("hey");
  }
  cout << "hello returns" << endl;
}

int main() {
  try {
    hello();
    cout << "done" << endl;
  } catch (const Error &e) {
    cout << e.get_msg() << endl;
  } catch (...) {
    cout << "unknown error" << endl;
  }
  cout << "main returns" << endl;
}

The throw statement in goodbye() throws an Error object constructed with the string "bye". There is no try in goodbye(), so the program checks whether it is currently within a try in hello(). Execution is indeed within the try there, and the catch block can handle the Error object. The catch block throws a different Error object, initialized with the string "hey". As with the previous example, this throw statement is not within a try in hello(), so the program checks for a try in main(). Both catch blocks can handle the Error object; the second is a catch-all that can handle any exception. However, the program considers the catch blocks in order, so it is the first one that runs. Its body retrieves the message from the Error object, printing out hey. Then execution proceeds past the try/catch. The full output is below:

hello called
goodbye called
hey
main returns

One more example is the following:

void goodbye() {
  cout << "goodbye called" << endl;
  throw GoodbyeError();
  cout << "goodbye returns" << endl;
}

void hello() {
  cout << "hello called" << endl;
  try {
    goodbye();
  } catch (const Error &e) {
    throw Error("hey");
  }
  cout << "hello returns" << endl;
}

int main() {
  try {
    hello();
    cout << "done" << endl;
  } catch (const Error &e) {
    cout << e.get_msg();
    cout << endl;
  } catch (...) {
    cout << "unknown error" << endl;
  }
  cout << "main returns" << endl;
}

Here, the goodbye() function throws a GoodbyeError, and the throw is not within a try in goodbye(), so the program looks for a try in hello(). Execution is within a try there, so the program examines the catch blocks to see whether one can handle a GoodbyeError. The lone catch block cannot, so the program propagates the exception to the caller and looks for a try in main(). The code is within a try in main(), so the program examines the catch blocks in order. The first cannot handle a GoodbyeError, but the second can, so the latter runs and prints unknown error. Execution continues after the try/catch, printing main returns. The full result is the following:

hello called
goodbye called
unknown error
main returns

Error Handling vs. Undefined Behavior

The error-detection mechanisms we discussed provide well-defined behavior in case of an error. As such, a function that uses one of these mechanisms should document it, describing when and what kind of error can be generated:

// EFFECTS: Computes the factorial of the given number. Returns 0 if
//          n is negative.
int factorial(int n);

// EFFECTS: Emails the given student the given grade. Throws
//          EmailError if sending the email fails.
void email_student(const string &name, double grade);

These functions do not have REQUIRES clauses that restrict the input; violating a REQUIRES clause results in undefined behavior, whereas these function produce well-defined behavior for erroneous input.

On the other hand, when code does produce undefined behavior, it cannot be detected through any of the error-handling mechanisms above. For example, dereferencing a null or uninitialized pointer does not necessarily throw an exception, set a global error code, or provide any other form of error detection. It is the programmer’s responsibility to avoid undefined behavior, and there are no constraints on what a C++ implementation can do if a program results in undefined behavior.