Template Specialisation & Tag Dispatch
Master full and partial specialisation, learn when to prefer overloading, and discover tag dispatch and if constexpr as alternatives for compile-time branching.
Why Specialise?
The primary template provides a generic implementation that works for most types. But sometimes a specific type needs fundamentally different treatment. For example, std::vector is a specialisation that packs bools into bits for space efficiency (though it is widely considered a mistake in the standard library, and is rather like that one relative nobody mentions at Christmas).
Template specialisation lets you provide an alternative definition for specific template arguments. There are two kinds:
1. Full (explicit) specialisation — you fix all template parameters to specific types.
2. Partial specialisation — you fix some parameters or constrain them (e.g., specialise for all pointer types). Partial specialisation is only available for class templates, not function templates.
Full Specialisation
A full specialisation provides a completely custom implementation for one specific set of template arguments. The syntax uses template <> (an empty parameter list) followed by the class or function with concrete arguments.
#include <iostream>
#include <cstring>
// Primary template
template <typename T>
class Formatter {
public:
static std::string format(const T& value) {
return std::to_string(value);
}
};
// Full specialization for const char*
template <>
class Formatter<const char*> {
public:
static std::string format(const char* value) {
return std::string("\"" ) + value + "\"";
}
};
// Full specialization for bool
template <>
class Formatter<bool> {
public:
static std::string format(bool value) {
return value ? "true" : "false";
}
};
int main() {
std::cout << Formatter<int>::format(42) << '\n'; // 42
std::cout << Formatter<const char*>::format("hi") << '\n'; // "hi"
std::cout << Formatter<bool>::format(true) << '\n'; // true
}Partial Specialisation
Partial specialisation constrains some template parameters while leaving others generic. This is extremely powerful for matching families of types — all pointers, all arrays, all pairs, etc. Only class/struct/variable templates support partial specialisation.
#include <iostream>
#include <string>
#include <typeinfo>
// Primary template
template <typename T>
struct TypeInfo {
static std::string name() { return "general type"; }
};
// Partial specialization for all pointer types
template <typename T>
struct TypeInfo<T*> {
static std::string name() {
return "pointer to " + TypeInfo<T>::name();
}
};
// Partial specialization for all references
template <typename T>
struct TypeInfo<T&> {
static std::string name() {
return "reference to " + TypeInfo<T>::name();
}
};
// Partial specialization for arrays
template <typename T, std::size_t N>
struct TypeInfo<T[N]> {
static std::string name() {
return "array of " + std::to_string(N) + " " + TypeInfo<T>::name();
}
};
int main() {
std::cout << TypeInfo<int>::name() << '\n'; // general type
std::cout << TypeInfo<int*>::name() << '\n'; // pointer to general type
std::cout << TypeInfo<int**>::name() << '\n'; // pointer to pointer to general type
std::cout << TypeInfo<int[5]>::name() << '\n'; // array of 5 general type
}Function templates cannot be partially specialised — only fully specialised. Worse, function template specialisations do not participate in overload resolution the way you might expect. The compiler first selects the best base template via overload resolution, and only then checks if that base template has a matching specialisation.
This leads to surprising behaviour:
template <typename T> void f(T) { /* #1 - primary */ }
template <typename T> void f(T*) { /* #2 - overload for pointers */ }
template <> void f<int*>(int*) { /* #3 - specialisation of #1 */ }Calling f((int*)nullptr) selects #2 (the better overload match), not #3 (which specialises #1, not #2). It is the C++ equivalent of queueing patiently and still being served last. The fix: use regular function overloading instead of function template specialisation. If you need compile-time type branching, use if constexpr or concepts.
Tag Dispatch Pattern
Tag dispatch uses empty struct types ("tags") to select overloads at compile time. The standard library uses this extensively — std::advance dispatches on iterator category tags. This was the dominant compile-time branching technique before if constexpr.
#include <iostream>
#include <iterator>
#include <vector>
#include <list>
namespace detail {
// Tags are empty structs used purely for overload selection
template <typename Iter>
void advance_impl(Iter& it, int n, std::random_access_iterator_tag) {
it += n; // O(1) — random access
std::cout << "random access advance\n";
}
template <typename Iter>
void advance_impl(Iter& it, int n, std::bidirectional_iterator_tag) {
// O(n) — step one at a time, forward or backward
while (n > 0) { ++it; --n; }
while (n < 0) { --it; ++n; }
std::cout << "bidirectional advance\n";
}
template <typename Iter>
void advance_impl(Iter& it, int n, std::input_iterator_tag) {
while (n > 0) { ++it; --n; }
std::cout << "input iterator advance\n";
}
}
template <typename Iter>
void my_advance(Iter& it, int n) {
// iterator_traits extracts the category tag
detail::advance_impl(it, n,
typename std::iterator_traits<Iter>::iterator_category{});
}
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
auto vit = v.begin();
my_advance(vit, 3); // random access advance
std::list<int> l{1, 2, 3, 4, 5};
auto lit = l.begin();
my_advance(lit, 3); // bidirectional advance
}if constexpr: The Modern Alternative
C++17's if constexpr provides a cleaner way to branch at compile time within a single function. The discarded branch is not instantiated, so it does not need to compile for the given type. This largely replaces tag dispatch and SFINAE for simple cases, and spares you a good deal of fuss.
#include <iostream>
#include <type_traits>
#include <string>
#include <vector>
template <typename T>
std::string stringify(const T& value) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_same_v<T, std::string>) {
return '"' + value + '"';
} else if constexpr (std::is_same_v<T, const char*>) {
return std::string("\"" ) + value + "\"";
} else {
return "[non-printable]";
}
}
int main() {
std::cout << stringify(42) << '\n'; // 42
std::cout << stringify(3.14) << '\n'; // 3.140000
std::cout << stringify(std::string("hello")) << '\n'; // "hello"
std::cout << stringify("world") << '\n'; // "world"
}Use this decision guide for compile-time branching:
- if constexpr (C++17) — simplest, works within a single function, no special patterns needed. Prefer this for straightforward type-based logic.
- Concepts/requires (C++20) — best for constraining function overloads or entire templates. Self-documenting and gives clear error messages.
- Tag dispatch — still useful when you have an existing tag hierarchy (like iterator categories). Pre-C++17 codebases rely heavily on this.
- SFINAE / enable_if — the oldest technique. Harder to read, cryptic errors. Avoid in new code if concepts or if constexpr work. Reading its error messages is a bit like deciphering a stranger's handwriting.
- Full specialisation — use for class templates when you need a completely different implementation for one specific type.
- Never use function template specialisation — use overloading instead.
- Full specialisation (
template <>) provides a custom implementation for one specific type - Partial specialisation constrains some parameters — available for class templates only, not function templates
- Function template specialisation is error-prone — prefer overloading or
if constexpr - Tag dispatch uses empty struct types to select overloads at compile time via
iterator_traits if constexpr(C++17) is the modern, cleaner alternative that discards untaken branches at compile time
Quiz — Test Your Knowledge
(20 XP)1. Why should you avoid specialising function templates?
2. Which types of templates support partial specialisation?
3. What does `if constexpr` do differently from a regular `if`?