CodeRigor

FREE LESSON

Sum types and value-semantic polymorphism

Model closed sets of alternatives with std::variant and exhaustive visitors, build recursive value types with std::indirect, and get copyable open hierarchies with std::polymorphic instead of unique_ptr plus clone().

Most data has shape: a JSON value is a null, a bool, a number, a string, an array or an object; an expression is a number, a variable, or an operation on sub-expressions. Classic C++ modelled "one of several kinds" with a base class and virtual functions, held through pointers. Modern C++ gives you two better-fitting tools:

  • std::variant — a closed set of alternatives, stored inline, with visitors the compiler checks for exhaustiveness.
  • std::polymorphic (C++26) — an open class hierarchy with value semantics: copy it and you get a deep copy, no clone() boilerplate.

And for recursive data, std::indirect (C++26) is a heap-allocated value that copies deeply, so a variant can contain "itself".

1. std::variant: one of a fixed set

A std::variant<A, B, C> holds exactly one value of one of its alternatives, inline (no heap allocation). Its size is roughly the largest alternative plus a small index.

C++
#include <print>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> v = 42;          // holds int
    std::println("index {} holds int? {}", v.index(), std::holds_alternative<int>(v));

    v = std::string("hello");                               // now holds string
    std::println("index {} value {}", v.index(), std::get<std::string>(v));

    if (auto* d = std::get_if<double>(&v))                  // pointer or nullptr
        std::println("double {}", *d);
    else
        std::println("not a double");

    try {
        std::get<int>(v);                                   // wrong alternative
    } catch (const std::bad_variant_access& e) {
        std::println("get<int> threw: {}", e.what());
    }
}
std::variant<int, double, std::string>: 40 bytes, no heap
GCC 16 on x86-64. One 32-byte storage area big enough for the largest alternative (std::string), shared by all three, plus a 1-byte index saying which one is alive: 0 int, 1 double, 2 string.
std::variant<int, double, std::string>: 40 bytes, no heap
GCC 16 on x86-64. One 32-byte storage area big enough for the largest alternative (std::string), shared by all three, plus a 1-byte index saying which one is alive: 0 int, 1 double, 2 string.

The access functions, from safest to sharpest:

Function Wrong alternative
std::holds_alternative<T>(v) returns false
std::get_if<T>(&v) returns nullptr — check it before dereferencing
std::get<T>(v) / std::get<I>(v) throws std::bad_variant_access
std::visit(f, v) / v.visit(f) can't happen: f is called with whatever is held

A default-constructed variant holds a value-initialised first alternative. If the first alternative isn't default-constructible, or you want an explicit "empty" state, put std::monostate first.

Which alternative does v = x pick?

The converting constructor picks the alternative overload resolution would pick, but since C++20 it excludes narrowing conversions (and pointer→bool). So std::variant<std::string, bool> v = "hi"; holds the std::string — in C++17 it famously held bool, because const char* → bool is a standard conversion and beat the user-defined conversion to std::string.

2. Visiting: one function per alternative

std::visit(f, v) calls f with the held value — f must be callable with every alternative, or the program doesn't compile. That's the payoff: when you add an alternative, every visitor that doesn't handle it becomes a compile error, pointing you at each place that needs updating.

The idiomatic visitor is a set of lambdas glued together with the overloaded helper:

C++
#include <format>
#include <print>
#include <string>
#include <variant>

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

using Token = std::variant<int, char, std::string>;

std::string describe(const Token& t) {
    return std::visit(overloaded{
        [](int n) { return std::format("number {}", n); },
        [](char op) { return std::format("operator '{}'", op); },
        [](const std::string& name) { return std::format("name {}", name); },
    }, t);
}

int main() {
    for (Token t : {Token{7}, Token{'+'}, Token{std::string("x")}})
        std::println("{}", describe(t));
}
std::visit dispatches on the index
The visitor must have a case for every alternative — that's what makes adding an alternative a compile error at every visit that doesn't handle it. A generic [](const auto&) catch-all quietly matches anything new.
std::visit dispatches on the index
The visitor must have a case for every alternative — that's what makes adding an alternative a compile error at every visit that doesn't handle it. A generic [](const auto&) catch-all quietly matches anything new.

overloaded{...} is an aggregate whose bases are the lambdas' closure types; the using declaration pulls every operator() into one overload set. (No deduction guide is needed since C++20.) C++26 adds member visit, so t.visit(overloaded{...}) reads left to right.

