Lecture 8. Exceptions

Date: 2023-06-13

1. Introduction

Exceptions in C++ provide a mechanism to handle unexpected situations (like runtime errors) in our programs. Instead of allowing a program to crash or produce unpredictable results, exceptions give us a controlled way to catch and handle errors, making software more robust and user-friendly.

2. Basics of Exceptions

Throwing Exceptions

In C++, you can signal an exception using the throw keyword. This interrupts the normal flow of execution and looks for the nearest exception handler.

Example:

int divide(int numerator, int denominator) {
    if (denominator == 0) {
        throw "Division by zero!";
    }
    return numerator / denominator;
}

In this example, if someone tries to divide by zero, an exception is thrown with a message "Division by zero!".

Catching Exceptions

To handle exceptions, you can use the try-catch blocks. You enclose the code that might throw an exception within a try block, and then specify handlers for possible exceptions using one or more catch blocks.

Example:

int main() {
    try {
        int result = divide(10, 0);  // This will throw an exception
        std::cout << "Result is: " << result << std::endl;
    } catch (const char* e) {
        std::cout << "Caught exception: " << e << std::endl;
    }
    return 0;
}

Output:

Caught exception: Division by zero!

The code within the try block is the risky code that might throw an exception. The catch block is there to handle it.

Exception Propagation

If an exception is thrown, but not caught in the current function, it will propagate (or bubble up) to the calling function, and so on, until it reaches main(). If it's not caught there either, the program terminates. This allows you to handle exceptions at a level where you have enough context to deal with them properly.

Example:

void riskyFunction() {
    throw "An exception from riskyFunction!";
}

int main() {
    try {
        riskyFunction();  // This function will throw an exception
    } catch (const char* e) {
        std::cout << "Caught exception in main: " << e << std::endl;
    }
    return 0;
}

Output:

Caught exception in main: An exception from riskyFunction!

Even though riskyFunction() doesn't have its own try-catch, the exception it throws is caught by the try-catch in main().


3. Standard Exceptions

Hierarchy

The C++ Standard Library provides a set of standard exception classes, which are organized in an inheritance hierarchy. At the top of this hierarchy is the std::exception class, from which all standard exceptions are derived.

Here's a simplified hierarchy of the standard exceptions:

std::exception
|
|-- std::logic_error
|   |
|   |-- std::domain_error
|   |-- std::invalid_argument
|   |-- std::length_error
|   |-- std::out_of_range
|
|-- std::runtime_error
    |
    |-- std::overflow_error
    |-- std::underflow_error
    |-- ...

This is by no means exhaustive, but it gives an idea of the organization.

Commonly Used Exceptions

  • std::runtime_error: This is a general exception that indicates a runtime error. cpp void someFunction() { throw std::runtime_error("A runtime error occurred!"); }

  • std::out_of_range: Thrown when an attempt is made to access an element of a container (like an array or vector) using an out-of-range index.

std::vector<int> myVec = {1, 2, 3};
if (index >= myVec.size()) {
    throw std::out_of_range("Index is out of range");
}
  • std::invalid_argument: Thrown when a function receives an argument that has invalid or unexpected value.
void setAge(int age) {
    if (age < 0) {
        throw std::invalid_argument("Age cannot be negative");
    }
}

Usage Example:

Here's a combined example demonstrating the usage of these exceptions:

#include <iostream>
#include <stdexcept>
#include <vector>

int getValueAt(const std::vector<int>& vec, int index) {
    if (index < 0 || index >= vec.size()) {
        throw std::out_of_range("Index is out of the vector's range");
    }
    return vec[index];
}

void setAge(int age) {
    if (age < 0) {
        throw std::invalid_argument("Age cannot be negative");
    }
    // ... some code to set age
}

int main() {
    std::vector<int> myVec = {10, 20, 30};

    try {
        std::cout << getValueAt(myVec, 5) << std::endl;  // out_of_range exception
        setAge(-5);  // invalid_argument exception
    } catch (const std::exception& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }

    return 0;
}

In the example, if getValueAt() is called with an invalid index, it throws std::out_of_range. If setAge() is called with a negative value, it throws std::invalid_argument.


4. Custom Exceptions

Defining Custom Exceptions

Sometimes, the standard exceptions provided by the C++ Standard Library might not suffice for conveying specific error information. In such cases, you can define your own custom exception classes.

A custom exception is typically a class that derives from one of the standard exception classes, and it might have additional member variables or methods to provide more information about the error.

Example:

class CustomError : public std::exception {
private:
    std::string message;

public:
    CustomError(const std::string& msg) : message(msg) {}

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

In this example, we've defined a CustomError exception class that derives from std::exception. It overrides the what() method to return a custom error message.

Inheriting from Standard Exceptions

Inheriting from standard exceptions has multiple benefits:

  1. Reusability: By inheriting from standard exceptions, your custom exception class can reuse behavior and interfaces defined in the standard exceptions, such as the what() method.

  2. Consistency: Using the standard exception interface ensures that anyone familiar with C++ can handle your custom exceptions in the same way they handle standard exceptions.

  3. Catch Mechanism: If you throw a custom exception and there's no specific catch block for your custom type, the exception will still be caught by a catch block designed for its base type (e.g., std::exception).

Example:

class DatabaseError : public std::runtime_error {
public:
    DatabaseError(const std::string& msg) : std::runtime_error(msg) {}
};

int main() {
    try {
        throw DatabaseError("Failed to connect to database");
    } catch (const std::runtime_error& e) {
        std::cout << "Runtime error: " << e.what() << std::endl;
    } catch (const std::exception& e) {
        std::cout << "General exception: " << e.what() << std::endl;
    }
    return 0;
}

In this example, DatabaseError is a custom exception that inherits from std::runtime_error. Even though we throw a DatabaseError, it is caught by the catch block for std::runtime_error because of the inheritance.


5. Good Practices

When to Throw

Throwing exceptions should be reserved for truly exceptional cases, i.e., unexpected scenarios that your code isn't designed to handle normally.

  • Validation Failures: If you're reading input from a user or a file and it doesn't meet certain criteria.

  • Resource Failures: If your program can't open a necessary file, establish a database connection, or acquire some other resource.

  • Invariant Violations: Situations where certain assumptions or conditions (invariants) that must hold true are violated.

When Not to Throw

  • Predictable Errors: If an error is anticipated as a part of normal operation, it's often better to handle it without throwing an exception.

  • In Destructors: Throwing exceptions in destructors can be problematic. If an exception is already in flight and a destructor throws an exception while cleaning up, the program will terminate. Use other mechanisms to handle errors in destructors.

  • Performance Critical Paths: Exception handling involves some overhead. In performance-sensitive areas, it might be better to handle errors differently.

Resource Management

Exception safety is crucial, especially when managing resources like memory, files, or network connections.

  • RAII (Resource Acquisition Is Initialization): This is a C++ idiom where resources are acquired in a constructor and released in a destructor. This ensures that resources are managed properly even if an exception is thrown. RAII objects automatically clean up their resources when they go out of scope, regardless of whether the block exits normally or due to an exception.

Example:

class FileHandler {
private:
    std::fstream file;
public:
    FileHandler(const std::string& filename) : file(filename) {
        if (!file.is_open()) {
            throw std::runtime_error("Failed to open file");
        }
    }
    ~FileHandler() {
        if (file.is_open()) {
            file.close();
        }
    }
};

Noexcept Specifier

The noexcept specifier indicates that a function does not throw exceptions. It's a promise you make, and if broken (i.e., if the function does throw), the program will call std::terminate().

  • Benefits: It can enable certain compiler optimizations, and it clearly communicates to other developers that a function isn't expected to throw.

  • Usage: Use noexcept for functions where you're confident that exceptions won't be thrown or where they shouldn't be thrown for other reasons.

    Example:

void safeFunction() noexcept {
    // Code that should not throw any exceptions
}

For move constructors and move assignment operators, noexcept can be particularly beneficial, as it can make certain standard library operations (like std::vector::resize) more efficient.


6. Advanced Topics

Stack Unwinding

When an exception is thrown, the program starts looking for the nearest catch block that can handle the thrown exception. This process is called stack unwinding.

During stack unwinding, the destructors of all local objects created within the scope of the try block (and any other blocks in between the throw site and the catch site) are called in reverse order of their construction. This ensures proper cleanup of resources.

Example:

class Demo {
public:
    Demo() {
        std::cout << "Constructor called!" << std::endl;
    }

    ~Demo() {
        std::cout << "Destructor called!" << std::endl;
    }
};

void functionWithException() {
    Demo d;
    throw std::runtime_error("Exception thrown");
}

int main() {
    try {
        functionWithException();
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << std::endl;
    }

    return 0;
}

In the above example, when the exception is thrown in functionWithException, the destructor of d is called before the control reaches the catch block in main.

Re-throwing Exceptions

Sometimes, you might catch an exception, perform some operations (like logging the error) and then decide that the exception should be handled at a higher level in your program. In such cases, you can re-throw the caught exception.

To re-throw a caught exception, simply use the throw keyword without any arguments inside a catch block.

Example:

void reThrowingFunction() {
    try {
        // some code that might throw
        throw std::runtime_error("An error occurred");
    } catch (const std::exception& e) {
        std::cout << "Error logged: " << e.what() << std::endl;
        throw;  // re-throw the caught exception
    }
}

Catching All Exceptions

In some scenarios, you might want to catch every possible thrown exception, regardless of its type. This can be done using the catch(...) catch-all block.

It's important to be cautious when using this, as it can make it harder to determine the exact type of the caught exception. However, it can be useful as a last resort to ensure your program doesn't crash from unhandled exceptions.

Example:

try {
    // some code that might throw various exceptions
} catch (const std::runtime_error& e) {
    std::cout << "Runtime error: " << e.what() << std::endl;
} catch (...) {
    std::cout << "Unknown exception caught!" << std::endl;
}

Note that in the above example, the catch-all block comes after specific exception type catches. If you place catch(...) first, it will catch all exceptions, and subsequent catch blocks will be unreachable.


7. Error Handling vs. Exceptions

Traditional Error Handling (Return Codes)

In traditional error handling, functions signal failure by returning a specific error code. The caller is then responsible for checking this return value to determine if the operation succeeded or failed.

Advantages:

  • Performance: Checking a return value is typically faster than handling an exception. In performance-critical code, this can be a consideration.

  • Simplicity: For some simple operations or in cases where failures are common and expected, it might be more straightforward to use error codes rather than exceptions.

Disadvantages:

  • Easy to Ignore: Callers might forget to check return values, leading to undetected errors.

  • Clutters Return Type: The function's return type gets consumed by the error signaling, which means you can't return other useful information without resorting to output parameters or other workarounds.

Example:

int divide(int a, int b) {
    if (b == 0) {
        return INT_MAX;  // indicates error
    }
    return a / b;
}

Exceptions

Exceptions provide a way to react to exceptional circumstances (like runtime errors) and allow a program to transfer control from one part to another.

Advantages:

  • Separation of Error Handling and Normal Code: Keeps the main logic clean and makes it easier to follow.

  • Catch Errors at Appropriate Level: With exceptions, you can catch an error several levels up from where it was thrown, allowing more flexible error handling.

  • Rich Error Information: Exceptions can carry detailed error information (through custom exception classes) to where they're caught.

Disadvantages:

  • Performance: Throwing and catching exceptions involve some overhead. This might not be ideal for performance-critical paths.

  • Complexity: In some scenarios, especially if not used properly, exceptions can make code harder to understand and maintain.

Example:

double divide(double a, double b) {
    if (b == 0) {
        throw std::runtime_error("Division by zero");
    }
    return a / b;
}

When to Prefer One Over the Other?

  1. Expected vs. Unexpected Errors: If an error is expected as a part of normal operation (e.g., failing to find an item in a database), return codes might be more appropriate. Use exceptions for unexpected scenarios (e.g., a database connection suddenly dropping).

  2. Performance-Critical Sections: If you're in a tight loop or a performance-sensitive part of your code, traditional error codes might be preferable due to their lower overhead.

