C++ Vocabulary Types


  • Description: A note on C++ "vocabulary" types — std::pair, std::tuple, structured bindings, std::optional, std::variant, std::any, and std::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?

  • "Vocabulary types" = stdlib types for common patterns — maybe a value, one of several types, value or error, tuple of values.
  • API uses them → everyone agrees on meaning. Avoid ad-hoc conventions: -1 for failure, output params, magic sentinels.

2. std::pair and std::tuple

  • std::pair<A, B> + std::tuple<Ts...> — 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 = values inside std::map.
  • Use pair/tuple only when "the things together" has no meaningful name; otherwise → named struct (Person{name, age} reads better than pair<string, int>).

3. Structured Bindings (C++17)

  • Unpack tuples, pairs, arrays, 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
  • Idiomatic way to iterate maps + consume multi-return functions in modern C++.

4. std::optional<T>

  • std::optional<T> — "maybe a T". Either holds T or 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:

  1. Return values that may be absent — instead of -1, nullptr, bool + output param.
  2. Optional members of a struct.
  3. Lazy init — store empty, populate on first use.
  • Don't use for "value or error" — use expected<T, E>. optional only says "did it work", not why.

5. std::variant<T...>

  • std::variant<Ts...> — type-safe union. Holds exactly one value from a fixed alternative list, knows which, prevents wrong-type access.
#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:

  1. Sum types / tagged unions — "this is one of A, B, or C."
  2. State machines — each state = different type.
  3. Heterogeneous error returnsvariant<Success, NetworkError, ParseError>.
  • Type-safe: can't read wrong type without get (throws on mismatch) or holds_alternative.

6. std::any

  • std::any (C++17) — holds any type. Recovered via any_cast<T>. Type-erased.
  • Impls encouraged to apply small-object optimization for small, nothrow-move types (int, double, small structs typically avoid heap). Larger/throwing-move types → 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
  • Rarely the right answer. Prefer variant (compile-time-known alternatives) or virtual interface (need polymorphism).
  • any mainly for frameworks that genuinely don't know value type — config systems, plugin APIs, scripting bridges.

7. std::expected<T, E> (C++23)

  • std::expected<T, E> — "a T on success, or an E on error". Richer than optional for fallible ops.
  • Modern alternative to error codes + 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:

  1. Function might fail in a small number of well-defined ways.
  2. Callers should handle inline (not propagate via exceptions).
  3. You need error reason, not just "failed."

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