Phase 3 · Generic Programming & the Standard LibraryModule 18~56 min read

Exceptions, optional, variant & expected

Model failures and alternatives with exceptions, safety guarantees, optional values, closed variants, and expected-style results.

What you'll learn

Errors are part of an interface. Exceptions separate failure propagation from the success path; optional and variant make absence or alternatives explicit in values; C++23 expected carries either a result or a typed error. You will choose based on the domain's contract.

By the end, you'll be able to:

  • Distinguish preconditions, expected absence, recoverable errors, and broken invariants
  • Use exceptions with RAII and understand safety guarantees
  • Model optional and closed alternative values
  • Return typed value errors with std::expected where C++23 is available

Classify failure first

A precondition states what a caller must provide; a postcondition states what success guarantees; an invariant must remain true for valid objects. Expected absence is not exceptional, while a disk failure or malformed external document may be recoverable. A violated internal invariant often indicates a bug rather than a normal runtime outcome.

SituationTypical representation
Value may legitimately be absentstd::optional<T>
One of a closed set of shapesstd::variant<...>
Typed recoverable failurestd::expected<T, E>
Failure crosses many layersException, when project policy permits
Broken internal invariantAssertion, diagnostic, or controlled termination

Key idea

Choose an error strategy per API boundary and use it consistently. Mixing sentinel values, logs, exceptions, and output parameters makes failure easy to miss.

Throwing, catching, and unwinding

throw creates an exception that propagates until a matching handler is found. As stack frames unwind, destructors run for fully constructed automatic objects. Catch standard and custom exceptions by const& to preserve dynamic type and avoid copies.

parse_port.cpp
#include <iostream>
#include <stdexcept>
#include <string>

int parse_port(const std::string& text) {
    std::size_t consumed{};
    int port{std::stoi(text, &consumed)};
    if (consumed != text.size() || port < 1 || port > 65535) {
        throw std::out_of_range{"invalid port"};
    }
    return port;
}

int main() {
    try {
        std::cout << parse_port("8080") << '\n';
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        return 1;
    }
}

Watch out

Do not catch an exception only to ignore it. Handle it, add useful context and rethrow, translate it at an architectural boundary, or let it propagate.

Safety guarantees and noexcept

The basic guarantee preserves invariants and prevents resource leaks. The strong guarantee also leaves the operation's observable state unchanged on failure. The no-throw guarantee promises completion without emitting an exception. RAII, work-on-a-copy, and commit steps make these guarantees practical.

noexcept is part of a function's contract. If an exception escapes anoexcept function, the program terminates. Apply it only when the implementation and every operation it calls can uphold the promise.

  • Destructors and cleanup operations should normally be no-throw
  • Move operations should be noexcept when their member moves are noexcept
  • Validate or allocate before mutating committed state
  • Prefer swap-based commit for strong exception safety

Optional values

std::optional<T> contains either a T or no value. It is ideal for lookup and parsing where absence is a normal result and needs no detailed error. Test before dereferencing, use value_or for a natural fallback, or use value()when an empty state should throw.

optional.cpp
#include <iostream>
#include <optional>
#include <string>
#include <vector>

std::optional<std::size_t> locate(const std::vector<std::string>& names,
                                  const std::string& target) {
    for (std::size_t index{}; index < names.size(); ++index) {
        if (names[index] == target) return index;
    }
    return std::nullopt;
}

int main() {
    std::vector<std::string> names{"Ada", "Maya", "Noah"};
    if (auto found{locate(names, "Maya")}) {
        std::cout << "index " << *found << '\n';
    }
}

Closed alternatives with variant

std::variant stores exactly one value from a fixed list of types. Usestd::visit to process it, often with an overloaded visitor. Unlike a base pointer, a variant has value semantics and makes the complete alternative set visible in its type.

variant.cpp
#include <iostream>
#include <string>
#include <variant>

struct Loading {};
struct Success { std::string value; };
struct Failure { std::string message; };
using State = std::variant<Loading, Success, Failure>;

int main() {
    State state{Success{"profile loaded"}};
    std::visit([](const auto& current) {
        using T = std::decay_t<decltype(current)>;
        if constexpr (std::is_same_v<T, Loading>) std::cout << "loading";
        else if constexpr (std::is_same_v<T, Success>) std::cout << current.value;
        else std::cout << "error: " << current.message;
    }, state);
}

Note

A variant can become valueless_by_exception in narrow cases when changing alternatives throws. Choose safely movable alternatives and account for that state where relevant.

Typed results with expected

C++23 std::expected<T, E> stores either a success value or an error value. It keeps failure visible in the return type and is useful when callers routinely branch on an error category. It is not part of the C++20 baseline, so projects must confirm library support.

expected.cpp
// C++23
#include <expected>
#include <iostream>
#include <string>

enum class ParseError { empty, invalid };

std::expected<int, ParseError> parse_positive(const std::string& text) {
    if (text.empty()) return std::unexpected{ParseError::empty};
    try {
        int value{std::stoi(text)};
        if (value <= 0) return std::unexpected{ParseError::invalid};
        return value;
    } catch (...) {
        return std::unexpected{ParseError::invalid};
    }
}

int main() {
    auto result{parse_positive("42")};
    if (result) std::cout << *result << '\n';
}
Compile in C++23 mode with a standard library that implements std::expected.

Tip

Use a small domain error type with useful categories or context. Returning expected<T, bool> rarely tells a caller enough to recover or report well.

Recap & quick check

Key takeaways

  • Error representation begins with classifying absence, recoverable failure, contract violation, and bugs.
  • Stack unwinding runs destructors, which makes RAII essential to exception-safe code.
  • noexcept is a real termination-affecting promise, not a performance decoration.
  • optional represents normal absence; variant represents one of a closed set of alternatives.
  • C++23 expected carries either a value or a typed error in the return type.

Quick check

1. How should standard exceptions normally be caught?

2. What happens if an exception escapes a noexcept function?

3. Which type best represents a lookup that may normally find nothing?

4. What does std::expected<T, E> contain?

Next: Module 19 — Streams, Filesystem & Serialization, where these error contracts protect persistent external data.