Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Creating Generic Class Types
Define classes that work with any type using template parameters.
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:
- Added
template <typename T>before the class declaration - Replaced specific types (
int,std::string) with the template parameterT - Used
T*for the data pointer andconst 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:
- Each function requires
template <typename T>before its definition - The class name becomes
Queue<T>(not justQueue) in the scope resolution operator - Inside the class definition, you can use just
Queuebecause 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
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 arraystd::array<T, N>- fixed-size arraystd::map<K, V>- key-value pairsstd::queue<T>- FIFO queuestd::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.
Creating Generic Class Types - Quiz
Test your understanding of the lesson.
Practice Exercises
Template Class Basics
Create a class template that works with any data type. Practice instantiating templates with different type arguments.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!