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.
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:
// 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());
}
} 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 totruewhen it holds a value.*hostandhost->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, andvalue()checks: on an empty optional it throwsstd::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:
// 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({}));
} 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)runsfonly when the optional is empty, here to look forbackup_portinstead;freturns another optional.and_then(f)runsfon the value when there is one;fitself returns an optional, asparse_intdoes when the text is not a number.transform(f)runsfon 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:
// *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);
} 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:
// 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));
} 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:
// 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;
} 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
// 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());
}
} 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:
// 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());
} 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:
// 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>());
} 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
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).
- std::optional<int> is 8 bytes with both libraries: room for the int, and a bool that says whether the optional holds a value.
- 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.
- 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.
- 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
| Criterion | optional | variant | any |
|---|---|---|---|
| Holds | A T, or nothing | One type of a fixed list | Any copyable value, or nothing |
| Checks | At compile time | At compile time | Only when read |
| Misread | value() throws | get throws; get_if gives nullptr | any_cast throws or gives nullptr |
| Heap | Never | Never | For larger values |
| When to choose | A lookup or a parse that can fail | One of a known set: shapes, tokens, commands | User 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.
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, orstd::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.
// 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
} 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:
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_charsturns the text after the space into a number.run(lines)keeps a list of numbers that starts empty:addappends a value,removedeletes every copy of it, andlistwrites the numbers as[5, 3]. A line that does not parse writeserror:followed by the line. Join the outputs with a newline.
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;
} 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 aTor nothing. Test it withif, read it with*only after testing, or usevalue_or;value()throws on an empty optional, while*is undefined behaviour there unless a hardened library catches it.- C++23’s
and_then,transformandor_elsechain steps that can each fail, with one fallback at the end. std::variantholds exactly one of its alternatives and never allocates.std::visitwithoverloadedlambdas handles each one, and both compilers reject a visit that misses an alternative.std::getthrows on the wrong alternative andstd::get_ifreturns a null pointer.std::anyholds any copyable type, checks it only when read and may allocate: libc++ kept values of up to 24 bytes inside, libstdc++ only 8. Prefervariantwhen 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.
References
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- N5051 Editors' Report: Programming Languages, C++ (ISO/IEC JTC1/SC22/WG21)
- P0220R1: Adopt Library Fundamentals V1 TS Components for C++17 (ISO/IEC JTC1/SC22/WG21)
- P0088R3: Variant, a type-safe union for C++17 (ISO/IEC JTC1/SC22/WG21)
- P0798R8: Monadic operations for std::optional (ISO/IEC JTC1/SC22/WG21)
- P1816R0: Wording for class template argument deduction for aggregates (ISO/IEC JTC1/SC22/WG21)
- P2082R1: Fixing CTAD for aggregates (ISO/IEC JTC1/SC22/WG21)
- P2988R12: std::optional<T&> (ISO/IEC JTC1/SC22/WG21)
- P3168R2: Give std::optional range support (ISO/IEC JTC1/SC22/WG21)
- Hardening Modes (libc++ documentation) (LLVM Project)
- The GNU C++ Library Manual: macros (_GLIBCXX_ASSERTIONS) (GNU Project)
- libc++ C++26 Status (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress