Перейти до змісту

Шаблони в C++ та перевантаження операторів потоків

Введення

На попередній лекції ми створили int_vector — контейнер, який працює тільки з типом int. Він має все необхідне: конструктор, деструктор, методи push_back, at, перевантаження operator[].

Але що робити, якщо нам потрібен вектор для double? Або для char? Чи для власної структури point?

Ми мали б дублювати весь код — змінювати лише тип з int на double:

struct double_vector {
    double* data;
    int size;
    int capacity;

    double_vector(int initial_capacity = 4) {
        data = new double[initial_capacity];
        size = 0;
        capacity = initial_capacity;
    }

    // ... remaining methods, but with 'double' instead of 'int'
};

Це досить незручно. Шаблони вирішують цю проблему.


Шаблонні функції

Проблема: код дублюється

Розглянемо просту функцію, що знаходить максимум:

int my_max(int a, int b) {
    if (a > b)
        return a;
    return b;
}

int main() {
    std::cout << my_max(3, 7) << std::endl;  // 7
    return 0;
}

Вона працює тільки для int. Якщо нам потрібен максимум для double, довелось би писати:

double my_max(double a, double b) {
    if (a > b)
        return a;
    return b;
}

Логіка абсолютно однакова, змінився тільки тип.

Рішення: шаблонна функція

Замість того щоб писати функцію для кожного типу, напишемо шаблон:

template<typename T>
T my_max(T a, T b) {
    if (a > b)
        return a;
    return b;
}

Ключове слово template<typename T> каже компілятору: "Це схема функції. Замінюй T на будь-який конкретний тип, коли потрібно".

Важливо: коли компілятор бачить виклик шаблонної функції, наприклад my_max(3, 7), він генерує новий код для конкретного типу. Тобто для int компілятор створить функцію int my_max(int a, int b), для double — функцію double my_max(double a, double b) тощо. Це означає, що шаблон — це лише схема, а реальний код генерується під час компіляції.

Синтаксис шаблону функції

Формальне визначення синтаксису шаблонної функції:

template<typename T, typename U, ...>
ReturnType function_name(T param1, U param2, ...) {
    // function body
}

де: - template<typename T> — декларація шаблону з одним або більше параметрами типу - T, U тощо — параметри типу (можна використовувати будь-які імена, але T — найпоширеніший) - ReturnType — тип повернення (може містити параметри типу: T, std::vector<T> тощо) - param1, param2 — параметри функції (можуть мати типи, що залежать від параметрів шаблону)

Як це працює під капотом

Коли компілятор зустрічає виклик шаблонної функції, наприклад my_max(3, 7), він: 1. Визначає тип параметрів: T = int 2. Генерує нову функцію з цим типом:

int my_max(int a, int b) { ... }
3. Компілює цю конкретну функцію

Для кожного унікального типу, для якого використовується шаблон, компілятор створює окремий код. Це називається конкретизацією (instantiation).

Тепер одна функція працює для всіх типів:

int main() {
    std::cout << my_max(3, 7) << std::endl;        // 7 (T = int)
    std::cout << my_max(2.5, 9.1) << std::endl;    // 9.1 (T = double)
    std::cout << my_max('a', 'z') << std::endl;    // z (T = char)
    return 0;
}

Компілятор автоматично виводить тип T з аргументів функції.

Явне задання типу

Якщо хочемо, можемо явно сказати компілятору, які тип використовувати:

std::cout << my_max<int>(3, 7) << std::endl;
std::cout << my_max<double>(2.5, 9.1) << std::endl;

Обмеження шаблонів

Не всі типи працюють з усіма шаблонами. Наприклад, в my_max використовується оператор >. Якщо тип його не підтримує, компіляція завершиться з помилкою.

Спробуємо:

struct Point { int x, y; };

Point p1{1, 2};
Point p2{3, 4};
my_max(p1, p2);  // Error: operator> not defined for Point

Для власних типів потрібно визначити оператор >.


Перевантаження функцій

Перед тим як переходити до шаблонних структур, розглянемо перевантаження функцій — це основа для розуміння того, як компілятор обирає яку функцію викликати.

