Skip to content
C++
Idiom
since C++98
Intermediate

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++98

A 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:

TechniqueIntroducedBest for
Template specializationC++98Per-type customization
Tag dispatchC++98Extensible overload sets, iterator categories
std::enable_if / SFINAEC++11Overload constraints on signatures
if constexprC++17Single-function branching
Concepts + requiresC++20Readable 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.

cpp
#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:

cpp
#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:

cpp
#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:

cpp
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:

cpp
#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:

cpp
#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.

cpp
#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.

Concepts-Based Dispatch: Two Distinct Patterns

The C++20 Concepts section above showed subsumption in action, but it is worth separating two patterns that look similar but behave differently.

Mutually exclusive constraints — each overload can satisfy at most one constraint for any given type. Overload resolution selects the sole viable candidate; there is no "best" to pick:

cpp
#include <concepts>
#include <ostream>
#include <iomanip>
#include <ranges>

// Mutually exclusive: no type is both integral and floating_point
template<typename T> requires std::integral<T>
void serialize(T val, std::ostream& os) {
    os << val;
}

template<typename T> requires std::floating_point<T>
void serialize(T val, std::ostream& os) {
    os << std::setprecision(6) << val;
}

template<typename T> requires std::ranges::range<T>
void serialize(T&& r, std::ostream& os) {
    for (const auto& elem : r) { serialize(elem, os); os << ','; }
}

No mutual-exclusion annotation is needed: the standard concepts are defined to be disjoint where that makes semantic sense. This is the simplest case — it is concept-constrained overloading, not concept overloading in Stroustrup's terminology.

Concept overloading (subsumption) — the more constrained overload wins when multiple constraints are simultaneously satisfied. This is the true power of concepts over enable_if:

cpp
#include <concepts>
#include <iterator>

// forward_iterator is the general case
template<std::forward_iterator It>
void advance(It& it, int n) {
    while (n-- > 0) ++it;          // O(n) — always viable
}

// random_access_iterator subsumes forward_iterator in the standard library hierarchy;
// this overload wins automatically for vector, deque, array iterators
template<std::random_access_iterator It>
void advance(It& it, int n) {
    it += n;                        // O(1) — selected when It is also random-access
}
// No requires (!std::random_access_iterator<It>) on the first overload needed

Subsumption works because std::random_access_iterator is defined in terms of std::bidirectional_iterator, which is defined in terms of std::forward_iterator — the nesting is visible to the compiler at the atomic-constraint level. An overload constrained by a concept that subsumes another concept's constraint is more constrained and therefore preferred by overload resolution ([over.match.best.general/2.6]).

Subsumption only works through named concepts, not raw expressions. The following looks like subsumption but is actually ambiguous:

cpp
template<typename T>
concept Meowable = is_meowable<T>;

// BadMeowableCat duplicates is_meowable<T> rather than reusing Meowable
template<typename T>
concept BadMeowableCat = is_meowable<T> && is_cat<T>;   // ambiguous with Meowable!

// GoodMeowableCat references the concept Meowable — subsumption works
template<typename T>
concept GoodMeowableCat = Meowable<T> && is_cat<T>;     // subsumes Meowable

The compiler tracks subsumption through atomic constraint identity: two atomic constraints are identical only if they originate from the same named concept with the same parameter mapping. Copy-pasting the expression body breaks that identity.

Technique Comparison

if constexprTag dispatchenable_if / SFINAEConcepts overloading
C++ versionC++17C++98C++11C++20
Error locationBody discarded silentlyInside _impl functionSubstitution failure siteAt call site, named concept
ExtensibilityNoYes (ADL)NoYes (open overload set)
Requires _impl naming layerNoYesNoNo
Subsumption orderingNoVia inheritance onlyNoYes
Mutual exclusion requiredNo (sequential branches)No (hierarchy handles it)Yes (conditions must partition)No (subsumption handles it)
Recommended forSingle-function branchingLibrary extension pointsLegacy C++11/14New C++20+ constrained APIs

if consteval (C++23): Dispatching on Evaluation Context

