Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 5 – Arrays, vectors and aggregate types

std::optional, std::variant and std::any

Return maybe-a-value with std::optional, hold one of several types with std::variant and std::visit, and see what std::any costs on two compilers.

  • Intermediate
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Represent a value that may be missing with std::optional
  • Model one of several types with std::variant and handle each one with std::visit
  • Explain what std::any gives up and when it is still the right tool
  • Choose between std::optional, std::variant and std::any for a design

Before you start

What you’ll learn · Before you start

What you will learn

  • Represent a value that may be missing with std::optional
  • Model one of several types with std::variant and handle each one with std::visit
  • Explain what std::any gives up and when it is still the right tool
  • Choose between std::optional, std::variant and std::any for a design

Before you start

On this page

Three types that the standard library gained in C++17 describe values more precisely than a plain type can. std::optional<T> holds a T or nothing: the result of a lookup that may find nothing, or of a parse that may fail. std::variant<A, B, C> holds exactly one value whose type is one of a fixed list, and std::visit runs the code you wrote for whichever type it is, with the compiler checking that you handled them all. std::any holds one value of any copyable type, and checks the type only when you read it back. Use optional instead of made-up values such as -1 or an empty string, and variant instead of a type code next to a union. any is rarely the right choice, for reasons this lesson measures.

Every example runs with Apple clang 21 (libc++) and GCC 16 (libstdc++), including the sizes of all three types, which differ between the two libraries.

std::optional: a value or nothing

A function that may have no answer can say so in its return type:

config_lookup.cpp C++ · config_lookup.cpp
// A lookup that may find nothing returns std::optional instead of a made-up value.
#include <map>
#include <optional>
#include <print>
#include <string>

using Config = std::map<std::string, std::string>;

std::optional<std::string> lookup(const Config& config, const std::string& key) {
    if (auto it = config.find(key); it != config.end()) return it->second;
    return std::nullopt;   // nothing found, and the type says so
}

int main() {
    Config config{{"host", "localhost"}, {"port", "8080"}};

    if (auto host = lookup(config, "host")) {           // true when the optional holds a value
        std::println("host: {} ({} characters)", *host, host->size());
    }
    std::println("user: {}", lookup(config, "user").value_or("guest"));
    std::println("has a timeout: {}", lookup(config, "timeout").has_value());

    try {
        std::println("{}", lookup(config, "user").value());   // value() checks first
    } catch (const std::bad_optional_access& e) {
        std::println("value() on an empty optional threw: {}", e.what());
    }
}
A lookup that may find nothing Python · config_lookup_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra config_lookup.cpp -o config_lookup && ./config_lookup
g++-16 -std=c++23 -Wall -Wextra config_lookup.cpp -o config_lookup-gcc && ./config_lookup-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra config_lookup.cpp -o config_lookup && ./config_lookup
host: localhost (9 characters)
user: guest
has a timeout: false
value() on an empty optional threw: bad_optional_access
$ g++-16 -std=c++23 -Wall -Wextra config_lookup.cpp -o config_lookup-gcc && ./config_lookup-gcc
host: localhost (9 characters)
user: guest
has a timeout: false
value() on an empty optional threw: bad_optional_access

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 config_lookup_session.py

return std::nullopt; makes an empty optional, and return it->second; turns the string found into an optional that holds it. The caller then has several ways to read it:

  • if (auto host = lookup(...)) tests and names it in one step: an optional converts to true when it holds a value.
  • *host and host->size() read the value without checking that there is one.
  • value_or("guest") gives the value, or the fallback when the optional is empty.
  • has_value() asks, and value() checks: on an empty optional it throws std::bad_optional_access.

The point is that the empty case cannot be mistaken for a real value. A function that returns -1 for “not found” has to hope every caller remembers, and breaks the day -1 becomes a valid answer; std::optional<int> makes the question part of the type. The value lives inside the optional object itself, never on the heap: a std::optional<int> is 8 bytes with both libraries, the int and a flag that says whether it is there.

Chaining steps with and_then, transform and or_else

C++23 adds three member functions (paper P0798R8) that join steps which can each come back empty, without an if for every step:

port_chain.cpp C++ · port_chain.cpp
// C++23: and_then, transform and or_else chain steps that can each come back empty.
#include <charconv>
#include <map>
#include <optional>
#include <print>
#include <string>
#include <string_view>

