Skip to content

09 · Exception Handling Basics

🎥 Video walkthrough

Exceptions represent errors or unusual conditions detected at runtime. C++ lets you handle them explicitly instead of crashing the whole program.

try / catch

#include <iostream>
#include <stdexcept>

int main() {
    int a = 10;
    int b = 0;

    try {
        if (b == 0) {
            throw std::runtime_error("Cannot divide by zero");
        }
        std::cout << a / b << std::endl;
    } catch (const std::runtime_error& e) {
        std::cout << "Error: " << e.what() << std::endl;
    }

    std::cout << "Program continues normally." << std::endl;
}
// Error: Cannot divide by zero
// Program continues normally.

Unlike some languages, C++ does not throw an exception on integer division by zero automatically — that's actually undefined behavior at the hardware level. You must check and throw explicitly, as shown above. e.what() returns a C-string description of the error.

The standard exception hierarchy

#include <stdexcept>

// std::exception                      -- the base class for all standard exceptions
//   +-- std::logic_error               -- errors detectable before running (bad arguments, etc.)
//   |     +-- std::invalid_argument
//   |     +-- std::out_of_range
//   |     +-- std::length_error
//   +-- std::runtime_error             -- errors only detectable at runtime
//         +-- std::overflow_error
//         +-- std::underflow_error
Exception Typically thrown when
std::invalid_argument A function received an argument that doesn't make sense
std::out_of_range An index or key is outside the valid range (e.g. vector::at)
std::runtime_error A general error only detectable while running
std::logic_error A programming/logic mistake, in principle avoidable

All of these derive from std::exception, so a single catch (const std::exception& e) can catch any of them.

Catching multiple exception types

#include <vector>
#include <string>

void process(const std::string& input) {
    try {
        int value = std::stoi(input);
        std::cout << "Parsed: " << value << std::endl;
    } catch (const std::invalid_argument& e) {
        std::cout << "Not a number: " << input << std::endl;
    } catch (const std::out_of_range& e) {
        std::cout << "Number too large: " << input << std::endl;
    }
}

int main() {
    process("42");        // Parsed: 42
    process("oops");      // Not a number: oops
    process("99999999999999999999");  // Number too large: 99999999999999999999
}

Catch blocks are checked in order, top to bottom — put more specific exception types before more general ones (a catch (const std::exception&) placed first would swallow everything below it).

Catching the base class as a safety net

try {
    riskyOperation();
} catch (const std::exception& e) {
    // Catches std::runtime_error, std::invalid_argument, std::out_of_range,
    // and any other standard exception -- useful as a final fallback.
    std::cout << "Something went wrong: " << e.what() << std::endl;
}

Throwing your own errors with standard exception types

void validateAge(int age) {
    if (age < 0 || age > 150) {
        throw std::invalid_argument("Age out of range: " + std::to_string(age));
    }
}

int main() {
    try {
        validateAge(-5);
    } catch (const std::invalid_argument& e) {
        std::cout << "Validation failed: " << e.what() << std::endl;
    }
}
// Validation failed: Age out of range: -5

You don't need to write a custom exception class to throw a meaningful error — reusing std::invalid_argument, std::runtime_error, etc. with a descriptive message covers most everyday cases. Custom exception classes (deriving from std::exception) are covered in Level 2.

catch(...) — catching anything

try {
    riskyOperation();
} catch (...) {
    std::cout << "An unknown error occurred." << std::endl;
}

catch (...) catches literally anything thrown, including types that aren't std::exception at all. It's a last resort — you lose all information about what actually went wrong, so prefer catching specific types whenever possible.

finally-equivalent: RAII, not a keyword

C++ has no finally keyword. Instead, cleanup that "always runs" is achieved through RAII (Resource Acquisition Is Initialization) — object destructors run automatically when a scope ends, whether it ended normally or via an exception. You'll cover this pattern properly in Level 3, but it's worth knowing the name now: it's the idiomatic C++ answer to "make sure this cleanup always happens."

How It Actually Works

Unlike a return value, an exception doesn't travel through the normal function-return path at all. Most modern compilers implement exceptions using zero-cost (table-based) unwinding: as long as no throw happens, a try block costs nothing extra at runtime — no per-call overhead the way error-code checking has. Instead, the compiler emits a separate table alongside the code that maps instruction addresses to "what destructors need running and where to jump if an exception passes through here." When throw expr; executes, the runtime allocates the exception object, then walks the call stack frame by frame using that table, looking for a catch whose type matches. For every frame it unwinds past, it calls the destructors of every local object that was fully constructed in that frame — this is the mechanism that makes RAII (Resource Acquisition Is Initialization) work safely with exceptions: a file handle or mutex wrapped in a destructor-owning object gets released automatically during unwinding, even though execution never reaches the code after the throw.

If unwinding reaches the top of the stack with no matching catch, std::terminate is called, which by default calls abort() — this is why an uncaught exception crashes the whole program rather than just "erroring out" gracefully. Because unwinding must run destructors correctly, throwing from inside a destructor while another exception is already unwinding is explicitly dangerous — it triggers std::terminate immediately, since the runtime can't unwind two exceptions on the same stack at once.

Catching const std::exception& rather than by value avoids object slicing: catching by value would copy only the base-class portion of a derived exception object, losing any derived-specific data and dispatch.

🔀 See this in another language

Exercise

Write a function int safeDivide(int a, int b) that returns a / b, but throws std::invalid_argument with the message "Cannot divide by zero" if b is 0. In main, call it inside a try/catch for both a valid and a zero divisor, printing the result or the caught error message. Then write a function int parseAge(const std::string& input) that calls std::stoi, catches std::invalid_argument, and rethrows it with the clearer message "Invalid age: " + input.