Шаблони класів та NTTP-параметри
Вступ: від дублювання до узагальнення
На попередній лекції ми дослідили шаблони функцій — механізм, що дозволяє писати один алгоритм для багатьох типів даних. Але узагальнене програмування — це не лише функції. Класи також потребують універсальності.
Уявіть: ви створили контейнерний клас ArrayInt для зберігання масиву цілих чисел. Завтра вам потрібен ArrayDouble для дійсних чисел. Післязавтра — ArrayString для рядків. Кожен новий тип вимагає повного копіювання коду класу та зміни лише типу даних.
Шаблони класів (class templates) розв'язують цю проблему: один шаблон класу генерує багато конкретних класів на етапі компіляції. У цій лекції ми:
- Побудуємо власний шаблонний контейнер
Array<T>. - Дослідимо параметри non-type (NTTP — Non-Type Template Parameter) для компіляційних констант.
- Розберемося з особливостями компіляції шаблонів (проблема роздільної компіляції).
- Зрозуміємо устрій
std::vector<T>таstd::array<T, N>«зсередини».
Мотивація: контейнерний клас без шаблонів
Проблема дублювання коду
На лекції про контейнерні класи ми створювали IntArray — клас, що інкапсулює динамічний масив цілих чисел:
class IntArray {
private:
int length;
int* data;
public:
IntArray(int len) {
data = new int[len];
length = len;
}
~IntArray() {
delete[] data;
}
int& operator[](int index) {
return data[index];
}
int getLength() const { return length; }
};
Тепер потрібен аналогічний клас для double:
class DoubleArray {
private:
int length;
double* data; // ← єдина зміна!
public:
DoubleArray(int len) {
data = new double[len]; // ← і тут
length = len;
}
~DoubleArray() {
delete[] data;
}
double& operator[](int index) { // ← і тут
return data[index];
}
int getLength() const { return length; }
};
resize()), її знову треба дублювати скрізь.Ідея шаблону класу
Якщо відмінність між класами — лише тип даних, то логічно параметризувати клас типом:
template <typename T>
class Array {
private:
int length;
T* data; // T — параметр типу, замінюється компілятором
public:
Array(int len);
~Array();
T& operator[](int index);
int getLength() const;
};
Тепер один шаблон породжує багато класів:
Array<int>→ клас для цілих чиселArray<double>→ клас для дійсних чиселArray<std::string>→ клас для рядків
Синтаксис шаблону класу
Базова структура
template <typename T>
class Array {
private:
int length;
T* data;
public:
Array(int len) {
data = new T[len];
length = len;
}
~Array() {
delete[] data;
}
T& operator[](int index) {
return data[index];
}
int getLength() const { return length; }
};
int main() {
Array<int> intArray(5);
intArray[0] = 42;
intArray[1] = 17;
Array<double> doubleArray(3);
doubleArray[0] = 3.14;
doubleArray[1] = 2.71;
std::cout << "intArray[0] = " << intArray[0] << '\n';
std::cout << "doubleArray[1] = " << doubleArray[1] << '\n';
return 0;
}
Розбір синтаксису
- Оголошення шаблону:
template <typename T>typename T— параметр типу (можна використовуватиclass T— ідентична семантика).T— ім'я типу-параметра, що використовується в тілі класу.
- Інстанціювання (instantiation):
Array<int> intArray(5);- Компілятор генерує окрему версію класу
Array, деTзамінено наint. - Процес називається інстанціюванням шаблону — компілятор створює конкретний клас з шаблону.
- Компілятор генерує окрему версію класу
- Використання
Tвсередині класу:T* data;— покажчик на типTT& operator[]— повернення посилання наTnew T[length]— виділення масиву елементів типуT
typename і class у контексті параметрів шаблону семантично ідентичні. Історично використовувалося class, але сучасний стандарт віддає перевагу typename для ясності: параметром може бути будь-який тип (не обов'язково клас).Визначення методів поза тілом класу
Inline-визначення
Якщо метод короткий, його зручно визначати прямо в тілі класу:
template <typename T>
class Array {
public:
int getLength() const { return length; } // inline
};
Визначення поза тілом
Якщо метод об'ємний, його виносять за межі класу. Кожен метод шаблону потребує власного оголошення template:
template <typename T>
class Array {
private:
int length;
T* data;
public:
Array(int len); // оголошення
~Array();
T& operator[](int index);
int getLength() const; // оголошення
};
// Визначення методу поза класом
template <typename T>
int Array<T>::getLength() const {
return length;
}
// Визначення конструктора
template <typename T>
Array<T>::Array(int len) {
data = new T[len];
length = len;
}
// Визначення деструктора
template <typename T>
Array<T>::~Array() {
delete[] data;
}
// Визначення оператора[]
template <typename T>
T& Array<T>::operator[](int index) {
return data[index];
}
// ❌ ПОМИЛКА: відсутнє оголошення шаблону
int Array<T>::getLength() const {
return length;
}
Важливі моменти
- Оголошення шаблону перед кожним методом:
template <typename T> T& Array<T>::operator[](int index) { ... } - Ім'я класу з параметром типу:
Array<T>::, а неArray::.Array::позначило б невідомий невшаблонний клас.Array<T>::позначає шаблон класу з параметромT.
- Повторення параметрів: якщо клас має кілька параметрів, усі повторюються:
template <typename T, typename Allocator> void MyVector<T, Allocator>::resize(int newSize) { ... }
Модель компіляції шаблонів: проблема роздільної компіляції
Стандартна модель роздільної компіляції
Для звичайних класів ми дотримуємося правила:
- Заголовковий файл (
.h): оголошення класу - Файл реалізації (
.cpp): визначення методів
Компілятор окремо компілює кожен .cpp-файл у об'єктний файл, а лінкер потім їх з'єднує.
Чому це не працює з шаблонами?
Шаблон — це не клас, а трафарет для генерації класів. Компілятору потрібно бачити повне визначення шаблону в момент інстанціювання.
Приклад проблеми
#ifndef ARRAY_H
#define ARRAY_H
template <typename T>
class Array {
private:
int length;
T* data;
public:
Array(int len);
~Array();
int getLength() const; // оголошення
};
#endif
#include "Array.h"
template <typename T>
int Array<T>::getLength() const {
return length;
}
template <typename T>
Array<T>::Array(int len) {
data = new T[len];
length = len;
}
template <typename T>
Array<T>::~Array() {
delete[] data;
}
#include "Array.h"
int main() {
Array<int> intArray(10);
int len = intArray.getLength(); // ❌ ПОМИЛКА ЛІНКЕРА
return 0;
}
Що відбувається?
- Компіляція
main.cpp:- Компілятор бачить
Array<int>і генерує класArray<int>. - Бачить виклик
getLength(), але не бачить його визначення (воно вArray.cpp). - Компілятор залишає символ
Array<int>::getLength()невизначеним, сподіваючись, що лінкер знайде його пізніше.
- Компілятор бачить
- Компіляція
Array.cpp:- Компілятор бачить шаблон
Array<T>та визначення методів. - Але не знає, що потрібен екземпляр
Array<int>(це інформація зmain.cpp). - Не генерує
Array<int>::getLength().
- Компілятор бачить шаблон
- Лінкування:
- Лінкер шукає
Array<int>::getLength()— не знаходить. - Помилка:
unresolved external symbol.
- Лінкер шукає
Розв'язання проблеми
Метод 1: Все в заголовку (.hpp)
Найпоширеніше рішення: розміщення визначень методів у заголовковому файлі.
// Array.hpp
#ifndef ARRAY_HPP
#define ARRAY_HPP
template <typename T>
class Array {
private:
int length;
T* data;
public:
Array(int len) {
data = new T[len];
length = len;
}
~Array() {
delete[] data;
}
int getLength() const { return length; }
};
#endif
Переваги:
- ✅ Працює завжди: компілятор бачить повне визначення.
- ✅ Простота: один файл.
Недоліки:
- ⚠️ Збільшує час компіляції: кожен файл, що включає
.hpp, перекомпілює весь шаблон. - ⚠️ Роздуває розмір заголовків.
<vector>, <map>, <algorithm> — усе це заголовки з повними визначеннями.Метод 2: Розділення на .h і .inl
Якщо визначення об'ємні, їх можна винести в окремий файл .inl (inline), а включити в кінець .h:
#ifndef ARRAY_H
#define ARRAY_H
template <typename T>
class Array {
private:
int length;
T* data;
public:
Array(int len);
~Array();
int getLength() const;
};
#include "Array.inl" // ← включення визначень
#endif
// Array.inl
template <typename T>
Array<T>::Array(int len) {
data = new T[len];
length = len;
}
template <typename T>
Array<T>::~Array() {
delete[] data;
}
template <typename T>
int Array<T>::getLength() const {
return length;
}
Переваги:
- ✅ Чистіше структурований код.
- ✅ Читабельність: оголошення відділене від визначень.
Недоліки:
- ⚠️ Додатковий файл у проєкті.
Метод 3: Явне інстанціювання (template class)
Якщо відомий обмежений набір типів, можна явно згенерувати екземпляри в .cpp-файлі:
#ifndef ARRAY_H
#define ARRAY_H
template <typename T>
class Array {
public:
Array(int length);
int getLength() const;
// ... решта методів
};
#endif
#include "Array.h"
template <typename T>
Array<T>::Array(int len) { /* ... */ }
template <typename T>
int Array<T>::getLength() const { return length; }
// Явне інстанціювання
template class Array<int>;
template class Array<double>;
template class Array<std::string>;
#include "Array.h"
int main() {
Array<int> intArray(10); // ✅ Працює
Array<double> doubleArray(5); // ✅ Працює
// Array<char> charArray(3); ❌ Не працює: не інстанційовано
return 0;
}
Переваги:
- ✅ Роздільна компіляція працює.
- ✅ Швидша компіляція (шаблон компілюється один раз).
Недоліки:
- ⚠️ Обмежена гнучкість: потрібно заздалегідь знати всі типи.
- ⚠️ Якщо користувач спробує
Array<char>, отримає помилку лінкера.
float і double).Параметри non-type (NTTP)
Що таке NTTP?
Non-Type Template Parameter (NTTP) — це параметр шаблону, що приймає значення (а не тип). Це значення повинно бути відоме на етапі компіляції (compile-time constant).
Допустимі типи NTTP (C++17/20):
- Цілочисельні типи (
int,size_t,long, ...) - Перерахування (
enum) - Покажчики та посилання на об'єкти з зовнішнім зв'язуванням
- Покажчики на функції або методи
std::nullptr_t- C++20: числа з плаваючою крапкою, літеральні класи
Мотивація: статичний масив фіксованого розміру
Розглянемо контейнер, розмір якого відомий на етапі компіляції:
template <typename T, int Size>
class StaticArray {
private:
T data[Size]; // ← Size — компіляційна константа
public:
T& operator[](int index) {
return data[index];
}
int getSize() const { return Size; }
};
Використання:
int main() {
StaticArray<int, 10> intArray;
intArray[0] = 42;
intArray[9] = 17;
StaticArray<double, 5> doubleArray;
doubleArray[0] = 3.14;
std::cout << "intArray size: " << intArray.getSize() << '\n';
std::cout << "doubleArray size: " << doubleArray.getSize() << '\n';
return 0;
}
Переваги NTTP
- Виділення на стеку:
- Немає
new/delete— немає витоків пам'яті. - Швидший доступ (кеш-локальність).
- Немає
- Компіляційна перевірка розміру:
StaticArray<int, 10>таStaticArray<int, 20>— різні типи.- Неможливо випадково передати масив неправильного розміру.
- Оптимізація:
- Компілятор знає розмір наперед — може розгорнути цикли, прибрати перевірки меж.
std::array<T, N> зі Стандартної бібліотеки!Обмеження NTTP
- Значення повинно бути відоме на етапі компіляції:
int n; std::cin >> n; StaticArray<int, n> arr; // ❌ ПОМИЛКА: n не є compile-time константою - Неможливість змінювати розмір:
- Після інстанціювання
StaticArray<int, 10>розмір назавжди 10.
- Після інстанціювання
- Окремий клас для кожного розміру:
StaticArray<int, 5>іStaticArray<int, 10>— різні типи (не взаємозамінні).
Зв'язок зі Стандартною бібліотекою
std::vector<T> — динамічний шаблонний контейнер
std::vector<T> — це шаблон класу з STL:
template <typename T, typename Allocator = std::allocator<T>>
class vector {
// ... внутрішня реалізація
};
T— тип елементів.Allocator— стратегія виділення пам'яті (за замовчуванням — стандартний алокатор).
Приклад:
#include <vector>
#include <string>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::vector<std::string> words = {"hello", "world"};
numbers.push_back(6);
words.push_back("!");
return 0;
}
std::array<T, N> — статичний шаблонний контейнер з NTTP
std::array<T, N> — це шаблон класу з параметром non-type:
template <typename T, std::size_t N>
struct array {
T elements[N]; // статичний масив
T& operator[](std::size_t index) { return elements[index]; }
constexpr std::size_t size() const { return N; }
};
Приклад:
#include <array>
int main() {
std::array<int, 5> arr = {1, 2, 3, 4, 5};
arr[0] = 42;
std::cout << "Size: " << arr.size() << '\n'; // 5
return 0;
}
Порівняння:
| Характеристика | std::vector<T> | std::array<T, N> |
|---|---|---|
| Розмір | Динамічний (змінюється) | Фіксований (compile-time) |
| Пам'ять | Купа (new) | Стек (або глобальна пам'ять) |
| Параметри шаблону | <typename T> | <typename T, size_t N> |
| Приклад | std::vector<int> | std::array<int, 10> |
Повний приклад: шаблонний клас Pair<T, U>
Створимо шаблон класу для пари значень (аналог std::pair):
#ifndef PAIR_HPP
#define PAIR_HPP
#include <iostream>
template <typename T, typename U>
class Pair {
private:
T first;
U second;
public:
Pair(const T& f, const U& s)
: first(f), second(s) {}
T& getFirst() { return first; }
const T& getFirst() const { return first; }
U& getSecond() { return second; }
const U& getSecond() const { return second; }
void print() const {
std::cout << "(" << first << ", " << second << ")\n";
}
};
#endif
#include "Pair.hpp"
#include <string>
int main() {
Pair<int, double> p1(42, 3.14);
p1.print(); // (42, 3.14)
Pair<std::string, int> p2("Answer", 42);
p2.print(); // (Answer, 42)
Pair<double, double> point(1.5, 2.5);
point.print(); // (1.5, 2.5)
return 0;
}
Практика: шаблонний клас Stack<T>
Реалізуйте шаблонний клас Stack<T> — стек (LIFO-структура) на основі динамічного масиву.
Вимоги:
- Метод
push(T value)— додати елемент на вершину стеку. - Метод
pop()— видалити елемент з вершини (не повертає значення). - Метод
top()— отримати посилання на верхній елемент (без видалення). - Метод
isEmpty()— перевірка, чи стек порожній. - Метод
getSize()— отримати кількість елементів.
Приклад використання:
int main() {
Stack<int> intStack;
intStack.push(10);
intStack.push(20);
intStack.push(30);
std::cout << "Top: " << intStack.top() << '\n'; // 30
intStack.pop();
std::cout << "Top after pop: " << intStack.top() << '\n'; // 20
Stack<std::string> stringStack;
stringStack.push("hello");
stringStack.push("world");
std::cout << "String stack top: " << stringStack.top() << '\n'; // world
return 0;
}
// Stack.hpp
#ifndef STACK_HPP
#define STACK_HPP
#include <cassert>
template <typename T>
class Stack {
private:
T* data;
int capacity;
int size;
void resize(int newCapacity) {
T* newData = new T[newCapacity];
for (int i = 0; i < size; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
public:
Stack()
: data(nullptr), capacity(0), size(0) {}
~Stack() {
delete[] data;
}
void push(const T& value) {
if (size == capacity) {
int newCapacity = (capacity == 0) ? 1 : capacity * 2;
resize(newCapacity);
}
data[size] = value;
++size;
}
void pop() {
assert(size > 0 && "Stack is empty!");
--size;
}
T& top() {
assert(size > 0 && "Stack is empty!");
return data[size - 1];
}
const T& top() const {
assert(size > 0 && "Stack is empty!");
return data[size - 1];
}
bool isEmpty() const {
return size == 0;
}
int getSize() const {
return size;
}
};
#endif
Тестування:
#include "Stack.hpp"
#include <iostream>
#include <string>
int main() {
Stack<int> intStack;
intStack.push(10);
intStack.push(20);
intStack.push(30);
std::cout << "Top: " << intStack.top() << '\n'; // 30
intStack.pop();
std::cout << "Top after pop: " << intStack.top() << '\n'; // 20
std::cout << "Size: " << intStack.getSize() << '\n'; // 2
Stack<std::string> stringStack;
stringStack.push("hello");
stringStack.push("world");
std::cout << "String stack top: " << stringStack.top() << '\n'; // world
stringStack.pop();
std::cout << "After pop: " << stringStack.top() << '\n'; // hello
return 0;
}
Питання для самоперевірки
- Чим шаблон класу відрізняється від звичайного класу?
- Чому визначення методів шаблону класу зазвичай розміщують у заголовковому файлі (
.hpp)? - Що таке NTTP-параметр? Наведіть приклад його використання.
- Чому
StaticArray<int, 5>таStaticArray<int, 10>— це різні типи? - Яка різниця між
std::vector<T>іstd::array<T, N>з погляду параметрів шаблону?
Резюме
У цій лекції ми:
- Створили шаблон класу
Array<T>, що замінює дублювання коду для різних типів. - Вивчили синтаксис шаблону класу:
template <typename T>, використанняTвсередині класу, визначення методів поза класом. - Розібралися з моделлю компіляції шаблонів: чому визначення зазвичай розміщують у заголовках (
.hppабо.inl). - Дослідили параметри non-type (NTTP): компіляційні константи як параметри шаблону, приклад
StaticArray<T, Size>. - Зрозуміли устрій STL:
std::vector<T>(динамічний контейнер),std::array<T, N>(статичний контейнер з NTTP). - Реалізували власні шаблони:
Pair<T, U>таStack<T>.
Ключові висновки:
Узагальнення коду
Модель компіляції
NTTP-параметри
Наступний крок: вивчення спеціалізації шаблонів (повної та часткової) для налаштування поведінки під конкретні типи.
Шаблони функцій: узагальнене програмування
Перехід від перевантаження функцій до узагальненого програмування. Синтаксис template<typename T> та механізм інстанціювання шаблонів компілятором. Автоматичне виведення типів та явна специфікація. Шаблони з кількома параметрами. Правила перевантаження шаблонних та нешаблонних функцій.
Повна (явна) спеціалізація шаблонів
Механізм налаштування поведінки шаблону під конкретні типи через повну спеціалізацію. Синтаксис template<> для спеціалізованих версій функцій та класів. Пріоритети виклику: спеціалізація > звичайний шаблон. Архітектурні патерни для типоспецифічних оптимізацій та поліморфних контейнерів.