Поняття перевантаження

У C++ можна мати кілька функцій з одним ім'ям, але різними параметрами:

void print(int x) {
    std::cout << "int: " << x << std::endl;
}

void print(double x) {
    std::cout << "double: " << x << std::endl;
}

void print(const char* str) {
    std::cout << "text: " << str << std::endl;
}

int main() {
    print(42);          // Calls print(int)
    print(3.14);        // Calls print(double)
    print("hello");      // Calls print(const char*)
    return 0;
}

Компілятор обирає правильну функцію на основі типу аргументу.

Порядок вибору (overload resolution)

  1. Точний збіг типів — якщо є функція з точно таким типом параметра
  2. Стандартні перетворення — якщо int можна автоматично перетворити на double
  3. Шаблонна функція — якщо немає точного збігу для звичайної функції

Приклад:

void process(int x) {
    std::cout << "int version" << std::endl;
}

template<typename T>
void process(T x) {
    std::cout << "template version" << std::endl;
}

int main() {
    process(5);      // "int version" (exact match)
    process(3.14);   // "template version" (template)
    return 0;
}

Шаблонні структури

Узагальнення int_vector

На попередній лекції ми написали int_vector. Тепер зробимо його узагальненим для будь-якого типу.

Беремо старий код і замінюємо int на параметр типу T:

#include <iostream>
#include <cassert>

template<typename T>
struct vector {
    T* data;
    int size;
    int capacity;

    vector(int initial_capacity = 4) {
        data = new T[initial_capacity];
        size = 0;
        capacity = initial_capacity;
    }

    ~vector() {
        delete[] data;
    }

    void resize(int new_capacity) {
        T* new_data = new T[new_capacity];
        int to_copy = (size < new_capacity) ? size : new_capacity;
        for (int i = 0; i < to_copy; ++i) {
            new_data[i] = data[i];
        }
        delete[] data;
        data = new_data;
        capacity = new_capacity;
        size = to_copy;
    }

    void push_back(T elem) {
        if (size == capacity) {
            resize(capacity * 2);
        }
        data[size] = elem;
        ++size;
    }

    T& at(int index) {
        assert(index >= 0 && index < size);
        return data[index];
    }

    T& operator[](int index) {
        return at(index);
    }
};

Використання

Тепер можна створювати вектори для будь-якого типу:

int main() {
    // vector for int
    vector<int> v_int;
    v_int.push_back(10);
    v_int.push_back(20);
    v_int.push_back(30);

    std::cout << "v_int[1] = " << v_int[1] << std::endl;  // 20

    // vector for double
    vector<double> v_double;
    v_double.push_back(3.14);
    v_double.push_back(2.71);

    std::cout << "v_double[0] = " << v_double[0] << std::endl;  // 3.14

    // vector for char
    vector<char> v_char;
    v_char.push_back('A');
    v_char.push_back('B');
    v_char.push_back('C');

    std::cout << "v_char[2] = " << v_char[2] << std::endl;  // C

    return 0;
}

Запис vector<int> означає: "Створи варіант структури vector, де T замінено на int". Аналогічно vector<double>, vector<char> тощо.

Під час компіляції компілятор генерує окремий код для кожного типу: один набір методів для vector<int>, інший — для vector<double> тощо. Це дозволяє шаблонам залишатися типобезпечними й ефективними.

Списки ініціалізації для vector

Часто хочемо створити вектор і одразу заповнити його значеннями. Можемо додати конструктор, який приймає список:

template<typename T>
struct vector {
    // ... previous members ...

    // Constructor with initializer list
    vector(std::initializer_list<T> init) {
        capacity = init.size() * 2;  // allocate extra space for future growth
        size = init.size();
        data = new T[capacity];

        // init.begin() returns const T* — a plain pointer to the first element
        const T* ptr = init.begin();
        for (int j = 0; j < (int)init.size(); ++j) {
            data[j] = ptr[j];
        }
    }

    // ... remaining methods ...
};

Тепер можна писати:

vector<int> v = {10, 20, 30, 40};
vector<double> d = {1.5, 2.5, 3.5};
vector<char> c = {'x', 'y', 'z'};

std::cout << v[2] << std::endl;  // 30
std::cout << d[1] << std::endl;  // 2.5
std::cout << c[0] << std::endl;  // x

Це значно зручніше, ніж викликати push_back кілька разів.

Якщо у вас буде два конструктори — з initializer_list і звичайний — компілятор обиратиме правильний:

vector<int> v1(10);           // Regular constructor: capacity = 10
vector<int> v2 = {10, 20, 30};   // Initializer list constructor: size = 3

Власні типи та оператор << (виведення)

Проблема: як вивести власний тип?

Створимо структуру point:

struct point {
    int x;
    int y;
};

int main() {
    point p{10, 20};
    std::cout << p << std::endl;  // Compilation error!
    return 0;
}

Компілятор не знає, як виводити об'єкт типу point. Потрібно визначити оператор <<.

Визначення оператора виведення

Оператор << — це функція, яка приймає: - потік виводу (std::ostream&) - об'єкт для виведення (const посилання на об'єкт)

Повертає посилання на потік, щоб можна було продовжувати ланцюжок операцій.

std::ostream& operator<<(std::ostream& out, const point& p) {
    out << "(" << p.x << ", " << p.y << ")";
    return out;
}

Тепер код компілюється:

int main() {
    point p{10, 20};
    std::cout << p << std::endl;  // (10, 20)
    return 0;
}

Як це працює?

Вираз std::cout << p еквівалентний:

operator<<(std::cout, p)

Функція виводить вміст p у потік std::cout і повертає сам потік. Це дозволяє писати ланцюжки:

std::cout << "Point: " << p << " end" << std::endl;

Що еквівалентно:

operator<<(operator<<(operator<<(std::cout, "Point: "), p), " end");
std::cout << std::endl;

Повна робоча програма

#include <iostream>

struct point {
    int x;
    int y;
};

std::ostream& operator<<(std::ostream& out, const point& p) {
    out << "(" << p.x << ", " << p.y << ")";
    return out;
}

int main() {
    point p1{5, 10};
    point p2{15, 20};

    std::cout << "p1 = " << p1 << std::endl;
    std::cout << "p2 = " << p2 << std::endl;
    std::cout << "Both: " << p1 << " and " << p2 << std::endl;

    return 0;
}

Вивід:

p1 = (5, 10)
p2 = (15, 20)
Both: (5, 10) and (15, 20)


Оператор >> (введення)

Аналогічно можемо визначити оператор для введення:

std::istream& operator>>(std::istream& in, point& p) {
    in >> p.x >> p.y;
    return in;
}

Зверніть увагу: на відміну від operator<<, тут немає const, бо ми змінюємо об'єкт під час введення.

Приклад використання

int main() {
    point p;
    std::cout << "Введіть координати (x y): ";
    std::cin >> p;
    std::cout << "Ви ввели точку: " << p << std::endl;
    return 0;
}

Сеанс:

Введіть координати (x y): 5 10
Ви ввели точку: (5, 10)


Повна програма: vector

Тепер об'єднаємо все разом. Маємо:

  1. Шаблонний вектор — працює з будь-яким типом
  2. Власний тип point — з операторами виведення та введення

Це означає, що можемо створити vector<point>:

#include <iostream>
#include <cassert>
#include <initializer_list>

template<typename T>
struct vector {
    T* data;
    int size;
    int capacity;

    // Regular constructor
    vector(int initial_capacity = 4) {
        data = new T[initial_capacity];
        size = 0;
        capacity = initial_capacity;
    }

    // Constructor with initializer list
    vector(std::initializer_list<T> init) {
        capacity = init.size() * 2;
        if (capacity < 4) capacity = 4;
        size = init.size();
        data = new T[capacity];

        // init.begin() returns const T* — a plain pointer to the first element
        const T* ptr = init.begin();
        for (int j = 0; j < (int)init.size(); ++j) {
            data[j] = ptr[j];
        }
    }

    ~vector() {
        delete[] data;
    }

