Compile-Time Dispatch
Selecting function implementations or code paths at compile time using type traits, tag types, if constexpr, and concepts — with no virtual dispatch overhead.
Compile-Time Dispatchsince C++98A family of template-based techniques that select among function overloads, template specializations, or code branches based on type properties evaluated entirely at compile time, eliminating the indirection and overhead of virtual dispatch.
Overview
When the type at a call site is known during compilation, resolving implementation selection at compile time is both faster and more flexible than virtual dispatch. The compiler can inline the chosen path, and the technique works on value types that cannot carry vtables.
The toolbox has grown across standards:
| Technique | Introduced | Best for |
|---|---|---|
| Template specialization | C++98 | Per-type customization |
| Tag dispatch | C++98 | Extensible overload sets, iterator categories |
std::enable_if / SFINAE | C++11 | Overload constraints on signatures |
if constexpr | C++17 | Single-function branching |
Concepts + requires | C++20 | Readable constraints with subsumption ordering |
All of these produce zero runtime branching overhead — the decision is made during instantiation.
Tag Dispatch
Tag dispatch encodes a type property as an empty struct, then passes a value of that struct to overloaded implementation functions. The standard library has used this since C++98 for iterator category selection.
#include <iterator>
#include <type_traits>
namespace detail {
template <typename Iter>
void advance_impl(Iter& it, int n, std::random_access_iterator_tag) {
it += n; // O(1)
}
template <typename Iter>
void advance_impl(Iter& it, int n, std::forward_iterator_tag) {
while (n-- > 0) ++it; // O(n)
}
} // namespace detail
template <typename Iter>
void advance(Iter& it, int n) {
detail::advance_impl(it, n,
typename std::iterator_traits<Iter>::iterator_category{});
}The tag object is zero-cost: passing an empty struct by value generates no code. The dispatch is resolved purely through overload resolution. Crucially, forward_iterator_tag is a base class of random_access_iterator_tag, so the random-access overload wins via the usual overload ranking for derived-to-base conversions.
You can build your own tag hierarchies for any Boolean or categorical property:
#include <type_traits>
#include <cstring>
struct trivial_tag {};
struct nontrivial_tag {};
template <typename T>
using copy_tag = std::conditional_t< // C++14
std::is_trivially_copyable_v<T>, // C++17 _v helper
trivial_tag,
nontrivial_tag
>;
template <typename T>
void copy_n(const T* src, T* dst, std::size_t n, trivial_tag) {
std::memcpy(dst, src, n * sizeof(T));
}
template <typename T>
void copy_n(const T* src, T* dst, std::size_t n, nontrivial_tag) {
for (std::size_t i = 0; i < n; ++i)
dst[i] = src[i];
}
template <typename T>
void copy_n(const T* src, T* dst, std::size_t n) {
copy_n(src, dst, n, copy_tag<T>{});
}Tag dispatch is uniquely valuable in library extension points: external code can inject new overloads into the dispatch set without modifying the library itself, which pure if constexpr branching cannot offer.
if constexpr (C++17)
C++17's if constexpr collapses multiple overloads into a single function body. Discarded branches are not instantiated, so you can write type-dependent code that would fail to compile for other types without triggering a hard error:
#include <type_traits>
#include <string>
template <typename T>
std::string to_string_dispatch(const T& val) {
if constexpr (std::is_same_v<T, std::string>) { // C++17
return val;
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(val);
} else if constexpr (std::is_convertible_v<T, std::string_view>) {
return std::string(static_cast<std::string_view>(val));
} else {
return val.to_string(); // only instantiated when T has .to_string()
}
}A critical subtlety: a static_assert(false, "...") in a discarded branch is still ill-formed before C++23 (CWG2518). Make the condition type-dependent instead:
template <typename T>
void process(T val) {
if constexpr (std::is_integral_v<T>) {
handle_int(val);
} else {
// Before C++23: false is evaluated regardless of instantiation
static_assert(sizeof(T) == 0, "process() requires an integral type");
// C++23+: static_assert(false, "...") in discarded branch is valid
}
}std::enable_if / SFINAE (C++11)
When overloads differ in signature — not just body — std::enable_if (C++11) constrains which overloads participate in resolution. The syntax is dense but the mechanic is reliable:
#include <type_traits>
// Overload for integral types
template <typename T>
std::enable_if_t<std::is_integral_v<T>> // C++14 enable_if_t, C++17 _v
process(T val) {
// integer path
}
// Overload for floating-point types
template <typename T>
std::enable_if_t<std::is_floating_point_v<T>>
process(T val) {
// floating-point path
}The conditions must be mutually exclusive. If both fire for the same type, you get an ambiguous overload and a hard error. For anything targeting C++20+, prefer concepts.
Concepts (C++20)
C++20 concepts express constraints readably and leverage subsumption: if concept A refines concept B, an overload constrained by A automatically wins over one constrained by B without writing !B<T> in the less-constrained overload:
#include <concepts>
#include <iterator>
// More constrained — wins for vector::iterator, deque::iterator
template <typename Iter>
requires std::random_access_iterator<Iter> // C++20
void advance(Iter& it, int n) {
it += n;
}
// Less constrained — wins for list::iterator
template <typename Iter>
requires std::forward_iterator<Iter> // C++20
void advance(Iter& it, int n) {
while (n-- > 0) ++it;
}
// random_access_iterator subsumes forward_iterator;
// no need for requires (!std::random_access_iterator<Iter>)Concepts also produce far better diagnostics than SFINAE when the wrong type is passed.
Complete Example: Serialization Dispatch
A realistic scenario: a binary serializer that selects the right strategy based on type properties, with an extension point for user types.
#include <cstring>
#include <span> // C++20
#include <concepts>
#include <type_traits>
// Extension point: user types opt in via ADL-found serialize()
template <typename T>
concept Serializable = requires(const T& v, std::span<std::byte> buf) {
{ serialize(v, buf) } -> std::same_as<std::size_t>; // C++20
};
struct trivial_serial_tag {};
struct custom_serial_tag {};
template <typename T>
using serial_tag = std::conditional_t<
std::is_trivially_copyable_v<T>,
trivial_serial_tag,
custom_serial_tag
>;
template <typename T>
std::size_t write(const T& val, std::span<std::byte> out, trivial_serial_tag) {
std::memcpy(out.data(), &val, sizeof(T));
return sizeof(T);
}
template <Serializable T>
std::size_t write(const T& val, std::span<std::byte> out, custom_serial_tag) {
return serialize(val, out); // ADL dispatch into user's namespace
}
template <typename T>
std::size_t write(const T& val, std::span<std::byte> out) {
return write(val, out, serial_tag<T>{});
}Best Practices
Prefer concepts for new C++20+ code. Subsumption ordering eliminates the mutual-exclusivity bookkeeping that enable_if demands, and error messages are actionable.
Prefer if constexpr over tag dispatch for single-function branching (C++17+). Tag dispatch adds an extra _impl naming layer and a separate overload set. Use it only when the dispatch must be extensible by external code.
Keep tag types in a detail namespace. Tags are implementation artifacts. Leaking them into the public API forces users to reason about dispatch mechanics they should not need to see.
Test constraint coverage. Each category of input type should have exactly one enabled overload. A quick static_assert grid at the bottom of the dispatch header catches coverage gaps during development.
Common Pitfalls
Discarded if constexpr branches still parse. Only instantiation is suppressed — the branch must be syntactically and semantically valid for some specialization. You cannot write val.nonexistent_member in a discarded branch outside a template context.
Missing typename on dependent names. typename std::iterator_traits<Iter>::iterator_category{} requires typename because iterator_category is a dependent name. The compiler will reject the expression without it.
Specializing function templates instead of overloading. Function template specializations do not participate in overload resolution the same way base templates and overloads do. Explicit specializations are chosen after overload resolution selects a base template, which leads to surprising behavior when mixed with constrained overloads. Prefer constrained overloads over full function template specializations.
Overlapping enable_if conditions. Two simultaneously-enabled overloads for the same type cause an ambiguous overload error at the call site. With SFINAE, always verify that conditions form a partition.
See Also
- Tag Dispatch — in-depth treatment of the tag-dispatch pattern
- ADL Customization — extension via argument-dependent lookup, frequently combined with compile-time dispatch