C++ Exceptions try catch throw Complete Guide
|

C++ Exceptions: Complete Error Handling Guide

Back to C++ RoadmapC++ Programming Course • 65 Lessons

What Are Exceptions?

Exceptions are C++’s mechanism for handling errors that cannot be dealt with locally. When a function encounters a problem it cannot solve — a file that does not exist, a network timeout, invalid input — it throws an exception. The runtime then unwinds the call stack, searching for a matching catch block. If one is found, execution continues there. If none is found, the program terminates.

Before exceptions, C used return codes to signal errors: int result = open_file("data.txt"); and checking if (result == -1). This approach has a critical flaw: callers can forget to check the return value, and the error silently propagates. Exceptions cannot be ignored — if nobody catches them, the program crashes. That makes bugs visible instead of hidden.

Exceptions separate the error-handling code from the normal logic, making both cleaner. But they come with tradeoffs: they are slower than return codes when thrown (10-100x), they complicate reasoning about control flow, and some codebases (embedded systems, game engines) disable them entirely. Understanding when and how to use exceptions is essential for writing robust C++.

try, throw, and catch

#include <iostream>
#include <string>
using namespace std;

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

int main() {
    try {
        cout << divide(10, 3) << endl;   // 3.33333
        cout << divide(10, 0) << endl;   // throws!
        cout << "This never prints" << endl;
    }
    catch (const runtime_error& e) {
        cout << "Error: " << e.what() << endl;  // Error: Division by zero
    }

    cout << "Program continues after catch" << endl;
    return 0;
}

throw creates an exception object and begins stack unwinding. Execution jumps to the nearest matching catch block. The .what() method returns the error message string. After the catch block executes, the program continues normally.

Standard Exception Types

The standard library provides a hierarchy of exception classes in <stdexcept>:

#include <iostream>
#include <stdexcept>
#include <vector>
#include <string>
using namespace std;

void demonstrateExceptions() {
    // logic_error — programmer mistakes (detectable before runtime)
    // throw logic_error("General logic error");
    // throw invalid_argument("Bad argument value");
    // throw out_of_range("Index out of bounds");
    // throw length_error("Container too large");
    // throw domain_error("Math domain error");

    // runtime_error — errors detected at runtime
    // throw runtime_error("General runtime error");
    // throw overflow_error("Arithmetic overflow");
    // throw underflow_error("Arithmetic underflow");
    // throw range_error("Out of representable range");

    // Standard library throws these automatically:
    vector<int> v = {1, 2, 3};

    try {
        v.at(10);  // throws out_of_range
    } catch (const out_of_range& e) {
        cout << "out_of_range: " << e.what() << endl;
    }

    try {
        stoi("not_a_number");  // throws invalid_argument
    } catch (const invalid_argument& e) {
        cout << "invalid_argument: " << e.what() << endl;
    }

    try {
        stoi("99999999999999999999");  // throws out_of_range
    } catch (const out_of_range& e) {
        cout << "out_of_range: " << e.what() << endl;
    }
}

int main() {
    demonstrateExceptions();
    return 0;
}

The hierarchy: std::exception is the base. std::logic_error and std::runtime_error inherit from it. Specific errors like out_of_range and invalid_argument inherit from logic_error. Always catch by const reference (const exception&) to avoid slicing and unnecessary copies.

Custom Exception Classes

For application-specific errors, create your own exception classes by inheriting from std::exception or its subclasses:

#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;

class HttpError : public runtime_error {
    int statusCode;
public:
    HttpError(int code, const string& msg)
        : runtime_error(msg), statusCode(code) {}

    int getStatusCode() const { return statusCode; }
};

class NotFoundError : public HttpError {
public:
    NotFoundError(const string& resource)
        : HttpError(404, "Not found: " + resource) {}
};

class UnauthorizedError : public HttpError {
public:
    UnauthorizedError()
        : HttpError(401, "Authentication required") {}
};

class ServerError : public HttpError {
public:
    ServerError(const string& detail)
        : HttpError(500, "Internal server error: " + detail) {}
};

