AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

15 Errors and exceptions / Lesson 4 of 5

std::variant and std::visit — a type-safe union

14 min · +10 XP · 2 exercises · 7 questions

Read the text14 min
Exercises0/2
Quiz7 q

Quick answer

What is std::variant in C++?

std::variant, in <variant> since C++17, is a type-safe union: a std::variant<int, double, std::string> holds exactly one of those types at a time and records which with index(). std::get<T> throws std::bad_variant_access on a mismatch, std::get_if<T> returns nullptr instead, and std::visit calls a visitor with the held value, refusing to compile unless every alternative is handled.

A value that is one of several types — a token that is a number or a word, a command that is a push or a pop, a result that is a value or an error — is a sum type, and C's tool for it was the union: overlapping storage with no record of which member is live, so reading the wrong one is undefined behaviour. std::variant, in <variant> since C++17, is a union that remembers. It stores exactly one of its alternatives at a time, tracks which by index, checks every access, and — through std::visit — makes the compiler prove that your code handles every alternative. This lesson settles construction and access, the visitor patterns, std::monostate, modelling a small command language, and why std::any is almost never the tool.

Declaring, assigning, asking

#include <iostream>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> v;   // holds int 0: the first alternative, default-constructed
    v = 2.5;                                    // now a double
    v = "text";                                 // now a std::string (the const char* converts)
    std::cout << v.index() << '\n';             // 2

    if (std::holds_alternative<std::string>(v)) {
        std::cout << std::get<std::string>(v).size() << '\n';   // 4
    }
    if (auto* d = std::get_if<double>(&v)) {    // pointer to the double, or nullptr
        std::cout << *d << '\n';                // not printed: v holds a string
    }
    std::cout << std::get<2>(v) << '\n';        // by index: text
    return 0;
}

A variant is never empty in normal use: default construction builds the first alternative, and assigning a value of another type destroys the current alternative and constructs the new one in the same storage. Access comes in two checked forms. std::get<T> (or std::get<I>) returns a reference and throws std::bad_variant_access when the variant holds something else; std::get_if<T> takes a pointer to the variant and returns a pointer to the value or nullptr, which is the form for an if. Two alternatives of the same type — std::variant<int, int> — are legal but make get<int> ambiguous; address them by index or, better, wrap each in a named struct.

Visiting

Testing index() in an if/else chain works and is the anti-pattern: add a fourth alternative and every chain silently falls through its last else. std::visit(visitor, v) calls visitor(held) with the held value at its real type, and refuses to compile unless the visitor accepts every alternative.

The smallest visitor is a generic lambda, for operations that read the same for every type:

std::visit([](const auto& x) { std::cout << x << '\n'; }, v);   // int, double and std::string all stream

When each alternative needs its own code, overload the call operator. The standard idiom is a struct that inherits every lambda you hand it:

template <typename... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};
template <typename... Ts> overloaded(Ts...) -> overloaded<Ts...>;   // C++20 deduces this itself; harmless

std::visit(overloaded{
    [](int i) { std::cout << "int " << i << '\n'; },
    [](double d) { std::cout << "double " << d << '\n'; },
    [](const std::string& s) { std::cout << "string " << s << '\n'; },
}, v);

overloaded is a class derived from each lambda's closure type (Module 12, lesson 4 covered the pack), and using Ts::operator()... pulls every call operator into one overload set, so the call resolves like any overloaded function — exact match first. Leave one alternative out and the error names the type that has no match. A trailing [](const auto&) { } is the catch-all when only some alternatives matter, at the cost of the compile-time check.

A visitor may return a value, and then every overload must return the same type:

int width = std::visit(overloaded{
    [](int) { return 1; },
    [](double) { return 2; },
    [](const std::string& s) { return static_cast<int>(s.size()); },   // size_t would not match int
}, v);

A visitor with state

Lambdas capture, but a visitor that carries state across many visits is clearer as a struct with overloaded operator()s:

struct Push { long long value; };
struct Pop {};
struct Add {};
using Command = std::variant<Push, Pop, Add>;

struct Executor {
    std::vector<long long>& stack;
    void operator()(const Push& p) const { stack.push_back(p.value); }
    void operator()(const Pop&) const { stack.pop_back(); }
    void operator()(const Add&) const {
        long long b = stack.back(); stack.pop_back();
        stack.back() += b;
    }
};

for (const Command& c : program) std::visit(Executor{stack}, c);

Empty structs as alternatives are the C++ spelling of an enum with payloads: Pop carries nothing but is still a distinct type the visitor can overload on.

std::monostate

Because a variant default-constructs its first alternative, a variant whose alternatives are all non-default-constructible cannot be default-constructed at all. std::monostate — an empty type that compares equal to itself — goes first to say "nothing yet":

std::variant<std::monostate, Connection, Error> state;   // default: monostate

It also models a legitimately empty state without reaching for std::optional<std::variant<...>>.

Modelling a small language

A variant of command structs plus a parser and a visitor is a complete interpreter, and the pattern scales to tokens, JSON values and abstract syntax trees. The trade-off it embodies has a name — the expression problem. A std::variant fixes the set of types and leaves the set of operations open: anyone can write a new visitor without touching the types. A class hierarchy with virtual functions (Module 10) fixes the set of operations and leaves the types open: anyone can derive a new class, but a new operation means editing every class. Choose by which axis your program grows. Recursive shapes — an expression that contains expressions — need indirection, because a type cannot contain itself by value:

struct Expr;
using Node = std::variant<long long, std::unique_ptr<Expr>>;   // the pointer breaks the recursion

std::any, and why it is rare

std::any holds a value of any copyable type and remembers which with typeid:

#include <any>
std::any a = 5;
a = std::string("five");
if (auto* s = std::any_cast<std::string>(&a)) std::cout << *s << '\n';   // pointer form: nullptr on mismatch
int n = std::any_cast<int>(a);                                            // throws std::bad_any_cast

There is no visit — the holder cannot enumerate what it does not know — and large values are heap-allocated. That is the tell: any is for a set of types that is genuinely open at compile time, such as a scripting bridge or a plugin's opaque configuration. When the set is known, std::variant is checked and faster; when the code is generic over the type, a template is. Most codebases contain no std::any at all.

Cost

A variant is as large as its largest alternative plus an index, padded, and lives wherever you put it — no heap. std::visit compiles to a table dispatch on the index, comparable to a virtual call. One rarely met state exists: if constructing the new alternative during an assignment throws after the old one was destroyed, the variant becomes valueless_by_exception() and visit throws; a noexcept move constructor (lesson 2) makes this impossible for your types.

Pitfalls

MistakeWhat happens
std::get<T> without checkingThrows std::bad_variant_access when another alternative is held
if/else on index()A new alternative is silently unhandled; std::visit would refuse to compile
Visitor overloads returning different typesCompile error: every return must convert to one type
std::variant<int, int> and get<int>Ambiguous; wrap each in a named struct
First alternative not default-constructibleThe variant is not default-constructible; put std::monostate first
A variant that contains itself by valueIncomplete type; go through std::unique_ptr or a std::vector
Reaching for std::any with a known set of typesNo visit, possible heap allocation, a cast per read

Key takeaways

  • std::variant<A, B, C> holds exactly one alternative, tracked by index(); default construction builds the first.
  • std::get<T> throws on a mismatch; std::get_if<T>(&v) returns nullptr instead — use it in an if.
  • std::visit calls the visitor with the real type and refuses to compile unless every alternative is handled; overloaded{…} builds the visitor from lambdas.
  • Empty structs are alternatives too; a visitor struct with a reference member carries state.
  • std::monostate is the "nothing yet" alternative; recursion needs a pointer.
  • std::any is for a truly open set of types and is almost never the right tool.

Common questions

How does std::visit work?

std::visit(visitor, v) calls the visitor with the value the variant currently holds, at its real type, and does not compile unless the visitor accepts every alternative. A generic lambda, [](const auto& x) { … }, handles all types alike; for per-type code, build one overload set from several lambdas with the overloaded helper. Every overload must return the same type.

What is the overloaded pattern for std::visit?