The catch-all trap. A generic lambda [](const auto&) { ... } matches every type. It's handy for "everything else", but it switches off the exhaustiveness check: add an alternative later and the catch-all silently handles it. Use catch-alls deliberately, not as a habit.

C++
#include <string>
#include <variant>

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

int main() {
    std::variant<int, double, std::string> v = 1;
    // No overload accepts std::string: std::visit fails to compile.
    return std::visit(overloaded{
        [](int) { return 1; },
        [](double) { return 2; },
    }, v);
}

The conversion trap. Exhaustiveness only catches alternatives that no overload can accept. Implicit conversions count: with std::variant<int, double, char>, the lambdas [](int) and [](double) compile fine — char silently goes to the int lambda. Name each alternative's exact type in its lambda, and be suspicious of numeric alternatives that convert into each other.

Every overload must return the same type (or you give std::visit<R> an explicit return type).

3. Closed set or open set?

The same problem — "a shape is a circle or a rectangle" — has two designs, and they're good at opposite things (the expression problem):

std::variant (closed) virtual functions (open)
Add a new operation easy: write one more visitor touch every class
Add a new type touch every visitor (the compiler finds them) easy: one more subclass
Storage inline, value semantics usually heap, through a pointer
Who can extend it only the owner of the variant type anyone who can derive
Exhaustiveness checked yes, at compile time no (a forgotten override uses the base)

Use a variant for data whose set of kinds you own and rarely changes (tokens, AST nodes, messages, states). Use a hierarchy when other code must add kinds (plugins, user extensions).

4. Recursion: std::indirect

An expression contains sub-expressions. You can't write

C++
using Expr = std::variant<double, Add>;
struct Add { Expr lhs, rhs; };    // Add contains Expr contains Add ... infinite size

The recursion has to go through the heap. std::unique_ptr<Expr> breaks it, but it also breaks value semantics: the type is no longer copyable, and == compares pointers. std::indirect<T> (C++26) is a heap-allocated T that behaves like a T:

  • copying copies the pointee (deep copy);
  • == and <=> compare the pointees;
  • * and -> access it; there is no null state in normal use;
  • after being moved from it is valueless (valueless_after_move()), and must only be assigned to or destroyed.

Its T may be incomplete where it's declared, which is exactly what recursive data needs.

Worked example: an expression tree

C++
#include <memory>
#include <print>
#include <string>
#include <variant>

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

struct Expr;
struct Num { double value; };
struct Add { std::indirect<Expr> lhs, rhs; };
struct Mul { std::indirect<Expr> lhs, rhs; };

// A struct wrapping the variant lets Add/Mul refer to Expr before it's complete.
struct Expr {
    std::variant<Num, Add, Mul> node;
};

Expr num(double v) { return Expr{Num{v}}; }
Expr operator+(Expr a, Expr b) { return Expr{Add{std::indirect<Expr>(std::move(a)), std::indirect<Expr>(std::move(b))}}; }
Expr operator*(Expr a, Expr b) { return Expr{Mul{std::indirect<Expr>(std::move(a)), std::indirect<Expr>(std::move(b))}}; }

double eval(const Expr& e) {
    return e.node.visit(overloaded{
        [](const Num& n) { return n.value; },
        [](const Add& a) { return eval(*a.lhs) + eval(*a.rhs); },
        [](const Mul& m) { return eval(*m.lhs) * eval(*m.rhs); },
    });
}

int main() {
    Expr e = num(2) * (num(3) + num(4));
    Expr copy = e;                         // deep copy: every node duplicated
    std::get<Mul>(copy.node).lhs->node = Num{10};
    std::println("{} {}", eval(e), eval(copy));   // the original is untouched
}
e = 2 * (3 + 4), and a deep copy of it
Every arrow is a std::indirect<Expr>: a separate heap object. Copying e copies all five nodes, so changing the copy's left operand to 10 leaves e alone: eval(e) = 14, eval(copy) = 70.
e = 2 * (3 + 4), and a deep copy of it
Every arrow is a std::indirect<Expr>: a separate heap object. Copying e copies all five nodes, so changing the copy's left operand to 10 leaves e alone: eval(e) = 14, eval(copy) = 70.

Read it as the type definitions say: an Expr is a number, a sum or a product. eval is one function with one case per kind — the shape of the code follows the shape of the data. The copy at the end is the value-semantics win: no aliasing, no clone().

5. Open hierarchies with value semantics: std::polymorphic