string fetchResource(const string& path, bool authenticated) {
    if (!authenticated) throw UnauthorizedError();
    if (path == "/missing") throw NotFoundError(path);
    if (path == "/crash") throw ServerError("database connection lost");
    return "Resource at " + path;
}

int main() {
    string paths[] = {"/api/users", "/missing", "/crash"};

    for (const string& path : paths) {
        try {
            string result = fetchResource(path, true);
            cout << "OK: " << result << endl;
        }
        catch (const NotFoundError& e) {
            cout << "404: " << e.what() << endl;
        }
        catch (const ServerError& e) {
            cout << "500: " << e.what() << endl;
        }
        catch (const HttpError& e) {
            cout << e.getStatusCode() << ": " << e.what() << endl;
        }
    }
    return 0;
}

Custom exceptions carry domain-specific data (like HTTP status codes) and create a hierarchy that lets callers catch at the appropriate level of specificity.

Catch Order and Multiple Catches

Catch blocks are checked top to bottom. Put derived (specific) exceptions before base (general) ones:

#include <iostream>
#include <stdexcept>
using namespace std;

void riskyFunction(int choice) {
    switch (choice) {
        case 1: throw out_of_range("index 99");
        case 2: throw runtime_error("disk full");
        case 3: throw 42;  // can throw anything (but shouldn't)
    }
}

int main() {
    for (int i = 1; i <= 3; i++) {
        try {
            riskyFunction(i);
        }
        // Most specific first
        catch (const out_of_range& e) {
            cout << "Out of range: " << e.what() << endl;
        }
        // Less specific
        catch (const runtime_error& e) {
            cout << "Runtime error: " << e.what() << endl;
        }
        // General catch for std::exception
        catch (const exception& e) {
            cout << "Exception: " << e.what() << endl;
        }
        // Catch-all for non-standard throws
        catch (...) {
            cout << "Unknown exception!" << endl;
        }
    }
    return 0;
}

If you put catch (const exception&) first, it would catch out_of_range and runtime_error too (because they derive from exception), and the specific handlers would never run. The catch (...) block catches anything — including non-exception types like integers. Use it as a last resort.

Rethrowing Exceptions

Sometimes you want to catch an exception, do some cleanup, and then rethrow it for the caller to handle:

#include <iostream>
#include <stdexcept>
using namespace std;

void processData() {
    try {
        throw runtime_error("corrupted data");
    }
    catch (const exception& e) {
        cout << "Logging error: " << e.what() << endl;
        throw;  // rethrow the SAME exception (preserves type)
        // throw e;  // BAD: slices to std::exception, loses derived type
    }
}

int main() {
    try {
        processData();
    }
    catch (const runtime_error& e) {
        cout << "Caught rethrown: " << e.what() << endl;
    }
    return 0;
}

Always use throw; (no argument) to rethrow. Writing throw e; creates a copy and slices the exception to the caught type, losing any derived class information.

Stack Unwinding and RAII

When an exception is thrown, the runtime unwinds the stack — it exits each function on the call stack and destroys all local objects by calling their destructors. This is why RAII (Resource Acquisition Is Initialization) is critical:

#include <iostream>
#include <fstream>
#include <memory>
using namespace std;

class Resource {
    string name;
public:
    Resource(string n) : name(n) { cout << "Acquired: " << name << endl; }
    ~Resource() { cout << "Released: " << name << endl; }
};

void innerFunction() {
    Resource r3("Database Connection");
    throw runtime_error("Something went wrong!");
    // r3 destructor runs during unwinding
}

void middleFunction() {
    Resource r2("File Handle");
    innerFunction();
    // r2 destructor runs during unwinding
}

void outerFunction() {
    Resource r1("Memory Buffer");
    middleFunction();
    // r1 destructor runs during unwinding
}

