Skip to content
VLSI Mentor

C++ Part I · Module 21 · Modern C++ Type and Utility Tools

std::variant and std::any: One Object, Several Types

Chapter 21.5 added one state to a type: present or absent. This chapter is the general case — an object that holds one value out of several possible types — and C++17 added two tools for it that look similar and differ on the single question that matters. std::variant requires the set of types to be written down in its declaration, which means the compiler knows them, which means mistakes can be caught before the program runs. std::any accepts anything, which means the compiler knows nothing, which means mistakes cannot be caught at all until the cast fails at run time. The frozen purpose states the conclusion directly and this chapter's job is to earn it by measurement. The sharpest result is a single mistake — forgetting to handle one of three alternatives — expressed three ways, where the detection escalates from four compile errors through a lone warning to complete silence. That middle result is the one worth knowing, because the if-constexpr visitor is the form most people write and it does not give the compile-time guarantee they believe it does; the overload-set form does. The chapter also measures the costs, which are not symmetric: a variant is the size of its largest alternative plus a tag and is guaranteed never to allocate, while an any is a fixed thirty-two bytes and allocated seventy-two more for a string that did not fit inside it.

Advanced19 min readC++Part IFundamentalsModern C++

Part I · Module 21 · Chapter 21.6 · std::variant and std::any: One Object, Several Types

1. A Closed Set and an Open One

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct Bits { std::uint32_t pos, width; };
struct Enum { std::uint32_t value; const char* name; };
struct Raw  { std::uint32_t word; };

using Field = std::variant<Bits, Enum, Raw>;   // exactly one of three
std::any   a = Bits{4, 8};                      // anything at all

A std::variant<Ts...> holds exactly one value, of exactly one of the listed types, and always holds something — it is never empty in normal use. The set is part of the type, so the compiler can enumerate it.

A std::any holds a value of any copyable type, or nothing. The type is recorded at run time only.

That difference is the entire chapter. Everything below is a consequence of whether the compiler can see the list.

The example is a realistic one: a register-map field that is either a bit range, an enumerated value with a name, or an opaque word. Three shapes, known when the code is written, which is the condition that makes a variant correct.

2. What They Cost

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(Bits)=8 sizeof(Enum)=16 sizeof(Raw)=4
sizeof(Field = variant<Bits,Enum,Raw>) = 24   (largest 16 + tag, padded)
variant_size = 3   index() of a default-constructed one = 0
global operator new calls: 0  (variant NEVER heap-allocates --
  that is a guarantee, not an observation)

A variant is the largest alternative plus a discriminant, padded. Twenty-four bytes for a 16-byte largest member: 16 of payload, a tag, and padding to the 8-byte alignment Enum requires.

The zero allocations are worth stating precisely. The standard guarantees std::variant does not dynamically allocate — the value lives inside the object, as with std::optional. The measured zero is a confirmation rather than a discovery, and the counter used is a global operator new override, because a per-container allocator would not see these: that is Chapter 20.9 §3's trap, which I walked into on the first attempt here as well.

A default-constructed variant holds the first alternative, value-initialised — index() == 0. That is why the first type in the list should be cheap and meaningful, and why std::monostate exists (§8).

Now std::any:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(std::any) = 32  (fixed, whatever it holds)

  any holding an int                 new=0 bytes=0
  any holding Bits (8 B)             new=0 bytes=0
  any holding Enum (16 B)            new=0 bytes=0
  any holding a 64-char string       new=1 bytes=72

Thirty-two bytes fixed, with a small-object buffer that held a 16-byte Enum inline and allocated 72 bytes for a string that did not fit. The same shape as std::function's buffer, which Chapter 20.9 §3 measured at a 16-byte threshold on this implementation.

So on cost: the variant is smaller for small alternatives, larger if one alternative is huge, and never allocates. The any is fixed-size and allocates for anything over its buffer.

3. Reading a Variant: Three Ways

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Field f = Bits{4, 8};
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
holds_alternative<Bits>(f) = 1   <- ask first
get<Bits>(f).width = 8
get_if<Enum>(&f) -> nullptr  (no throw, no UB: the safe query)
get<Enum>(f) threw std::bad_variant_access: bad_variant_access

std::holds_alternative<T>(v) asks whether the variant currently holds a T. A bool, no side effects.

std::get<T>(v) returns a reference to the value, and throws std::bad_variant_access if the variant holds something else. Note the contrast with Chapter 21.5 §6: an empty std::optional dereferenced with * is undefined behaviour, while a wrong std::get on a variant is a caught exception. The variant is the safer of the two by default.