All techniques above dispatch on type properties. if consteval dispatches on a different axis: whether the current call is happening during constant evaluation or at runtime.

cpp
#include <cmath>

// std::sqrt is not constexpr in most implementations, but __builtin_sqrt is
constexpr double safe_sqrt(double x) {
    if consteval {
        // compile-time branch: only consteval-safe operations allowed here;
        // a consteval function called here may receive non-constexpr-typed args
        // because the compiler knows this branch executes in a constant context
        return __builtin_sqrt(x);   // GCC/Clang compile-time intrinsic
    } else {
        return std::sqrt(x);        // runtime branch: full libm quality
    }
}

constexpr double r = safe_sqrt(2.0);    // takes the if consteval branch
double           s = safe_sqrt(2.0);    // takes the else branch

if consteval was adopted for C++23 (P1938R3) to fix a gap in std::is_constant_evaluated(): inside an if (std::is_constant_evaluated()) block the compiler still analyses both branches statically, so calling a consteval function with a non-constexpr-typed parameter fails even though that branch only runs at compile time. if consteval is a true compile-time construct — the compiler commits to one branch during static analysis and does not even instantiate the other.

The negated form if !consteval gates code that must only run at runtime:

cpp
constexpr void debug_log(const char* msg) {
    if !consteval {
        // Calling I/O in a constexpr function is only valid at runtime
        std::fputs(msg, stderr);
    }
}

When to use each:

  • Use if consteval / if !consteval when a single constexpr function needs to call different implementations depending on whether it is being constant-evaluated (e.g., to call compiler intrinsics at compile time and library functions at runtime).
  • Use concepts dispatch when the selection depends on the type of the arguments, not on the evaluation context.
  • They can be combined: a concepts-constrained overload can internally use if consteval to further branch on evaluation context.

std::variant and std::visit: When Runtime Type-Erasure Is the Better Fit

Not every dispatch problem belongs at compile time. When the set of types is closed and fixed (known at compile time) but the active member is determined at runtime, std::variant paired with std::visit (C++17) offers a sweet spot: the visitor itself is fully resolved at compile time while the selection among alternatives happens at runtime with no vtable overhead.

cpp
// C++17
#include <variant>
#include <string>
#include <iostream>

using Number = std::variant<int, float, double>;

struct Printer {
    void operator()(int v)    const { std::cout << "int: "    << v << '\n'; }
    void operator()(float v)  const { std::cout << "float: "  << v << '\n'; }
    void operator()(double v) const { std::cout << "double: " << v << '\n'; }
};

void print(const Number& n) {
    std::visit(Printer{}, n);   // dispatch resolved to the matching overload
}

std::visit generates a jump table (or chain of ifs) internally, but the overload resolution for each alternative — which operator() to call — happens entirely at compile time. The combination is often faster than a traditional virtual call because the visitor type is fully known to the optimizer.

For single-expression visitors, a generic lambda or overloaded helper avoids the boilerplate of a named struct:

cpp
// C++17
#include <variant>
#include <string>

// Canonical overloaded helper — one struct, deduction guide, zero overhead
template<typename... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

template<typename... Ts>
overloaded(Ts...) -> overloaded<Ts...>;   // deduction guide (C++17)

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

std::string describe(const Token& tok) {
    return std::visit(overloaded{
        [](int v)               { return "integer " + std::to_string(v); },
        [](const std::string& s){ return "string \"" + s + "\""; },
        [](bool b)              { return std::string(b ? "true" : "false"); },
    }, tok);
}

Prefer std::variant + std::visit over virtual dispatch when the type set is closed (not meant to be extended by callers), objects are stored by value (no heap allocation), and you want exhaustiveness checking — the compiler errors if any alternative is unhandled in the visitor.

Dispatching on Value at Compile Time

Type traits let you turn a runtime Boolean into a compile-time type. std::integral_constant (C++11) and its alias std::bool_constant (C++17) wrap compile-time values as types, enabling overload resolution to select between implementations based on those values — a pattern called integral constant dispatch.

cpp
// C++17
#include <type_traits>
#include <cstddef>