template <typename... Ts> struct overloaded : Ts... { using Ts::operator()...; }; is a struct that inherits from every lambda you pass it and pulls all their call operators into one overload set. std::visit(overloaded{ [](int i) { … }, [](const std::string& s) { … } }, v) then calls the lambda that matches the held type, as ordinary overload resolution would.

What is the difference between std::variant and a union?

A C union overlaps its members' storage but does not record which member is live, so reading the wrong one is undefined behaviour. std::variant also shares storage but tracks the active alternative, checks every access, destroys and constructs alternatives correctly on assignment, and works with std::visit.

What is std::monostate used for?

std::monostate is an empty type placed as a variant's first alternative. A variant default-constructs its first alternative, so when none of the real alternatives is default-constructible, putting std::monostate first makes the variant default-constructible and gives it an explicit nothing-yet state.

When should I use std::any instead of std::variant?

Rarely. std::any holds any copyable type, so it suits a set of types that is genuinely open at compile time, such as a plugin's opaque data. It has no visit, may allocate on the heap and needs an any_cast for every read. When the set of types is known, std::variant is checked by the compiler and faster.

Mark as readSign in to keep your place.

C++ std::variant and std::visit: A Type-Safe Union

std::variant holds one of several types and checks every access; std::visit proves every case is handled. The overloaded lambda idiom, monostate and std::any.

  • Course: C++ study plan
  • Module: Errors and exceptions
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: Clang 18 · C++20

What is std::variant in C++?

std::variant, in <variant> since C++17, is a type-safe union: a std::variant<int, double, std::string> holds exactly one of those types at a time and records which with index(). std::get<T> throws std::bad_variant_access on a mismatch, std::get_if<T> returns nullptr instead, and std::visit calls a visitor with the held value, refusing to compile unless every alternative is handled.

Lesson

A value that is one of several types — a token that is a number or a word, a command that is a push or a pop, a result that is a value or an error — is a sum type, and C's tool for it was the union: overlapping storage with no record of which member is live, so reading the wrong one is undefined behaviour. std::variant, in <variant> since C++17, is a union that remembers. It stores exactly one of its alternatives at a time, tracks which by index, checks every access, and — through std::visit — makes the compiler prove that your code handles every alternative. This lesson settles construction and access, the visitor patterns, std::monostate, modelling a small command language, and why std::any is almost never the tool.

Declaring, assigning, asking

#include <iostream>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> v;   // holds int 0: the first alternative, default-constructed
    v = 2.5;                                    // now a double
    v = "text";                                 // now a std::string (the const char* converts)
    std::cout << v.index() << '\n';             // 2

    if (std::holds_alternative<std::string>(v)) {
        std::cout << std::get<std::string>(v).size() << '\n';   // 4
    }
    if (auto* d = std::get_if<double>(&v)) {    // pointer to the double, or nullptr
        std::cout << *d << '\n';                // not printed: v holds a string
    }
    std::cout << std::get<2>(v) << '\n';        // by index: text
    return 0;
}

A variant is never empty in normal use: default construction builds the first alternative, and assigning a value of another type destroys the current alternative and constructs the new one in the same storage. Access comes in two checked forms. std::get<T> (or std::get<I>) returns a reference and throws std::bad_variant_access when the variant holds something else; std::get_if<T> takes a pointer to the variant and returns a pointer to the value or nullptr, which is the form for an if. Two alternatives of the same type — std::variant<int, int> — are legal but make get<int> ambiguous; address them by index or, better, wrap each in a named struct.

Visiting

Testing index() in an if/else chain works and is the anti-pattern: add a fourth alternative and every chain silently falls through its last else. std::visit(visitor, v) calls visitor(held) with the held value at its real type, and refuses to compile unless the visitor accepts every alternative.

The smallest visitor is a generic lambda, for operations that read the same for every type:

std::visit([](const auto& x) { std::cout << x << '\n'; }, v);   // int, double and std::string all stream

When each alternative needs its own code, overload the call operator. The standard idiom is a struct that inherits every lambda you hand it:

template <typename... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};
template <typename... Ts> overloaded(Ts...) -> overloaded<Ts...>;   // C++20 deduces this itself; harmless

std::visit(overloaded{
    [](int i) { std::cout << "int " << i << '\n'; },
    [](double d) { std::cout << "double " << d << '\n'; },
    [](const std::string& s) { std::cout << "string " << s << '\n'; },
}, v);

overloaded is a class derived from each lambda's closure type (Module 12, lesson 4 covered the pack), and using Ts::operator()... pulls every call operator into one overload set, so the call resolves like any overloaded function — exact match first. Leave one alternative out and the error names the type that has no match. A trailing [](const auto&) { } is the catch-all when only some alternatives matter, at the cost of the compile-time check.

A visitor may return a value, and then every overload must return the same type:

int width = std::visit(overloaded{
    [](int) { return 1; },
    [](double) { return 2; },
    [](const std::string& s) { return static_cast<int>(s.size()); },   // size_t would not match int
}, v);

A visitor with state

Lambdas capture, but a visitor that carries state across many visits is clearer as a struct with overloaded operator()s:

struct Push { long long value; };
struct Pop {};
struct Add {};
using Command = std::variant<Push, Pop, Add>;

struct Executor {
    std::vector<long long>& stack;
    void operator()(const Push& p) const { stack.push_back(p.value); }
    void operator()(const Pop&) const { stack.pop_back(); }
    void operator()(const Add&) const {
        long long b = stack.back(); stack.pop_back();
        stack.back() += b;
    }
};

for (const Command& c : program) std::visit(Executor{stack}, c);

Empty structs as alternatives are the C++ spelling of an enum with payloads: Pop carries nothing but is still a distinct type the visitor can overload on.

std::monostate

Because a variant default-constructs its first alternative, a variant whose alternatives are all non-default-constructible cannot be default-constructed at all. std::monostate — an empty type that compares equal to itself — goes first to say "nothing yet":

std::variant<std::monostate, Connection, Error> state;   // default: monostate

It also models a legitimately empty state without reaching for std::optional<std::variant<...>>.

Modelling a small language

A variant of command structs plus a parser and a visitor is a complete interpreter, and the pattern scales to tokens, JSON values and abstract syntax trees. The trade-off it embodies has a name — the expression problem. A std::variant fixes the set of types and leaves the set of operations open: anyone can write a new visitor without touching the types. A class hierarchy with virtual functions (Module 10) fixes the set of operations and leaves the types open: anyone can derive a new class, but a new operation means editing every class. Choose by which axis your program grows. Recursive shapes — an expression that contains expressions — need indirection, because a type cannot contain itself by value:

struct Expr;
using Node = std::variant<long long, std::unique_ptr<Expr>>;   // the pointer breaks the recursion

std::any, and why it is rare

std::any holds a value of any copyable type and remembers which with typeid:

#include <any>
std::any a = 5;
a = std::string("five");
if (auto* s = std::any_cast<std::string>(&a)) std::cout << *s << '\n';   // pointer form: nullptr on mismatch
int n = std::any_cast<int>(a);                                            // throws std::bad_any_cast

There is no visit — the holder cannot enumerate what it does not know — and large values are heap-allocated. That is the tell: any is for a set of types that is genuinely open at compile time, such as a scripting bridge or a plugin's opaque configuration. When the set is known, std::variant is checked and faster; when the code is generic over the type, a template is. Most codebases contain no std::any at all.

Cost

A variant is as large as its largest alternative plus an index, padded, and lives wherever you put it — no heap. std::visit compiles to a table dispatch on the index, comparable to a virtual call. One rarely met state exists: if constructing the new alternative during an assignment throws after the old one was destroyed, the variant becomes valueless_by_exception() and visit throws; a noexcept move constructor (lesson 2) makes this impossible for your types.

Pitfalls

MistakeWhat happens
std::get<T> without checkingThrows std::bad_variant_access when another alternative is held
if/else on index()A new alternative is silently unhandled; std::visit would refuse to compile
Visitor overloads returning different typesCompile error: every return must convert to one type
std::variant<int, int> and get<int>Ambiguous; wrap each in a named struct
First alternative not default-constructibleThe variant is not default-constructible; put std::monostate first
A variant that contains itself by valueIncomplete type; go through std::unique_ptr or a std::vector
Reaching for std::any with a known set of typesNo visit, possible heap allocation, a cast per read

Key takeaways

  • std::variant<A, B, C> holds exactly one alternative, tracked by index(); default construction builds the first.
  • std::get<T> throws on a mismatch; std::get_if<T>(&v) returns nullptr instead — use it in an if.
  • std::visit calls the visitor with the real type and refuses to compile unless every alternative is handled; overloaded{…} builds the visitor from lambdas.
  • Empty structs are alternatives too; a visitor struct with a reference member carries state.
  • std::monostate is the "nothing yet" alternative; recursion needs a pointer.
  • std::any is for a truly open set of types and is almost never the right tool.

Common questions

How does std::visit work?

std::visit(visitor, v) calls the visitor with the value the variant currently holds, at its real type, and does not compile unless the visitor accepts every alternative. A generic lambda, [](const auto& x) { … }, handles all types alike; for per-type code, build one overload set from several lambdas with the overloaded helper. Every overload must return the same type.

What is the overloaded pattern for std::visit?

template <typename... Ts> struct overloaded : Ts... { using Ts::operator()...; }; is a struct that inherits from every lambda you pass it and pulls all their call operators into one overload set. std::visit(overloaded{ [](int i) { … }, [](const std::string& s) { … } }, v) then calls the lambda that matches the held type, as ordinary overload resolution would.

What is the difference between std::variant and a union?

A C union overlaps its members' storage but does not record which member is live, so reading the wrong one is undefined behaviour. std::variant also shares storage but tracks the active alternative, checks every access, destroys and constructs alternatives correctly on assignment, and works with std::visit.

What is std::monostate used for?

std::monostate is an empty type placed as a variant's first alternative. A variant default-constructs its first alternative, so when none of the real alternatives is default-constructible, putting std::monostate first makes the variant default-constructible and gives it an explicit nothing-yet state.

When should I use std::any instead of std::variant?

Rarely. std::any holds any copyable type, so it suits a set of types that is genuinely open at compile time, such as a plugin's opaque data. It has no visit, may allocate on the heap and needs an any_cast for every read. When the set of types is known, std::variant is checked by the compiler and faster.

Exercises

A token stream

Classify a stream of tokens with using Token = std::variant<int, double, std::string>; and visit each one. parseToken (given) decides the alternative by shape: an optional sign followed by digits is an int, digits with one . is a double, anything else is a std::string (integers fit in int). Write the overloaded helper from the lesson and a single std::visit(overloaded{...}, token) with three lambdas that print the alternative's name and value, count it, and add numbers to a running double sum. Doubles print with two decimals.

Input: whitespace-separated tokens until EOF. Output: int <v>, double <v> or string <v> per token, then ints <a>, doubles <b>, strings <c>, sum <s>.

42 -7 2.5 hello .5 3. 1e5 +8

prints

int 42
int -7
double 2.50
string hello
double 0.50
double 3.00
string 1e5
int 8
ints 3, doubles 3, strings 2, sum 49.00

Turtle commands

Model a small command language as a variant and interpret it with a stateful visitor. Define struct Move { int steps; }, struct Turn { bool right; }, struct Report {}, struct Unknown { std::string line; } and using Command = std::variant<Move, Turn, Report, Unknown>;. parse(line) turns move <n> into Move, turn left/turn right into Turn, report into Report, and any other line into Unknown{line}. Turtle is a struct with x, y, a heading (north, east, south, west; starts at (0, 0) facing north) and a step total, and one operator() overload per alternative: Move walks steps in the current heading and adds steps to the total, Turn rotates a quarter turn, Report prints at (<x>, <y>) facing <N|E|S|W>, Unknown prints unknown: <line>. Drive it with std::visit(turtle, parse(line)) — pass the visitor by name so its state persists.

Input: one command per line until EOF. Output: the report and unknown lines as they occur, then total <steps>.

move 3
turn right
move 2
report
jump 5
turn left
turn left
move 4
report

prints

at (2, 3) facing E
unknown: jump 5
at (-2, 3) facing W
total 9

In this module: Errors and exceptions

← std::optional — a value that may be absent · Error codes, assertions and an error-handling strategy →