  3. Library/SDK Development: If you're developing a library or SDK that'll be used by others, consider your audience. Using exceptions can make the API simpler, but it can also impose exception handling on the user. Some libraries offer both options, leaving the choice to the developer using the library.


8. Q&A

Q1: What is the output of the following code snippet, and why?

try {
    throw 42;
} catch (double e) {
    std::cout << "Caught double: " << e << std::endl;
} catch (...) {
    std::cout << "Caught unknown exception!" << std::endl;
}

A1: The output will be Caught unknown exception!. The integer 42 doesn't match the double type specified in the first catch block, so the catch-all block is executed.


Q2: In the code below, what happens when an exception is thrown?

class Demo {
public:
    ~Demo() {
        std::cout << "Destructor called!" << std::endl;
    }
};

void testFunction() {
    Demo d;
    throw std::runtime_error("An error");
}

int main() {
    try {
        testFunction();
    } catch (...) {
        std::cout << "Exception caught!" << std::endl;
    }
    return 0;
}

A2: The program will first print Destructor called! and then Exception caught!. When the exception is thrown in testFunction, the local objects (like d) are destroyed (i.e., their destructors are called) during stack unwinding before the catch block in main is executed.


Q3: Can you catch an exception thrown by a different thread in the main thread? For instance, can the main thread catch an exception thrown by a worker thread?

A3: No, exceptions cannot be caught by another thread that did not throw them. If a thread throws an exception and doesn't catch it, that thread will be terminated, but other threads will continue to run. If you want to handle such cases, you'll need to devise a mechanism to propagate or communicate the exception or error state between threads.


Q4: What's the primary issue with the following code?

void someFunction() noexcept {
    throw std::runtime_error("Oops!");
}

A4: The function someFunction is marked with the noexcept specifier, indicating it won't throw exceptions. However, inside the function, we throw a std::runtime_error. If this exception is thrown at runtime, the program will call std::terminate() and end abruptly, because it violates the noexcept promise.


Q5: Why might one use the std::exception_ptr and std::rethrow_exception functions provided by the C++ Standard Library?

A5: std::exception_ptr and associated functions provide a way to capture, store, and re-throw exceptions. This is particularly useful in multithreaded programs where you might want to capture an exception in one thread and handle (or re-throw) it in another.

For example, if a worker thread throws an exception, you can capture it using std::current_exception(), store the exception, and later, in a different thread (e.g., the main thread), you can re-throw it using std::rethrow_exception and handle it appropriately.


Q6: What is the potential issue with the following code snippet?

class MyException {
public:
    MyException(const char* msg) : message(msg) {}
    const char* what() const { return message; }
private:
    const char* message;
};

void functionA() {
    const char* errorMsg = "Something went wrong.";
    throw MyException(errorMsg);
}

A6: The potential issue here is that errorMsg is a local pointer pointing to a string literal. When the exception object is thrown and caught somewhere else, it might outlive the scope of functionA(). If the exception tries to use the message pointer after functionA() has exited, it could lead to undefined behavior. A safer approach would be to use std::string inside MyException to store the message.


Q7: Why is it generally recommended not to throw exceptions from destructors?

A7: If an exception is thrown while another exception is already being processed (i.e., stack unwinding for another exception is in progress), the C++ standard mandates the program to terminate immediately by calling std::terminate(). Throwing exceptions from destructors can easily lead to such scenarios, especially since destructors are automatically invoked during stack unwinding.


Q8: What does the following code do?

try {
    // ... some code
} catch (const std::exception& e) {
    std::cerr << e.what();
    throw;
}

A8: This code catches exceptions derived from std::exception, prints their messages to the error console using e.what(), and then re-throws them. The re-throwing (throw; without an argument) ensures the exception can be caught and possibly handled by an outer try-catch block.


Q9: What is the advantage of catching exceptions by reference rather than by value?

A9: There are a couple of key reasons: 1. Avoid Slicing: When catching polymorphic exceptions (i.e., those derived from a base exception class), catching by value can lead to object slicing. This means that you'll only catch the base part of the derived exception, losing any additional information or behavior provided by the derived exception class. 2. Performance: Catching exceptions by reference avoids unnecessary copying of the exception object.


Q10: Consider the following code snippet:

void process() {
    throw std::runtime_error("Error in process");
}

void execute() noexcept {
    process();
}

int main() {
    try {
        execute();
    } catch (const std::exception& e) {
        std::cout << e.what() << std::endl;
    }
    return 0;
}

What happens when this code is run?

A10: The function execute is marked with noexcept, which indicates it won't throw exceptions. However, it calls process(), which does throw an exception. Because of this violation of the noexcept promise, when process() throws the exception, std::terminate() will be called, and the program will end abruptly. The catch block in main will not be reached.


Q11: In C++, is it possible to throw exceptions of primitive data types (like int, char, etc.)?

A11: Yes, in C++, you can throw exceptions of any data type, including primitives. For example:

try {
    throw 404;
} catch (int e) {
    std::cout << "Caught an integer: " << e << std::endl;
}

However, it's generally a good practice to throw objects of classes that derive from std::exception to provide more context and meaningful error messages.


Q12: Why might someone use the catch(...) clause?

A12: The catch(...) clause is a catch-all handler that can catch exceptions of any type. It's often used as a last resort in scenarios where: 1. The specific types of exceptions that might be thrown are unknown or varied. 2. You want to ensure that no exception goes unhandled, even if it's just to log it or perform some cleanup before potentially re-throwing or terminating the program.


Q13: Given the following code, what will be printed?

try {
    try {
        throw "inner exception";
    } catch (const char* e) {
        std::cout << e << std::endl;
        throw;
    }
} catch (...) {
    std::cout << "outer catch" << std::endl;
}

A13: The code will first print inner exception from the inner catch block. Since the inner catch block re-throws the exception, it will be caught by the outer catch block, printing outer catch.


Q14: If a function doesn't specify any exception specification (like noexcept or a dynamic exception specification), what kinds of exceptions is it allowed to throw?

A14: If a function doesn't specify any exception specification, it is allowed to throw exceptions of any type. In other words, by default, C++ functions don't place any restrictions on the exceptions they might throw.


Q15: Is it possible for constructors and destructors to throw exceptions? What are the implications if they do?

A15: - Constructors: Yes, constructors can throw exceptions. If a constructor throws an exception, the object's creation is considered unsuccessful, and its memory is reclaimed. If the object was being created as part of an array, any previously constructed objects in the array will be destroyed.

  • Destructors: While destructors can technically throw exceptions, it's highly discouraged. As mentioned previously, if a destructor is being run due to stack unwinding from another exception and it throws an exception itself, std::terminate will be called. To avoid this, destructors should catch and handle any exceptions they might generate or that might be thrown by functions they call.