When the set of kinds must stay open, you still don't have to give up value semantics. The pre-C++26 pattern was

C++
struct Shape {
    virtual ~Shape() = default;
    virtual std::unique_ptr<Shape> clone() const = 0;   // every subclass repeats this
};
std::vector<std::unique_ptr<Shape>> shapes;              // not copyable without a loop of clone()

std::polymorphic<Base> owns a heap object of Base or any class derived from it, and copying it copies the dynamic type — it remembers how to copy a Circle even though you only see a Shape. So a std::vector<std::polymorphic<Shape>> is copyable as-is, and a class holding one follows the rule of zero.

C++
#include <memory>
#include <numbers>
#include <print>
#include <vector>

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual void scale(double k) = 0;
};

struct Circle : Shape {
    double r;
    explicit Circle(double r) : r(r) {}
    double area() const override { return std::numbers::pi * r * r; }
    void scale(double k) override { r *= k; }
};

struct Square : Shape {
    double side;
    explicit Square(double s) : side(s) {}
    double area() const override { return side * side; }
    void scale(double k) override { side *= k; }
};

int main() {
    std::vector<std::polymorphic<Shape>> shapes;
    shapes.emplace_back(std::in_place_type<Circle>, 1.0);
    shapes.emplace_back(std::in_place_type<Square>, 2.0);

    auto copy = shapes;                  // deep copy of each Circle and Square
    for (auto& s : copy) s->scale(10);

    std::println("original square {}, scaled copy {}", shapes[1]->area(), copy[1]->area());
}
Copying a vector<polymorphic<Shape>> copies the dynamic types
Each element owns a heap object of whatever class it was built with. auto copy = shapes; creates a new Circle and a new Square — no clone() anywhere — so scaling the copies leaves the originals unchanged.
Copying a vector<polymorphic<Shape>> copies the dynamic types
Each element owns a heap object of whatever class it was built with. auto copy = shapes; creates a new Circle and a new Square — no clone() anywhere — so scaling the copies leaves the originals unchanged.

Construct with std::in_place_type<Derived> and the constructor arguments. Like std::indirect, a moved-from std::polymorphic is valueless.

Faded example — your turn

Scene below holds shapes. Make Scene copyable with deep copies, without writing a copy constructor. What should shapes_ be? (Hint: §5.)

C++
class Scene {
public:
    void add_square(double side) { /* ??? */ }
    double total_area() const { /* sum of areas */ }
private:
    /* ??? */ shapes_;
};
Answer

std::vector<std::polymorphic<Shape>> shapes_; with shapes_.emplace_back(std::in_place_type<Square>, side); — the defaulted copy constructor copies the vector, which copies each polymorphic, which copies the dynamic type.

6. valueless_by_exception

Assigning a new alternative destroys the old value and constructs the new one in place. If that construction throws, the variant has nothing to hold: it becomes valueless by exception (v.valueless_by_exception() is true, v.index() is std::variant_npos), and visiting it throws std::bad_variant_access. Implementations avoid this when they can (e.g. by building a temporary first when the move is noexcept), so in practice you only see it with alternatives whose construction can throw and whose moves can throw.

7. And std::any?

std::any holds a value of any copyable type, decided at run time, and you get it back with std::any_cast<T>, naming the exact type. It's an escape hatch for "I genuinely don't know the type" (plugin property bags, type-erased user data). It gives you no exhaustiveness, no visiting and no idea what's inside without guessing. If you can list the possible types, use a variant.

Summary

  • std::variant<Ts...> = one of a closed set, stored inline. Access with get_if (nullable), get (throws) or — best — a visitor.
  • std::visit with overloaded{lambdas...} is checked for exhaustiveness at compile time; generic catch-all lambdas switch that check off.
  • Closed set of kinds → variant; open set → class hierarchy.
  • Recursive data → std::indirect<T>: heap storage with value semantics (deep copy, value comparison).
  • Polymorphic values → std::polymorphic<Base>: copies the dynamic type, so containers of shapes are copyable without clone().
  • Moved-from indirect/polymorphic are valueless: assign to them or let them die, nothing else.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

pair, tuple, optional and variant as algebraic data types; replacing virtual dispatch with visitation.

Writing visitors for std::visit (overload sets, generic lambdas).

Runtime polymorphism with value semantics — the motivation behind std::polymorphic.

The longer original: value semantics and concepts-based polymorphism.

Type erasure from the ground up — the technique behind std::function, std::any and value-semantic polymorphism.