Chapter 18 of 23

Exception Handling in C++

Learn how to write robust C++ programs using try, catch, and throw. Understand the standard exception hierarchy, custom exception classes, noexcept, and how RAII keeps resources safe even when errors occur.

Meritshot11 min read
C++Exception HandlingRAIInoexceptError HandlingCustom Exceptions
All C++ Chapters

Exception Handling in C++

Every program encounters unexpected situations: a file that does not exist, a division by zero, a network timeout, or user input that violates an assumption. How a program behaves in these moments defines its quality. C++ provides a structured mechanism for handling such situations — exceptions — which separate the normal flow of code from the error-handling flow, making both cleaner.

In FAANG interview circuits, exception safety is a recurring topic. Understanding it also matters in production code at companies like Infosys, Wipro, or Razorpay, where a crash in a financial transaction handler can have real consequences.


The Basics: try, catch, throw

#include <iostream>
#include <stdexcept>

int safeDivide(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("Division by zero is not allowed");
    }
    return a / b;
}

int main() {
    try {
        int result = safeDivide(10, 0);
        std::cout << "Result: " << result << "\n";
    }
    catch (const std::invalid_argument& e) {
        std::cerr << "Caught exception: " << e.what() << "\n";
    }
    std::cout << "Program continues normally after exception.\n";
    return 0;
}

Output:

Caught exception: Division by zero is not allowed
Program continues normally after exception.
  • throw raises an exception. Execution immediately leaves the current function and travels up the call stack searching for a matching catch block.
  • try marks a block of code to monitor for exceptions.
  • catch defines a handler for a specific exception type.

The Standard Exception Hierarchy

C++ provides a family of exception classes in <stdexcept> and <exception>. Understanding this hierarchy lets you write targeted handlers.

std::exception
├── std::logic_error
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range
└── std::runtime_error
    ├── std::range_error
    ├── std::overflow_error
    └── std::underflow_error
ClassWhen to use
std::logic_errorBugs that should have been caught at compile time or by assertions
std::invalid_argumentA function argument violates a precondition
std::out_of_rangeIndex or value is outside an acceptable range
std::runtime_errorErrors detectable only at runtime (network, file I/O)
std::overflow_errorArithmetic overflow
std::range_errorResult of a computation is outside a representable range

All standard exception classes expose a .what() method that returns a const char* description.


Catching by Reference

Always catch exceptions by reference to const — not by value. Catching by value slices the object, losing information from derived exception classes.

try {
    throw std::runtime_error("Disk full");
}
catch (const std::exception& e) {   // catches any std::exception
    std::cerr << e.what() << "\n";
}

You can chain multiple catch blocks. Place more specific types before more general ones:

try {
    // ... code that may throw
}
catch (const std::invalid_argument& e) {
    std::cerr << "Invalid argument: " << e.what() << "\n";
}
catch (const std::runtime_error& e) {
    std::cerr << "Runtime error: " << e.what() << "\n";
}
catch (const std::exception& e) {
    std::cerr << "Unknown std exception: " << e.what() << "\n";
}
catch (...) {
    std::cerr << "Unknown non-std exception caught\n";
}

The catch (...) block is a catch-all that handles anything, including integers or strings thrown by mistake. Use it as the last resort.


Rethrowing Exceptions

Sometimes you want to log or partially handle an exception and then let it propagate further. Use throw; (with no argument) inside a catch block to rethrow the current exception without slicing it.

void processPayment(double amount) {
    try {
        if (amount <= 0) {
            throw std::invalid_argument("Payment amount must be positive");
        }
        // ... actual payment processing
    }
    catch (const std::exception& e) {
        std::cerr << "[processPayment] Logging error: " << e.what() << "\n";
        throw;  // rethrow to the caller
    }
}

int main() {
    try {
        processPayment(-500.0);
    }
    catch (const std::invalid_argument& e) {
        std::cerr << "[main] Handled: " << e.what() << "\n";
    }
}

The noexcept Specifier (C++11)

The noexcept specifier declares that a function will not throw exceptions. This is a contract: if the function does throw, std::terminate is called immediately.

int add(int a, int b) noexcept {
    return a + b;
}

Marking functions noexcept has two benefits:

  1. The compiler can apply optimisations (especially for move constructors and destructors).
  2. It communicates intent clearly to callers.

When to use noexcept

