Шаблоны и типизация в C++: от дженериков к концептам

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Generics и типизация в C++: почему это не «просто шаблоны»?

Когда разработчик переходит с Python или Java на C++, его часто пугает слово «шаблон» (template). В голове возникают ассоциации с бесконечными угловыми скобками, непонятными ошибками компиляции и магией std::vector. На самом деле generics в C++ (или обобщённое программирование) — это мощнейший инструмент, который позволяет писать код один раз, а использовать его с любыми типами данных. И да, это совсем не то же самое, что дженерики в Java или C#.

В этой статье мы разберём, как работает типизация в C++ через шаблоны, чем отличаются шаблоны классов от шаблонов функций, и зачем в C++20 добавили концепты. Будет много примеров — от простых до тех, которые реально пригодятся в продакшене. Поехали!

Что такое generics (шаблоны) в C++?

Если совсем просто: шаблоны в C++ — это способ написать код, который работает с любым типом данных, не теряя при этом производительности. В отличие от динамической типизации Python, где тип проверяется в рантайме, C++ генерирует отдельную версию функции или класса для каждого используемого типа на этапе компиляции. Это называется статической типизацией с параметризацией.

Синтаксически шаблон объявляется с помощью ключевого слова template и угловых скобок с параметрами:

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

Здесь typename T — это параметр шаблона. Вы можете использовать class вместо typename — разницы нет. Когда вы пишете max(3, 7), компилятор автоматически подставляет int вместо T и создаёт функцию int max(int, int). Если напишете max(3.14, 2.71) — получите версию для double. Всё просто и эффективно.

Шаблоны функций: первый шаг к generics

Начнём с малого — шаблоны функций в C++. Это идеальный способ избежать дублирования кода, когда логика одинакова, а типы разные.

Пример: функция, которая печатает элемент в консоль (полезно для отладки):

#include <iostream>
#include <string>

template <typename T>
void print(const T& value) {
    std::cout << value << std::endl;
}

int main() {
    print(42);          // int
    print(3.1415);      // double
    print("Hello");     // const char*
    print(std::string("Generics!")); // std::string
    return 0;
}

Обратите внимание: мы используем const T& — это типизация в C++ с передачей по константной ссылке, чтобы избежать копирования больших объектов. Для встроенных типов (int, double) это не критично, но привычка хорошая.

А что, если нужно передать два аргумента разных типов? Легко:

template <typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a + b) {
    return a + b;
}

// В C++14 и новее можно просто:
template <typename T1, typename T2>
auto add(T1 a, T2 b) {
    return a + b;
}

Здесь auto выводит тип возвращаемого значения автоматически. Это и есть generics в действии — один код, бесконечное количество комбинаций типов.

Шаблоны классов: строим универсальные структуры

Теперь перейдём к шаблонам классов в C++. Это основа стандартной библиотеки (STL): std::vector<T>, std::map<Key, Value>, std::optional<T> — все они реализованы через шаблоны.

Давайте напишем простой, но показательный пример — шаблонный класс «Пара» (аналог std::pair):

template <typename First, typename Second>
class Pair {
private:
    First first_;
    Second second_;
public:
    Pair(const First& f, const Second& s) : first_(f), second_(s) {}

    First getFirst() const { return first_; }
    Second getSecond() const { return second_; }

    void print() const {
        std::cout << "(" << first_ << ", " << second_ << ")" << std::endl;
    }
};

int main() {
    Pair<int, std::string> student(1, "Alice");
    student.print(); // (1, Alice)

    Pair<double, double> coordinates(48.8566, 2.3522);
    coordinates.print(); // (48.8566, 2.3522)

    return 0;
}

Обратите внимание: типизация в C++ здесь строгая — вы не сможете передать int туда, где ожидается std::string, на этапе компиляции. Это огромный плюс по сравнению с динамическими языками.

Специализация шаблонов: когда общее решение не подходит

Иногда нужно, чтобы для конкретного типа шаблон работал иначе. Это называется специализация шаблонов. Бывает полная (для одного типа) и частичная (для группы типов).

Пример: мы хотим, чтобы для типа bool наша пара хранила значения более эффективно (упаковывала биты). Полная специализация:

// Основной шаблон
template <typename T>
class Storage {
public:
    void store(const T& value) { /*...*/ }
};

// Полная специализация для bool
template <>
class Storage<bool> {
private:
    unsigned char bits_;
public:
    void store(bool value) {
        bits_ = value ? 1 : 0;
        std::cout << "Stored bool efficiently!" << std::endl;
    }
};

Это мощный приём, но злоупотреблять им не стоит — усложняет чтение кода. В современных C++ (17/20) часто можно обойтись if constexpr.

Концепты (C++20): типизация на стероидах

Долгое время главной проблемой generics в C++ были чудовищные ошибки компиляции. Если вы передали в шаблон тип, у которого нет нужного оператора, компилятор выдавал километровые сообщения, в которых трудно было найти суть. Решение пришло в C++20 — концепты.

Концепты в C++20 позволяют явно указать, какими свойствами должен обладать тип-параметр. Это делает код самодокументированным, а ошибки — понятными.

Синтаксис:

#include <concepts                

Блоги

Рекомендаций по книгам