std::get_if<T>(&v) returns a pointer, or nullptr on a mismatch. No throw, no undefined behaviour — this is the right call when a mismatch is expected and ordinary.

std::get<N>(v) by index also exists, and is worse for the same reason get<1> on a tuple is worse than a named member — Chapter 21.4 §9.

The important property: every one of these three is safe. A variant cannot be read as the wrong type silently. Compare §5's std::any, where the only difference is that the exception is bad_any_cast, and §6, where the compile-time story diverges completely.

4. std::visit and the Exhaustiveness Question

std::visit applies a callable to whatever the variant currently holds:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static const char* describe(const Field& f)
{
    return std::visit([](auto&& v) -> const char* {
        using T = std::decay_t<decltype(v)>;
        if constexpr (std::is_same_v<T, Bits>) return "Bits";
        else if constexpr (std::is_same_v<T, Enum>) return "Enum";
        else return "Raw";
    }, f);
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
index=0 -> Bits
index=1 -> Enum
index=2 -> Raw

That works, and the usual claim made for it is that visit forces you to handle every alternative. Measured, that claim is true only for one of the two visitor forms, and the form above is not it.

Leaving one alternative unhandled, three ways:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
FORMULATION                                ERRORS   WARNINGS   WHEN FOUND
variant + visit + overload set (missing)   4        0          COMPILE (error)
variant + visit + if constexpr (missing)   0        1          compile (warning only)
std::any + any_cast (wrong type)           0        0          RUN TIME (throws)

The if constexpr chain gives a warning, not an error. With the Raw branch removed, the lambda simply falls off the end without returning, and clang reports -Wreturn-type: non-void lambda does not return a value — one warning, zero errors. The program compiles. At run time, returning nothing from a non-void function is undefined behaviour.

The overload-set form gives four errors. The idiom:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

std::visit(overloaded{
    [](const Bits&) { return "Bits"; },
    [](const Enum&) { return "Enum"; },
    [](const Raw&)  { return "Raw";  },
}, f);

Remove the Raw overload and there is no operator() that accepts a Raw, so std::visit's requirement that the visitor be invocable for every alternative fails. Four errors across 147 diagnostic lines, the first reported inside <variant> at line 612 as a static_assert on is_invocable — which is a genuinely poor diagnostic, and exactly the problem Chapter 21.8 solves.

overloaded is a small piece of machinery worth understanding: it inherits from each lambda and pulls each operator() into one overload set, so ordinary overload resolution picks the right one. The deduction guide lets you write overloaded{...} without naming the types. In C++20 the guide is unnecessary, as aggregate deduction covers it.

So the practical rule: use the overload-set form when exhaustiveness matters. The if constexpr form is fine for a quick dispatch where you control all the cases, and it will not protect you when someone adds a fourth alternative.

5. std::any Checks Nothing

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(std::any) = 32  (fixed, whatever it holds)
a.has_value() = 1   a.type().name() = 4Bits
any_cast<Bits>(a).width = 8
any_cast<Enum>(a) threw std::bad_any_cast: bad any cast
-> the error is at RUN time. There is no set of permitted types, so
nothing can be checked when you compile.

std::any_cast<T>(a) returns the value if the held type is exactly T and throws std::bad_any_cast otherwise. The pointer form std::any_cast<T>(&a) returns nullptr instead, mirroring get_if.

Three properties follow from the open type set.

Nothing is checkable at compile time. The compiler cannot enumerate what an any might hold, so there is no exhaustiveness to verify and no visit. Dispatching on the type means a chain of any_cast attempts.

The match must be exact. any_cast<long> on an any holding an int throws, with no conversion. any_cast<Base> on an any holding a Derived throws too — there is no polymorphism here.

It requires RTTI. a.type() returns a std::type_info, so std::any does not work in builds with RTTI disabled, which is common in embedded and in some game engines. std::variant has no such dependency.

And the measured size and allocation behaviour from §2: 32 bytes, with anything over the small buffer heap-allocated.

6. The Escalation Is the Argument

§4's table is the chapter's central result, so it is worth restating as the thing it demonstrates.

One mistake — failing to handle one case out of three — produced:

  • a compile error when the type set was closed and the visitor was an overload set;
  • a compile warning when the type set was closed and the visitor was an if constexpr chain;
  • nothing at all when the type set was open, until bad_any_cast at run time.

The first two differ in the visitor, not the container. The second and third differ in the container. So the compile-time guarantee you get is a product of two independent choices, and getting either wrong loses it.

The failure modes are also ordered. A compile error costs a build. A warning costs a build only if warnings are errors, and otherwise ships undefined behaviour. A run-time throw costs a production incident unless the call site catches it — and the exception type tells you a cast failed, not which alternative you forgot.

That ordering is why the frozen purpose says any is "almost never what you want instead": not because it is slow or large, but because it discards the information the compiler would have used.

7. When std::any Is Genuinely Right

Three cases, and they share a property: the type set is genuinely open, not merely unwritten.

A plugin or extension boundary, where types are registered by code you do not compile with. You cannot enumerate what you have not seen.

A property bag whose contents are configured rather than coded — a settings map, a scripting bridge, an attribute dictionary read from a file.

Erasing a type to cross a boundary you do not control, where the far side will cast it back.

In every other case the set is known at the point of writing, and writing it down costs one line and buys §6's checking.

There is also a third option this chapter should name, because it is often the best one. When the alternatives share operations rather than merely storage, Chapter 17.9's polymorphic interface is the right tool: a base class with virtual functions, where each alternative implements the role. The division is:

  • variant — a closed set of types with different operations, dispatched by visit.
  • virtual interface — an open set of types with the same operations, dispatched by the vtable.
  • any — an open set with no shared operations, dispatched by casting and hoping.

8. monostate and the Empty Case

A variant always holds something, so representing "nothing yet" needs an explicit alternative:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
using Maybe = std::variant<std::monostate, Bits>;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
variant<monostate,Bits> default index = 0 (the empty state)
after assignment, index = 1

std::monostate is an empty type that exists for exactly this. It also solves the default-construction problem from §2: if your first alternative is expensive or has no sensible default, put monostate first and the default-constructed variant is cheap and meaningful.

The guidance: with two states, one of which is "nothing", use std::optional — it is smaller, clearer and has a purpose-built interface. Use monostate when there are several alternatives plus an empty one.

A variant can also become valueless by exception — if an assignment throws part-way through changing the active alternative, valueless_by_exception() becomes true and index() returns variant_npos. It is rare, it requires a throwing move, and the usual response is to ensure your alternatives have noexcept moves, which Chapter 13.8 §7 argues for anyway.

9. The Comparison

std::variantstd::anyvirtual interface
type setclosed, in the declarationopenopen
known at compile timeyesnothe base is
exhaustiveness checkableyes, with an overload set (measured 4 errors)never (measured 0/0)n/a
with an if constexpr visitorwarning only (measured 1 warning)——
wrong accessbad_variant_access, or get_if → nullptrbad_any_cast (measured)n/a
sizeoflargest + tag (measured 24)32, fixed (measured)8, the pointer
allocatesnever — a guarantee (measured 0)over its buffer (measured 72 B)on construction
needs RTTInoyesyes, for dynamic_cast
conversions on accessnone — exact typenone — exact typeimplicit to base
alternatives share operationsno — visit per typenoyes — that is the point
empty statemonostate, or use optionalnatural (has_value())null pointer

10. What to Actually Do

  • Write the type set down. If you can enumerate the alternatives, std::variant is the tool.
  • Write the visitor as an overload set when exhaustiveness matters. Measured: 4 errors against 1 warning.
  • Paste the overloaded helper once per codebase and use it everywhere.
  • Turn on -Werror=return-type at minimum, so the if constexpr form's warning cannot be ignored.
  • Use get_if when a mismatch is expected, get when it is a bug, holds_alternative to ask.
  • Put std::monostate first when the first alternative has no cheap default.
  • Prefer std::optional to variant<monostate, T> for exactly two states.
  • Give alternatives noexcept moves so a variant cannot become valueless by exception.
  • Reach for a virtual interface when the alternatives share operations rather than just storage.
  • Use std::any only for a genuinely open set — plugins, configured property bags, an ABI you do not control.
  • Do not use std::any where RTTI is disabled. It will not work.

11. Mistakes Worth Making Once

An if constexpr visitor missing a case. Measured: one warning, zero errors, and undefined behaviour for that alternative at run time.

Adding a fourth alternative and not revisiting the visitors. The overload-set ones break loudly; the if constexpr ones warn.

std::any where the type set was known. You wrote one line fewer and gave up all the checking.

any_cast<long> on an any holding an int. Exact match only; it throws.

any_cast<Base> on an any holding a Derived. No polymorphism; it throws.

Using a per-container allocator to measure variant or any allocations. They use global operator new — Chapter 20.9 §3's trap, which I repeated here.

Assuming a variant can be empty. It always holds something; monostate is the explicit empty alternative.

A variant whose largest alternative is huge. Every instance pays for it, since there is no allocation.

12. Interview Relevance

The frozen curriculum marks this chapter High, and the interesting question is the comparison rather than either API.

"When would you use variant over any?" Whenever the set of types is known, which is nearly always — because a closed set is checkable and an open one is not. The strong answer gives the measured escalation: 4 errors, 1 warning, or nothing.

"Does std::visit guarantee you handled every case?" With an overload-set visitor, yes. With an if constexpr chain, no — measured as one warning. That distinction is unusual to know and lands well.

"How big is a variant?" The largest alternative plus a discriminant, padded — and it never heap-allocates, which is a standard guarantee.

"What happens on a wrong access?" std::bad_variant_access from get, nullptr from get_if. Contrast with std::optional's *, which is undefined behaviour.

"When is any right?" A genuinely open type set — plugins, configured data, an ABI boundary. And it needs RTTI.

"variant or a virtual interface?" A closed set with different operations gets a variant; an open set with shared operations gets the interface. That framing is the answer.

13. What You Should Be Able to Say Now

  • That the single difference is whether the type set is written down, and that everything else follows.
  • That a variant is the largest alternative plus a tag — measured 24 — and never allocates, by guarantee.
  • That std::any is a fixed 32 bytes and allocated 72 for a string past its 16-byte buffer, and needs RTTI.
  • That a wrong get throws bad_variant_access and get_if returns nullptr — both safe.
  • That exhaustiveness is checked only with an overload-set visitor: measured 4 errors, against 1 warning for if constexpr and nothing for any.
  • That the compile-time guarantee is the product of two choices — closed set and overload-set visitor.
  • That a missing if constexpr branch is undefined behaviour at run time for that alternative.
  • The three-way division between variant, a virtual interface, and any.

14. Exercises

Exercise 1 — the escalation. Reproduce §4's three formulations and record errors, warnings and run-time behaviour for each. Then add a fourth alternative to the variant and see which visitors break.

Exercise 2 — the helper. Write the overloaded idiom from scratch, explain the using Ts::operator()...; line, and show that removing one lambda is an error.

Exercise 3 — the costs. Measure sizeof for variants whose largest alternative is 1, 8, 64 and 4096 bytes. Then measure std::any holding each and record the allocations with a global operator new override.

Exercise 4 — safe access. Write the same dispatch with holds_alternative, get, get_if and visit. State which you would ship and why.

Exercise 5 — the wrong tool. Model a register field with a virtual interface instead of a variant. Compare the sizes, the dispatch cost and what happens when a new field shape is added.

Exercise 6 — monostate. Build variant<monostate, A, B> and optional<variant<A, B>>. Compare sizes and the code at the use site, and say which you prefer.

Exercise 7 — valueless. Construct a variant whose alternative has a throwing move, force the throw during assignment, and show valueless_by_exception() becoming true.

15. Summary

  • std::variant has a closed type set in its declaration; std::any has an open one. Everything else follows from that.
  • Measured: sizeof(variant<Bits,Enum,Raw>) = 24 — largest alternative plus a tag, padded — and 0 global operator new calls, which the standard guarantees.
  • Measured: sizeof(std::any) = 32 fixed, holding a 16-byte type inline and allocating 72 bytes for a 64-char string. It also requires RTTI.
  • Measured: a wrong std::get throws bad_variant_access; get_if returns nullptr — both safe, unlike std::optional's *.
  • Measured escalation for ONE missing case: overload-set visitor 4 errors / 0 warnings; if constexpr visitor 0 errors / 1 warning (-Wreturn-type); std::any 0 / 0, throwing bad_any_cast at run time.
  • So the compile-time guarantee is the product of two choices — a closed set and an overload-set visitor — and the if constexpr form most people write gives only a warning.
  • A missing if constexpr branch is undefined behaviour for that alternative at run time.
  • std::monostate is the explicit empty alternative; prefer std::optional when there are only two states.
  • Use any only for a genuinely open set — plugins, configured property bags, an ABI you do not control.
  • Use a virtual interface when the alternatives share operations rather than just storage.

Five chapters have each added something a type can say about itself — a name, a compile-time value, a queried type, several values at once, an absent value, a choice among several. Chapter 21.7 takes the one that is purely about prevention: giving two quantities that happen to share a representation two different types, so that passing one where the other was expected stops compiling. Chapter 21.1 §6 measured that an alias does not do this, and the chapter measures both halves of what a real strong type delivers — the four distinct compile errors it produces, and the generated assembly, which comes out byte-identical to the raw type it wraps.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.