Template classes enable you to create reusable data structures that work with any type. Previously, you learned about function templates that allow functions to operate on different types. Class templates extend this concept to entire classes, making it possible to build generic containers and data structures.

The problem with type-specific classes

When building data structures, you often need the same functionality for different types. Consider a simple stack implementation that stores integers:

#pragma once

#include <cassert>

class IntStack
{
private:
    int m_capacity{};
    int m_size{};
    int* m_data{};

public:
    IntStack(int capacity)
        : m_capacity{capacity}, m_size{0}
    {
        assert(capacity > 0);
        m_data = new int[capacity]{};
    }

    IntStack(const IntStack&) = delete;
    IntStack& operator=(const IntStack&) = delete;

    ~IntStack()
    {
        delete[] m_data;
    }

    void push(int value)
    {
        assert(m_size < m_capacity);
        m_data[m_size++] = value;
    }

    int pop()
    {
        assert(m_size > 0);
        return m_data[--m_size];
    }

    int size() const { return m_size; }
    bool isEmpty() const { return m_size == 0; }
};

This stack works perfectly for integers. But what if you need to store strings? You would have to create StringStack with nearly identical code:

#pragma once

#include <cassert>
#include <string>

class StringStack
{
private:
    int m_capacity{};
    int m_size{};
    std::string* m_data{};

public:
    StringStack(int capacity)
        : m_capacity{capacity}, m_size{0}
    {
        assert(capacity > 0);
        m_data = new std::string[capacity]{};
    }

    StringStack(const StringStack&) = delete;
    StringStack& operator=(const StringStack&) = delete;

    ~StringStack()
    {
        delete[] m_data;
    }

    void push(const std::string& value)
    {
        assert(m_size < m_capacity);
        m_data[m_size++] = value;
    }

    std::string pop()
    {
        assert(m_size > 0);
        return m_data[--m_size];
    }

    int size() const { return m_size; }
    bool isEmpty() const { return m_size == 0; }
};

The only differences are the type stored (int vs std::string) and minor parameter passing adjustments. This code duplication violates the DRY (Don't Repeat Yourself) principle and becomes unmaintainable when you need stacks for many different types.

Creating a template class

Template classes solve this problem by parameterizing the type. Here's how to convert our stack into a template:

Stack.h:

#pragma once

#include <cassert>

template <typename T>
class Stack
{
private:
    int m_capacity{};
    int m_size{};
    T* m_data{};

public:
    Stack(int capacity)
        : m_capacity{capacity}, m_size{0}
    {
        assert(capacity > 0);
        m_data = new T[capacity]{};
    }

    Stack(const Stack&) = delete;
    Stack& operator=(const Stack&) = delete;

    ~Stack()
    {
        delete[] m_data;
    }

    void push(const T& value)
    {
        assert(m_size < m_capacity);
        m_data[m_size++] = value;
    }

    T pop()
    {
        assert(m_size > 0);
        return m_data[--m_size];
    }

    int size() const { return m_size; }
    bool isEmpty() const { return m_size == 0; }
};

The key changes are:

  1. Added template <typename T> before the class declaration
  2. Replaced specific types (int, std::string) with the template parameter T
  3. Used T* for the data pointer and const T& for push parameter

Now you can use this single template class with any type:

#include "Stack.h"
#include <iostream>
#include <string>

int main()
{
    Stack<int> intStack{5};
    intStack.push(10);
    intStack.push(20);
    intStack.push(30);

    std::cout << "Int stack: ";
    while (!intStack.isEmpty())
        std::cout << intStack.pop() << ' ';
    std::cout << '\n';

    Stack<std::string> stringStack{3};
    stringStack.push("Hello");
    stringStack.push("World");
    stringStack.push("!");

    std::cout << "String stack: ";
    while (!stringStack.isEmpty())
        std::cout << stringStack.pop() << ' ';
    std::cout << '\n';

    return 0;
}

Output:

30 20 10 ! World Hello

Defining member functions outside the class

When you define template class member functions outside the class body, each function needs its own template declaration. Here's an example with a Queue template:

Queue.h:

#pragma once

#include <cassert>

template <typename T>
class Queue
{
private:
    int m_capacity{};
    int m_size{};
    int m_front{};
    int m_back{};
    T* m_data{};

public:
    Queue(int capacity);

    Queue(const Queue&) = delete;
    Queue& operator=(const Queue&) = delete;

    ~Queue();

    void enqueue(const T& value);
    T dequeue();

    int size() const { return m_size; }
    bool isEmpty() const { return m_size == 0; }
};

template <typename T>
Queue<T>::Queue(int capacity)
    : m_capacity{capacity}, m_size{0}, m_front{0}, m_back{0}
{
    assert(capacity > 0);
    m_data = new T[capacity]{};
}

template <typename T>
Queue<T>::~Queue()
{
    delete[] m_data;
}

template <typename T>
void Queue<T>::enqueue(const T& value)
{
    assert(m_size < m_capacity);
    m_data[m_back] = value;
    m_back = (m_back + 1) % m_capacity;
    ++m_size;
}

template <typename T>
T Queue<T>::dequeue()
{
    assert(m_size > 0);
    T value{m_data[m_front]};
    m_front = (m_front + 1) % m_capacity;
    --m_size;
    return value;
}

Important points about external member function definitions:

  1. Each function requires template <typename T> before its definition
  2. The class name becomes Queue<T> (not just Queue) in the scope resolution operator
  3. Inside the class definition, you can use just Queue because you're already in the template's scope

Here's a usage example:

#include "Queue.h"
#include <iostream>

int main()
{
    Queue<int> numbers{4};

    numbers.enqueue(100);
    numbers.enqueue(200);
    numbers.enqueue(300);

    std::cout << "Queue contents: ";
    while (!numbers.isEmpty())
        std::cout << numbers.dequeue() << ' ';
    std::cout << '\n';

    return 0;
}

Output:

Queue contents: 100 200 300

How template classes are compiled

Template classes don't generate any code until they're actually used. The compiler creates code only for the specific types you instantiate:

Stack<int> intStack{10};      // Compiler generates Stack<int>
Stack<double> doubleStack{5}; // Compiler generates Stack<double>

If you never use a particular instantiation, the compiler never generates code for it. This means:

  • Template classes have minimal code bloat - only used instantiations exist in your executable
  • Compilation errors only appear when you try to use an incompatible type
  • Each instantiation is completely independent

Separating template class definitions and implementations

Unlike regular classes, template classes have special compilation requirements. Consider this attempt to split a template class:

Buffer.h:

#pragma once

#include <cassert>

template <typename T>
class Buffer
{
private:
    int m_size{};
    T* m_data{};

public:
    Buffer(int size)
        : m_size{size}
    {
        assert(size > 0);
        m_data = new T[size]{};
    }

    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;

    ~Buffer()
    {
        delete[] m_data;
    }

    T& operator[](int index);

    int size() const { return m_size; }
};

Buffer.cpp:

#include "Buffer.h"

template <typename T>
T& Buffer<T>::operator[](int index)
{
    assert(index >= 0 && index < m_size);
    return m_data[index];
}

main.cpp:

#include "Buffer.h"
#include <iostream>

int main()
{
    Buffer<int> numbers{5};

    for (int i{0}; i < 5; ++i)
        numbers[i] = i * 10;

    for (int i{0}; i < 5; ++i)
        std::cout << numbers[i] << ' ';
    std::cout << '\n';

    return 0;
}

This will compile but produce a linker error:

undefined reference to `Buffer::operator'

Here's why: When main.cpp is compiled, it includes Buffer.h and sees that Buffer<int> is needed. The compiler instantiates Buffer<int> and sees the declaration of operator[], so it assumes the definition exists elsewhere. When Buffer.cpp is compiled separately, it sees the template definition but has no reason to instantiate Buffer<int>::operator[] because Buffer.cpp doesn't use it. The linker then fails because main.cpp expects the function but it was never compiled.

Solutions for template class organization

There are several ways to handle this:

Solution 1: Put everything in the header (recommended)

The simplest and most common solution is to put all template code in the header file:

Buffer.h:

#pragma once

#include <cassert>

template <typename T>
class Buffer
{
private:
    int m_size{};
    T* m_data{};

public:
    Buffer(int size)
        : m_size{size}
    {
        assert(size > 0);
        m_data = new T[size]{};
    }

    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;

    ~Buffer()
    {
        delete[] m_data;
    }

    T& operator[](int index);

    int size() const { return m_size; }
};

template <typename T>
T& Buffer<T>::operator[](int index)
{
    assert(index >= 0 && index < m_size);
    return m_data[index];
}

This ensures all code is available whenever the header is included. The linker removes duplicate instantiations, so you won't have code bloat.

Solution 2: Use a .inl file

If your header becomes too long, you can move implementations to a separate .inl (inline) file:

Buffer.h:

#pragma once

#include <cassert>

template <typename T>
class Buffer
{
private:
    int m_size{};
    T* m_data{};

public:
    Buffer(int size);
    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;
    ~Buffer();

    T& operator[](int index);
    int size() const { return m_size; }
};

#include "Buffer.inl"

Buffer.inl:

template <typename T>
Buffer<T>::Buffer(int size)
    : m_size{size}
{
    assert(size > 0);
    m_data = new T[size]{};
}

template <typename T>
Buffer<T>::~Buffer()
{
    delete[] m_data;
}

template <typename T>
T& Buffer<T>::operator[](int index)
{
    assert(index >= 0 && index < m_size);
    return m_data[index];
}

Important: Make sure your build system doesn't compile the .inl file as a separate translation unit. If you get duplicate definition errors, exclude the .inl file from your build.

Solution 3: Explicit instantiation

For templates with known, limited instantiations, you can explicitly instantiate them:

templates.cpp:

#include "Buffer.h"
#include "Buffer.cpp"

template class Buffer<int>;
template class Buffer<double>;
template class Buffer<std::string>;

This explicitly tells the compiler to generate these specific instantiations. Other files can include just Buffer.h and link against the compiled instantiations. However, this approach requires maintaining the templates.cpp file and only works for predetermined types.

Template classes and container design

Template classes are ideal for containers because containers should work with any data type. The C++ Standard Library extensively uses template classes for its containers:

  • std::vector<T> - dynamic array
  • std::array<T, N> - fixed-size array
  • std::map<K, V> - key-value pairs
  • std::queue<T> - FIFO queue
  • std::stack<T> - LIFO stack

All these containers are template classes that work with any type, allowing you to create std::vector<int>, std::vector<std::string>, std::vector<CustomClass>, etc., without duplicating container logic.

Template classes are one of C++'s most powerful features. While the syntax can be verbose and error messages cryptic, the ability to write type-safe, reusable code without duplication makes templates essential for modern C++ programming.