Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 10 of 12

Three vocabulary types answer three questions that used to be answered with conventions:

  • std::optional<T> — a T, or nothing.
  • std::variant<A, B, C> — exactly one of these types.
  • std::expected<T, E> — a T, or an error explaining why not.

Each replaces something worse: a sentinel value, a tagged union, an out-parameter with a bool.

std::optional and std::variant

#include <iostream>
#include <optional>
#include <string>
#include <variant>
#include <vector>

// Returning "maybe a value" without a sentinel, an out-parameter, or a pointer.
std::optional<int> parse_int(const std::string& text) {
    try {
        std::size_t consumed = 0;
        int value = std::stoi(text, &consumed);
        if (consumed != text.size()) {
            return std::nullopt;              // trailing junk
        }
        return value;
    } catch (...) {
        return std::nullopt;
    }
}

int main() {
    for (const std::string& s : std::vector<std::string>{"42", "abc", "7x", ""}) {
        std::optional<int> r = parse_int(s);
        std::cout << "\"" << s << "\" -> ";
        if (r) {
            std::cout << *r << "\n";
        } else {
            std::cout << "no value\n";
        }
    }

    std::optional<int> maybe = parse_int("abc");
    std::cout << "value_or: " << maybe.value_or(-1) << "\n";
    std::cout << "has_value: " << std::boolalpha << maybe.has_value() << "\n";

    try {
        maybe.value();                        // throws, unlike operator*
    } catch (const std::bad_optional_access& e) {
        std::cout << "value() threw: bad_optional_access\n";
    }

    // variant: one of a fixed set of types, known at compile time.
    std::variant<int, double, std::string> v = 42;
    std::cout << "index " << v.index() << ", int " << std::get<int>(v) << "\n";

    v = std::string("text");
    std::cout << "index " << v.index() << ", string " << std::get<std::string>(v) << "\n";

    // std::visit handles every alternative — and fails to compile if you miss one.
    std::visit([](const auto& value) {
        std::cout << "visited: " << value << "\n";
    }, v);

    try {
        std::get<int>(v);                     // holds a string right now
    } catch (const std::bad_variant_access& e) {
        std::cout << "get<int> threw: bad_variant_access\n";
    }

    if (const std::string* p = std::get_if<std::string>(&v)) {
        std::cout << "get_if worked: " << *p << "\n";
    }
    return 0;
}
"42" -> 42
"abc" -> no value
"7x" -> no value
"" -> no value
value_or: -1
has_value: false
value() threw: bad_optional_access
index 0, int 42
index 2, string text
visited: text
get<int> threw: bad_variant_access
get_if worked: text

What optional replaces

Before it, "maybe a value" was expressed by convention, and every convention had a hole:

// int parse(const std::string& s);              // returns -1 on failure...
//                                               // ...so -1 cannot be a valid result
//
// bool parse(const std::string& s, int& out);   // out-parameter: what is `out`
//                                               // if it returns false?
//
// int* parse(const std::string& s);             // nullptr on failure — who deletes it?

std::optional<int> says "maybe an int" in the type, so a caller cannot forget to check without the compiler having a chance to complain. And every int remains a valid result.

The two access forms differ in exactly one way:

// *opt          — undefined behaviour if empty. Fast, unchecked.
// opt.value()   — throws std::bad_optional_access if empty.
// opt.value_or(fallback) — never fails.

*opt on an empty optional is undefined behaviour, not a crash you can rely on. Under ASan (lesson 11.3) it is often silent, because the storage is real memory that simply holds nothing meaningful. Check first, or use value().

C++23 adds monadic operations, which chain without nesting ifs:

// auto result = parse_int(text)
//     .transform([](int x) { return x * 2; })     // only if it has a value
//     .and_then(validate)                          // returns another optional
//     .value_or(0);

std::optional is not free

It stores a T plus a bool, and pays alignment padding:

// sizeof(int)                  4
// sizeof(std::optional<int>)   8      — 4 for the int, 1 for the flag, 3 padding

For a large T the overhead is negligible; for an int it doubles the size. In an array of a million optionals that matters, and a sentinel value or a separate bitmask may genuinely be better. That is an optimisation to make after measuring, not before.

What variant replaces

A std::variant is a type-safe union. The old C form:

// struct Value {
//     enum { Int, Double, String } tag;
//     union { int i; double d; char* s; };
// };
//
// Nothing enforces that `tag` matches what was last written. Read `i` after
// writing `s` and you reinterpret a pointer as an integer — undefined, silent,
// and the standard bug this construct has produced for forty years.

std::variant tracks which alternative is active and throws if you ask for the wrong one. It also destroys the old alternative correctly when you assign a new one, which the union cannot do.

std::visit is the important part. It calls your handler with whichever alternative is active, and — with a handler per type — fails to compile if you miss one. Add a fourth type to the variant and every exhaustive visit becomes a compile error until you handle it. That is a real safety property no switch on an enum gives you.

// A handler per alternative, using the "overloaded" idiom:
//
// template <typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// template <typename... Ts> overloaded(Ts...) -> overloaded<Ts...>;
//
// std::visit(overloaded{
//     [](int i)                { std::cout << "int " << i; },
//     [](double d)             { std::cout << "double " << d; },
//     [](const std::string& s) { std::cout << "string " << s; },
// }, v);

That two-line template is variadic inheritance from lesson 12.6, and it is worth copying verbatim — it appears in essentially every codebase that uses variant seriously.

std::expected: the error, not just the absence

optional tells you a value is missing. It does not tell you why, which is usually what the caller needs.

// REQUIRES C++23
#include <expected>
#include <iostream>
#include <string>
#include <vector>

enum class ParseError { Empty, NotANumber, TrailingJunk, OutOfRange };

const char* describe(ParseError e) {
    switch (e) {
        case ParseError::Empty:        return "empty input";
        case ParseError::NotANumber:   return "not a number";
        case ParseError::TrailingJunk: return "trailing characters";
        case ParseError::OutOfRange:   return "out of range";
    }
    return "unknown";
}

// Unlike optional, expected says WHY it failed.
std::expected<int, ParseError> parse_int(const std::string& text) {
    if (text.empty()) {
        return std::unexpected(ParseError::Empty);
    }
    try {
        std::size_t consumed = 0;
        int value = std::stoi(text, &consumed);
        if (consumed != text.size()) {
            return std::unexpected(ParseError::TrailingJunk);
        }
        return value;
    } catch (const std::out_of_range&) {
        return std::unexpected(ParseError::OutOfRange);
    } catch (const std::invalid_argument&) {
        return std::unexpected(ParseError::NotANumber);
    }
}

int main() {
    const std::vector<std::string> inputs = {"42", "", "abc", "7x", "99999999999999999999"};
    for (const std::string& s : inputs) {
        auto r = parse_int(s);
        std::cout << "\"" << s << "\" -> ";
        if (r) {
            std::cout << *r << "\n";
        } else {
            std::cout << "error: " << describe(r.error()) << "\n";
        }
    }

    // Monadic chaining: each step runs only if the previous succeeded.
    auto doubled = parse_int("21").transform([](int x) { return x * 2; });
    std::cout << "transform on success: " << *doubled << "\n";

    auto failed = parse_int("abc").transform([](int x) { return x * 2; });
    std::cout << "transform on failure: " << describe(failed.error()) << "\n";

    std::cout << "value_or: " << parse_int("nope").value_or(-1) << "\n";
    return 0;
}
"42" -> 42
"" -> error: empty input
"abc" -> error: not a number
"7x" -> error: trailing characters
"99999999999999999999" -> error: out of range
transform on success: 42
transform on failure: not a number
value_or: -1

Compare that with the optional version at the top of this lesson, which reported "no value" four times and could not distinguish empty input from a number too large to represent.

transform on a failed expected does nothing and passes the error through. That is the monadic chaining property: a pipeline of five steps needs one error check at the end rather than five in the middle.

Availability. std::expected is C++23. GCC 13 supports it with -std=c++23, Clang 16+ likewise. This lesson's example is compiled with -std=c++23; the rest of the course uses C++20. Before C++23, the usual substitutes are tl::expected (a header-only library) or a std::variant<T, Error>.

Choosing

| Situation | Type | |---|---| | A value may be absent, and why does not matter | std::optional<T> | | A value may be absent, and the caller needs the reason | std::expected<T, E> | | One of several unrelated types | std::variant<A, B, C> | | Failure is exceptional and callers cannot handle it locally | throw (Module 15) |

The last row is the real decision, and Module 15 argues it properly. The short version: expected forces every caller to acknowledge the error, which is right for expected failures like bad input; exceptions travel silently up the stack, which is right for genuine emergencies.

std::variant is also how you build a recursive data structure — a JSON value is a variant of null, bool, number, string, array of values, and object of values. That is Milestone Project 3 in Module 16.

Try it

Write a small configuration reader. get_int(key) returns std::optional<int>. Then write get_setting(key) returning std::variant<int, double, std::string, bool> and use std::visit to print any setting. Say what happens if you add a fifth alternative and forget to handle it.

Checkpoint

  • std::optional<T> says "maybe a T" in the type, replacing sentinels and out-parameters.
  • *opt on an empty optional is undefined; value() throws; value_or never fails.
  • sizeof(std::optional<int>) is 8, not 4 — a flag plus padding.
  • std::variant<A, B, C> is a type-safe union that tracks which alternative is active.
  • std::visit with a handler per type fails to compile if an alternative is unhandled.
  • The overloaded idiom is two lines of variadic inheritance and is worth copying verbatim.
  • Beware variants of types with implicit conversions between them, such as int and bool.
  • std::expected<T, E> carries the reason for failure, which optional cannot.
  • transform and and_then chain without nested checks, passing errors straight through.
  • std::expected is C++23; GCC 13 and Clang 16 support it with -std=c++23.
  • Use expected for failures callers should handle, exceptions for genuine emergencies.

Questions about this lesson

Log in to ask a question.