Lab 20: Error Handling

Error Handling

Error handling is an essential part of robust and reliable software development. This lab will introduce you to the concepts and mechanisms of error handling in C++. While this lab is focused on C++-specific features, many concepts demonstrated here are also applicable in other languages.

§1 Types of Errors #

Syntax Errors:

int x = ; // Missing initializer

Logical Errors:

int divide(int a, int b) { return a + b; } // should divide instead of add

Runtime Errors:

int arr[3] = {1, 2, 3};
std::cout << arr[3]; // Out-of-bounds access

§2 Error Handling Schemes #

C++ provides several complementary strategies for detecting, reporting, and recovering from runtime problems. This section introduces three of the most common schemes: return codes, error flags, and exceptions. Each approach has a trade-offs between code clarity, performance, and maintainability.

Return Codes:
Use return values to indicate success or failure. 0 typically signifies success, all other values signify failure. The meaning of the return codes are arbitrarily defined by the programmer.

int divide(int numerator, int denominator, int& result) {
  if (denominator == 0) return -1; // Error: Division by zero. return 1
  result = numerator / denominator;
  return 0; // Success. return 0
}

int main() {
  int res;
  if (divide(10, 0, res) == -1) {
    std::cerr << "Error: Division by zero" << std::endl;
  } else {
    std::cout << "Result: " << res << std::endl;
  }
}

Note: the main() function utilizes a return code, returning any value other than 0 is interpreted as the program exiting with a failure.

Error Flags:
Use status flags within objects (e.g., std::ios::fail() in file streams).

int main() {
  std::ifstream file("nonexistent.txt");
  if (file.fail()) { // use error flag to check for issues
    std::cerr << "Error: Could not open the file." << std::endl;
  } else {
    std::cout << "File opened successfully." << std::endl;
  }
}

Exceptions:
A structured mechanism to handle errors by separating error-handling logic from normal code flow.

int safeDivide(int numerator, int denominator) {
  if (denominator == 0) throw std::invalid_argument("ERROR: Division by zero");
  return numerator / denominator;
}

int main() {
  try {
    int result = safeDivide(10, 0);
    std::cout << "Result: " << result << std::endl;
  } catch (const std::exception& ex) {
    std::cerr << "Exception: " << ex.what() << std::endl;
  }
}
./exception-example
ERROR: Division by zero

§3 Asynchronous vs. Synchronous Errors #

Errors in programs fall into two main categories: synchronous and asynchronous. Synchronous errors occur during normal code execution, such as accessing an invalid array index or a missing file. Synchronous errors be caught with standard error-handling. Asynchronous errors arise unexpectedly from external events such as hardware failures or system interrupts, these types of errors are harder to predict and manage.

Synchronous Errors:

Asynchronous Errors:

§4 Exception Handling: try, throw, and catch #

Exception handling in C++ enables programs to handle runtime errors gracefully. This is achieved using try, throw, and catch. The try block contains code that has potential to fail, throw signals an error has occurred, and catch handles the error. This combination can be used to prevent crashes and improving reliability.

Basic Syntax:

  1. try Block: Encapsulates code that may throw exceptions.
  2. throw Statement: Used to signal an error condition by throwing an exception.
  3. catch Block: Handles the exception. Example:
void divide(int a, int b) {
  if (b == 0)
    throw std::runtime_error("Division by zero!"); // throw error
  std::cout << "Result: " << a / b << std::endl;
}

int main() {
  try { // "try" code that may throw an error
    divide(10, 0);
  } catch (const std::exception& e) { // "catch" error and handle it gracefully
    std::cerr << "Error: " << e.what() << std::endl;
  }
}
./exception-example
Error: Division by zero!

§5 Stack Unwinding #

When an exception is thrown, C++ cleans up the stack by calling destructors for all objects created in the try block before the exception was thrown.

Steps:

  1. Exception is Thrown:
    • When an exception is thrown, the normal flow of execution is interrupted, and the program searches for an appropriate exception handler (catch block).
  2. Function Exits:
    • Functions in the call stack begin to exit in reverse order (from the most recent call downwards). This process is known as unwinding the stack.
    • As functions exit, their local objects are destroyed, and their destructors (if any) are called.
  3. Catch Block Found:
    • The stack unwinding stops as soon as a matching catch block is found.
    • If no suitable handler is found, the program terminates.

Example:

class Resource {
public:
  Resource(const std::string& name) : name(name) {
    std::cout << "Acquiring " << name << std::endl;
  }
  ~Resource() {
    std::cout << "Releasing " << name << std::endl;
  }

private:
  std::string name;
};

void faultyFunction() {
  Resource res1("Resource1"); // creates object, this gets unwound last
  Resource res2("Resource2"); // creates object, this gets unwound first
  throw std::runtime_error("Something went wrong");
  Resource res3("Resource3"); // Never executed
}

int main() {
  try {
    faultyFunction(); // call faulty function
  } catch (const std::exception& e) {
    std::cerr << "Caught: " << e.what() << std::endl; // catch thrown error
  }
}
$ ./unwinding-example
Acquiring Resource1
Acquiring Resource2
Releasing Resource2
Releasing Resource1
Caught: Something went wrong

§6 Re-throwing an Exception #

In C++, re-throwing an exception allows you to propagate an already caught exception to a higher-level catch block. This can be useful when a lower-level function or code block handles part of the exception’s consequences but the exception requires further processing by a higher-level handler.

To re-throw an exception, use the throw; statement without specifying an exception object. This continues the propagation of the currently handled exception.

Example:

void level2() {
  std::cerr << "Running faulty function." << std::endl;
  throw std::runtime_error("Error in level 2");
}

void level1() {
  try {
    level2();
  } catch (...) { // ellipsis to catch any type of error
    std::cerr << "Handling at level 1, re-throwing..." << std::endl;
    throw; // Re-throws the same exception
  }
}

int main() {
  try {
    level1();
  } catch (const std::exception& e) { // catch error object to use in output
    std::cerr << "Caught at main: " << e.what() << std::endl;
  }
}
./rethrow-example
Running faulty function.
Handling at level 1, re-throwing...
Caught at main: Error in level 2

Explanation:

§7 Further Reading #

§8 Questions #

  1. Why are logical errors more challenging to debug than syntax and runtime errors?
  1. What happens when an array is accessed out of its bounds in C++? What type of error is this an example of?
  1. What is the purpose of return codes in error handling?
  1. How does the following code use return codes to handle errors?
int divide(int numerator, int denominator, int& result) {
  if (denominator == 0) return -1;
  result = numerator / denominator;
  return 0;
}
  1. What C++ feature allows you to check for issues in file operations like opening a file?
  1. What happens when an exception is thrown in C++?
  1. How does the try, throw, and catch mechanism work in C++?
  1. What is stack unwinding in the context of exception handling?
  1. In the following code, why does “Resource3” never get acquired?
void faultyFunction() {
  Resource res1("Resource1");
  Resource res2("Resource2");
  throw std::runtime_error("Something went wrong");
  Resource res3("Resource3");
}
  1. What is the benefit of re-throwing an exception?