Tag Dispatch
Select function overloads at compile time by passing empty tag structs. The canonical pre-C++17 technique for type-based branching without SFINAE.
Tag Dispatchsince C++98Tag dispatch selects a function overload at compile time by passing a default-constructed empty struct whose type encodes the desired code path, delegating the selection to the compiler's overload resolution rather than to runtime conditionals or SFINAE.
Overview
Before C++17's if constexpr and C++20's concepts, tag dispatch was the primary mechanism for branching on type properties inside function templates. The technique works by introducing a tag type β an empty struct whose identity carries semantic meaning β and passing a default-constructed instance of it to an overloaded implementation function.
The compiler picks the correct overload at zero runtime cost. Because overload resolution operates on types, not values, the tag object is never stored or examined; it exists only to direct the compiler.
The standard library has used this pattern since C++98 in <algorithm> and <iterator>. std::advance, std::distance, and std::next all dispatch on iterator category tags to select O(1) versus O(n) implementations.
Tag dispatch remains relevant even in C++20 codebases for two reasons that neither if constexpr nor concepts replicate well: priority chains (controlled fallback ordering via tag inheritance) and open-world extensibility (users add overloads in their own namespace; the dispatch function never needs to know about them).
Syntax
// Step 1: define tag types
struct forward_path {};
struct random_access_path {};
// Step 2: write an overloaded _impl for each tag
template<typename Iter>
void advance_impl(Iter& it, int n, forward_path) {
while (n-- > 0) ++it;
}
template<typename Iter>
void advance_impl(Iter& it, int n, random_access_path) {
it += n; // O(1)
}
// Step 3: public dispatch function constructs the tag
template<typename Iter>
void my_advance(Iter& it, int n) {
using Cat = typename std::iterator_traits<Iter>::iterator_category; // C++98
using Tag = std::conditional_t< // C++14
std::is_base_of_v<std::random_access_iterator_tag, Cat>, // C++17
random_access_path,
forward_path>;
advance_impl(it, n, Tag{});
}The dispatch function is the only public surface. Callers never see the tags; the _impl overloads are implementation detail.
Examples
Iterator Category Dispatch (the canonical example)
The standard iterator tag types form an inheritance hierarchy (since C++98), and the compiler's overload resolution picks the most-derived match:
#include <iterator>
// std tag hierarchy:
// input_iterator_tag
// ββ forward_iterator_tag
// ββ bidirectional_iterator_tag
// ββ random_access_iterator_tag (C++98)
// ββ contiguous_iterator_tag (C++20)
template<typename Iter, typename Distance>
void advance_impl(Iter& it, Distance n, std::input_iterator_tag) {
// Covers forward iterators too via implicit upcast
while (n-- > 0) ++it;
}
template<typename Iter, typename Distance>
void advance_impl(Iter& it, Distance n, std::bidirectional_iterator_tag) {
if (n >= 0) while (n-- > 0) ++it;
else while (n++ < 0) --it;
}
template<typename Iter, typename Distance>
void advance_impl(Iter& it, Distance n, std::random_access_iterator_tag) {
it += n; // O(1) β also covers contiguous_iterator_tag (C++20)
}
template<typename Iter, typename Distance>
void my_advance(Iter& it, Distance n) {
// Construct the category tag; overload resolution picks the best match
advance_impl(it, n,
typename std::iterator_traits<Iter>::iterator_category{});
}A std::list iterator carries bidirectional_iterator_tag. A std::vector iterator carries random_access_iterator_tag. The right overload is chosen without any runtime check.
true_type / false_type β the simplest tags
std::true_type and std::false_type (C++11) are themselves tag types. Any type trait that derives from them can be used directly as a tag:
#include <type_traits>
#include <cstring>
template<typename T>
void copy_n_impl(const T* src, T* dst, std::size_t n, std::true_type) {
std::memcpy(dst, src, n * sizeof(T)); // trivially copyable: raw copy
}
template<typename T>
void copy_n_impl(const T* src, T* dst, std::size_t n, std::false_type) {
for (std::size_t i = 0; i < n; ++i)
new (dst + i) T(src[i]); // requires copy constructor
}
template<typename T>
void copy_n(const T* src, T* dst, std::size_t n) {
copy_n_impl(src, dst, n,
std::is_trivially_copyable<T>{}); // C++11
}Priority Chains via Tag Inheritance
Tag inheritance lets you express "prefer A over B over C" without SFINAE gymnastics. Pass the highest-priority tag; the compiler walks up the hierarchy until it finds a viable overload:
struct priority_low {};
struct priority_mid : priority_low {};
struct priority_high : priority_mid {};
// Called only if T has a .serialize() member
template<typename T>
auto to_string_impl(const T& v, priority_high)
-> decltype(v.serialize())
{
return v.serialize();
}
// Called if T works with std::to_string (C++11)
template<typename T>
auto to_string_impl(const T& v, priority_mid)
-> decltype(std::to_string(v))
{
return std::to_string(v);
}
// Unconditional fallback
template<typename T>
std::string to_string_impl(const T&, priority_low) {
return "[opaque]";
}
template<typename T>
std::string to_string(const T& v) {
return to_string_impl(v, priority_high{}); // start at top of chain
}When T has .serialize(), the high overload wins. If not, the SFINAE on the trailing return type eliminates it, and mid is tried. The unconditional low overload is always available as a backstop. This pattern is significantly cleaner than nested std::enable_if chains.
Best Practices
Keep tags in a details namespace. Exposing advance_impl and the tag types in the public API clutters the interface. Put them in a detail:: namespace or an anonymous namespace.
Prefer std::true_type/std::false_type for binary dispatch. Rolling custom tag types for a two-way branch adds ceremony without benefit. Use std::is_trivially_copyable<T>{} directly.
Use priority chains instead of SFINAE stacking. When you have more than two candidates, priority-tag inheritance is more readable and easier to extend than a chain of std::enable_if conditions.
Design tags for open-world extensibility. If users of your library should be able to add dispatch paths for their types, expose the tag types and the _impl name in your namespace. ADL will find their overloads without requiring changes to your dispatch function.
Match difference_type precisely in iterator dispatch. Standard iterator algorithms use iterator_traits<Iter>::difference_type, not int. Use the same type in your _impl signatures to avoid implicit conversion warnings.
Common Pitfalls
Overload resolution walks the inheritance chain upward. A random_access_iterator_tag implicitly converts to bidirectional_iterator_tag and all its ancestors. If you omit the bidirectional overload, random-access iterators fall through to the input overload β silently, without a compile error. Audit every tier of the chain.
ADL can silently select the wrong overload. If your tag type and implementation functions live in different namespaces, ADL may not find the right overload. Keep tags and their _impl functions in the same namespace.
if constexpr is not always a drop-in replacement. if constexpr branches are syntactically inside a single function: both branches must parse, and only instantiation is suppressed. Tag dispatch sends each path to a separate function, so an overload whose body is entirely invalid for a given type simply won't be instantiated at all. Priority-chain dispatch also has no direct if constexpr equivalent.
Priority chains require the fallback to be unconstrained. The low_priority overload must accept any T. If you add a requires clause or a SFINAE condition to it, types that fall through every higher-priority overload will produce a hard error rather than a graceful fallback.
Scalable Priority Chains with priority_tag<N>
Named tag structs (priority_low, priority_mid, priority_high) work for a fixed number of levels, but inserting a new level between two existing ones forces a rename of every tag in the chain. The priority_tag<N> template β used in Eric Niebler's range-v3, nlohmann/json, and described by Arthur O'Dwyer β solves this by parameterising the hierarchy depth with an integer.
// One definition handles the entire chain via recursive inheritance
template<int N>
struct priority_tag : priority_tag<N - 1> {};
template<>
struct priority_tag<0> {};
// priority_tag<2> IS-A priority_tag<1> IS-A priority_tag<0>Overload resolution prefers "shallower" conversions: passing priority_tag<2>{} to a set of overloads will match the priority_tag<2> overload first, fall back to priority_tag<1> if it is eliminated (e.g. by SFINAE on the return type), and finally land on priority_tag<0> as an unconditional backstop.
#include <type_traits>
#include <string>
// priority_tag definition (usually placed in a detail namespace)
namespace detail {
template<int N> struct priority_tag : priority_tag<N - 1> {};
template<> struct priority_tag<0> {};
} // namespace detail
// Level 2 β preferred: type has operator<< for stream output
template<typename T>
auto format_impl(const T& v, detail::priority_tag<2>)
-> decltype(std::declval<std::ostream&>() << v, std::string())
{
std::ostringstream oss;
oss << v;
return oss.str();
}
// Level 1 β fallback: type has a .to_string() member
template<typename T>
auto format_impl(const T& v, detail::priority_tag<1>)
-> decltype(v.to_string())
{
return v.to_string();
}
// Level 0 β final backstop: always compiles, returns placeholder
template<typename T>
std::string format_impl(const T&, detail::priority_tag<0>)
{
return "[opaque]";
}
// Public entry point β always passes the highest tag
template<typename T>
std::string format(const T& v)
{
return detail::format_impl(v, detail::priority_tag<2>{});
}Why integers beat named tags for long chains. Adding a fourth level requires only adding a new overload for priority_tag<3> and bumping the call site to priority_tag<3>{}. No existing overload is renamed or disturbed. The same priority_tag template is reusable across entirely unrelated dispatch families in the same codebase.
The ranking rule. The C++ standard (over.ics.rank Β§4.4.4) ranks implicit conversions by depth: converting priority_tag<2> to priority_tag<1> (one step up the chain) is preferred over converting it to priority_tag<0> (two steps). The compiler always chooses the shallowest valid conversion, which is the highest-numbered viable overload.
The std::iterator_tags Inheritance Hierarchy
The standard library has used tag-dispatch inheritance since C++98. Understanding the hierarchy explains why the fallback mechanism works without any explicit condition.
// <iterator> β exact definitions from the C++ standard
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};
struct contiguous_iterator_tag : public random_access_iterator_tag {}; // C++20Every concrete iterator type exposes its category as a typedef accessible via std::iterator_traits<It>::iterator_category. The inheritance means:
std::vector<int>::iteratorcarriesrandom_access_iterator_tag, which is also abidirectional_iterator_tag,forward_iterator_tag, andinput_iterator_tag.std::list<int>::iteratorcarriesbidirectional_iterator_tag, which is aforward_iterator_tagandinput_iterator_tag, but NOT arandom_access_iterator_tag.std::forward_list<int>::iteratorcarriesforward_iterator_tag, which is only aninput_iterator_tag.
When advance_impl is called with a random_access_iterator_tag, the overload taking that exact type is the best match. If that overload does not exist, the compiler silently promotes to bidirectional_iterator_tag, and so on. This promotion is the core mechanism of tag-dispatch fallback β it is pure overload resolution, not a runtime check.
Iterator type iterator_category advance_impl selected
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
std::vector<T>::iterator random_access_iterator_tag += n (O(1))
std::list<T>::iterator bidirectional_iterator_tag loop with --it support
std::forward_list<T>::it forward_iterator_tag input overload (fallback)
std::istream_iterator<T> input_iterator_tag input overloadC++20 adds contiguous_iterator_tag, which inherits from random_access_iterator_tag. Existing random_access_iterator_tag overloads automatically cover contiguous iterators β the promotion still works β but you can add a dedicated contiguous_iterator_tag overload for algorithms that exploit memory contiguity (e.g. bulk memcpy).
Note: there is no separate legacy-category tag for LegacyContiguousIterator. To specialize an algorithm for contiguous iterators in pre-C++20 code, you need a different mechanism (pointer check, allocator trait, or C++20 std::contiguous_iterator concept).
C++20 Concepts: The Modern Replacement
C++20 iterator concepts replicate the tag-dispatch hierarchy through concept subsumption instead of struct inheritance. The mechanism is different but the observable behaviour β best-matching dispatch β is identical.
#include <iterator> // std::input_iterator, std::bidirectional_iterator,
// std::random_access_iterator (C++20)
// Three overloads; no tag structs, no _impl helper, no iterator_traits call
template<std::input_iterator I>
void advance_(I& it, int n)
{
// Covers forward iterators too: forward_iterator subsumes input_iterator
if (n >= 0) while (n--) ++it;
}
template<std::bidirectional_iterator I>
void advance_(I& it, int n)
{
// bidirectional_iterator subsumes input_iterator, so this wins for list
if (n >= 0) while (n--) ++it;
else while (n++) --it;
}
template<std::random_access_iterator I>
void advance_(I& it, int n)
{
// random_access_iterator subsumes bidirectional_iterator; wins for vector
it += n;
}The compiler selects the "most constrained" overload whose concept is satisfied, following the subsumption rules in [over.match.best.general Β§2.6]: std::random_access_iterator subsumes std::bidirectional_iterator which subsumes std::input_iterator. No explicit inheritance hierarchy is needed in user code.
Comparison: tag dispatch vs. concept overloading on std::advance
| Aspect | Tag dispatch (C++98) | Concepts (C++20) |
|---|---|---|
| Mechanism | struct inheritance + overload resolution | concept subsumption + overload resolution |
| Error messages | Cryptic: wrong tag type selected | Descriptive: constraint not satisfied |
| Open-world extension | Add overloads in any namespace via ADL | Add constrained overloads; subsumption handles priority |
| Syntax overhead | Requires tag structs + _impl function | Single public overload set, no helper needed |
| Requires clause | Not needed | requires (!std::random_access_iterator<I>) to make constraints mutually exclusive (optional) |
| C++ version | C++98 | C++20 |
When tag dispatch is still preferable to concepts. In C++20 code, prefer concepts for new iterator-style dispatch. Keep tag dispatch when: (a) you need open-world extensibility across library boundaries where users will add overloads by name in their own namespace; (b) the C++ version target is pre-C++20; or (c) the "priority chain" pattern needs more than two levels and the concept subsumption graph would become unclear.
Concept-based advance with explicit mutual exclusion (for comparison). Arthur O'Dwyer notes that you can make constraints mutually exclusive using requires (!...), which turns concept overloading into something closer to SFINAE. This is usually unnecessary because subsumption already handles priority:
// Mutually exclusive version β valid but verbose
template<std::forward_iterator I>
requires (!std::random_access_iterator<I>)
void advance_(I& it, int n)
{
while (n-- > 0) ++it;
}
template<std::random_access_iterator I>
void advance_(I& it, int n)
{
it += n;
}The version without requires (!...) is idiomatic C++20 and relies on concept subsumption, which is both more concise and more in the spirit of the language.
Tag Dispatch vs if constexpr β When to Prefer Each
if constexpr is the right default for C++17+ single-function branching. It requires no helper functions, no tag types, and produces cleaner error messages. Tag dispatch earns its place only when the branching logic benefits from separate function bodies, open-world extensibility, or a priority chain.
The table below captures the decision criteria:
| Scenario | Prefer |
|---|---|
| Binary branch on a type trait in one function | if constexpr |
| Three or more priority levels with fallback | Tag dispatch with priority_tag<N> |
| Library users must add new dispatch paths | Tag dispatch (ADL-visible _impl overloads) |
| Both branches must be separately overloadable | Tag dispatch |
| C++ version is C++14 or earlier | Tag dispatch |
| C++ version is C++17+ and neither above applies | if constexpr |
Migrating an existing binary tag-dispatch function to if constexpr is usually mechanical. The before/after below shows copy_n_impl from the true_type/false_type example earlier in this page:
// C++11 β tag dispatch version
template<typename T>
void copy_n_impl(const T* src, T* dst, std::size_t n, std::true_type) {
std::memcpy(dst, src, n * sizeof(T));
}
template<typename T>
void copy_n_impl(const T* src, T* dst, std::size_t n, std::false_type) {
for (std::size_t i = 0; i < n; ++i)
new (dst + i) T(src[i]);
}
template<typename T>
void copy_n(const T* src, T* dst, std::size_t n) {
copy_n_impl(src, dst, n, std::is_trivially_copyable<T>{});
}// C++17 β if constexpr version (all branches in one function)
template<typename T>
void copy_n(const T* src, T* dst, std::size_t n) {
// C++17
if constexpr (std::is_trivially_copyable_v<T>) {
std::memcpy(dst, src, n * sizeof(T));
} else {
for (std::size_t i = 0; i < n; ++i)
new (dst + i) T(src[i]);
}
}The if constexpr form is strictly shorter for this two-branch case. The key caveat is that both branches must parse even though only one is instantiated. If the eliminated branch contains a call to a member that does not exist on T, if constexpr does not protect you unless the call is dependent on a template parameter in that branch. Tag dispatch sidesteps this entirely because the eliminated overload is never instantiated.
Tag Types as Standard Library Customization Points
The standard library itself uses empty tag structs as vocabulary types for constructor disambiguation. Tag dispatch is not only an algorithm-selection idiom; it is also a disciplined way to invoke overloaded constructors and factory functions without ambiguity.
Three widely-used examples from <optional>, <variant>, <utility>, and <tuple> are std::in_place_t, std::nullopt_t, and std::piecewise_construct_t. Each is an empty struct; the standard defines a single global constant of that type so call sites read naturally.
// C++17
#include <optional>
#include <variant>
#include <utility>
#include <tuple>
#include <string>
// std::in_place_t β construct the contained object directly inside optional/variant/any
std::optional<std::string> opt(std::in_place, 5, 'x'); // constructs "xxxxx"
std::variant<int, std::string> var(std::in_place_type<std::string>, "hello");
// std::nullopt_t β unambiguously construct an empty optional
std::optional<int> empty(std::nullopt); // not optional<int>{} which might be confused
// std::piecewise_construct_t β construct pair members in-place from forwarded tuples
std::pair<std::string, std::vector<int>> p(
std::piecewise_construct,
std::forward_as_tuple(3, 'a'), // builds std::string from (3, 'a')
std::forward_as_tuple(1, 2, 3)); // builds vector from {1, 2, 3}The definitions in the standard are minimal:
// C++17 β how the standard defines these tag types (simplified)
struct in_place_t { explicit in_place_t() = default; };
inline constexpr in_place_t in_place{};
struct nullopt_t { explicit nullopt_t(/* unspecified */) {} };
inline constexpr nullopt_t nullopt{ /* unspecified */ };
struct piecewise_construct_t { explicit piecewise_construct_t() = default; };
inline constexpr piecewise_construct_t piecewise_construct{};You can apply the same pattern in your own classes. Provide an overloaded constructor that accepts a tag, name the tag type after its semantic role, and expose one inline constexpr constant so call sites are self-documenting:
// C++17 β user-defined tag type for constructor disambiguation
struct from_string_t { explicit from_string_t() = default; };
inline constexpr from_string_t from_string{};
class Config {
public:
explicit Config(int port, std::string host); // normal ctor
explicit Config(from_string_t, std::string_view str); // parse from "host:port"
};
Config c1(8080, "localhost");
Config c2(from_string, "localhost:8080"); // unambiguous, self-documentingNested Tag Hierarchies β Multi-Level Dispatch
Combining two independent tag axes enables a two-dimensional dispatch table without any runtime branching. When an algorithm depends on two independent type properties β such as container category and value mutability β a single tag type per dimension keeps each overload set small and independently extensible.
The idea is to pass two tag arguments to _impl. The compiler's overload resolution considers both parameters simultaneously, selecting the overload that best matches the pair.
// C++11
// Axis 1: storage layout
struct contiguous_storage_tag {};
struct node_based_storage_tag {};
// Axis 2: element mutability
struct mutable_elements_tag {};
struct const_elements_tag {};
// Four overloads β one per (storage, mutability) combination
template<typename C>
void process_impl(C& c,
contiguous_storage_tag,
mutable_elements_tag)
{
// SIMD-friendly in-place transform
}
template<typename C>
void process_impl(C& c,
contiguous_storage_tag,
const_elements_tag)
{
// Read-only scan β can use pointer arithmetic
}
template<typename C>
void process_impl(C& c,
node_based_storage_tag,
mutable_elements_tag)
{
// Iterator-based mutation on linked structures
}
template<typename C>
void process_impl(C& c,
node_based_storage_tag,
const_elements_tag)
{
// Read-only traversal with iterator
}
// Dispatch function extracts both tags from type traits
template<typename C>
void process(C& c) {
using StorageTag = std::conditional_t<
std::is_same_v<typename C::iterator_category,
std::random_access_iterator_tag>,
contiguous_storage_tag,
node_based_storage_tag>;
using MutTag = std::conditional_t<
std::is_const_v<C>,
const_elements_tag,
mutable_elements_tag>;
process_impl(c, StorageTag{}, MutTag{});
}For hierarchical tag types (where each axis has its own inheritance chain), the same promotion rules apply independently to each argument. This makes it straightforward to add a "partially-specialized" overload such as (node_based_storage_tag, mutable_elements_tag) while relying on the fallback for the remaining quadrants.
Nested priority_tag<N> axes require care. When both axes use priority_tag<N>, the compiler must pick the overload that best matches both parameters simultaneously. The standard conversion ranking is applied per-parameter and then compared lexicographically, so ties can occur. Prefer named tag types for multi-axis dispatch; reserve priority_tag<N> for single-axis priority chains.
See Also
std::iterator_traitsβ the traits class whoseiterator_categorytypedef is the tag- SFINAE β the alternative mechanism for overload removal; heavier syntax, same compile-time effect
if constexprβ C++17 single-function alternative for simpler branch scenarios- Concepts β C++20 overload constraints that supersede tag dispatch for most new code
- CRTP β another compile-time polymorphism technique, complementary to tag dispatch