CaseUse noexcept?
DestructorsAlways (destructors are implicitly noexcept)
Move constructors and move assignmentYes — critical for STL container efficiency
Simple arithmetic/utility functionsYes
Functions that call code which can throwNo
class Buffer {
public:
    Buffer(Buffer&& other) noexcept   // move constructor — safe to mark noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    ~Buffer() noexcept { delete[] data_; }

private:
    char* data_;
    std::size_t size_;
};

Custom Exception Classes

Defining your own exception classes allows callers to distinguish your errors from generic library errors and to carry domain-specific information.

#include <stdexcept>
#include <string>

class DatabaseError : public std::runtime_error {
public:
    explicit DatabaseError(const std::string& msg, int errorCode)
        : std::runtime_error(msg), errorCode_(errorCode) {}

    int errorCode() const noexcept { return errorCode_; }

private:
    int errorCode_;
};

class ConnectionError : public DatabaseError {
public:
    explicit ConnectionError(const std::string& host)
        : DatabaseError("Cannot connect to database host: " + host, 5001),
          host_(host) {}

    const std::string& host() const noexcept { return host_; }

private:
    std::string host_;
};

Usage:

void connectDB(const std::string& host) {
    if (host.empty()) {
        throw ConnectionError("(empty)");
    }
    // attempt connection ...
    throw ConnectionError(host);  // simulate failure
}

int main() {
    try {
        connectDB("db.internal.mycompany.in");
    }
    catch (const ConnectionError& e) {
        std::cerr << "Connection failed to " << e.host()
                  << " (code " << e.errorCode() << "): " << e.what() << "\n";
    }
    catch (const DatabaseError& e) {
        std::cerr << "Database error (code " << e.errorCode() << "): " << e.what() << "\n";
    }
}

RAII and Exceptions

RAII (Resource Acquisition Is Initialisation) is a C++ idiom where resources (memory, file handles, locks) are tied to the lifetime of objects. When an exception unwinds the stack, destructors run automatically, releasing resources without any extra cleanup code.

#include <fstream>
#include <stdexcept>

class ScopedFile {
public:
    explicit ScopedFile(const std::string& path) : file_(path) {
        if (!file_.is_open()) {
            throw std::runtime_error("Cannot open file: " + path);
        }
    }
    ~ScopedFile() {
        // destructor always runs — even during stack unwinding
        if (file_.is_open()) file_.close();
    }
    std::ifstream& get() { return file_; }

private:
    std::ifstream file_;
};

void readConfig(const std::string& path) {
    ScopedFile cfg(path);  // throws if file missing
    std::string line;
    while (std::getline(cfg.get(), line)) {
        // process line
    }
}  // ScopedFile destructor closes the file here, even if an exception occurs above

This is why modern C++ prefers smart pointers, std::fstream, and std::lock_guard over raw resource management — they implement RAII for you.


Worked Example: Safe Division and File Reading

#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

// Custom exception for our application
class AppError : public std::runtime_error {
public:
    explicit AppError(const std::string& msg) : std::runtime_error(msg) {}
};

// Safe integer division — throws on zero divisor
double safeDivide(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::invalid_argument("Denominator cannot be zero");
    }
    return numerator / denominator;
}

// Read a file and return its lines — throws if file cannot be opened
std::vector<std::string> readFileLines(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        throw AppError("File not found or cannot be read: " + path);
    }

    std::vector<std::string> lines;
    std::string line;
    while (std::getline(file, line)) {
        if (!line.empty()) {
            lines.push_back(line);
        }
    }

    if (lines.empty()) {
        throw AppError("File is empty: " + path);
    }
    return lines;
}

// Compute class average from a marks file
// File format: one integer mark per line
double computeClassAverage(const std::string& filePath) {
    auto lines = readFileLines(filePath);  // may throw AppError

    double sum = 0.0;
    int count = 0;

    for (const auto& line : lines) {
        try {
            double mark = std::stod(line);  // may throw std::invalid_argument
            sum += mark;
            ++count;
        }
        catch (const std::invalid_argument&) {
            std::cerr << "Warning: Skipping non-numeric line: '" << line << "'\n";
        }
    }

    return safeDivide(sum, count);  // may throw std::invalid_argument if count is 0
}

