Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 5 of 5

Exceptions are one option, not the answer. This lesson is about choosing, and the choice turns on two questions: how often does this fail, and can the immediate caller do anything about it.

The measurement that settles the performance argument

#include <chrono>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>

template <typename F>
long long time_us(F f) {
    auto t0 = std::chrono::steady_clock::now();
    f();
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
}

int parse_throwing(const std::string& s) {
    if (s.empty() || s.find_first_not_of("0123456789") != std::string::npos) {
        throw std::invalid_argument("not a number: " + s);
    }
    return std::stoi(s);
}

std::optional<int> parse_optional(const std::string& s) {
    if (s.empty() || s.find_first_not_of("0123456789") != std::string::npos) {
        return std::nullopt;
    }
    return std::stoi(s);
}

int main() {
    const int N = 200000;
    std::vector<std::string> all_good(N, "12345");
    std::vector<std::string> all_bad(N, "abcde");

    long long sink = 0;

    auto good_throw = time_us([&] {
        for (const auto& s : all_good) {
            try { sink += parse_throwing(s); } catch (const std::exception&) { }
        }
    });
    auto good_opt = time_us([&] {
        for (const auto& s : all_good) {
            if (auto v = parse_optional(s)) sink += *v;
        }
    });
    auto bad_throw = time_us([&] {
        for (const auto& s : all_bad) {
            try { sink += parse_throwing(s); } catch (const std::exception&) { ++sink; }
        }
    });
    auto bad_opt = time_us([&] {
        for (const auto& s : all_bad) {
            if (auto v = parse_optional(s)) sink += *v; else ++sink;
        }
    });

    std::cout << N << " parses:\n";
    std::cout << "  success path, exceptions: " << good_throw << " us\n";
    std::cout << "  success path, optional:   " << good_opt << " us\n";
    std::cout << "  failure path, exceptions: " << bad_throw << " us\n";
    std::cout << "  failure path, optional:   " << bad_opt << " us\n";
    std::cout << "(sink " << sink << ")\n";
    return 0;
}

A representative run, compiled -O2:

200000 parses:
  success path, exceptions: 5998 us
  success path, optional:   6131 us
  failure path, exceptions: 325713 us
  failure path, optional:   1658 us
(sink 4938400000)

Two results, and they point in opposite directions.

On the success path the two are indistinguishable — 5998 µs against 6131 µs, with the exception version marginally faster, which is noise. This is what "zero-cost exceptions" means: a try block that does not throw costs nothing at run time. The compiler emits unwinding tables consulted only when an exception is actually thrown.

On the failure path exceptions were 196× slower — 326 ms against 1.7 ms. Throwing allocates the exception object, walks the unwinding tables, runs destructors, and matches handlers. That is a large fixed cost per throw.

So the rule is not "exceptions are slow". It is:

Exceptions cost nothing until thrown, and a great deal when thrown. Use them for what is genuinely exceptional.

If a failure happens on 30% of calls, you are paying 196× on nearly a third of your work. If it happens on 0.001% of calls, the cost is invisible and the clarity is free.

The four mechanisms

// 1. Exception — for failures the immediate caller usually cannot handle.
//    void save(const Data& d);                       // throws on I/O failure
//
// 2. std::optional — for "absent", when the reason does not matter.
//    std::optional<User> find_user(int id);
//
// 3. std::expected — for "failed, and here is why" (C++23, lesson 13.10).
//    std::expected<Config, ParseError> parse(std::string_view text);
//
// 4. Error code — for C APIs, embedded targets, and hot loops.
//    std::error_code connect(const Address& a);

And two that should not be used:

// A sentinel return value — every caller must remember to check, and the
// sentinel steals a legal result.
//    int find(const std::string& s);       // returns -1 on failure
//
// An out-parameter plus bool — what is `out` when it returns false?
//    bool parse(const std::string& s, int& out);

std::optional and std::expected exist precisely to replace those two (lesson 13.10).

Choosing

The decision is about who handles it, not about taste:

| Situation | Use | |---|---| | The caller can act on it, and it happens routinely | optional / expected | | The caller cannot act; something far up the stack must | exception | | Failure is expected input, not a malfunction | optional / expected | | The program cannot continue meaningfully | exception | | A constructor fails | exception — there is no return value | | An operator fails (operator[], operator+) | exception — no room for a code | | Crossing a C ABI boundary | error code | | Inside a hot loop with a high failure rate | error code / optional | | Exceptions are disabled (-fno-exceptions) | error code |

