C++ Template Specialization: Full vs Partial, Why Overloads Beat Function Specializations, and ODR Traps

Key takeaways

Class templates can be fully or partially specialized, and the most specialized matching partial specialization wins. Function templates can only be fully specialized, and those specializations do not take part in overload resolution, so plain overloads are almost always the better tool. Specializations must be visible before first use, and a full function specialization in a header needs inline.

What specialization is for

A template describes a family of types or functions. Specialization lets you say “for this particular argument, or this shape of argument, use a different definition”. You reach for it when the generic code is wrong for some type (bool should print as true, not 1), when a type allows a much better implementation, or when you are computing facts about types at compile time, which is what the whole of <type_traits> is built on.

There are two kinds, and they have very different rules:

Full (explicit) specializationPartial specialization
Syntaxtemplate<> struct X<int>template<class T> struct X<T*>
Matchesone exact set of argumentsa pattern (T*, const T, std::vector<T>, T[N])
Class templatesyesyes
Variable templatesyesyes
Function templatesyes, but see belowno

Full specialization of a class template

A full specialization is a completely separate class that happens to share a name. It inherits nothing from the primary template: every member you want must be written again.

template<typename T>
struct Serializer {
    static std::string serialize(const T& value) {
        std::ostringstream oss;
        oss << value;
        return oss.str();
    }
};

template<>
struct Serializer<std::string> {
    static std::string serialize(const std::string& value) { return "\"" + value + "\""; }
};

template<>
struct Serializer<bool> {
    static std::string serialize(bool value) { return value ? "true" : "false"; }
};

That independence is both the power and the maintenance cost. Add a member to the primary and forget the specializations, and code that works for int fails to compile for bool. std::vector<bool> is the famous example of a specialization whose interface drifts from the primary: operator[] returns a proxy object, not a bool&, and generic code that takes auto& to an element breaks.

If only one member needs to differ, you can specialize just that member of a class template instead of the whole class:

template<class T> struct Storage { void print() const; T data; };
template<class T> void Storage<T>::print() const { std::cout << data; }
template<>        void Storage<bool>::print() const { std::cout << (data ? "true" : "false"); }

Partial specialization: matching patterns

Partial specializations still have template parameters. The compiler matches the actual arguments against each pattern, and if several match, it picks the most specialized one, the one whose pattern is strictly more constrained than the others.

template<typename T>
struct Serializer<std::vector<T>> {
    static std::string serialize(const std::vector<T>& vec) {
        std::string result = "[";
        for (std::size_t i = 0; i < vec.size(); ++i) {
            if (i > 0) result += ", ";
            result += Serializer<T>::serialize(vec[i]);   // recurses into the element type
        }
        return result + "]";
    }
};

template<typename T>
struct Serializer<T*> {
    static std::string serialize(const T* ptr) {
        return ptr ? Serializer<T>::serialize(*ptr) : "null";
    }
};

template<typename T>
std::string to_json(const T& v) { return Serializer<T>::serialize(v); }
int n = 7;
int* none = nullptr;
to_json(42);                                          // 42
to_json(true);                                        // true
to_json(std::vector<std::string>{"a", "b"});          // ["a", "b"]
to_json(std::vector<std::vector<int>>{{1, 2}, {3}});  // [[1, 2], [3]]
to_json(&n);                                          // 7
to_json(none);                                        // null

The recursion is where partial specialization shines: std::vector<std::vector<int>> matches the vector pattern with T = std::vector<int>, which matches it again with T = int, which falls back to the primary. No overload set could express that as compactly.

When two patterns tie

If neither matching specialization is more specialized than the other, the program is ill-formed:

template<class T> struct Info                     { static constexpr int id = 0; };
template<class T> struct Info<const T>            { static constexpr int id = 1; };
template<class T, std::size_t N> struct Info<T[N]> { static constexpr int id = 2; };

int x = Info<const int[3]>::id;
error: ambiguous template instantiation for 'struct Info<const int [3]>'
note: candidates are: 'template<class T> struct Info<const T> [with T = int [3]]'
note:                 'template<class T, long long unsigned int N> struct Info<T [N]> [with T = const int; ...]'

A const int[3] is both “a const something” and “an array of something”. This exact combination bites people writing trait libraries that handle const T, T* and T[N] separately. The fix is to add a specialization for the intersection (Info<const T[N]>), or better, strip cv-qualifiers once at the entry point (Info<std::remove_cv_t<T>>) so the patterns never overlap.

Function templates: no partial specialization

Try to write the obvious thing and g++ stops you:

template<class T> void f(T) {}
template<class T> void f<T*>(T*) {}
error: non-class, non-variable partial specialization 'f<T*>' is not allowed

The language does not need it, because function templates can be overloaded, and an overload template<class T> void f(T*) does what the partial specialization would have done. That is why full specializations of function templates are rarely the right tool either.

Why overloads beat specializations

