56 / 163 · C++17 · 10 min
optional and variant: put the state in the type
optional expresses that a value may be absent; variant expresses one alternative among a closed set of types. Both manage the lifetime of the object they hold. Safe use depends on determining the current state first, then accessing the correct branch, rather than smuggling state through magic numbers or a bare union.
In this lesson
optional's empty state is not a special numeric value
C++17's optional<T> contains its own storage for a T and records whether a value is present; it is not a pointer that default-allocates a T on the heap. nullopt means there is no result. Even when T is int, zero can be a fully valid result, so there is no need to reuse -1 or zero as an implicit failure flag.
Confirm the state with has_value or a contextual conversion before access. Calling value on an empty optional throws bad_optional_access; dereferencing it directly has a precondition that a value is present. For optional<bool> in particular, a condition checks whether a value is present, not whether that bool is true. The two layers of state must be read separately. optional is the vocabulary for "a T or nothing," and the contained T is alive only while the optional is engaged.
variant models a closed set of types
variant<int, string> ordinarily holds one of the alternative objects at a time and tracks which type is active. get_if takes a pointer to the variant and returns a null pointer when the type does not match; get throws bad_variant_access on a type mismatch. visit applies visitor logic to the currently active alternative. At compile time the visitor must be able to handle the relevant combination of alternatives.
Default-constructing a variant does not mean "empty"; it attempts to construct the first alternative. When an explicit no-content state is required, monostate can be one of the alternatives. Some type-changing assignments that throw can also leave the variant valueless_by_exception, so do not claim that a variant always contains a value in every situation. Because the alternative set is closed, visit can demand that every state be handled, which a tag plus a bare union cannot do as reliably.
Choose the container by domain meaning
The example models a single-digit parse as optional, then represents a message as either an integer or a string. Every state branch is checked before access. visit renders both message forms as a string, without treating an integer as an address or misinterpreting the bytes in storage.
If the reason for failure also matters, optional often carries too little information; C++23 expected fits "a success value or an error" more closely. If the state is a closed set of distinct business events, variant is the more natural type. Do not mechanically replace optional with variant<monostate, T>, and do not replace an independent value with a nullable pointer unless the interface truly needs borrow semantics. Callers should not have to reverse-engineer whether zero, an empty string, or the first alternative is a sentinel; those distinctions belong in the type.
Pitfalls
- optional<bool>{false} is still engaged, so
if (option)enters the branch; check the business truth only after a value is present by reading *option. - A visit visitor must cover every alternative type and must meet the return-type rules of the overloads you use; handling only the alternative that appears in the current test does not make a complete interface.
Run an example
Minimum C++17 · complete program · Download .cpp
#include <cassert>
#include <iostream>
#include <optional>
#include <string>
#include <variant>
std::optional<int> digit(char c) {
if (c >= '0' && c <= '9') return c - '0';
return std::nullopt;
}
struct Render {
std::string operator()(int n) const { return std::to_string(n); }
std::string operator()(const std::string& s) const { return s; }
};
int main() {
auto zero = digit('0');
assert(zero.has_value() && *zero == 0);
assert(!digit('x'));
std::variant<int, std::string> message = 7;
assert(std::visit(Render{}, message) == "7");
message = std::string("ready");
assert(std::get_if<int>(&message) == nullptr);
assert(std::visit(Render{}, message) == "ready");
std::cout << *zero << ' ' << std::visit(Render{}, message) << '\n';
}
Compile locally
g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread modern-optional-variant.cpp -o example && ./exampleExpected result
0 ready
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
If message should explicitly represent that no message has been received yet, how should you change the type and the visitor?
Show a reference answer
Change it to variant<monostate, int, string> and default-construct it so the first alternative, monostate, is the initial state. Add string operator()(monostate) const { return "pending"; } to Render. Each state then has explicit handling, avoiding the use of integer zero or an empty string as both a business value and a missing-data marker.
Check the sources
Drafts and official chapters change. The version mark is only the example’s minimum.