Two rows are not preferences but constraints.

A constructor has no return value. If it cannot establish the class invariant, the only ways out are to throw or to construct a zombie object with an is_valid() flag — and a zombie defeats RAII, because now every user must check. Throw.

Embedded and game targets often compile with -fno-exceptions, so the mechanism is simply not available. That is a real constraint, and it is why std::expected was standardised.

Worked judgement

// User input is wrong: EXPECTED. Happens constantly, the caller shows a
// message. Use expected, so the reason can be displayed.
//   std::expected<Date, ParseError> parse_date(std::string_view);
//
// A key is missing from a map: ABSENT, not an error. Use optional.
//   std::optional<int> lookup(const std::string& key);
//
// A config file the program requires is missing: EXCEPTION. Nothing sensible
// to do locally; startup must fail loudly.
//   Config load_config(const std::string& path);   // throws
//
// Out of memory: EXCEPTION, and you do not get a choice — std::bad_alloc.
//
// An array index is out of range: it depends on the contract.
//   v[i]     — unchecked, undefined behaviour. Your bug (lesson 7.5).
//   v.at(i)  — checked, throws std::out_of_range.

That last pair is the standard library making the choice explicit rather than for you: the same container offers both, and picking at is picking the exception.

Mixing mechanisms at a boundary

A layer using exceptions internally must not leak them across a C ABI, a thread boundary, or a plugin interface. The translation is mechanical:

// extern "C" int library_do_work(const char* input, char* out, int out_size) {
//     try {
//         std::string result = do_work(input);    // may throw
//         if (result.size() >= static_cast<std::size_t>(out_size)) {
//             return ERR_BUFFER_TOO_SMALL;
//         }
//         std::copy(result.begin(), result.end(), out);
//         out[result.size()] = '\0';
//         return OK;
//     } catch (const std::bad_alloc&) {
//         return ERR_OUT_OF_MEMORY;
//     } catch (const std::exception&) {
//         return ERR_FAILED;
//     } catch (...) {
//         return ERR_UNKNOWN;
//     }
// }

Every extern "C" function that can be reached from C++ needs that shape. An exception escaping into C is undefined behaviour — the C caller has no unwinding tables, and the process usually dies.

The same applies to a thread's entry function (Module 20): an exception escaping it calls std::terminate, so the thread body needs its own try/catch and a way to pass the failure back — which is what std::promise::set_exception is for.

What not to do

// Using exceptions for control flow:
//   try { while (true) v.at(i++); } catch (const std::out_of_range&) { }
//   That loop is 196x slower than checking i < v.size(). Exceptions are not
//   a loop-exit mechanism.
//
// Swallowing:
//   try { risky(); } catch (...) { }
//   The failure is now invisible. If you truly can ignore it, say why in a
//   comment; otherwise log, or rethrow.
//
// Catching what you cannot handle:
//   catch (const std::bad_alloc&) { /* now what? */ }
//   Catching it is only useful if you have a plan — freeing a cache, say.
//
// Throwing from a destructor:
//   Terminates during unwinding (lesson 15.3).

Try it

Design the error handling for a small HTTP client with five failure modes: malformed URL, DNS failure, connection refused, timeout, and a non-2xx status. Choose a mechanism for each, justify it from the table above, and implement the result type.

Checkpoint

  • Measured: exceptions cost nothing on the success path and 196× more when thrown.
  • "Zero-cost exceptions" means zero when not thrown, not zero overall.
  • Choose by who handles it: the caller (optional/expected) or something far up (exception).
  • std::optional for absent, std::expected for failed-with-a-reason, exceptions for exceptional.
  • Sentinel returns and bool-plus-out-parameter are what optional and expected replaced.
  • A constructor must throw — it has no return value, and a zombie object defeats RAII.
  • Operators must throw too; there is nowhere to put a code.
  • Routine failures should never be exceptions — you pay the cost on the common path.
  • A non-2xx HTTP status is a successful request, not an error; do not conflate the two.
  • Never let an exception cross a C ABI or a thread boundary — translate it at the edge.
  • Exceptions are not a loop-exit mechanism.
  • catch (...) that does nothing makes a failure invisible; log or rethrow.

Questions about this lesson

Log in to ask a question.