using Config = std::map<std::string, std::string>;

std::optional<std::string> lookup(const Config& config, const std::string& key) {
    if (auto it = config.find(key); it != config.end()) return it->second;
    return std::nullopt;
}

// "8080" gives 8080; "eighty", "80x" and "" give nothing.
std::optional<int> parse_int(std::string_view text) {
    int value = 0;
    auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
    if (error != std::errc{} || end != text.data() + text.size()) return std::nullopt;
    return value;
}

std::string port_text(const Config& config) {
    return lookup(config, "port")
        .or_else([&] { return lookup(config, "backup_port"); })   // a second place to look
        .and_then(parse_int)                                       // a step that may fail
        .transform([](int port) { return "port " + std::to_string(port); })   // a step that cannot
        .value_or("no usable port");
}

int main() {
    std::println("{}", port_text({{"port", "8080"}}));
    std::println("{}", port_text({{"backup_port", "9090"}}));
    std::println("{}", port_text({{"port", "eighty"}}));
    std::println("{}", port_text({}));
}
Four configurations through one chain Python · port_chain_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra port_chain.cpp -o port_chain && ./port_chain
g++-16 -std=c++23 -Wall -Wextra port_chain.cpp -o port_chain-gcc && ./port_chain-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra port_chain.cpp -o port_chain && ./port_chain
port 8080
port 9090
no usable port
no usable port
$ g++-16 -std=c++23 -Wall -Wextra port_chain.cpp -o port_chain-gcc && ./port_chain-gcc
port 8080
port 9090
no usable port
no usable port

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 port_chain_session.py

  • or_else(f) runs f only when the optional is empty, here to look for backup_port instead; f returns another optional.
  • and_then(f) runs f on the value when there is one; f itself returns an optional, as parse_int does when the text is not a number.
  • transform(f) runs f on the value and wraps its plain result in an optional, for steps that cannot fail.

An empty optional passes through every later step untouched, so value_or at the end supplies one fallback for every way the chain can fail. parse_int uses std::from_chars, which reads a number without exceptions or locale rules and reports where it stopped, so "80x" is rejected rather than read as 80.

Reading an empty optional with *

*port on an empty optional is undefined behaviour: no check happens, and the program may print a meaningless number, crash or appear to work. Both standard libraries can be asked to check:

empty_deref.cpp C++ · empty_deref.cpp
// *port on an empty optional is undefined behaviour: nothing checks it unless the standard library is asked to.
#include <optional>
#include <print>

int main() {
    std::optional<int> port;   // empty
    int p = *port;             // reads a value that is not there
    std::println("port {}", p);
}
The same mistake with each library's checks on Python · empty_deref_session.py
import signal
import subprocess

from session import run

# Optimised builds with the checks of each standard library turned on: libc++'s debug hardening mode and
# libstdc++'s assertions.
run("""
clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG empty_deref.cpp -o empty_deref
g++-16 -std=c++23 -O2 -D_GLIBCXX_ASSERTIONS empty_deref.cpp -o empty_deref-gcc
""")

