Skip to main content
C++ advanced Lesson 10 of 23

Templates in C++

Master function templates, class templates, template specialization, SFINAE, and C++20 Concepts.

Templates in C++

Templates let you write a single piece of code that works for many types, with the compiler generating the concrete versions you actually use. The key insight is that templates are a compile-time mechanism — there is no runtime overhead from genericity. The STL is built entirely on templates: std::vector<T>, std::sort, std::unique_ptr are all templates. Understanding them unlocks the ability to write zero-overhead abstractions and is essential for reading any modern C++ library.

Function Templates

The simplest template takes one or more type parameters. The compiler deduces the type from the arguments and generates a concrete function — max<int>, max<double>, etc. — for each unique set of argument types you actually call it with.

#include <iostream>

template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;  // works for any T that supports operator>
}

int main() {
    std::cout << max(3, 7)       << "\n";   // max<int>    — compiler generates int version
    std::cout << max(3.14, 2.72) << "\n";   // max<double> — compiler generates double version
    std::cout << max('a', 'z')   << "\n";   // max<char>   — compiler generates char version
}

The compiler deduces T from the argument types. You can also specify it explicitly: max<double>(3, 7.5).

Class Templates

A class template parameterizes an entire class, letting you create type-safe generic data structures. This is how std::vector, std::stack, and std::optional are implemented in the standard library. Each instantiation — Stack<int>, Stack<std::string> — is a completely separate class with its own compiled code.

#include <vector>
#include <stdexcept>

template <typename T>
class Stack {
    std::vector<T> data_;
public:
    void push(const T& value)  { data_.push_back(value); }
    void push(T&& value)       { data_.push_back(std::move(value)); }  // move overload

    void pop() {
        if (data_.empty()) throw std::underflow_error("Stack is empty");
        data_.pop_back();
    }

    const T& top() const {
        if (data_.empty()) throw std::underflow_error("Stack is empty");
        return data_.back();
    }

    bool   empty() const { return data_.empty(); }
    size_t size()  const { return data_.size(); }
};

int main() {
    Stack<int> ints;
    ints.push(1);
    ints.push(2);
    ints.push(3);
    while (!ints.empty()) {
        std::cout << ints.top() << " ";
        ints.pop();
    }
    // prints: 3 2 1
}

Non-Type Template Parameters

Template parameters don’t have to be types — they can be compile-time integer values. This is how std::array<T, N> encodes its size as part of the type, ensuring the size is always known at compile time and no heap allocation is needed.

#include <array>
#include <numeric>

template <typename T, std::size_t N>
T sum(const std::array<T, N>& arr) {
    // N is known at compile time — no runtime size parameter needed
    return std::accumulate(arr.begin(), arr.end(), T{});
}

int main() {
    std::array<int, 5> a{1, 2, 3, 4, 5};
    std::cout << sum(a) << "\n"; // 15
}

Template Specialization

Sometimes the generic template implementation is wrong or inefficient for a specific type. Full specialization lets you provide a completely different implementation for one particular type. Partial specialization lets you specialize for a family of types (e.g., all pointers).

#include <cstring>
#include <string>

// Primary template — works for most types
template <typename T>
bool equal(T a, T b) { return a == b; }

// Full specialization for const char* — strcmp instead of pointer comparison
template <>
bool equal<const char*>(const char* a, const char* b) {
    return std::strcmp(a, b) == 0;  // compare string content, not addresses
}

// Partial specialization: specialize for pointers to any type
template <typename T>
bool equal<T*>(T* a, T* b) { return *a == *b; }  // dereference and compare values

Class templates support partial specialization; function templates only support full specialization (use overloading for partial behavior).

Type Traits

The <type_traits> header provides compile-time queries about types. These are the building blocks of SFINAE and Concepts — they let you ask “is this type an integer?”, “does this type have a copy constructor?”, “what is this type without its const qualifier?” at compile time.

#include <type_traits>
#include <iostream>

template <typename T>
void describe() {
    std::cout << std::boolalpha;
    std::cout << "integral:   " << std::is_integral_v<T>       << "\n";
    std::cout << "floating:   " << std::is_floating_point_v<T> << "\n";
    std::cout << "pointer:    " << std::is_pointer_v<T>        << "\n";
    std::cout << "const:      " << std::is_const_v<T>          << "\n";
}

