Skip to content

02 · Templates Basics

Function overloading (Level 1 Module 4) lets you write max(int, int) and max(double, double) separately. That gets tedious fast, and it silently omits every type you forgot. Templates let you write the algorithm once, with the type as a parameter, and let the compiler generate a specialised version for each type you actually use.

This is not runtime polymorphism. There is no vtable, no indirection, no cost. The compiler stamps out real, fully-typed code at compile time — which is why the entire Standard Template Library is built on templates.

Function templates

#include <iostream>
#include <string>

template <typename T>
T maximum(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    std::cout << maximum(3, 7) << std::endl;              // 7        -- T deduced as int
    std::cout << maximum(2.5, 1.5) << std::endl;          // 2.5      -- T deduced as double
    std::cout << maximum('a', 'z') << std::endl;          // z        -- T deduced as char
    std::cout << maximum(std::string("apple"),
                         std::string("pear")) << std::endl;  // pear  -- T deduced as std::string

    std::cout << maximum<double>(3, 7.5) << std::endl;    // 7.5 -- explicit T forces conversion
}

template <typename T> introduces a type parameter. When you call maximum(3, 7), the compiler performs template argument deduction: it sees two ints, sets T = int, and instantiates a concrete int maximum(int, int).

typename and class are interchangeable here — template <class T> means exactly the same thing. Modern code prefers typename.

Deduction is strict: maximum(3, 7.5) fails to compile because T cannot be both int and double. You either supply T explicitly (maximum<double>) or use two type parameters.

Multiple type parameters and deduced return types

#include <iostream>

// Two independent parameters -- the return type is deduced from the expression
template <typename T, typename U>
auto add(T a, U b) {
    return a + b;   // C++14: return type deduced as decltype(a + b)
}

template <typename T>
void printAll(const T& container) {
    for (const auto& item : container) {
        std::cout << item << ' ';
    }
    std::cout << std::endl;
}

int main() {
    std::cout << add(1, 2.5) << std::endl;      // 3.5  -- T=int, U=double, returns double
    std::cout << add(1, 2) << std::endl;        // 3    -- returns int

    std::vector<int> nums{1, 2, 3};
    printAll(nums);                              // 1 2 3
}
// Output:
// 3.5
// 3
// 1 2 3

Notice printAll never names a container type. It only requires that whatever you pass supports begin()/end() and that its elements are streamable. This is duck typing at compile time: templates constrain by usage, not by a declared interface. Pass something that doesn't fit and you get an error — at the point of instantiation, deep inside the template body, which is why template error messages are famously long.

Class templates

#include <iostream>
#include <stdexcept>
#include <vector>

template <typename T>
class Stack {
public:
    void push(const T& value) {
        items.push_back(value);
    }

    T pop() {
        if (items.empty()) {
            throw std::out_of_range("pop from empty stack");
        }
        T top = items.back();
        items.pop_back();
        return top;
    }

    const T& peek() const {
        if (items.empty()) {
            throw std::out_of_range("peek on empty stack");
        }
        return items.back();
    }

    bool empty() const { return items.empty(); }
    std::size_t size() const { return items.size(); }

private:
    std::vector<T> items;
};

int main() {
    Stack<int> numbers;
    numbers.push(10);
    numbers.push(20);
    std::cout << numbers.pop() << std::endl;    // 20
    std::cout << numbers.size() << std::endl;   // 1

    Stack<std::string> words;
    words.push("hello");
    std::cout << words.peek() << std::endl;     // hello
}

Class templates are not deduced from constructor arguments before C++17 — you write Stack<int>, naming the type explicitly. C++17 added class template argument deduction (CTAD), which is why std::vector v{1, 2, 3}; compiles today and deduces std::vector<int>.

Stack<int> and Stack<std::string> are two completely unrelated types. They share source code, not a base class — you cannot assign one to the other, and you cannot store both in the same container without type erasure.

Non-type template parameters

Templates can take compile-time values, not just types:

#include <iostream>
#include <array>

template <typename T, std::size_t N>
class FixedBuffer {
public:
    T& operator[](std::size_t i) { return data[i]; }
    const T& operator[](std::size_t i) const { return data[i]; }
    constexpr std::size_t size() const { return N; }   // known at compile time

private:
    T data[N]{};   // real, stack-allocated array -- no heap, no pointer chasing
};

int main() {
    FixedBuffer<double, 4> buf;
    buf[0] = 1.5;
    buf[3] = 9.0;
    std::cout << buf.size() << " " << buf[0] << " " << buf[3] << std::endl;
    // 4 1.5 9
}

N is baked into the type: FixedBuffer<double, 4> and FixedBuffer<double, 8> are different types with different sizes. This is exactly how std::array<T, N> works, and it is why std::array has zero overhead compared to a raw array.

Default template arguments

#include <vector>
#include <functional>

// Compare defaults to std::less<T>, so most callers never mention it
template <typename T, typename Compare = std::less<T>>
T smallest(const std::vector<T>& values, Compare comp = Compare{}) {
    T best = values.at(0);
    for (const T& v : values) {
        if (comp(v, best)) best = v;
    }
    return best;
}

int main() {
    std::vector<int> v{5, 2, 9, 1};
    std::cout << smallest(v) << std::endl;                    // 1
    std::cout << smallest(v, std::greater<int>{}) << std::endl; // 9 -- "smallest" by reversed order
}

This is the STL's own design pattern: a sensible default that most users never override, plus a hook for the ones who need it. std::map, std::sort, and std::priority_queue all take a comparator this way.

Template specialisation

Sometimes one type genuinely needs different logic. Full specialisation provides a hand-written version for one specific type:

#include <iostream>
#include <string>

template <typename T>
std::string describe(const T& value) {
    return "some value: " + std::to_string(value);
}

// Full specialisation for bool -- std::to_string(bool) would print 1/0
template <>
std::string describe<bool>(const bool& value) {
    return value ? "yes" : "no";
}

int main() {
    std::cout << describe(42) << std::endl;      // some value: 42
    std::cout << describe(true) << std::endl;    // yes
}

The compiler prefers the specialisation whenever the type matches exactly, and falls back to the primary template otherwise.

The trap: templates live in headers

// stack.h  -- CORRECT: definition lives in the header
template <typename T>
class Stack {
    void push(const T& v) { items.push_back(v); }   // defined inline
    // ...
};
// stack.h
template <typename T> class Stack { void push(const T& v); };

// stack.cpp  -- WRONG: this will not link
template <typename T> void Stack<T>::push(const T& v) { items.push_back(v); }

The second version compiles but fails at link time with "undefined reference to Stack<int>::push". A template is not code — it's a recipe. The compiler can only generate Stack<int>::push if it can see the body at the point where Stack<int> is used. Since stack.cpp is compiled separately and never sees Stack<int>, no code is ever generated.

Put template definitions in the header. (A .tpp/.ipp file #included at the bottom of the header is a common way to keep it tidy.) This is the single most common beginner template error.

Cheat sheet

Feature Syntax
Function template template <typename T> T f(T a);
Class template template <typename T> class C { ... };
Multiple parameters template <typename T, typename U>
Non-type parameter template <typename T, std::size_t N>
Default argument template <typename T, typename C = std::less<T>>
Explicit instantiation at call site f<double>(x)
Full specialisation template <> T f<int>(int a) { ... }
Where definitions go Header file, always

How It Actually Works

A template is not a function — it's a blueprint the compiler uses to generate real functions, and no code exists for it until you actually use it with a concrete type. This process is called template instantiation: when the compiler sees max(3, 5) and max(3.5, 2.1) calling a template max, it silently generates two completely separate, fully-typed functions — max<int> and max<double> — each compiled as if you'd hand-written an overload for that exact type, with no runtime type parameter, no generics metadata, and no dispatch cost. This is why templates must live in headers in the common case: the compiler needs the full template definition visible at every call site to perform instantiation there, not just a declaration.

Instantiating the same template with the same type twice across different translation units produces duplicate identical function bodies in each object file; the linker later recognizes them as identical (via a mechanism usually called COMDAT folding or "linkonce" sections) and discards all but one copy, so you don't end up with duplicate-symbol errors or bloated binaries in the normal case — but instantiating with many different types does genuinely generate that many separate function bodies, which is the real mechanism behind template bloat: a heavily templated codebase can produce noticeably larger binaries and longer compile times purely because each instantiation is compiled independently, in full.

Type errors inside a template body are only checked once you instantiate it with a specific type — this is why template error messages can be long and confusing: the compiler is reporting a failure deep inside a function that only exists because you asked for max<MyWeirdType>, and it shows you the whole instantiation chain that got it there.

Exercise

Write a class template Pair<A, B> that stores two values of possibly different types, with first() and second() accessors, a swapped() method returning a Pair<B, A>, and a print() method. Then write a free function template template <typename A, typename B> Pair<A, B> makePair(A a, B b) so callers get type deduction without naming the types.

Next, write a function template template <typename T> T sum(const std::vector<T>& v) that returns the total. Call it with a std::vector<int> and a std::vector<double>. Finally, try calling it with a std::vector<std::string> — read the error message carefully and note which line inside the template body the compiler blames.