int main() {
    try {
        outerFunction();
    }
    catch (const exception& e) {
        cout << "Caught: " << e.what() << endl;
    }
    return 0;
}
// Output:
// Acquired: Memory Buffer
// Acquired: File Handle
// Acquired: Database Connection
// Released: Database Connection   ← unwinding starts
// Released: File Handle
// Released: Memory Buffer
// Caught: Something went wrong!

Every resource is properly released, even though an exception was thrown three levels deep. This only works because resources are managed by objects with destructors (RAII). If you used raw new without smart pointers, the memory would leak during unwinding.

Critical rule: never throw in a destructor. If a destructor throws during stack unwinding (while another exception is already in flight), the program calls std::terminate and crashes immediately.

The noexcept Specifier

noexcept promises that a function will not throw exceptions. If it does throw, std::terminate is called immediately (no stack unwinding):

#include <iostream>
#include <vector>
using namespace std;

// Promise: this function never throws
int add(int a, int b) noexcept {
    return a + b;
}

// Conditional noexcept
template<typename T>
void safeSwap(T& a, T& b) noexcept(noexcept(swap(a, b))) {
    swap(a, b);
}

class Widget {
    int value;
public:
    Widget(int v) : value(v) {}

    // Move operations SHOULD be noexcept
    Widget(Widget&& other) noexcept : value(other.value) {
        other.value = 0;
    }

    Widget& operator=(Widget&& other) noexcept {
        value = other.value;
        other.value = 0;
        return *this;
    }

    int getValue() const noexcept { return value; }
};

int main() {
    cout << boolalpha;
    cout << "add is noexcept: " << noexcept(add(1, 2)) << endl;  // true

    Widget w1(42), w2(99);
    cout << "move is noexcept: "
         << noexcept(Widget(std::move(w1))) << endl;  // true
    return 0;
}

Why noexcept matters:

  • Performance: the compiler can optimize more aggressively when it knows no exceptions will occur
  • std::vector: during reallocation, vector uses move operations only if they are noexcept. Otherwise it falls back to copying for exception safety. This can be a massive performance difference.
  • Documentation: noexcept tells other programmers this function is safe to call in destructors and move operations

Exception Safety Guarantees

C++ defines three levels of exception safety for functions:

Basic guarantee: if an exception occurs, no resources are leaked and objects remain in a valid (but possibly modified) state. This is the minimum acceptable level.

Strong guarantee: if an exception occurs, the operation has no effect — the program state is rolled back to what it was before the function was called. This is “all or nothing” semantics.

No-throw guarantee: the function never throws exceptions. Marked with noexcept. Required for destructors, move operations, and swap.

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

class SafeVector {
    vector<int> data;

public:
    // Strong guarantee: copy-and-swap
    void safeInsert(int value) {
        vector<int> copy = data;  // copy current state
        copy.push_back(value);     // modify the copy
        // If push_back throws, 'data' is untouched
        swap(data, copy);          // noexcept swap — commits the change
    }

    // Basic guarantee: state is valid but may be partially modified
    void basicInsert(int value) {
        data.push_back(value);
        // If this throws, data might have grown but value not added
    }

    // No-throw guarantee
    int size() const noexcept { return data.size(); }
    void clear() noexcept { data.clear(); }

    void print() const {
        for (int x : data) cout << x << " ";
        cout << endl;
    }
};

int main() {
    SafeVector sv;
    sv.safeInsert(10);
    sv.safeInsert(20);
    sv.safeInsert(30);
    sv.print();  // 10 20 30
    cout << "Size: " << sv.size() << endl;
    return 0;
}

When NOT to Use Exceptions

Not for expected conditions. A user entering invalid input is not exceptional — it is expected. Use validation and return values instead. Exceptions should represent truly unexpected failures.

Not for control flow. Using exceptions to break out of loops or implement logic branches is a severe anti-pattern. Exceptions are 10-100x slower than normal control flow when thrown.

Not in performance-critical hot paths. The cost of throwing is high. If an operation fails frequently (cache misses, socket timeouts in a server), use return codes or std::optional.

Not in destructors. Throwing in destructors during stack unwinding calls std::terminate. Destructors should be noexcept.