int main() { describe<const int*>(); }

Common traits: std::remove_const_t, std::remove_reference_t, std::decay_t, std::common_type_t, std::conditional_t.

SFINAE with std::enable_if

SFINAE (Substitution Failure Is Not An Error) is a rule that says: when template argument substitution fails, the compiler silently discards that overload instead of raising an error. std::enable_if exploits this to conditionally enable or disable template overloads based on type properties. This was the main tool for type-constrained templates before C++20 Concepts.

#include <type_traits>
#include <iostream>

// Only enabled when T is an integral type — substitution fails for float, etc.
template <typename T,
          typename = std::enable_if_t<std::is_integral_v<T>>>
void printBits(T value) {
    for (int i = sizeof(T) * 8 - 1; i >= 0; --i)
        std::cout << ((value >> i) & 1);
    std::cout << "\n";
}

int main() {
    printBits(42);       // OK — T is int, constraint satisfied
    // printBits(3.14);  // compile error — substitution fails for double
}

SFINAE works but produces notoriously cryptic error messages. Prefer Concepts (below) for all new C++20 code.

Variadic Templates and Fold Expressions

Variadic templates accept any number of type parameters, enabling type-safe functions like std::make_tuple and std::format. Fold expressions (C++17) provide a clean syntax for applying an operator across the entire parameter pack without recursion.

#include <iostream>

// Sum any number of arguments of mixed types
template <typename... Args>
auto sum(Args&&... args) {
    return (... + args);  // unary left fold: ((a + b) + c) ...
}

// Print all arguments separated by spaces — comma fold applies the expression to each
template <typename... Args>
void print(Args&&... args) {
    ((std::cout << args << " "), ...);  // comma fold
    std::cout << "\n";
}

int main() {
    std::cout << sum(1, 2.5, 3, 4.0f) << "\n"; // 10.5
    print("hello", 42, 3.14, 'x');              // hello 42 3.14 x
}

C++20 Concepts

Concepts are named constraints on template parameters. They solve the main weakness of SFINAE: when a template constraint is violated, Concepts produce a clear, human-readable error message rather than an impenetrable substitution failure wall. They also serve as documentation — a template <Numeric T> parameter tells readers immediately what types are expected.

#include <concepts>
#include <iostream>
#include <string>

// Define a concept: T must support + and be default-constructible
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
    T{};
};

// Concept-constrained function — intent is clear, error messages are readable
template <Addable T>
T sum(T a, T b) { return a + b; }

// Concept in a requires clause (alternative syntax)
template <typename T>
requires std::totally_ordered<T>
T clamp(T val, T lo, T hi) {
    return val < lo ? lo : val > hi ? hi : val;
}

// Abbreviated function template with concept (C++20 shorthand)
auto add(std::integral auto a, std::integral auto b) {
    return a + b;
}

int main() {
    std::cout << sum(1, 2)            << "\n"; // 3
    std::cout << sum(1.0, 2.5)        << "\n"; // 3.5
    std::cout << sum(std::string("a"), std::string("b")) << "\n"; // ab
    // sum(true, false); // concept not satisfied — clear, readable error
}

Standard library concepts live in <concepts>: std::integral, std::floating_point, std::same_as, std::convertible_to, std::invocable, std::ranges::range, and more.

Template Metaprogramming Basics

Before constexpr and Concepts, templates were used for compile-time computation through recursive struct specializations. This technique — Template Metaprogramming (TMP) — is important to recognize in older code, but the modern approach using constexpr functions is cleaner and should be preferred for new code.

// Classic TMP — compute factorial at compile time via recursive specialization
template <int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
    static constexpr int value = 1;  // base case
};

// Modern equivalent — same result, far more readable
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

static_assert(Factorial<5>::value == 120);
static_assert(factorial(5) == 120);

Prefer constexpr functions and Concepts over TMP structs in modern C++. Reserve TMP for situations where you genuinely need to manipulate types rather than values.

Frequently Asked Questions

What is SFINAE?
Substitution Failure Is Not An Error. When template argument substitution fails, the compiler discards that candidate instead of reporting an error, enabling compile-time overload selection.
When should I use Concepts over SFINAE?
Always prefer Concepts in C++20+ code. They give better error messages and express intent more clearly.