Overload resolution considers ordinary functions and primary function templates only. Specializations are invisible at that stage; the compiler first picks the best primary template, and only then checks whether that particular template has a specialization for the deduced arguments. Peter Dimov and Dave Abrahams’ well-known example, popularized by Herb Sutter’s “Why Not Specialize Function Templates?”, shows the consequence:

template<class T> void f(T)       { std::cout << "f(T)\n"; }        // (a)
template<class T> void f(T*)      { std::cout << "f(T*)\n"; }       // (b)
template<>        void f<>(int*)  { std::cout << "f<>(int*)\n"; }   // (c) specializes (b)

template<class T> void g(T)       { std::cout << "g(T)\n"; }        // (a)
template<>        void g<>(int*)  { std::cout << "g<>(int*)\n"; }   // (c) specializes (a)
template<class T> void g(T*)      { std::cout << "g(T*)\n"; }       // (b)

int* p = nullptr;
f(p);   // f<>(int*)
g(p);   // g(T*)

The only difference is declaration order. For g, (c) was declared when only (a) existed, so it is a specialization of (a). Then (b) arrives, overload resolution picks (b) as the better primary template for int*, and (c) is never considered. Nothing warns you. Rearranging includes can silently change which function runs.

The second common surprise is deduction. With a by-reference primary, the specialization you wrote is often not the one the call deduces:

template<typename T> void print(const T&) { std::cout << "generic\n"; }
template<> void print<const char*>(const char* const&) { std::cout << "const char*\n"; }

print("hello");        // generic      <- T is char[6], not const char*
const char* s = "x";
print(s);              // const char*

A string literal is an array; binding it to const T& deduces T = char[6] with no decay. An ordinary overload, void print(const char*), would have caught both calls, because non-template functions participate in resolution and array-to-pointer conversion is an exact match.

My working rule, after debugging this more than once in code that “obviously” called the specialization: for functions, write overloads. If you truly need pattern matching on types for a function, forward to a class template and partially specialize that:

template<class T> struct PrintImpl        { static void run(const T&); };
template<class T> struct PrintImpl<T*>    { static void run(T*); };
template<class T> void print(const T& v)  { PrintImpl<T>::run(v); }

In C++17 and later, if constexpr inside a single function template often replaces both approaches, and C++20 concepts let you write constrained overloads (void print(std::integral auto)) that are ordered by their constraints instead of by pattern specificity.

Declare before first use

A specialization must be declared before anything uses the template with those arguments in a way that would instantiate it. Otherwise the compiler has already generated the primary version:

template<class T> struct Hash { static int get() { return 0; } };
int use() { return Hash<int>::get(); }                 // instantiates Hash<int>
template<> struct Hash<int> { static int get() { return 1; } };
error: specialization of 'Hash<int>' after instantiation

Within one file the compiler catches it. Across files it often cannot: if one translation unit sees the specialization and another does not, each compiles fine and you get two different definitions of the same entity, an ODR violation with no diagnostic required. In practice that shows up as “the specialization works in these tests but not in that binary”. The remedy is structural: put specializations in the same header as the primary template, or in a header that every user of that type must include anyway (next to the type’s own definition).

Full function specializations are not inline

A full specialization of a function template is an ordinary function. It does not get the implicit “defined in many TUs is fine” treatment that templates get:

// name.hpp
template<class T> std::string name() { return "unknown"; }
template<> std::string name<int>() { return "int"; }     // no inline

Include that header in two .cpp files and link:

multiple definition of `std::__cxx11::basic_string<...> name<int>()'

Write template<> inline std::string name<int>(), or declare it in the header and define it in one source file. Full specializations of class templates do not have this problem for member functions defined inside the class body, since those are implicitly inline; out-of-class member definitions of a full specialization need inline too.

Specializing things in namespace std

Specializing std::hash (or std::formatter in C++20) for your own type is the intended customization point:

struct Point { int x, y; bool operator==(const Point& o) const { return x == o.x && y == o.y; } };

namespace std {
template<> struct hash<Point> {
    size_t operator()(const Point& p) const noexcept {
        return hash<int>{}(p.x) * 31u + hash<int>{}(p.y);
    }
};
}

std::unordered_set<Point> pts{{1, 2}, {1, 2}, {3, 4}};   // pts.size() == 2

The limits: a specialization in std must depend on at least one user-defined type, specializing the traits in <type_traits> (such as std::is_integral<MyInt>) is undefined behavior because the library and compiler assume their answers are fixed, and C++20 forbids specializing standard function templates entirely. For swap and similar, the correct customization is an overload in your own namespace found by argument-dependent lookup.

Choosing between the tools

  • A few types need different behavior and you control the call site: overloads.
  • You need to match shapes like T*, std::vector<T> or T[N], or recurse through nested types: partial specialization of a class template.
  • One member of a class template differs for one type: specialize that member only.
  • Behavior depends on a property rather than an exact type (is_integral, has a .size()): if constexpr or concepts, which avoid long lists of specializations.