// Two implementation overloads selected by a Boolean type, not a Boolean value
template <typename T>
void init_storage(T* p, std::size_t n, std::true_type  /*zero-init*/) {
    for (std::size_t i = 0; i < n; ++i) new (p + i) T();
}

template <typename T>
void init_storage(T* p, std::size_t n, std::false_type /*skip-init*/) {
    // trivial types: leave storage uninitialized intentionally
}

template <typename T, bool ZeroInit>
void make_buffer(T* p, std::size_t n) {
    init_storage(p, n, std::bool_constant<ZeroInit>{});  // C++17 bool_constant
}

This differs from if constexpr in one important way: external code can add overloads to init_storage for new tag values without touching the dispatch site. For a single self-contained function body, if constexpr is the cleaner choice; for extensible two-layer APIs, integral constant dispatch preserves the open overload set.

std::integral_constant generalises beyond Booleans to any constexpr integer value, enabling dispatch across enumeration states at compile time:

cpp
// C++17
#include <type_traits>

enum class Endian { little, big, native };

template <Endian E>
using endian_tag = std::integral_constant<Endian, E>;

uint32_t read32(const std::byte* p, endian_tag<Endian::little>) {
    return static_cast<uint32_t>(p[0])
         | static_cast<uint32_t>(p[1]) << 8
         | static_cast<uint32_t>(p[2]) << 16
         | static_cast<uint32_t>(p[3]) << 24;
}

uint32_t read32(const std::byte* p, endian_tag<Endian::big>) {
    return static_cast<uint32_t>(p[0]) << 24
         | static_cast<uint32_t>(p[1]) << 16
         | static_cast<uint32_t>(p[2]) << 8
         | static_cast<uint32_t>(p[3]);
}

template <Endian E>
uint32_t read32(const std::byte* p) {
    return read32(p, endian_tag<E>{});
}

Each call to read32<Endian::little>(p) compiles to the exact byte-swap sequence for that endianness, with no branch at runtime.

Compile-Time Dispatch Tables with std::array

A dispatch table is just an array of function pointers — and arrays can be constexpr. When dispatch is indexed by an enum or small integer rather than a type, a constexpr std::array of function pointers is more cache-friendly than a chain of if constexpr or switch branches and sidesteps virtual dispatch entirely.

cpp
// C++17
#include <array>
#include <cstdint>
#include <functional>

enum class Op : uint8_t { Add, Sub, Mul, Div, COUNT };

using BinaryFn = int(*)(int, int);

constexpr std::array<BinaryFn, static_cast<std::size_t>(Op::COUNT)> op_table = {
    [](int a, int b) { return a + b; },  // Op::Add
    [](int a, int b) { return a - b; },  // Op::Sub
    [](int a, int b) { return a * b; },  // Op::Mul
    [](int a, int b) { return a / b; },  // Op::Div
};

int dispatch(Op op, int a, int b) {
    return op_table[static_cast<std::size_t>(op)](a, b);
}

The entire table lives in read-only data (.rodata), the indexed call compiles to a single indirect branch, and there are no virtual thunks. Because op_table is constexpr, calls with a compile-time op can be constant-folded by the optimizer.

For richer payloads — function pointers plus metadata — a constexpr array of structs scales the same way:

cpp
// C++20
#include <array>
#include <string_view>

struct Handler {
    std::string_view name;
    void (*fn)(const char* arg);
};

void handle_open  (const char* arg);
void handle_close (const char* arg);
void handle_reload(const char* arg);

constexpr std::array<Handler, 3> command_table = {{
    { "open",   handle_open   },
    { "close",  handle_close  },
    { "reload", handle_reload },
}};

// At compile time: find a handler by name
consteval const Handler* find_handler(std::string_view name) {
    for (const auto& h : command_table)   // C++20 consteval
        if (h.name == name) return &h;
    return nullptr;
}

consteval (C++20) forces find_handler to run at compile time, so looking up a command by a string literal baked into the source produces a direct function pointer with no runtime search. This pattern is common in embedded command parsers, opcode interpreters, and plugin registries where the handler set is fixed at build time.

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