C++ / STANDARD CONTAINERS
optional, variant, and expressing absence or choice
Model a missing value with std::optional and a choice between types with std::variant, then read both safely with value_or, get_if, and std::visit.
What you will learn
- Return std::optional<T> instead of sentinels like -1, npos, or a bool out-parameter
- Check has_value() or get_if before reading; *opt on an empty optional is undefined
- Dispatch on a variant with std::visit so a new alternative breaks the build
- Explain why if (opt) on an optional<bool> tests presence, never the stored bool
Understanding optional, variant, and expressing absence or choice
Before C++17 the only ways to say "this int might not be there" were to pick a value nobody would use (-1 for a port, npos for an index, nullptr for a pointer) or to return bool and write through an out-parameter. Those contracts live in comments, so nothing stops a caller from passing -1 along as a real port number. std::optional<T> puts the empty state into the type itself: it holds storage for exactly one T plus a flag saying whether that T has been constructed, all inside the optional object, with no heap allocation. An empty optional does not hold a default-constructed T; the constructor simply has not run, which is why optional works for types that cannot be default-constructed at all.
std::variant<int, bool, std::string> is the same idea widened from "value or nothing" to "exactly one of these". Its storage is a block large enough for the biggest alternative plus an index naming the alternative that is currently alive, and that index is exactly what a bare union lacks: the variant knows to run std::string's destructor when you assign an int over a string, and it can refuse a read of the wrong alternative. Seen this way, optional<T> is a variant of "empty" and T, and the discipline for both is identical: consult the tag before touching the payload. A variant is never empty in normal use, and default-constructing one value-initializes its first alternative, which is what std::monostate is for when no real type makes sense as the default.
Each type gives you a checked door and an unchecked one. Writing *opt or opt->field skips the flag test and is undefined behaviour when the optional is empty, which in practice means reading bytes where no object was ever constructed rather than a clean crash; value() throws std::bad_optional_access instead, and value_or hands back a fallback in one expression. On the variant side std::get<T> throws std::bad_variant_access for the wrong alternative while std::get_if<T> returns a null pointer. Prefer std::visit over a hand-written chain of get_if tests: because visit demands that the visitor be callable with every alternative, adding a fourth type to the variant turns a silently wrong else branch into a compile error.
<iostream>
<optional>
<string>
<variant>
// "no port" is a state of its own, not a magic number
std::optional<int> parse_port(const std::string& text) {
if (text.empty()) return std::nullopt;
int value = 0;
for (char c : text) {
if (c < '0' || c > '9') return std::nullopt;
value = value * 10 + (c - '0');
if (value > 65535) return std::nullopt;
}
return value; // wrapped into optional<int>
}
using Setting = std::variant<int, bool, std::string>;
void describe(const Setting& s) {
if (const int* pi = std::get_if<int>(&s))
std::cout << "int " << *pi << '\n';
else if (const bool* pb = std::get_if<bool>(&s))
std::cout << "bool " << (*pb ? "true" : "false") << '\n';
else
std::cout << "string " << std::get<std::string>(s) << '\n';
}
int main() {
for (std::string text : {"8080", "70000", "80a0", ""}) {
std::optional<int> port = parse_port(text);
std::cout << '[' << text << "] -> ";
if (port) std::cout << *port << '\n';
else std::cout << "not a port\n";
}
std::cout << "value_or: " << parse_port("443").value_or(80)
<< ' ' << parse_port("nope").value_or(80) << '\n';
Setting s = 42;
describe(s);
s = std::string("verbose");
describe(s);
s = true;
describe(s);
std::cout << "active index " << s.index() << '\n';
}
optional and variant are tagged unions whose tag (a present-flag or an alternative index) travels with the value, so absence and choice become facts the library can check instead of conventions you must remember.
Worked examples
Dispatching with std::visit
Shows visit selecting the overload for the live alternative, and get<T> refusing a read of the wrong one.
<iostream>
<string>
<variant>
using Value = std::variant<int, double, std::string>;
struct Show {
void operator()(int i) const { std::cout << "int " << i << '\n'; }
void operator()(double d) const { std::cout << "double " << d << '\n'; }
void operator()(const std::string& s) const {
std::cout << "string of " << s.size() << " chars\n";
}
};
int main() {
Value values[] = { 7, 2.5, std::string("hello") };
for (const Value& v : values) std::visit(Show{}, v);
Value v = 2.5;
std::cout << "index " << v.index()
<< " is_double " << std::holds_alternative<double>(v) << '\n';
try {
std::cout << std::get<int>(v) << '\n';
} catch (const std::bad_variant_access&) {
std::cout << "get<int> refused: the live alternative is double\n";
}
}
Example explained
Line 1std::visit(Show{}, v) picks the operator() overload from the alternative index stored in v, so the branching is generated rather than written by hand.
Line 2Delete any one of the three operator() overloads and this file stops compiling: visit requires the visitor to be callable with every alternative.
Line 3v.index() prints 1 because double is the second alternative of variant<int, double, std::string>; that index is the tag kept next to the value.
Line 4std::get<int>(v) inspects the tag first and throws std::bad_variant_access, whereas the same read through a raw union would have been undefined behaviour.
The optional<bool> trap and the checked accessors
Demonstrates that a present false is truthy as an optional, and how value(), assignment, and reset() change the flag.
<iostream>
<optional>
<string>
std::optional<bool> read_flag(const std::string& raw) {
if (raw == "on") return true;
if (raw == "off") return false;
return std::nullopt; // the text was not a flag at all
}
int main() {
for (std::string raw : {"on", "off", "maybe"}) {
std::optional<bool> f = read_flag(raw);
std::cout << raw
<< " has_value=" << f.has_value()
<< " if(f)=" << static_cast<bool>(f)
<< " value_or(false)=" << f.value_or(false) << '\n';
}
std::optional<bool> f = read_flag("maybe");
try {
bool b = f.value();
std::cout << "unreachable " << b << '\n';
} catch (const std::bad_optional_access&) {
std::cout << "value() threw on the empty optional\n";
}
f = false;
std::cout << "assigned: has_value=" << f.has_value() << " *f=" << *f << '\n';
f.reset();
std::cout << "reset: has_value=" << f.has_value() << '\n';
}
Example explained
Line 1For "off" the optional contains false: has_value() is 1 while the stored bool is 0, so if (f) and if (*f) answer different questions.
Line 2f.value() on the empty optional throws std::bad_optional_access; *f in that same state would have been undefined behaviour with no diagnostic at all.
Line 3f = false constructs a bool inside the optional's own storage and flips the flag on, since there was no bool there to assign to.
Line 4f.reset() destroys the contained bool and returns the optional to the empty state; the storage stays inside the optional either way.
Important notes
std::optional cannot hold a reference in C++17 through C++23; use a pointer or std::reference_wrapper when you want to express a maybe-present alias to an existing object.
The variant converting constructor runs overload resolution across the alternatives, so alternatives that convert into one another can be ambiguous or pick an unexpected one; use std::in_place_type<T> or an explicit cast when it matters.
Common mistakes
Writing int port = *parse_port(text); with no check: on an empty optional this reads storage where no int was ever constructed, so you get a garbage port or a sanitizer report instead of an error you can handle.
Reading if (flag) on a std::optional<bool> as "the flag is on": a parsed false is still a present value, so input meaning off takes the enabled branch. Test flag.has_value() first, then *flag.
Calling std::get<int>(v) after the variant was reassigned to another alternative: it throws std::bad_variant_access only on the code path that changed the alternative, so tests that never store a string never see the bug.
Try it yourself
Change, predict, then run
In one file write std::optional<double> safe_div(double a, double b) that returns std::nullopt when b is 0, plus using Result = std::variant<double, std::string> and Result checked_div(double, double) that returns an error message instead. Print 6/3 and 6/0 through both, using value_or(0.0) for the optional and std::visit for the variant.
Open the C++ workspaceCheck your understanding
You handle a std::variant<int, double, std::string> with a chain of std::get_if tests and a final else. A teammate adds a fourth alternative, bool. What does std::visit with an explicit overload set give you that the get_if chain does not?
- Nothing: both report the unhandled alternative by throwing std::bad_variant_access at runtime.
- std::visit checks the active index while get_if does not, so only visit notices the new alternative.
- std::visit fails to compile until a bool overload exists, while the get_if chain still compiles and quietly sends bool values into the final else.
- std::visit moves the new alternative to the heap, so adding a type cannot affect existing code.
Show answer
visit requires the visitor to be invocable with every alternative, so an overload set with no bool case becomes a compile error the moment bool is added. Option 1 is tempting but wrong: get_if does check the active index and returns nullptr for a mismatch, so the difference is when you learn about the new alternative, not whether the tag is consulted.