Lesson 10 of 12
Three vocabulary types answer three questions that used to be answered with conventions:
std::optional<T>— aT, or nothing.std::variant<A, B, C>— exactly one of these types.std::expected<T, E>— aT, 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.*opton an empty optional is undefined;value()throws;value_ornever 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::visitwith a handler per type fails to compile if an alternative is unhandled.- The
overloadedidiom is two lines of variadic inheritance and is worth copying verbatim. - Beware variants of types with implicit conversions between them, such as
intandbool. std::expected<T, E>carries the reason for failure, whichoptionalcannot.transformandand_thenchain without nested checks, passing errors straight through.std::expectedis C++23; GCC 13 and Clang 16 support it with-std=c++23.- Use
expectedfor failures callers should handle, exceptions for genuine emergencies.
Questions about this lesson
Log in to ask a question.