    void resize(int new_capacity) {
        T* new_data = new T[new_capacity];
        int to_copy = (size < new_capacity) ? size : new_capacity;
        for (int i = 0; i < to_copy; ++i) {
            new_data[i] = data[i];
        }
        delete[] data;
        data = new_data;
        capacity = new_capacity;
        size = to_copy;
    }

    void push_back(T elem) {
        if (size == capacity) {
            resize(capacity * 2);
        }
        data[size] = elem;
        ++size;
    }

    T& at(int index) {
        assert(index >= 0 && index < size);
        return data[index];
    }

    T& operator[](int index) {
        return at(index);
    }
};

struct point {
    int x;
    int y;
};

std::ostream& operator<<(std::ostream& out, const point& p) {
    out << "(" << p.x << ", " << p.y << ")";
    return out;
}

std::istream& operator>>(std::istream& in, point& p) {
    in >> p.x >> p.y;
    return in;
}

int main() {
    // Vector of points, initialized from a list
    vector<point> points = {
        {1, 2},
        {10, 20},
        {5, 5}
    };

    std::cout << "Points in vector:" << std::endl;
    for (int i = 0; i < points.size; ++i) {
        std::cout << "  points[" << i << "] = " << points[i] << std::endl;
    }

    // Add a new point
    point new_point;
    std::cout << "\nEnter a new point (x y): ";
    std::cin >> new_point;
    points.push_back(new_point);

    // Display all points again
    std::cout << "\nAll points:" << std::endl;
    for (int i = 0; i < points.size; ++i) {
        std::cout << "  points[" << i << "] = " << points[i] << std::endl;
    }

    return 0;
}

Приклад виводу:

Points in vector:
  points[0] = (1, 2)
  points[1] = (10, 20)
  points[2] = (5, 5)

Enter a new point (x y): 100 200
All points:
  points[0] = (1, 2)
  points[1] = (10, 20)
  points[2] = (5, 5)
  points[3] = (100, 200)


Важливі моменти

Типові помилки студентів

Чому потрібно писати vector<int>, а не просто vector?

Тому що vector — це шаблон, а не конкретний тип. vector<int> — це конкретний тип, який компілятор створює на основі шаблону.

Чому operator<< повертає std::ostream&?

Щоб можна було писати ланцюжки на кшталт std::cout << p << std::endl. Без повернення потоку був би синтаксис operator<<(std::cout, p); operator<<(std::cout, std::endl); — незручно.

Чому в operator>> немає const?

Тому що під час введення ми змінюємо об'єкт. У operator<< немає змін, тому параметр const.

Чому шаблон не працює з усіма типами однаково?

Тому що всередині шаблону використовуються певні операції. Для my_max потрібен оператор >. Якщо тип його не має, компіляція завершиться з помилкою.

Кілька параметрів шаблону

Можна мати кілька параметрів типу:

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

Але на цій лекції це не обов'язково.

Спеціалізація шаблонів

Іноді для конкретного типу потрібна інша реалізація. Це називається спеціалізацією. Наприклад:

// General template
template<typename T>
void print(T x) {
    std::cout << x << std::endl;
}

// Specialization for bool
template<>
void print<bool>(bool x) {
    std::cout << (x ? "true" : "false") << std::endl;
}

Але це розширена тема, на цій лекції її опускаємо.


Підсумок

Концепція Синтаксис Приклад
Шаблонна функція template<typename T> T func(T x) { ... } template<typename T> T my_max(T a, T b)
Шаблонна структура template<typename T> struct Name { T field; ... } template<typename T> struct vector
Список ініціалізації vector<int> v = {10, 20, 30}; Конструктор з std::initializer_list<T>
Оператор виведення std::ostream& operator<<(std::ostream&, const T&) operator<< для point
Оператор введення std::istream& operator>>(std::istream&, T&) operator>> для point

Шаблони дозволяють писати один раз і використовувати для всіх типів. Перевантаження операторів дозволяють власним типам працювати з потоками вводу/виводу, як вбудовані типи.


Попередня: Динамічний масив | Наступна: Рекурсія