int main() {
    // Test safe division
    std::cout << "--- Safe Division ---\n";
    try {
        std::cout << "10 / 4 = " << safeDivide(10, 4) << "\n";
        std::cout << "10 / 0 = " << safeDivide(10, 0) << "\n";  // throws
    }
    catch (const std::invalid_argument& e) {
        std::cerr << "Division error: " << e.what() << "\n";
    }

    // Test file reading with a valid file
    std::cout << "\n--- File Reading ---\n";
    try {
        double avg = computeClassAverage("marks.txt");
        std::cout << "Class average: " << avg << "\n";
    }
    catch (const AppError& e) {
        std::cerr << "Application error: " << e.what() << "\n";
    }
    catch (const std::invalid_argument& e) {
        std::cerr << "Data error: " << e.what() << "\n";
    }
    catch (const std::exception& e) {
        std::cerr << "Unexpected error: " << e.what() << "\n";
    }

    // Test file reading with a missing file
    std::cout << "\n--- Missing File ---\n";
    try {
        computeClassAverage("nonexistent_marks.txt");
    }
    catch (const AppError& e) {
        std::cerr << "Caught AppError: " << e.what() << "\n";
    }

    std::cout << "\nProgram finished cleanly.\n";
    return 0;
}

Sample output (assuming marks.txt does not exist):

--- Safe Division ---
10 / 4 = 2.5
Division error: Denominator cannot be zero

--- File Reading ---
Application error: File not found or cannot be read: marks.txt

--- Missing File ---
Caught AppError: File not found or cannot be read: nonexistent_marks.txt

Program finished cleanly.

Common Pitfalls

1. Catching by value instead of by const reference

Catching std::exception e (by value) copies the object and slices off the derived type. The dynamic type is lost and .what() may return the wrong message.

// WRONG
catch (std::exception e) { ... }

// CORRECT
catch (const std::exception& e) { ... }

2. Catching a more general type before a specific one

catch blocks are checked in order. Placing catch (const std::exception&) before catch (const std::runtime_error&) means the runtime_error block is unreachable.

3. Throwing from a destructor

If a destructor throws during stack unwinding from another exception, std::terminate is called immediately. Destructors must be noexcept (they are by default in C++11 onward). Swallow exceptions inside destructors.

4. Using exceptions for normal control flow

Exceptions have overhead (stack unwinding, RTTI). Do not throw exceptions for expected conditions like "value not found in a collection." Use return codes, std::optional, or std::expected for those.

5. Throwing and catching integers or raw strings

throw 42; or throw "error"; is legal but makes handlers fragile and loses the .what() interface. Always throw objects derived from std::exception.


Practice Exercises

  1. Write a function parseInt(const std::string& s) that throws std::invalid_argument if the string is not a valid integer, and std::out_of_range if it exceeds INT_MAX. Test it with "abc", "99999999999999", and "42".

  2. Create a custom exception class NetworkError that inherits from std::runtime_error and stores an HTTP status code. Write a function fetchData(int statusCode) that throws NetworkError for status codes 400 and above.

  3. Write a stack class (IntStack) with push and pop methods. pop should throw std::underflow_error when the stack is empty. Write a test program that demonstrates catching this error.

  4. Implement a SafeArray class wrapping a std::vector<int>. Override the subscript operator to throw std::out_of_range with a helpful message when the index is invalid.

  5. Write a function loadConfig(const std::string& path) that reads a config file and throws a custom ConfigError if the file is missing, and a different ParseError if a line is malformed. Demonstrate both scenarios.

  6. Explore std::terminate: write a destructor that throws, and observe the program crash. Then fix it by catching and suppressing the exception inside the destructor.


Summary

  • throw raises an exception; try monitors a block; catch handles specific exception types.
  • Always catch exceptions by const reference to avoid object slicing.
  • The standard exception hierarchy is rooted at std::exception; all standard exceptions expose .what().
  • Place more specific catch blocks before more general ones; catch (...) is the catch-all of last resort.
  • Use bare throw; inside a catch block to rethrow the current exception without slicing it.
  • noexcept declares that a function will not throw; violating this contract calls std::terminate.
  • Custom exception classes should inherit from std::runtime_error or std::logic_error and add domain-specific fields.
  • RAII ensures that destructors release resources automatically during stack unwinding — no cleanup code needed in catch blocks.
  • Never throw from destructors; always mark destructors noexcept.
  • Use exceptions for truly exceptional situations, not for routine flow control.