# Each program is started directly, so that the report names the signal that stopped it.
for program in ["./empty_deref", "./empty_deref-gcc"]:
    print(f"$ {program}")
    result = subprocess.run([program], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    print(result.stdout, end="")
    if result.returncode < 0:
        print(f"[stopped by {signal.Signals(-result.returncode).name}]")
    else:
        print(f"[exit status {result.returncode}]")

Output

$ clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG empty_deref.cpp -o empty_deref
$ g++-16 -std=c++23 -O2 -D_GLIBCXX_ASSERTIONS empty_deref.cpp -o empty_deref-gcc
$ ./empty_deref
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/optional:1139: libc++ Hardening assertion this->has_value() failed: optional operator* called on a disengaged value
[stopped by SIGABRT]
$ ./empty_deref-gcc
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/optional:1251: constexpr _Tp& std::optional<_Tp>::operator*() & [with _Tp = int]: Assertion 'this->_M_is_engaged()' failed.
[stopped by SIGABRT]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 empty_deref_session.py

With libc++’s debug hardening mode and libstdc++’s assertions, both optimised programs stop at the bad read with a message that names the check, instead of carrying on with a wrong value. GCC 16 turns its assertions on by itself in builds without optimisation and leaves them out from -O1 on, so define _GLIBCXX_ASSERTIONS to keep them. libc++’s debug mode is meant for tests and development; its fast mode, which its documentation recommends for production builds, also stops the program but without a message. A later lesson on sanitizers and hardened builds covers these options in full.

std::variant: exactly one of several types

A std::variant<Circle, Rectangle, Triangle> always holds one of its three alternatives, and only one:

shapes.cpp C++ · shapes.cpp
// A std::variant holds exactly one of its alternatives; std::visit calls the code written for that one.
#include <numbers>
#include <print>
#include <variant>
#include <vector>

struct Circle { double radius = 0; };
struct Rectangle { double width = 0, height = 0; };
struct Triangle { double base = 0, height = 0; };
using Shape = std::variant<Circle, Rectangle, Triangle>;

// overloaded{f, g, h} is one object with every lambda's call operator; visit picks the one that fits.
template <class... Fs>
struct overloaded : Fs... {
    using Fs::operator()...;
};

double area(const Shape& shape) {
    return std::visit(overloaded{
        [](const Circle& c) { return std::numbers::pi * c.radius * c.radius; },
        [](const Rectangle& r) { return r.width * r.height; },
        [](const Triangle& t) { return 0.5 * t.base * t.height; },
    }, shape);
}

int main() {
    std::vector<Shape> shapes{Circle{1.0}, Rectangle{3, 4}, Triangle{6, 2}};
    for (const Shape& s : shapes) std::println("alternative {}: area {:.2f}", s.index(), area(s));

    Shape s;                      // holds the first alternative, value-initialised
    std::println("a new Shape holds alternative {}", s.index());
    s = Triangle{4, 5};           // assigning another type switches the alternative
    std::println("holds a Triangle: {}", std::holds_alternative<Triangle>(s));
}
Areas through std::visit Python · shapes_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes
g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes
alternative 0: area 3.14
alternative 1: area 12.00
alternative 2: area 6.00
a new Shape holds alternative 0
holds a Triangle: true
$ g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc
alternative 0: area 3.14
alternative 1: area 12.00
alternative 2: area 6.00
a new Shape holds alternative 0
holds a Triangle: true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes_session.py

index() says which alternative the variant holds, counting from 0, and std::holds_alternative<Triangle>(s) asks about one by type. A default-constructed variant holds its first alternative, value-initialised, which is why the new Shape reports alternative 0, a circle of radius 0. Assigning a value of another alternative destroys the old one and switches. The variant needs room for its largest alternative plus the index, and the variant itself never allocates memory.

std::visit(visitor, shape) calls the visitor with whatever the variant holds. The visitor here is built from three lambdas, functions written in place as [](const Circle& c) { … }. The small overloaded template combines them: it inherits from every lambda and brings all their call operators into one overload set, so std::visit picks the lambda whose parameter matches. It is an aggregate, so since C++20 overloaded{…} deduces its template arguments from the lambdas directly (papers P1816R0 and P2082R1); C++17 code adds a deduction guide line for that.

The real benefit appears when a lambda is missing:

forgot_triangle.cpp C++ · forgot_triangle.cpp
// The visitor below has no lambda for Triangle, so neither compiler accepts the call to std::visit.
#include <numbers>
#include <variant>

struct Circle { double radius = 0; };
struct Rectangle { double width = 0, height = 0; };
struct Triangle { double base = 0, height = 0; };
using Shape = std::variant<Circle, Rectangle, Triangle>;

template <class... Fs>
struct overloaded : Fs... {
    using Fs::operator()...;
};

double area(const Shape& shape) {
    return std::visit(overloaded{
        [](const Circle& c) { return std::numbers::pi * c.radius * c.radius; },
        [](const Rectangle& r) { return r.width * r.height; },
    }, shape);
}

int main() {
    return area(Circle{1.0}) > 3 ? 0 : 1;
}
A visitor without a Triangle case Python · forgot_triangle_session.py
from session import run

# The first error line of each compiler's report (the full reports run to dozens of lines).
run("""
clang++ -std=c++23 -fsyntax-only forgot_triangle.cpp 2>&1 | grep -m1 "error:"
g++-16 -std=c++23 -fsyntax-only forgot_triangle.cpp 2>&1 | grep -m1 "error:"
""")

Output

$ clang++ -std=c++23 -fsyntax-only forgot_triangle.cpp 2>&1 | grep -m1 "error:"
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/variant:612:19: error: static assertion failed due to requirement 'is_invocable_v<overloaded<(lambda at forgot_triangle.cpp:17:9), (lambda at forgot_triangle.cpp:18:9)>, const Triangle &>': `std::visit` requires the visitor to be exhaustive.
$ g++-16 -std=c++23 -fsyntax-only forgot_triangle.cpp 2>&1 | grep -m1 "error:"
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/variant:1145:14: error: no type named 'type' in 'struct std::invoke_result<overloaded<area(const Shape&)::<lambda(const Circle&)>, area(const Shape&)::<lambda(const Rectangle&)> >, const Triangle&>'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forgot_triangle_session.py

Neither compiler builds a visit that leaves an alternative unhandled. libc++ says so in plain words, “std::visit requires the visitor to be exhaustive”, and libstdc++ names the alternative that has no match, const Triangle&. Add a fourth shape to Shape later, and every std::visit that does not handle it fails to compile until you write its case. A chain of if statements on a type code gives no such guarantee.

Reading one alternative: std::get and std::get_if

wrong_alternative.cpp C++ · wrong_alternative.cpp
// std::get on the wrong alternative throws; std::get_if returns a null pointer instead.
#include <print>
#include <string>
#include <variant>

int main() {
    std::variant<int, std::string> token = std::string("total");

    if (auto* text = std::get_if<std::string>(&token)) std::println("a name: {}", *text);
    if (auto* number = std::get_if<int>(&token)) std::println("a number: {}", *number);
    std::println("get_if<int> gave a null pointer: {}", std::get_if<int>(&token) == nullptr);

    try {
        std::println("{}", std::get<int>(token));
    } catch (const std::bad_variant_access& e) {
        std::println("std::get<int> threw: {}", e.what());
    }
}
The wrong alternative, read two ways Python · wrong_alternative_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra wrong_alternative.cpp -o wrong_alternative && ./wrong_alternative
g++-16 -std=c++23 -Wall -Wextra wrong_alternative.cpp -o wrong_alternative-gcc && ./wrong_alternative-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra wrong_alternative.cpp -o wrong_alternative && ./wrong_alternative
a name: total
get_if<int> gave a null pointer: true
std::get<int> threw: bad_variant_access
$ g++-16 -std=c++23 -Wall -Wextra wrong_alternative.cpp -o wrong_alternative-gcc && ./wrong_alternative-gcc
a name: total
get_if<int> gave a null pointer: true
std::get<int> threw: bad_variant_access

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrong_alternative_session.py

std::get<int>(token) checks the alternative and throws std::bad_variant_access when the variant holds something else. std::get_if<int>(&token) takes a pointer to the variant and returns a pointer to the value, or a null pointer when the variant holds another alternative, which makes it the natural partner of an if. A type that is not one of the alternatives, such as std::get<double>(token) here, does not compile at all. One rare state remains: if assigning a new alternative throws partway through, the variant can end up holding nothing, which valueless_by_exception() reports and which makes std::visit throw std::bad_variant_access.

std::any: any type, checked only when you read it

std::any holds one value of any copy-constructible type, or nothing, and can be given a value of a different type at any moment:

any_basics.cpp C++ · any_basics.cpp
// std::any holds one value of any copyable type; the type is checked only when you read it.
#include <any>
#include <print>
#include <string>
#include <typeinfo>

int main() {
    std::any slot = 42;
    std::println("holds an int: {}", slot.type() == typeid(int));
    std::println("value: {}", std::any_cast<int>(slot));

    slot = std::string("forty-two");   // any copyable type can replace it
    if (auto* text = std::any_cast<std::string>(&slot)) std::println("now a string: {}", *text);
    if (auto* number = std::any_cast<int>(&slot)) std::println("still an int: {}", *number);

    try {
        std::println("{}", std::any_cast<int>(slot));
    } catch (const std::bad_any_cast& e) {
        std::println("any_cast<int> threw: {}", e.what());
    }

    slot.reset();
    std::println("empty after reset: {}", !slot.has_value());
}
One slot, two types Python · any_basics_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra any_basics.cpp -o any_basics && ./any_basics
g++-16 -std=c++23 -Wall -Wextra any_basics.cpp -o any_basics-gcc && ./any_basics-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra any_basics.cpp -o any_basics && ./any_basics
holds an int: true
value: 42
now a string: forty-two
any_cast<int> threw: bad any cast
empty after reset: true
$ g++-16 -std=c++23 -Wall -Wextra any_basics.cpp -o any_basics-gcc && ./any_basics-gcc
holds an int: true
value: 42
now a string: forty-two
any_cast<int> threw: bad any cast
empty after reset: true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 any_basics_session.py

std::any_cast<int>(slot) returns the value when the any holds exactly an int; even std::any_cast<long> on an int throws std::bad_any_cast, because no conversion is tried. The pointer form, std::any_cast<int>(&slot), returns a null pointer instead of throwing, and type() reports what is inside as a std::type_info. Move-only types such as std::unique_ptr cannot be stored at all, because an any must be copyable.

Where the value is kept is up to the library. The standard asks implementations to avoid heap memory for small values, without saying how small, so measure.cpp checks whether a value lies inside the any object:

measure.cpp C++ · measure.cpp
// Sizes of optional, variant and any, and which values std::any keeps inside itself.
#include <any>
#include <array>
#include <cstdint>
#include <optional>
#include <print>
#include <string>
#include <variant>

// True when the value lies inside the std::any object, false when the any points to memory elsewhere.
template <std::size_t N>
bool stored_inside() {
    std::any a = std::array<char, N>{};
    auto start = reinterpret_cast<std::uintptr_t>(&a);
    auto value = reinterpret_cast<std::uintptr_t>(std::any_cast<std::array<char, N>>(&a));
    return value >= start && value < start + sizeof(a);
}

int main() {
    std::println("optional<int>                {:>2} bytes", sizeof(std::optional<int>));
    std::println("optional<std::string>        {:>2} bytes", sizeof(std::optional<std::string>));
    std::println("variant<int, double>         {:>2} bytes", sizeof(std::variant<int, double>));
    std::println("variant<int, double, string> {:>2} bytes", sizeof(std::variant<int, double, std::string>));
    std::println("any                          {:>2} bytes", sizeof(std::any));
    std::println("inside the any: 8 bytes {}, 16 bytes {}, 24 bytes {}, 32 bytes {}",
                 stored_inside<8>(), stored_inside<16>(), stored_inside<24>(), stored_inside<32>());
}
Sizes and where std::any keeps a value Python · measure_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra measure.cpp -o measure && ./measure
g++-16 -std=c++23 -Wall -Wextra measure.cpp -o measure-gcc && ./measure-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra measure.cpp -o measure && ./measure
optional<int>                 8 bytes
optional<std::string>        32 bytes
variant<int, double>         16 bytes
variant<int, double, string> 32 bytes
any                          32 bytes
inside the any: 8 bytes true, 16 bytes true, 24 bytes true, 32 bytes false
$ g++-16 -std=c++23 -Wall -Wextra measure.cpp -o measure-gcc && ./measure-gcc
optional<int>                 8 bytes
optional<std::string>        40 bytes
variant<int, double>         16 bytes
variant<int, double, string> 40 bytes
any                          16 bytes
inside the any: 8 bytes true, 16 bytes false, 24 bytes false, 32 bytes false

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure_session.py

optional holds a value and a flag; variant holds room for its largest alternative and an index; any keeps small values inside.optional<int>8 bytes with both librariesvariant<int, double, string>32 bytes with libc++, 40 with libstdc++any32 bytes with libc++, 16 with libstdc++a larger value,allocated on the heapthe int,if there is onea bool:holds a value?room for the largestalternative, the stringthe index of thealternative helda small value, or apointer to a larger onea pointer to code thatcopies and destroys itvalues over 24 bytes (libc++)or over 8 bytes (libstdc++)

What std::optional, std::variant and std::any hold, with the sizes measured on both libraries

Text description of the diagram

The diagram shows the parts of three objects, with the sizes that measure.cpp printed on 64-bit arm64 with libc++ (Apple clang 21) and libstdc++ (GCC 16).

  1. std::optional<int> is 8 bytes with both libraries: room for the int, and a bool that says whether the optional holds a value.
  2. std::variant<int, double, std::string> is 32 bytes with libc++ and 40 with libstdc++: room for its largest alternative, the std::string, and an index that says which alternative it holds now.
  3. std::any is 32 bytes with libc++ and 16 with libstdc++: a buffer that holds a small value, or a pointer to a larger one, and a pointer to the code that copies and destroys whatever it holds.
  4. A value too large for the buffer is allocated on the heap. In the measurements, libc++ kept values of up to 24 bytes inside the any, and libstdc++ values of up to 8 bytes.

On this arm64 Mac, libc++’s any is 32 bytes and keeps values of up to 24 bytes inside itself; libstdc++’s is 16 bytes and keeps only 8. A std::string, 32 bytes in libstdc++, therefore costs an extra allocation every time it is put into an any built with GCC, while with Apple clang it fits. The same code allocates on one compiler and not the other.

That variability is one reason std::any is rarely the best tool. The bigger ones are in the code: the compiler can no longer check anything, every read names a type and may throw, and a reader cannot tell which types to expect without searching for every place a value is stored. When the set of types is known, a std::variant says it in the type and the compiler enforces it; when only the operations are known, a base class with virtual functions, covered in a later module, keeps the types open and the calls checked. std::any fits where a library has to carry a value for its user without knowing the type, such as a “user data” slot that an event library passes back, unopened, to the code that put it there.

Choosing between them

optional, variant and any side by side
Criterion optionalvariantany
Holds A T, or nothingOne type of a fixed listAny copyable value, or nothing
Checks At compile timeAt compile timeOnly when read
Misread value() throwsget throws; get_if gives nullptrany_cast throws or gives nullptr
Heap NeverNeverFor larger values
When to choose A lookup or a parse that can failOne of a known set: shapes, tokens, commandsUser data a library cannot know

Some combinations are common: std::optional<Command>, where Command is a variant, is “a command or nothing”, the shape of the exercise below. For failures that need a reason as well as an empty result, C++23 also has std::expected, covered in the error-handling module.

JSON to Code Generator Paste several JSON samples: a field missing from one of them becomes a std::optional member of the generated C++ struct.

C++26: optional references and an optional you can loop over

C++26, whose final draft is complete but not yet published as an ISO standard, extends std::optional twice:

  • std::optional<T&> (paper P2988R12) holds a reference or nothing. Before C++26 the usual stand-ins were a pointer, which can be confused with an array or an owner, or std::optional<std::reference_wrapper<T>>.
  • An optional is a range of zero or one elements (paper P3168R2), so for (int s : score) runs its body once when there is a value and never when there is none.
cpp26_optional.cpp C++ · cpp26_optional.cpp
// C++26 previews: an optional reference, and an optional used as a range of zero or one values.
#include <optional>
#include <print>
#include <string>
#include <vector>

// The first name with the given initial, as a reference into the vector, or nothing.
std::optional<std::string&> find_name(std::vector<std::string>& names, char initial) {
    for (std::string& n : names) {
        if (!n.empty() && n[0] == initial) return n;
    }
    return std::nullopt;
}

int main() {
    std::vector<std::string> names{"Asha", "Dev", "Meera"};
    if (auto dev = find_name(names, 'D')) *dev += " (captain)";   // changes the element itself
    std::println("{}", names[1]);

    std::optional<int> score{91};
    for (int s : score) std::println("loop body ran with {}", s);   // once
    for (int s : std::optional<int>{}) std::println("never {}", s);   // zero times
}
Both features with -std=c++2c Python · cpp26_optional_session.py
from session import run

# -std=c++2c selects the C++26 draft. Apple clang's libc++ offers the loop over an optional only with
# -fexperimental-library, so the first command shows its first error, then the build with that option.
run("""
clang++ -std=c++2c -fsyntax-only cpp26_optional.cpp 2>&1 | grep -m1 "error:"
clang++ -std=c++2c -fexperimental-library cpp26_optional.cpp -o cpp26_optional && ./cpp26_optional
g++-16 -std=c++2c cpp26_optional.cpp -o cpp26_optional-gcc && ./cpp26_optional-gcc
""")

Output

$ clang++ -std=c++2c -fsyntax-only cpp26_optional.cpp 2>&1 | grep -m1 "error:"
cpp26_optional.cpp:21:16: error: invalid range expression of type 'std::optional<int>'; no viable 'begin' function available
$ clang++ -std=c++2c -fexperimental-library cpp26_optional.cpp -o cpp26_optional && ./cpp26_optional
Dev (captain)
loop body ran with 91
$ g++-16 -std=c++2c cpp26_optional.cpp -o cpp26_optional-gcc && ./cpp26_optional-gcc
Dev (captain)
loop body ran with 91

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cpp26_optional_session.py

Both compilers accept std::optional<std::string&> in C++26 mode, and the name found is changed inside the vector itself. GCC 16 also accepts the loop over an optional, while Apple clang’s libc++ provides it only with -fexperimental-library, as the first command shows, even though the libc++ status page lists the paper as done.

Your turn: a tiny command interpreter

commands.h defines three commands as structs, Add, Remove and List, and Command, a variant of the three. Complete its two functions:

  1. parse(line) returns the command for "add 5", "remove 2" or "list", and an empty optional for anything else, such as "add", "add five" or "jump 3". std::from_chars turns the text after the space into a number.
  2. run(lines) keeps a list of numbers that starts empty: add appends a value, remove deletes every copy of it, and list writes the numbers as [5, 3]. A line that does not parse writes error: followed by the line. Join the outputs with a newline.
The starter code and its sample tests

C header · commands.h

// Starter code for "Your turn": parse one command, then run a list of commands.
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <variant>
#include <vector>

struct Add { int value = 0; bool operator==(const Add&) const = default; };
struct Remove { int value = 0; bool operator==(const Remove&) const = default; };
struct List { bool operator==(const List&) const = default; };
using Command = std::variant<Add, Remove, List>;

template <class... Fs>
struct overloaded : Fs... {
    using Fs::operator()...;
};

// "add 5" is Add{5}, "remove 2" is Remove{2}, "list" is List{}. Anything else is not a command.
inline std::optional<Command> parse(std::string_view line) {
    // TODO
    (void)line;
    return std::nullopt;
}

// Runs the lines on a list of numbers that starts empty: add appends the value, remove deletes every copy of it,
// and list writes the numbers as [5, 3]. A line that is not a command writes "error: " and the line.
// The outputs are joined with "\n".
inline std::string run(const std::vector<std::string>& lines) {
    // TODO: parse each line, then std::visit the command with overloaded lambdas
    (void)lines;
    return "";
}

C++ · commands_tests.cpp

// Sample tests for commands.h. Build: clang++ -std=c++23 -Wall -Wextra commands_tests.cpp -o commands_tests
#include <print>
#include "commands.h"

int checks = 0;
int failures = 0;

void check(bool ok, const char* what) {
    ++checks;
    if (!ok) {
        ++failures;
        std::println("FAIL {}", what);
    }
}

int main() {
    check(parse("add 5") == Command{Add{5}}, "add 5 is Add{5}");
    check(parse("add -4") == Command{Add{-4}}, "add -4 is Add{-4}");
    check(parse("remove 2") == Command{Remove{2}}, "remove 2 is Remove{2}");
    check(parse("list") == Command{List{}}, "list is List{}");
    check(!parse("add"), "add without a number is not a command");
    check(!parse("add five"), "add five is not a command");
    check(!parse("jump 3"), "jump is not a command");

    check(run({"add 5", "add 3", "add 5", "list", "remove 5", "list"}) == "[5, 3, 5]\n[3]", "add, list and remove");
    check(run({"list"}) == "[]", "an empty list");
    check(run({"add 1", "pop", "list"}) == "error: pop\n[1]", "a line that is not a command");

    std::println("{} of {} checks passed", checks - failures, checks);
    return failures == 0 ? 0 : 1;
}
The sample tests against the starter code Python · commands_session.py
from session import run

# The sample tests run against the starter code, before any TODO is done.
run("""
clang++ -std=c++23 -Wall -Wextra commands_tests.cpp -o commands_tests && ./commands_tests
g++-16 -std=c++23 -Wall -Wextra commands_tests.cpp -o commands_tests-gcc && ./commands_tests-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra commands_tests.cpp -o commands_tests && ./commands_tests
FAIL add 5 is Add{5}
FAIL add -4 is Add{-4}
FAIL remove 2 is Remove{2}
FAIL list is List{}
FAIL add, list and remove
FAIL an empty list
FAIL a line that is not a command
3 of 10 checks passed
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra commands_tests.cpp -o commands_tests-gcc && ./commands_tests-gcc
FAIL add 5 is Add{5}
FAIL add -4 is Add{-4}
FAIL remove 2 is Remove{2}
FAIL list is List{}
FAIL add, list and remove
FAIL an empty list
FAIL a line that is not a command
3 of 10 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 commands_session.py

The three checks that pass already are the ones that expect no command, which an always-empty parse gets right by accident. When everything is done, both compilers print 10 of 10 checks passed. In run, visit each command with an overloaded set of three lambdas; std::erase(numbers, value) removes every copy of a value from a vector, and in C++23 std::format("{}", numbers) writes a vector as [5, 3]. Then add a fourth command, clear, to the variant and watch the compiler point at the visit that does not handle it yet.

Interview questions

Fresher · What happens when you call std::get<int> on a std::variant that holds a std::string, and how does std::get_if differ?

std::get<int> checks which alternative the variant holds and throws std::bad_variant_access, because it holds a std::string, not an int; nothing undefined happens. std::get_if<int> takes a pointer to the variant and returns a pointer to the int, or a null pointer when the variant holds something else, so it suits code that tests and reads in one step: if (auto* n = std::get_if<int>(&v)). Both check at run time. A good answer adds two points. std::get with a type that is not an alternative at all, such as std::get<double> on a std::variant<int, std::string>, does not compile. And std::visit with a case for every alternative is often better than either, because the compiler then rejects any visit that forgets one.

Key takeaways

  • std::optional<T> is a T or nothing. Test it with if, read it with * only after testing, or use value_or; value() throws on an empty optional, while * is undefined behaviour there unless a hardened library catches it.
  • C++23’s and_then, transform and or_else chain steps that can each fail, with one fallback at the end.
  • std::variant holds exactly one of its alternatives and never allocates. std::visit with overloaded lambdas handles each one, and both compilers reject a visit that misses an alternative.
  • std::get throws on the wrong alternative and std::get_if returns a null pointer.
  • std::any holds any copyable type, checks it only when read and may allocate: libc++ kept values of up to 24 bytes inside, libstdc++ only 8. Prefer variant when the types are known.
  • C++26 adds std::optional<T&> and loops over an optional; with Apple clang 21 the loop still needs -fexperimental-library.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 std::optional<int> port; is empty. Which of these throws an exception?

    Choose one answer.

    Show the answer to question 1

    Answer: port.value()

    value() checks and throws std::bad_optional_access. *port does not check: on an empty optional it is undefined behaviour unless a hardened standard library stops the program. value_or(80) returns 80, and has_value() returns false.

  2. Question 2 of 6 What does std::variant<int, std::string> v; hold?

    Choose one answer.

    Show the answer to question 2

    Answer: An int with the value 0

    A default-constructed variant holds its first alternative, value-initialised: v.index() is 0 and the int is 0. Put std::monostate first when you want a variant that starts with no real value.

  3. Question 3 of 6 A visitor passed to std::visit has lambdas for Circle and Rectangle but none for Triangle, the third alternative. What happens?

    Choose one answer.

    Show the answer to question 3

    Answer: The call does not compile

    std::visit must be able to call the visitor with every alternative, so both compilers reject the call: libc++ with "std::visit requires the visitor to be exhaustive", libstdc++ by naming the Triangle it cannot match.

  4. Question 4 of 6 A token from a calculator's input is a number, a name or an operator, and nothing else. Which type models it best?

    Choose one answer.

    Show the answer to question 4

    Answer: std::variant<Number, Name, Operator>

    The set of types is known and fixed, and a token is exactly one of them: that is a variant, and std::visit then checks that every kind of token is handled. A tuple would hold all three at once, and std::any gives up the compile-time checks.

  5. Question 5 of 6 How many bytes did std::optional<int> take in the measurements, with each of the two libraries?

    Type a number.

    Show the answer to question 5

    Answer: 8 bytes

    An optional<int> holds the int itself plus a flag that says whether it holds a value, and padding rounds that to 8 bytes with both libc++ and libstdc++. The value never goes to the heap.

  6. Question 6 of 6 std::any a = 42; What does std::any_cast<long>(a) do?

    Choose one answer.

    Show the answer to question 6

    Answer: It throws std::bad_any_cast

    any_cast succeeds only for exactly the type that is stored, here int; it never converts. The pointer form, std::any_cast<long>(&a), would return a null pointer instead of throwing.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.