Overload Pattern
A compile-time technique that merges multiple callables into a single object exposing all their operator() overloads via variadic using declarations.
Overload Patternsince C++17A template idiom that derives from an arbitrary set of callable types and brings all their operator() overloads into a single scope using a C++17 variadic using declaration, enabling exhaustive, type-safe dispatch over std::variant and similar discriminated unions.
Overview
std::variant arrived in C++17 alongside std::visit, which invokes a callable on whatever alternative a variant currently holds. The catch: a single lambda can only match one type. Visiting a variant<int, double, std::string> requires a callable that handles all three, and that callable's overload set must be resolvable at compile time.
The overload pattern solves this with four lines of template machinery:
template<class... Ts>
struct overload : Ts... {
using Ts::operator()...; // C++17: variadic using declaration
};The using Ts::operator()... pack expansion pulls every operator() from every base class into the derived class's overload set. The compiler then resolves calls through normal overload resolution β no virtual dispatch, no runtime cost, no type erasure.
Before C++17, pack expansions were not permitted in using declarations. The idiom was expressible but required recursive template specializations. The C++17 change made the canonical two-line form possible.
Syntax
Core template
template<class... Ts>
struct overload : Ts... {
using Ts::operator()...; // C++17
};Deduction guide (C++17 only)
In C++17, aggregate CTAD (class template argument deduction) does not automatically deduce template arguments for aggregates. A deduction guide is required:
template<class... Ts>
overload(Ts...) -> overload<Ts...>; // needed in C++17In C++20, aggregate CTAD was extended to cover this case. The deduction guide is no longer needed when targeting C++20 and later.
Instantiation
auto v = overload{
[](int x) { /* handles int */ },
[](double x) { /* handles double */ },
[](std::string s){ /* handles string */ },
};Each lambda's unique closure type becomes one of the Ts. The resulting object v has all three operator() in scope simultaneously.
Examples
Exhaustive variant visitation
#include <variant>
#include <string>
#include <print> // C++23
using Document = std::variant<int, double, std::string>;
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<class... Ts> // C++17 deduction guide
overload(Ts...) -> overload<Ts...>;
void describe(const Document& doc) {
std::visit(overload{
[](int i) { std::print("integer: {}\n", i); },
[](double d) { std::print("real: {}\n", d); },
[](const std::string& s){ std::print("text: {}\n", s); },
}, doc);
}The compiler verifies exhaustiveness: if any alternative in the variant lacks a matching overload, the program fails to compile. This is the pattern's core value β the type checker enforces completeness.
Inheritance hierarchy and base-class fallback
The overload pattern respects normal C++ overload resolution, including the preference for the most-derived type. You can provide a specific handler for a subclass and a fallback for its base:
#include <variant>
#include <print>
struct Shape {};
struct Circle : Shape { double radius; };
struct Square : Shape { double side; };
using ShapeVar = std::variant<Circle, Square>;
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<class... Ts>
overload(Ts...) -> overload<Ts...>; // C++17 deduction guide
int main() {
ShapeVar s = Circle{3.0};
double area = std::visit(overload{
[](const Circle& c) { return 3.14159 * c.radius * c.radius; },
[](const Square& sq){ return sq.side * sq.side; },
}, s);
std::print("area = {}\n", area);
}Stateful visitors
Lambdas with captures work identically β the capture state becomes part of the lambda's closure type and is carried through the inheritance chain:
#include <variant>
#include <vector>
#include <string>
using Token = std::variant<int, double, std::string>;
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<class... Ts>
overload(Ts...) -> overload<Ts...>;
std::string serialise(const std::vector<Token>& tokens) {
std::string out;
auto visitor = overload{
[&out](int i) { out += std::to_string(i); out += ' '; },
[&out](double d) { out += std::to_string(d); out += ' '; },
[&out](const std::string& s){ out += '"'; out += s; out += "\" "; },
};
for (const auto& tok : tokens)
std::visit(visitor, tok);
return out;
}Capturing by reference into multiple lambdas is safe here because all lambdas share the same enclosing scope and the overload object does not outlive it.
Multi-variant visitation (C++17+)
std::visit accepts more than one variant. The visitor must supply overloads for every combination of alternatives:
using A = std::variant<int, std::string>;
using B = std::variant<float, bool>;
std::visit(overload{
[](int i, float f) { /* ... */ },
[](int i, bool b) { /* ... */ },
[](const std::string& s, float f) { /* ... */ },
[](const std::string& s, bool b) { /* ... */ },
}, a, b);The compiler expands the Cartesian product of alternatives and verifies every combination is handled.
Best Practices
Name the template once, use everywhere. Define overload (or overloaded) in a project-wide utility header. The definition is two lines in C++20, three in C++17 β not worth duplicating.
Keep visitor arms focused. Each lambda in an overload instantiation should do one thing. If the body grows beyond a few lines, promote it to a named function and capture or pass a function pointer.
Prefer const auto& for non-trivial alternatives. Lambda parameters accepting variant alternatives by value force a copy for each visit. Use const auto& for string-like or heap-owning types unless you explicitly need ownership transfer.
Return-type consistency. All arms of std::visit must return the same type (or types convertible to a common type). If arms return different things, use an explicit return type on each lambda and ensure they share a common type β or use std::visit<ReturnType>(...) available in C++20 via the explicit return-type overload.
Common Pitfalls
Missing deduction guide in C++17. Without the guide, overload{...} fails to deduce Ts. The error message is typically a wall of substitution failures. The fix is one line; add it next to the struct definition.
Ambiguous overloads. If two alternatives in a variant share a common conversion path, overload resolution may be ambiguous. A classic trap is std::variant<int, long> with a visitor that provides only [](long){} β the int alternative is converted, but if both arms exist and neither is an exact match, the compiler may flag ambiguity. Always provide exact-match arms for each alternative.
Capturing by reference across thread boundaries. Stateful visitors that capture by reference are not thread-safe if multiple threads call std::visit on distinct variants that reference the same mutable state. In concurrent contexts, capture by value or use atomic state.
Slicing through base-class overloads. If a callable base type has multiple operator() overloads itself (not just lambdas, but hand-written functors), all of them are brought in via using. That is usually desired, but verify the full overload set when mixing lambdas and functor types.
C++20 aggregate CTAD regression. Some older Clang versions shipping with toolchains labeled "C++20" did not implement aggregate CTAD fully. If you target those environments, keep the deduction guide unconditionally β it is harmless in C++20 and eliminates the portability issue.
C++20: Constraining Individual Arms with Concepts
In C++20, lambda parameters can carry requires clauses, letting you restrict individual arms of an overload set to types that satisfy a concept. The constraint participates in overload resolution: the arm simply does not exist for types that fail the check.
#include <variant>
#include <concepts>
#include <string>
#include <print>
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
using Token = std::variant<int, unsigned, double, std::string>;
void display(const Token& tok) {
std::visit(overload{
// arm matches any std::integral type β int and unsigned both qualify
[]<std::integral T>(T v) {
std::print("integral: {}\n", v);
},
// arm matches only double explicitly
[](double d) {
std::print("real: {}\n", d);
},
[](const std::string& s) {
std::print("text: {}\n", s);
},
}, tok);
}The []<std::integral T>(T v) syntax is a C++20 template lambda. The compiler expands it into a function template whose single parameter is constrained by std::integral. When std::visit tries to resolve a double alternative, the concept check fails for that arm, so the explicit [](double d) arm wins via normal overload ranking.
Error messages when no arm matches. Without concepts, a missing overload for type X produces a substitution-failure wall. With a constrained arm acting as a catch-all, you can emit a deliberate compile-time error instead:
template<class... Ts>
struct overload : Ts... {
using Ts::operator()...;
// catch-all for types not handled by any arm
template<typename T>
void operator()(T) const {
static_assert(sizeof(T) == 0,
"overload: no arm matches this variant alternative");
}
};In C++23, static_assert(false, "message") inside a template is legal β it fires only on instantiation β so the sizeof(T) == 0 workaround is no longer needed.
Constraining by category. Concepts let you group alternatives semantically rather than by exact type. A single arm annotated requires std::floating_point<T> handles float, double, and long double in one stroke, avoiding repetition across a wide variant.
std::visit Performance Internals
std::visit is guaranteed by the standard to dispatch in O(1) time with respect to the number of variant alternatives (the standard says the complexity must not depend on sizeof...(Types)). Understanding how that guarantee is met helps set performance expectations.
The function-pointer table. The canonical standard-library implementation builds a static constexpr array of function pointers at compile time β one entry per alternative β then indexes into it with variant::index() at runtime:
// Illustrative sketch of the dispatch mechanism used by std::visit
namespace detail {
template<std::size_t... I, typename F, typename V>
auto visit_impl(std::index_sequence<I...>, F&& f, V&& v)
{
using FPtr = decltype(&f) (*)(F&&, V&&);
static constexpr FPtr table[] = {
[](F&& fn, V&& var) {
return fn(std::get<I>(std::forward<V>(var)));
}...
};
if (v.index() == std::variant_npos) throw std::bad_variant_access{};
return table[v.index()](std::forward<F>(f), std::forward<V>(v));
}
}The table[v.index()] lookup is a single array dereference β O(1). The valueless-by-exception guard (variant_npos check) precedes the table call and is a minor runtime cost that implementations must pay.
Why inlining is hard. The indirection through a function pointer prevents the compiler from inlining the visitor body into the call site. For visitors that do significant work this rarely matters. For trivial visitors on hot paths β incrementing a counter, setting a flag β the call overhead can dominate. Benchmarks by BjΓΆrn Fahller (2018) showed a measurable performance gap between the standard jump-table implementation and a hand-rolled if constexpr chain when the visitor body was near-empty.
Switch vs. pointer table. Some implementations use a switch on variant::index() rather than a raw pointer array. A switch over dense contiguous integers is compiled to a jump table by most compilers, achieving the same O(1) complexity. The pointer-table form is generally preferred in standard library implementations because it maps cleanly to index sequences generated by std::make_index_sequence.
Multi-variant complexity. When std::visit receives n variants with sizes sβ, sβ, β¦, sβ, the table grows to sβ Γ sβ Γ β¦ Γ sβ entries (the Cartesian product of alternatives). The O(1) guarantee still holds β the dispatch itself is one table lookup β but code size and compile time scale with the product.
Practical guidance. For the overwhelming majority of use cases, the overload pattern with std::visit is the correct choice. Consider if constexpr chains inside a single generic lambda only when profiling confirms that the pointer-table indirection is a bottleneck for a specific trivial visitor in a hot loop.
Stateful Overload Sets: The Accumulator Pattern
Each lambda in an overload instantiation is an independent closure with its own capture state. Capturing the same mutable reference in multiple arms produces a visitor that accumulates results across heterogeneous alternatives.
#include <variant>
#include <vector>
#include <string>
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
struct Stats {
int integer_count = 0;
int real_count = 0;
double real_sum = 0.0;
};
using Measurement = std::variant<int, double, std::string>;
Stats analyse(const std::vector<Measurement>& data) {
Stats s;
auto acc = overload{
[&s](int) { ++s.integer_count; },
[&s](double d) {
++s.real_count;
s.real_sum += d;
},
[](const std::string&) { /* skip labels */ },
};
for (const auto& m : data)
std::visit(acc, m);
return s;
}Each arm captures s by reference independently; all three closures remain in scope together inside acc. The visitor is reused across calls in the loop without rebuilding the overload set on each iteration β store it once, call std::visit many times.
Value capture for thread-safe accumulators. If visits happen concurrently, capture by value and use atomic types, or build the overload set fresh per thread. Mutable shared state captured by reference across std::visit calls from multiple threads is a data race.
Recursive Variant Visitors
A variant whose alternatives include a pointer or reference to a node type that itself contains the variant can model trees and other recursive structures. Visiting such a structure requires the visitor to call std::visit on sub-nodes, which means the visitor must be able to reference itself.
The pre-C++23 workaround: a named visitor struct. Define a named functor whose operator() arms can invoke std::visit(*this, ...):
#include <variant>
#include <memory>
struct Leaf { int value; };
struct Node;
// Forward-declare the variant so Node can hold it
using Expr = std::variant<Leaf, std::unique_ptr<Node>>;
struct Node {
Expr left;
Expr right;
};
struct SumVisitor {
int operator()(const Leaf& l) const {
return l.value;
}
int operator()(const std::unique_ptr<Node>& n) const {
// *this is the same visitor β self-referential dispatch
return std::visit(*this, n->left)
+ std::visit(*this, n->right);
}
};
int sum(const Expr& e) {
return std::visit(SumVisitor{}, e);
}The key is std::visit(*this, ...): passing the visitor by value hands std::visit a fresh copy, but since SumVisitor carries no state, copies are free.
C++23: recursive lambdas via deducing this. C++23's explicit object parameter (this auto const& self) lets a lambda name itself in its own parameter list, enabling recursion without a separate struct:
#include <variant>
#include <memory>
struct Leaf { int value; };
struct Node;
using Expr = std::variant<Leaf, std::unique_ptr<Node>>;
struct Node { Expr left; Expr right; };
int sum(const Expr& e) {
return std::visit(overload{
[](const Leaf& l) { return l.value; },
[](this auto const& self, const std::unique_ptr<Node>& n) -> int {
return std::visit(self, n->left)
+ std::visit(self, n->right);
}
}, e);
}this auto const& self deduces to the closure type itself. When std::visit(self, n->left) recurses, self is passed as the visitor β the same closure β so the correct arm is chosen again at each level of the tree. The -> int trailing return type is required here because the recursive call makes return-type deduction circular without it.
Depth and stack limits. Recursive std::visit calls consume one stack frame per level of recursion, just like any other recursive function. For deeply nested trees, prefer an explicit stack or work queue over unbounded recursion.
Generic Lambda Arms: One Arm for Many Alternatives
A single auto-parameter lambda arm can absorb multiple variant alternatives at once. When two or more alternatives in a variant share the same handling logic, repeating identical lambda bodies for each type is noise. A generic lambda β one whose parameter is auto or a template type parameter β acts as a catch-all that matches any alternative not already claimed by a more specific arm.
// C++17
#include <variant>
#include <string>
#include <print>
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<class... Ts>
overload(Ts...) -> overload<Ts...>;
using Number = std::variant<int, long, unsigned long, double, float>;
void print_number(const Number& n) {
std::visit(overload{
// specific arm for double β handles floating-point formatting
[](double d) { std::print("double: {:f}\n", d); },
// generic arm absorbs int, long, unsigned long, and float
[](auto v) { std::print("integral-or-float: {}\n", v); },
}, n);
}Overload resolution prefers the most specific match. The [](double d) arm is an exact match for the double alternative, so the generic arm never fires for double. Every other alternative falls through to [](auto v). The compiler still verifies exhaustiveness: if the generic arm is absent and an alternative has no specific handler, the program fails to compile.
Combining generic and concept-constrained arms in C++20. A C++20 template lambda can carry a concept constraint, giving the generic arm a predicate without listing each type individually:
// C++20
#include <variant>
#include <concepts>
#include <string>
#include <print>
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
using Mixed = std::variant<int, unsigned, long, double, std::string>;
void describe(const Mixed& m) {
std::visit(overload{
[]<std::floating_point T>(T v) {
std::print("float-like: {}\n", v);
},
[]<std::integral T>(T v) {
std::print("integral: {}\n", static_cast<long long>(v));
},
[](const std::string& s) {
std::print("string: \"{}\"\n", s);
},
}, m);
}Both constrained arms are templates; the compiler instantiates the matching one based on the active alternative. This avoids duplicating arms for int, unsigned, and long while keeping double in a logically separate bucket.
Stateful Overload Sets: Mixing Captured and Uncaptured Arms
Not every arm in an overload set needs to carry state β combining stateful and stateless arms is both legal and common. Each lambda in an overload instantiation is its own closure type, and each closure's capture list is independent. Stateless arms (empty []) incur zero storage overhead; stateful arms carry exactly the captures they declare. Mixing the two is idiomatic when only some branches need external context.
// C++17
#include <variant>
#include <string>
#include <vector>
template<class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<class... Ts>
overload(Ts...) -> overload<Ts...>;
using Event = std::variant<int, double, std::string, bool>;
// results is external state; only the int and double arms need it.
// The string arm logs to stderr; the bool arm is a no-op.
void process(const std::vector<Event>& events, std::vector<double>& results) {
auto handler = overload{
[&results](int i) { results.push_back(static_cast<double>(i)); },
[&results](double d) { results.push_back(d); },
[](const std::string& s) {
// stateless: log but don't touch results
std::fputs(("event: " + s + "\n").c_str(), stderr);
},
[](bool) { /* explicitly ignored */ },
};
for (const auto& e : events)
std::visit(handler, e);
}The handler object stores a reference to results inside the first two closure sub-objects and nothing inside the last two. The total size of handler is sizeof(results reference) * 2 plus any alignment padding β no overhead for the stateless arms.
Reuse vs. rebuild trade-off. Constructing an overload object has the cost of constructing each lambda closure. For stateless lambdas that cost is zero. For capturing lambdas it is the cost of copying or moving each captured value. When calling std::visit in a tight loop, build the overload object once outside the loop (as shown above) rather than reconstructing it on every iteration.
Overload Pattern with std::any and Type-Index Dispatch
std::any erases the type entirely, so std::visit is not available β but the overload pattern can still drive structured dispatch via a std::unordered_map of std::type_index to handlers. This technique trades the compile-time exhaustiveness guarantee of std::variant for runtime flexibility: new types can be registered without recompiling the dispatch table.
// C++17
#include <any>
#include <functional>
#include <typeindex>
#include <unordered_map>
#include <string>
#include <print>
#include <stdexcept>
// A runtime overload table keyed by type_index.
class AnyVisitor {
public:
template<typename T>
AnyVisitor& on(std::function<void(const T&)> handler) {
table_[std::type_index(typeid(T))] =
[h = std::move(handler)](const std::any& a) {
h(std::any_cast<const T&>(a));
};
return *this;
}
void visit(const std::any& a) const {
auto it = table_.find(std::type_index(a.type()));
if (it == table_.end())
throw std::runtime_error("AnyVisitor: unhandled type");
it->second(a);
}
private:
std::unordered_map<std::type_index,
std::function<void(const std::any&)>> table_;
};
int main() {
AnyVisitor v;
v.on<int> ([](const int& i) { std::print("int: {}\n", i); })
.on<double>([](const double& d) { std::print("double: {}\n", d); })
.on<std::string>([](const std::string& s) {
std::print("string: \"{}\"\n", s);
});
v.visit(std::any{42});
v.visit(std::any{3.14});
v.visit(std::any{std::string{"hello"}});
}The AnyVisitor::on<T> method registers a typed handler by wrapping it in a type-erased std::function<void(const std::any&)>. At dispatch time, a.type() returns the std::type_info of the stored value; std::type_index wraps it for use as a hash map key.
When to prefer std::variant over std::any. If the set of types is known at compile time, std::variant is always the better choice: exhaustiveness is checked at compile time, dispatch is O(1) via a static table, and there is no heap allocation for handlers. std::any dispatch is appropriate when types are registered dynamically at runtime β plugin systems, scripting bridges, or message buses where new type registrations arrive without recompiling the core library.
Fallback handlers. The AnyVisitor above throws on an unrecognised type. A production implementation might instead call a fallback std::function<void(const std::any&)> registered under a sentinel key, mirroring the catch-all generic-lambda arm available in the std::variant form of the overload pattern.
See Also
std::visitβ the standard library function this pattern most commonly servesstd::variantβ the discriminated union type whose alternatives the visitor dispatches on- Algebraic Types β broader treatment of sum types and product types in C++
- Compile-Time Dispatch β related compile-time branching techniques
- Detection Idiom β another compile-time introspection pattern built on similar template mechanics