Some codebases ban exceptions entirely. Google’s C++ style guide historically discouraged them. Game engines often use -fno-exceptions for deterministic performance. Embedded systems avoid them due to code size overhead. These codebases use error codes, std::optional, or std::expected instead.

Real-World Example: File Parser

#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <sstream>
#include <stdexcept>
using namespace std;

class ParseError : public runtime_error {
    int line;
    string filename;
public:
    ParseError(const string& file, int lineNum, const string& msg)
        : runtime_error("Parse error in " + file + " line " + to_string(lineNum) + ": " + msg),
          line(lineNum), filename(file) {}

    int getLine() const { return line; }
    const string& getFilename() const { return filename; }
};

class FileOpenError : public runtime_error {
public:
    FileOpenError(const string& path)
        : runtime_error("Cannot open file: " + path) {}
};

struct ConfigEntry {
    string key, value;
};

class ConfigParser {
public:
    vector<ConfigEntry> parse(const string& filename) {
        ifstream file(filename);
        if (!file.is_open()) {
            throw FileOpenError(filename);
        }

        vector<ConfigEntry> entries;
        string line;
        int lineNum = 0;

        while (getline(file, line)) {
            lineNum++;

            // Skip empty lines and comments
            if (line.empty() || line[0] == '#') continue;

            auto pos = line.find('=');
            if (pos == string::npos) {
                throw ParseError(filename, lineNum, "missing '=' in: " + line);
            }

            string key = line.substr(0, pos);
            string value = line.substr(pos + 1);

            if (key.empty()) {
                throw ParseError(filename, lineNum, "empty key");
            }

            entries.push_back({key, value});
        }

        if (entries.empty()) {
            throw ParseError(filename, lineNum, "file contains no configuration entries");
        }

        return entries;
    }
};

int main() {
    ConfigParser parser;

    // Simulate with a non-existent file
    try {
        auto config = parser.parse("app.conf");
        for (const auto& entry : config) {
            cout << entry.key << " = " << entry.value << endl;
        }
    }
    catch (const FileOpenError& e) {
        cout << "FILE ERROR: " << e.what() << endl;
        cout << "Using default configuration..." << endl;
    }
    catch (const ParseError& e) {
        cout << "PARSE ERROR at line " << e.getLine() << ": "
             << e.what() << endl;
    }
    catch (const exception& e) {
        cout << "UNEXPECTED: " << e.what() << endl;
    }

    return 0;
}

This parser demonstrates proper exception usage: custom exception classes carry context (filename, line number), different exception types enable different recovery strategies (missing file → use defaults; parse error → report and abort), and the caller can catch at the appropriate level of specificity.

Practice Exercises

Exercise 1: Write a SafeStack<T> class that throws underflow_error on empty pop and overflow_error when full. Test with a try/catch block that handles each case differently.

Exercise 2: Create a custom exception hierarchy for a banking application: BankError (base), InsufficientFundsError (includes balance and amount), AccountLockedError, InvalidAccountError. Write a transfer() function that throws appropriate exceptions.

Exercise 3: Demonstrate the difference between basic and strong exception safety. Create a function that modifies two data structures — make it fail halfway and show how strong guarantee (copy-and-swap) preserves the original state while basic guarantee does not.

Exercise 4: Write a ResourceManager that acquires three resources (use RAII wrapper classes). Throw an exception during the third acquisition and verify that the first two are properly released during stack unwinding.

Summary

Exceptions handle errors by unwinding the call stack to a matching catch block. Use the standard exception hierarchy (std::runtime_error, std::invalid_argument, etc.) and create custom exceptions for domain-specific errors. Always catch by const reference. RAII ensures resources are cleaned up during stack unwinding — never use raw new without smart pointers in code that might throw. Mark move operations and destructors noexcept. Do not use exceptions for expected conditions or control flow. The three levels of exception safety (basic, strong, no-throw) guide how you design functions. In the next lesson, you will explore alternative error-handling strategies including error codes, std::optional, and std::expected.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *