Занятие 24. Шаблоны и исключения

Последняя языковая лекция семестра — легализация двух механизмов, которыми мы пользовались с первого дня: буква T в угловых скобках (vector<int>, sort, variant<Ts...>) и исключения, которые уже ловили (bad_variant_access, at()). Сегодня оба крупным планом: как компилятор пишет код за нас — и что происходит, когда ошибка летит через стек. Все выводы и замеры реальные (Apple M-серия, clang++ -O2).

Функции-шаблоны: штамп и штамповщик

1
2
3
4
5
template <typename T>
T myMax(T a, T b) { return a < b ? b : a; }

myMax(2, 7);       // T выведен: int
myMax(1.5, 0.5);   // T выведен: double
$ nm tmpl24.o | c++filt | grep myMax
...T double myMax<double>(double, double)
...T int    myMax<int>(int, int)

Шаблон сам по себе — не код, а рецепт. Код появляется при инстанцировании: по одной настоящей функции на каждый использованный T — nm (занятия 1 и 11) показывает обе. T выводится из аргументов по правилам, родственным auto (занятие 11). Отсюда же ответ на вопрос «почему шаблоны живут в заголовках»: штамповщику нужен рецепт целиком в каждой единице трансляции, а дубликаты потом склеит линкер.

Тонкости вывода:

1
2
3
4
5
6
7
myMax(1, 2.5);           // ошибка: T = int или double? Компилятор не выбирает за вас
myMax<double>(1, 2.5);   // явно: ок

template <typename T>
int counter() { static int n = 0; return ++n; }
counter<int>(); counter<int>(); counter<double>(); counter<char>();
// печать: 1 2 1 1 — у КАЖДОЙ инстанциации своя static-переменная!

Каждая инстанциация — отдельная функция со своей static (занятие 11, смысл № 1 — теперь помноженный на число типов). Цена штамповки: каждый T добавляет код в бинарник — «bloat» реален.

Классы-шаблоны: DynArray дорастает до DynArray<T>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
template <typename T>        // «class T» — полный синоним:
class DynArray {             // typename просто моложе и точнее
public:
    void push_back(const T& x);
    T& operator[](size_t i) { return data_[i]; }
    // ... правило пяти из занятий 7 и 22 ...
private:
    T* data_ = nullptr;
    size_t size_ = 0, cap_ = 0;
};

DynArray<int> a;             // штамп для int
DynArray<std::string> b;     // штамп для string

Класс из занятий 7 → 16 → 22 делает финальный шаг: тип элемента становится параметром — это и есть vector, мы честно дошли до него сами. Методы инстанцируются лениво: не вызвали — не отштамповано (потому vector<T> компилируется и для T без operator< — пока не позовёте sort). Весь STL занятий 16–17 — одна большая штамповочная фабрика.

Цена дженериков: ошибка из глубины

1
2
3
struct Point { double x, y; };
std::vector<Point> v(3);
std::sort(v.begin(), v.end());   // Point не умеет operator<
$ clang++ -std=c++20 noconc.cpp
# 308 строк ошибок! Первая — из недр:
In file included from .../algorithm:1842:
In file included from .../__algorithm/adjacent_find.h:13:
...

Шаблон проверяется в точке подстановки: sort честно штампуется для Point и падает глубоко внутри, где впервые встретилось a < b. Итог — 308 строк про внутренности libc++, и ни одна не указывает на вашу строку.

Концепты C++20: 308 строк против 19

1
2
3
4
5
6
#include <concepts>

template <std::totally_ordered T>    // контракт!
T myMax(T a, T b) { return a < b ? b : a; }

myMax(Point{}, Point{});             // ошибка — но какая:
$ clang++ -std=c++20 conc.cpp        # 19 строк:
note: candidate template ignored: constraints not satisfied [with T = Point]
note: because 'Point' does not satisfy 'totally_ordered'

Концепт — именованное требование к типу; ставится на место typename и проверяется до штамповки. Ошибка сжалась в 16 раз, указывает на вызов и называет нарушенный контракт по имени. Стандартная библиотека концептов готова: totally_ordered, integral, copyable, invocable

Свой контракт — через requires-выражение:

1
2
3
4
5
6
7
template <typename T>
concept HasArea = requires(const T& t) {
    { t.area() } -> std::convertible_to<double>;
};

template <HasArea T>
double totalArea(const std::vector<T>& v);

Читается как интерфейс, но проверяется статически — без vtable и наследования. К дихотомии занятия 20 (закрытый variant против открытого наследования) концепты добавляют третий путь: открытый набор типов со статической проверкой. Это продолжение главной линии курса «расскажи компилятору больше»: enum class (11) → variant (20) → концепты.

Правило курса

Публичная шаблонная функция — с концептом; «голый typename» оставляем внутренним помощникам.

Исключения: ошибки, которые летают

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
double parse(const std::string& s) {
    if (s.empty()) throw std::invalid_argument("пусто");
    // ...
}

try {
    double x = parse(input);
    use(x);                        // при throw — пропущено
} catch (const std::invalid_argument& e) {
    std::cerr << e.what();         // ловим по const&
} catch (const std::exception& e) {
    // более общий тип — ПОСЛЕ частных
}

throw прерывает функцию и летит вверх по стеку вызовов (занятие 5), пока не встретит подходящий catch. Ловим по const& — без копий и срезки; порядок catch-блоков — от частного к общему. Стандартная иерархия: exceptionlogic_error/runtime_error → знакомые bad_variant_access, out_of_range.

Раскрутка стека: RAII сдаёт главный экзамен

1
2
3
4
5
6
struct Guard {   // печатает «+ имя» при создании и «~ имя» в деструкторе
    ...
};
void inner() { Guard c("c"); throw std::runtime_error("авария!"); }
void outer() { Guard b("b"); inner(); Guard never("never"); }
int main()   { Guard a("a"); try { outer(); } catch (...) { ... } }
+ a   + b   + c
~ c   ~ b              # раскрутка: деструкторы в обратном порядке
поймано: авария!       # («never» так и не создался)
~ a

Пока исключение летит, каждый пролетаемый кадр стека аккуратно убирается: локальные объекты умирают в обратном порядке, деструкторы вызваны, ресурсы освобождены. Это финальный экзамен RAII: занятие 11 показало утечку на раннем return — исключения делают ручной delete окончательно безнадёжным, а RAII-владельцев (vector, string, unique_ptr из занятия 14) — обязательными. Сырой указатель в пролетаемом кадре — гарантированная утечка; RAII-объект — гарантированная уборка.

Цена: бесплатно, пока не летит

# 10^6 вызовов, clang++ -O2
happy path (ошибок нет):    throw-версия  2.1 мс | код ошибки 1.3 мс
error path (10^6 ошибок):   throw+catch   2275 мс | код ошибки 0.0 мс

Современная модель — «zero-cost»: пока исключение не брошено, накладных почти нет. Но сам полёт дорог: ~2.3 микросекунды на бросок (таблицы раскрутки, поиск обработчика) — в тысячи раз дороже возврата кода.

Правило курса

Исключения — для исключительного: сломанный инвариант, невозможность продолжать. Ожидаемые ошибки данных — Result-variant из занятия 20. И замыкание занятия 22: исключение, вылетевшее из noexcept-функции, — мгновенный std::terminate; потому move-операции и обещают не бросать.

Проверьте себя

  1. Что напечатает?

    1
    2
    3
    
    template <typename T>
    int tick() { static int n = 0; return ++n; }
    std::cout << tick<int>() << tick<long>() << tick<int>() << tick<const int>();
    Ответ
    1121. int, long и const int — три разные инстанциации, у каждой своя static: tick<int> даёт 1, затем tick<long> — 1, снова tick<int> — уже 2, tick<const int> — 1. (Строго говоря, порядок вычисления операндов цепочки << до C++17 не был фиксирован — с C++17 слева направо, поэтому ответ детерминирован.)
  2. Напишите концепт Printable (тип умеет os << x) и добейтесь короткой ошибки.

    Ответ
    1
    2
    3
    4
    5
    6
    7
    
    template <typename T>
    concept Printable = requires(std::ostream& os, const T& x) {
        { os << x } -> std::same_as<std::ostream&>;
    };
    
    template <Printable T>
    void printAll(const std::vector<T>& v) { for (auto& x : v) std::cout << x << ' '; }

    Для типа без operator<< ошибка — пара строк «‘X’ does not satisfy ‘Printable’» с указанием на вызов, вместо простыни из глубин iostream.

  3. Порядок деструкторов. f() создаёт Guard a, Guard b, бросает, дальше Guard c. Что напечатается? А если throw случится в конструкторе b?

    Ответ
    +a, +b, ~b, ~a, «поймано» — обратный порядок создания, c не рождался. Если бросает конструктор b: сам b считается несозданным — его деструктор не вызовется (нечего разрушать), раскрутка уберёт только a: +a, ~a, «поймано». Отсюда правило: конструктор, бросивший исключение, обязан сам прибрать то, что успел захватить, — ещё один довод хранить ресурсы в RAII-полях.

Домашнее задание (сдача через Git)

  1. DynArray<T>: обобщите свой класс из занятий 7/22 шаблоном; проверьте на int, std::string и BigInt — включая правило пяти с move.
  2. Концепт-апгрейд: добавьте концепты трём своим шаблонным функциям; приложите тексты ошибок до/после — с числом строк.
  3. Раскрутка своими глазами: Guard-класс + три уровня вложенности; письменный прогноз вывода, затем запуск; вариант с исключением из конструктора.
  4. Замер цены: воспроизведите таблицу happy/error path; найдите долю ошибок, при которой обе версии сравниваются по времени.
  5. * Концепт Sortable (итераторы + сравнимость) и таблица «кто проходит» для пяти контейнеров занятия 17.

Следующее занятие — двоичная куча: добавление и удаление минимума, построение за O(n), heapsort (та самая страховка introsort из занятия 21) и очередь с приоритетами. Финальный семинар перед РК2.