C++ Vocabulary Types
- Description: A note on C++ "vocabulary" types —
std::pair,std::tuple, structured bindings,std::optional,std::variant,std::any, andstd::expected(C++23) - My Notion Note ID: K2A-B1-14
- Created: 2019-05-20
- Updated: 2026-02-28
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. Why Vocabulary Types?
- 2.
std::pairandstd::tuple - 3. Structured Bindings (C++17)
- 4.
std::optional<T> - 5.
std::variant<T...> - 6.
std::any - 7.
std::expected<T, E>(C++23) - 8. Decision Guide
1. Why Vocabulary Types?
"Vocabulary types" are standard library types for expressing common patterns — maybe a value, one of several types, value or error, a tuple of values. Using them in API signatures means everyone reading the code agrees on the meaning, instead of inventing ad-hoc conventions (returning -1 for failure, output parameters, magic sentinel values, etc.).
2. std::pair and std::tuple
std::pair<A, B> and std::tuple<Ts...> are heterogeneous fixed-size containers.
#include <utility> // pair
#include <tuple> // tuple
std::pair<std::string, int> kv{"Alice", 30};
kv.first; // "Alice"
kv.second; // 30
std::tuple<int, std::string, double> t{1, "two", 3.0};
std::get<0>(t); // 1
std::get<1>(t); // "two"
std::get<std::string>(t); // "two" (by type, C++14)
auto t2 = std::make_tuple(1, "two", 3.0); // type-deduced
auto p = std::make_pair("a", 1);
std::tuple_size_v<decltype(t)>; // 3
Pairs are the values inside std::map. Use them only when "the two things together" doesn't have a more meaningful name; otherwise prefer a named struct — Person{name, age} reads better than pair<string, int>.
3. Structured Bindings (C++17)
Structured bindings unpack tuples, pairs, arrays, and aggregate structs into named locals.
std::pair<int, std::string> kv{1, "one"};
auto [n, s] = kv; // n = 1, s = "one"
std::map<std::string, int> ages = {/* ... */};
for (const auto& [name, age] : ages) {
std::cout << name << " is " << age;
}
struct Point { int x, y, z; };
Point p{1, 2, 3};
auto [x, y, z] = p; // works on aggregates
// const auto& and auto& are also supported
const auto& [name, age] = *ages.begin(); // no copy
auto& [a, b] = kv; // mutable references — write through
Structured bindings are how you idiomatically iterate maps and consume multi-return functions in modern C++.
4. std::optional<T>
std::optional<T> represents "maybe a T". It either holds a T or is empty (std::nullopt).
#include <optional>
std::optional<int> find_id(const std::string& name) {
if (name == "Alice") return 42;
return std::nullopt; // or just `return {};`
}
auto id = find_id("Alice");
if (id) { // contextual bool: true when set
std::cout << *id; // dereference like a pointer
std::cout << id.value(); // throws std::bad_optional_access if empty
}
int n = id.value_or(0); // 42 or default
id.reset(); // clear
// Monadic operations (C++23)
auto result = id
.transform([](int x) { return x * 2; }) // map: optional<int> -> optional<int>
.or_else([] { return std::optional{0}; }) // alternative if empty
.and_then([](int x) -> std::optional<int> { // chain that may return nullopt
return x > 0 ? std::optional{x} : std::nullopt;
});
Use optional<T> for:
- Return values that may be absent — instead of
-1,nullptr,bool+ output parameter. - Optional members of a struct.
- Lazy initialization — store an empty optional, populate on first use.
Don't use it for "value or error" — use expected<T, E> (or exceptions) for that. optional only tells you "did it work", not why it didn't.
5. std::variant<T...>
std::variant<Ts...> is a type-safe union: holds exactly one value from a fixed list of alternatives, knows which one, and prevents access to the wrong type.
#include <variant>
std::variant<int, std::string, double> v; // initialized to int{}
v = "hello"; // now holds string
v = 3.14; // now holds double
v.index(); // 2 (third alternative)
std::holds_alternative<double>(v); // true
std::get<double>(v); // 3.14 (throws bad_variant_access if wrong)
std::get<2>(v); // 3.14 (by index)
// std::visit applies a callable to whichever alternative is held:
std::visit([](auto&& val) {
std::cout << val; // works because << is defined for all 3
}, v);
// Type-specific handling via a visitor struct:
struct Visitor {
void operator()(int x) { std::cout << "int: " << x; }
void operator()(double x) { std::cout << "double: " << x; }
void operator()(const std::string& s) { std::cout << "str: " << s; }
};
std::visit(Visitor{}, v);
Use variant for:
- Sum types / tagged unions — "this is one of A, B, or C."
- State machines — each state is a different type.
- Heterogeneous error returns —
variant<Success, NetworkError, ParseError>.
variant is type-safe: you cannot read a value of the wrong type without going through get (which throws on mismatch) or holds_alternative.
6. std::any
std::any (C++17) holds a value of any type, recovered with any_cast<T>. It is type-erased; implementations are encouraged to apply a small-object optimization for small, nothrow-move-constructible types (so int, double, and small structs typically avoid heap allocation), but larger or throwing-move types are stored on the heap.
#include <any>
std::any a = 42;
a = std::string("hello");
a = 3.14;
double d = std::any_cast<double>(a); // throws bad_any_cast on type mismatch
double* p = std::any_cast<double>(&a); // pointer overload: returns nullptr on mismatch
any is rarely the right answer. Prefer variant (when the alternatives are known at compile time) or a virtual interface (when you need polymorphism). any is mostly used by frameworks that genuinely don't know the value type — config systems, plugin APIs, scripting bridges.
7. std::expected<T, E> (C++23)
std::expected<T, E> represents "a T on success, or an E on error" — a richer alternative to optional for fallible operations. It's the modern alternative to error codes and exceptions.
#include <expected>
enum class ParseError { Empty, InvalidChar, Overflow };
std::expected<int, ParseError> parse(std::string_view s) {
if (s.empty()) return std::unexpected{ParseError::Empty};
int n = 0;
for (char c : s) {
if (!std::isdigit(c)) return std::unexpected{ParseError::InvalidChar};
n = n * 10 + (c - '0');
}
return n;
}
auto r = parse("42");
if (r) {
std::cout << *r; // dereference like optional
}
if (!r) {
auto err = r.error(); // access the error
}
// Monadic chaining:
auto doubled = parse("21")
.transform([](int x) { return x * 2; })
.or_else([](ParseError) { return std::expected<int, ParseError>{0}; });
Use expected when:
- The function might fail in a small number of well-defined ways.
- Callers should handle the error inline (not propagate via exceptions).
- You need the error reason, not just "failed."
See also: C++ Error Handling § 6.
8. Decision Guide
| Situation | Use |
|---|---|
| Return value or "missing" | std::optional<T> |
| Return value or specific error | std::expected<T, E> (C++23) |
| Multiple possible types from a fixed list | std::variant<T...> |
| Two values of related kinds | struct (or std::pair if names don't matter) |
| Several values, possibly unrelated | std::tuple (or a struct) |
| Type genuinely unknown at compile time | std::any (rare) |
| "Failed but with no info" | bool, or optional |