optional, variant and expected

C++ · Advanced · 15 min

These vocabulary types replace sentinel values, null pointers and many exceptions with types the compiler checks.

`std::optional<T>` (C++17) holds either a T or nothing. It's the clear return type for "might not have a result": std::optional<int> parse_int(std::string_view). Test it with if (opt) or has_value(), read it with *opt (unchecked) or opt.value() (throws std::bad_optional_access), and supply a fallback with value_or(x). The value is stored inline, so there's no heap allocation. C++23 adds monadic chaining: and_then, transform and or_else.

`std::variant<A, B, C>` (C++17) holds exactly one of several types, a type-safe tagged union, also stored inline. It is a closed alternative to inheritance: no virtual functions, no heap, and the set of types is fixed.

  • std::holds_alternative<B>(v) tests the active type, std::get<B>(v) reads it (throws if wrong), std::get_if<B>(&v) returns a pointer or null.
  • std::visit(visitor, v) calls the visitor with the active alternative. With the overloaded helper (a struct inheriting from several lambdas) you write one lambda per type, and forgetting a type is a compile error.

`std::expected<T, E>` (C++23, <expected>) holds either a value or an error of type E. It reports failure without exceptions while keeping the reason:

std::expected<Config, std::string> load(std::string_view path);
auto cfg = load("app.toml");
if (!cfg) log(cfg.error());

Return std::unexpected(err) to signal failure. It has the same monadic operations as optional.

Which to use: exceptions for rare failures far from where they're handled; optional when absence needs no explanation; expected when callers handle errors right away and need the reason; variant for states and messages with a fixed set of shapes.

Example

#include <charconv>
#include <iostream>
#include <optional>
#include <string_view>
#include <variant>

std::optional<int> parse_int(std::string_view s) {
    int v{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
    return v;
}

struct Circle { double r; };
struct Rect { double w, h; };
using Shape = std::variant<Circle, Rect>;          // exactly one of these, no heap

template <class... Fs> struct overloaded : Fs... { using Fs::operator()...; };

double area(const Shape& s) {
    return std::visit(overloaded{
        [](const Circle& c) { return 3.14159 * c.r * c.r; },
        [](const Rect& r) { return r.w * r.h; },
    }, s);                                         // missing a case = compile error
}

int main() {
    std::cout << parse_int("42").value_or(-1) << ' ' << parse_int("4x").value_or(-1) << '\n';  // 42 -1
    if (auto n = parse_int("7")) std::cout << *n * 2 << '\n';   // 14
    Shape s = Rect{2, 3};
    std::cout << area(s) << ' ' << std::holds_alternative<Rect>(s) << '\n';  // 6 1
}

Remember

  • optional<T>: a value or nothing; value_or gives a fallback
  • variant + std::visit: a closed set of types, checked exhaustively
  • C++23 expected<T, E>: a value or an error, without exceptions

Watch out for

  • Calling *opt without checking — dereferencing an empty optional is undefined behaviour.

Check yourself

  1. A lookup may find nothing, and the caller just needs to know whether it did. Which return type fits best?
  2. What does std::visit with an `overloaded` set of lambdas give you over a chain of holds_alternative checks?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/utility/optional

Builds on

Unlocks

Nothing builds on this yet.