C++ Exception Handling: The Art of Transitioning from Crashes to Elegant Error Management

In the past, when writing C code, error handling often involved a series of if-else statements that returned error codes. For more complex business logic, these error codes would be nested, making tracking them quite cumbersome. If you find returning error codes through if-else statements troublesome, you could simply trigger an assertion with assert() and analyze it slowly with gdb…

This predicament ultimately arises from the lack of native error and exception handling mechanisms in C. However, as a superset of C, C++ improves upon this flaw by introducing a set of exception handling mechanisms. It acts like a dedicated “problem courier”: once an error occurs, it directly throws the “error package” (exception) without needing to pass it through multiple layers, until someone (the catch block) catches and handles it.

Basic Concepts of C++ Exception Handling

The core of C++ exception handling revolves around three keywords: try (monitor), throw (throw exception), and catch (catch exception). Their roles are as follows:

  • try: “I’m watching this block of code, and if something goes wrong, I’ll call for help!” — This wraps the code block that may throw an error.
  • throw: “There’s a problem here! Throw this error out!” — When an error is detected, it throws an “exception object” (which can be int, string, custom class, etc.).
  • catch: “I’ll handle this type of error!” — Specifically receives exceptions thrown by throw, and the type must match.

Let’s take a look at the classic exception scenario of “division by zero” and how it can be handled in C++.

#include <iostream>
#include <string>
using namespace std;
// Division function: throws an exception if the divisor is 0
int divide(int a, int b) {
    if (b == 0) {
        // Throw exception: type is string, carrying specific error message
        throw string("Error: Divisor cannot be 0!");
    }
    return a / b;  // Return result normally if no error
}
int main() {
    int x = 10, y = 0, result;
    // 1. try block: monitor code that may throw an exception
    try {
        cout << "Starting division calculation..." << endl;
        result = divide(x, y);  // This will throw an exception
        cout << "Calculation result: " << result << endl;  // This line will not execute after the exception is thrown
    }
    // 2. catch block: receive string type exception (must match the type of throw)
    catch (string& err_msg) {
        // Handle exception: print error message
        cerr << "Caught exception: " << err_msg << endl;
    }
    // 3. After handling the exception, the program continues executing here
    cout << "Program did not crash, continuing to run..." << endl;
    return 0;
}

Output Result

C++ Exception Handling: The Art of Transitioning from Crashes to Elegant Error Management

Isn’t it amazing? Even when a “division by zero” error occurs, the program does not crash directly but handles the error gracefully and continues to run. This is the core value of exception handling.

Here, I would like to mention the parameter type of the catch block. Like function parameters, the parameter type in catch also has differences between value passing and reference passing.

  • If the parameter is a non-reference type, the catch block actually uses a copy of the original exception object, and it will not change the exception object itself. This can incur copying overhead for large custom structures.

  • If the parameter is a reference type, it directly passes the object itself without copying overhead. If modifications are made within the block, it changes the object itself. This also avoids slicing issues when converting base class objects to derived class objects.

Therefore, for exceptions that are class objects, it is generally recommended to use reference types for catching.

How to Use Exception Handling

Using try, throw, and catch to handle exceptions is not as simple as it appears in syntax; at least, you need to know the following points:

1. Exception types must match strictly (unless they are parent-child classes)

The type of exception thrown by throw must exactly match the type received by catch; otherwise, the exception will go “uncaught,” ultimately leading to a program crash.

For example, in the following erroneous example: throw is int, and catch is double, which is a type mismatch:

#include <iostream>
using namespace std;
void test() {
    throw 1;  // Throw int type exception
}
int main() {
    try {
        test();
    }
    // Error: trying to catch double type, but cannot catch int type exception
    catch (double err) {
        cerr << "Caught double exception: " << err << endl;
    }
    return 0;
}

The program will crash directly, outputting

C++ Exception Handling: The Art of Transitioning from Crashes to Elegant Error Management

This is like sending an “int package” but having a recipient for only a “double package”; the package goes unreceived, and the system directly “goes on strike”.

2. Multiple catch blocks: from specific to general, order matters

If a try block may throw multiple types of exceptions, you can write multiple catch blocks, but the order must be “specific types first, general types later”.

For example, in the following example, first handle int and string types, and finally use catch (…) to handle “all other types” (the … is a universal catcher that can catch any type):

#include <iostream>
#include <string>
using namespace std;
void process(int type) {
    if (type == 1) throw 1;          // int: parameter error
    if (type == 2) throw string("File not found");  // string: file error
    if (type == 3) throw 3.14;       // double: other error
}
int main() {
    try {
        process(2);  // Throw string type exception
    }
    // 1. First catch specific type: int
    catch (int err) {
        cerr << "Parameter error: error code " << err << endl;
    }
    // 2. Then catch specific type: string
    catch (string& err) {
        cerr << "File error: " << err << endl;  // This will be executed
    }
    // 3. Finally catch universal type: ... (can catch any type, but not recommended to abuse)
    catch (...) {
        cerr << "Caught unknown type exception!" << endl;
    }
    return 0;
}

Never place the universal catchercatch (…) at the front, otherwise the subsequent specific catch blocks will never execute (the universal catcher will catch all exceptions). This is similar to the default case in a switch statement, which is a fallback branch that must be placed last.

3. Exceptions automatically propagate across functions

This is one of the unique features of exception handling: if function A calls B, B calls C, and C throws an exception, the exception will automatically “run back” along the call stack until it finds a catch block that can handle it, without the intermediate functions needing to manually pass the error. This feature is calledstack unwinding.

The underlying implementation of this feature will not be elaborated here; interested readers can research it further. Let’s see the effect through the following example:

#include <iostream>
#include <string>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
using namespace std;
// Low-level function: read file content
void read_content(int fd, char* buf, int size) {
    ssize_t len = read(fd, buf, size - 1);
    if (len == -1) {
        // Throw exception, no need to care how the upper layer handles it
        throw string("read failed: ") + strerror(errno);
    }
    buf[len] = '\0';
}
// Mid-level function: open file and call read
void open_and_read(const char* filename, char* buf, int size) {
    int fd = open(filename, O_RDONLY);
    if (fd == -1) {
        // Throw exception, no need to manually return error code
        throw string("open failed: ") + strerror(errno);
    }
    read_content(fd, buf, size);  // Call low-level function, do not handle exception
    close(fd);  // This line will only execute if no exception
}
int main() {
    char buf[1024];
    const char* filename = "test.txt";
    // Top-level function: uniformly handle all exceptions
    try {
        open_and_read(filename, buf, sizeof(buf));
        cout << "File content: " << buf << endl;
    }
    catch (string& err) {
        // Here can catch exceptions thrown by open or read
        cerr << "Handling exception: " << err << endl;
        return 1;
    }
    return 0;
}

If test.txt does not exist, the output will be:

Handling exception: open failed: No such file or directory

Both the mid-level function open_and_read and the low-level function read_content do not need to handle exceptions; the exceptions will return to the top-level main function for unified handling. This feature is much more convenient than passing error codes layer by layer in C!

Keyword noexcept: Tell the Compiler “I Will Never Throw Exceptions”

Sometimes we can guarantee that a function “will absolutely not throw exceptions” (for example, simple addition, taking absolute values). In this case, we can use the noexcept keyword to inform the compiler: “Don’t worry, I won’t have any issues here, no need to prepare exception handling code for me”.

Once the compiler knows this, it will do two things:

  • Optimize the code: reduce the overhead of exception handling (for example, no need to save call stack information);
  • Enforce constraints: if a noexcept function actually throws an exception, the program will crash directly (reminding you “I said no exceptions, why did you throw?”).

Using noexcept is very simple; just add noexcept after the function declaration (you can add parentheses or not)

void swap(T& a, T& b) noexcept {  // Promises not to throw exceptions
    std::swap(a, b);
}

Do not abuse noexcept; if you accidentally throw an exception in a noexcept function, the compiler will directly call terminate() to terminate the program, with no room for negotiation.

When to use noexcept?

  • Simple pure functions: such as addition, subtraction, modulus, logic so simple that it cannot go wrong;
  • Performance-sensitive code: such as real-time control systems, high-frequency trading programs, to reduce any unnecessary overhead;
  • Move constructors and move assignment operators: this point was also mentioned in the previous article on move semantics, especially for operations on STL containers.
  • Destructors: After C++11, destructors are noexcept by default (because throwing exceptions in destructors can cause program crashes).

exception class: The Standard Library’s “Unified Interface for Exceptions”

The C++ standard library provides an exception base class std::exception (in the header file exception), and all standard exceptions (such as memory allocation failures, array out of bounds) are its subclasses. We can also inherit it to create custom exceptions, and the benefits of doing so are:It allows for unified catching of all exceptions without writing a bunch of different types of catch blocks..

Let’s first look at a few commonly used exception subclasses provided by the standard library:

Exception Class

Usage

Example of Throwing Scenario

bad_alloc

Memory allocation failure

When trying to new a very large memory

out_of_range

Out of bounds access

vector::at(5) (when vector has only 3 elements)

invalid_argument

Invalid argument

stoi(“abc”) (when the string cannot be converted to an integer)

  • Using Standard Library Exceptions

Let’s take bad_alloc (memory allocation failure) and out_of_range (out of bounds) as examples:

#include <iostream>
#include <new>      // Header for bad_alloc
#include <vector>   // Header for vector
#include <stdexcept> // Header for out_of_range
using namespace std;
int main() {
    // Test 1: Memory allocation failure (bad_alloc)
    try {
        // Attempt to allocate 10 billion ints (about 40GB), causing memory allocation failure
        int* p = new int[10000000000];
        delete[] p;
    }
    catch (bad_alloc& err) {
        // what(): returns the description of the exception (virtual function of exception class)
        cerr << "Caught bad_alloc: " << err.what() << endl;
    }
    // Test 2: Vector out of bounds (out_of_range)
    vector<int> vec = {1, 2, 3};
    try {
        // at() checks for out of bounds, throws out_of_range
        cout << "vec[5] = " << vec.at(5) << endl;
    }
    catch (out_of_range& err) {
        cerr << "Caught out_of_range: " << err.what() << endl;
    }
    return 0;
}

After running, the output will be:

C++ Exception Handling: The Art of Transitioning from Crashes to Elegant Error Management

There is a bit of doubt about the printing of the second statement; the exception is caused by vec.at(5), so it still printed the preceding “vec[5] = “. I wonder if different compilers handle this differently.

  • Using Custom Exceptions (Inheriting from exception)

In actual development, we often need to create custom exceptions (such as “file error” or “network error”). In this case, we just need to inherit from std::exception and override the what() method.

Let’s create a custom “file error exception” in conjunction with Linux file operations:

#include <iostream>
#include <string>
#include <exception>  // Header for exception
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
using namespace std;
// Custom file exception: inherits from exception
class FileException : public exception {
private:
    string err_msg;  // Store error message
public:
    // Constructor: receives error description
    FileException(const string& msg) : err_msg(msg) {}
    // Override what() method: returns error message (must be const char*, and noexcept)
    const char* what() const noexcept override {
        return err_msg.c_str();
    }
};
// Open file: throw custom exception
int open_file(const char* filename) {
    int fd = open(filename, O_RDONLY);
    if (fd == -1) {
        // Throw custom exception, carrying detailed information
        throw FileException(string("Failed to open file: ") + strerror(errno));
    }
    return fd;
}
int main() {
    const char* filename = "test.txt";
    int fd;
    try {
        fd = open_file(filename);
        cout << "File opened successfully, fd = " << fd << endl;
        close(fd);
    }
    // 1. First catch subclass exception (FileException)
    catch (FileException& err) {
        cerr << "File error: " << err.what() << endl;
        return 1;
    }
    // 2. Then catch parent class exception (exception): handle other standard exceptions
    catch (exception& err) {
        cerr << "Other exception: " << err.what() << endl;
        return 1;
    }
    return 0;
}

If test.txt does not exist, the output will be:

File error: Failed to open file: No such file or directory

Here’s a key point: the catch block for subclass exceptions should be placed before the parent class. If you place catch (exception& err) first, it will catch the parent class exception first, and the subsequent catch (FileException& err) will never execute.

Summary of the Advantages and Disadvantages of Exception Handling

The three core advantages of C++ exception handling are:

  • Clearer Code

Error handling and normal logic are completely separated. For example, in the previous file operation example, the normal logic is “open → read → close”, and all error handling is in the catch block, eliminating the need to add if (error) after each step, enhancing code readability.

  • Automatic Exception Propagation

No need for intermediate functions to pass error codes. The low-level function throws an exception, and the top-level function handles it uniformly, reducing a lot of repetitive code.

  • Can Carry More Information

Exceptions can be of any type (such as string, custom classes) and can carry detailed information (such as “which file went wrong” or “what the error code is”), while C language error codes can only be int, which is limited in information.

However, using exception handling also comes at a cost; in addition to increasing code volume, the main issue is the performance overhead (saving call stacks, searching for catch blocks, etc.), so it should be used cautiously in certain situations.

When Should Exceptions Not Be Used?

  • Performance-Sensitive Code

For example, real-time control systems or high-frequency trading programs may cause system bottlenecks in high-concurrency scenarios.

  • Simple Logic Errors

For example, invalid function parameters (such as passing a negative number to “calculate square root”). This is a program bug that should be handled with assert statements or by directly returning error codes, rather than throwing exceptions (bugs need to be fixed, not just thrown out).

  • Destructors Should Not Throw Exceptions

Destructors may be called during exception propagation (for example, objects created in try blocks will call destructors if an exception is thrown). If destructors throw exceptions, it can lead to program crashes (hence destructors are noexcept by default).

The mechanism of exception handling is actually quite complex, and for most programmers, mastering the above basic principles is necessary to correctly handle various exceptions in programs, thereby enhancing the robustness of the program.

If you encounter any questions in practical use (such as how to combine exceptions with RAII, or how to debug exceptions), feel free to leave a comment for discussion~

Previous Articles

C++ Lambda Functions: The Anonymous Tool for Simplifying Code

C++ Smart Pointers: The Tool to Say Goodbye to Memory Leaks

C++ Templates: Say Goodbye to Repetitive Wheel Creation and Embrace Generic Programming

Welcome to follow, and continue sharing insights on learning C++

Leave a Comment