optional, variant and expected
Three standard "vocabulary types" make common situations explicit in the type system: std::optional<T> for "a value or nothing", std::variant<A, B…> for "one of several types", and C++23's std::expected<T, E> for "a result or an error". They replace sentinel values, null pointers and ad-hoc unions.
std::optional
When a function may have no answer — a search that finds nothing, a parse that fails — return an optional instead of a magic value like -1:
#include <iostream> #include <optional> #include <string> #include <vector> std::optional<std::size_t> find_index(const std::vector<std::string>& v, const std::string& wanted) { for (std::size_t i = 0; i < v.size(); ++i) { if (v[i] == wanted) return i; } return std::nullopt; // no value } int main() { std::vector<std::string> names{"Ana", "Ben", "Chloe"}; if (auto i = find_index(names, "Ben")) { // true if it holds a value std::cout << "Ben at index " << *i << '\n'; } auto z = find_index(names, "Zoe"); std::cout << std::boolalpha << z.has_value() << '\n'; std::cout << "Zoe index or 99: " << z.value_or(99) << '\n'; }
Ben at index 1 false Zoe index or 99: 99
| Operation | Meaning |
|---|---|
if (opt) / opt.has_value() | Is there a value? |
*opt, opt->member | The value (undefined behaviour if empty) |
opt.value() | The value, or throws std::bad_optional_access |
opt.value_or(fallback) | The value, or the fallback |
opt = std::nullopt; / opt.reset() | Make it empty |
optional also suits optional members ("middle name") and lazily computed values.
std::variant
A variant holds exactly one value from a fixed list of types, and knows which. It's a type-safe union:
#include <iostream> #include <string> #include <variant> #include <vector> using Setting = std::variant<int, double, bool, std::string>; int main() { std::vector<std::pair<std::string, Setting>> config{ {"port", 8080}, {"ratio", 0.75}, {"debug", true}, {"host", std::string{"example.org"}}}; for (const auto& [key, value] : config) { std::cout << key << " = "; std::visit([](const auto& v) { std::cout << v; }, value); // call with whatever it holds std::cout << " (type index " << value.index() << ")\n"; } Setting s = 42; if (std::holds_alternative<int>(s)) std::cout << "an int: " << std::get<int>(s) << '\n'; s = std::string{"now a string"}; std::cout << std::get<std::string>(s) << '\n'; }
port = 8080 (type index 0) ratio = 0.75 (type index 1) debug = 1 (type index 2) host = example.org (type index 3) an int: 42 now a string
std::visit calls a function with the current value; a generic lambda handles every alternative. std::get<T> throws std::bad_variant_access if the variant holds a different type; std::get_if returns a pointer or nullptr instead.
Handling each type differently
The "overloaded" helper builds one visitor from several lambdas — a common idiom:
#include <cmath> #include <iostream> #include <variant> #include <vector> struct Circle { double r; }; struct Rect { double w, h; }; struct Triangle { double a, b, c; }; using Shape = std::variant<Circle, Rect, Triangle>; template <typename... Fs> struct overloaded : Fs... { using Fs::operator()...; }; double area(const Shape& s) { return std::visit(overloaded{ [](const Circle& c) { return 3.14159265 * c.r * c.r; }, [](const Rect& r) { return r.w * r.h; }, [](const Triangle& t) { double p = (t.a + t.b + t.c) / 2; return std::sqrt(p * (p - t.a) * (p - t.b) * (p - t.c)); }, }, s); } int main() { std::vector<Shape> shapes{Circle{1}, Rect{2, 3}, Triangle{3, 4, 5}}; for (const auto& s : shapes) std::cout << area(s) << '\n'; }
3.14159 6 6
This is an alternative to virtual functions (lesson 37): no heap allocation and no inheritance, and the compiler checks that every alternative is handled. It suits closed sets of types that rarely change.
std::expected (C++23)
std::expected<T, E> holds either a value of type T or an error of type E. It brings error-code-style handling with the convenience of optional — the error says why there's no value. It needs a C++23 compiler (GCC 12+, Clang 16+ with libc++), and this example was compiled with -std=c++23:
#include <expected> #include <iostream> #include <string> enum class ParseError { empty, not_a_number, out_of_range }; std::expected<int, ParseError> parse_percent(const std::string& s) { if (s.empty()) return std::unexpected(ParseError::empty); int value = 0; for (char c : s) { if (c < '0' || c > '9') return std::unexpected(ParseError::not_a_number); value = value * 10 + (c - '0'); if (value > 100) return std::unexpected(ParseError::out_of_range); } return value; } const char* describe(ParseError e) { switch (e) { case ParseError::empty: return "empty input"; case ParseError::not_a_number: return "not a number"; case ParseError::out_of_range: return "more than 100"; } return "?"; } int main() { for (std::string s : {"75", "", "7x", "250"}) { auto result = parse_percent(s); if (result) std::cout << '"' << s << "\" -> " << *result << "%\n"; else std::cout << '"' << s << "\" -> error: " << describe(result.error()) << '\n'; } }
"75" -> 75% "" -> error: empty input "7x" -> error: not a number "250" -> error: more than 100
Like optional, it supports value_or, and C++23 adds chaining with and_then, transform and or_else.
Which to use?
| Situation | Use |
|---|---|
| "No result" is normal and needs no explanation | std::optional<T> |
| A value is one of a fixed set of types | std::variant<…> |
| Failure is expected and the caller needs the reason | std::expected<T, E> |
| Failure is exceptional and should propagate up | An exception (lesson 42) |
Try it yourself
Write a function that looks up a product's price and returns an optional; total a shopping list, skipping unknown items.
no price for caviar total: 6.65