Для чего нужно шаблонное метапрограммирование

Шаблонное метапрограммирование (TMP) позволяет писать код, параметризованный по типам — и ограничивать, какие типы допустимы. Цель — ловить ошибки типов на этапе компиляции, а не во время выполнения, и генерировать специализированный код на тип без его ручного написания.

Практические применения:

  • Принять std::span<uint8_t> и std::vector<uint8_t> в одной функции
  • Ограничить контейнер тривиально копируемыми типами (безопасны для DMA-передачи)
  • Включить разные ветви кода в зависимости от того, является ли тип знаковым или вещественным
  • Абстракции с нулевой стоимостью через if constexpr

Трейты типов — запрос типов на этапе компиляции

<type_traits> предоставляет предикаты о типах:

 1#include <type_traits>
 2
 3// T — целое число?
 4std::is_integral<int>::value          // true
 5std::is_integral<float>::value        // false
 6std::is_integral_v<int>               // сокращение C++17 — то же самое
 7
 8// T — тривиально копируемый? (безопасен для memcpy)
 9std::is_trivially_copyable_v<float>   // true
10std::is_trivially_copyable_v<std::vector<int>> // false — имеет деструктор
11
12// T — указатель?
13std::is_pointer_v<int*>    // true
14std::is_pointer_v<int>     // false

Используйте для защиты DMA-безопасных передач:

1template <typename T>
2void dmaTransfer(std::span<T> data, DMA_HandleTypeDef* hdma) {
3    static_assert(std::is_trivially_copyable_v<T>,
4                  "DMA-передача требует тривиально копируемого типа");
5    HAL_DMA_Start(hdma, reinterpret_cast<uint32_t>(data.data()),
6                  dmaDestination, data.size() * sizeof(T));
7}

Ошибка срабатывает на этапе компиляции, а не когда прошивка уже запущена.


SFINAE — Substitution Failure Is Not An Error

До C++20 способом условного включения шаблона функции было SFINAE:

 1// Включить эту перегрузку только если T — целое число
 2template <typename T,
 3          typename = std::enable_if_t<std::is_integral_v<T>>>
 4T clamp(T value, T lo, T hi) {
 5    return value < lo ? lo : value > hi ? hi : value;
 6}
 7
 8// Включить эту перегрузку только если T — вещественное число
 9template <typename T,
10          typename = std::enable_if_t<std::is_floating_point_v<T>>>
11T clamp(T value, T lo, T hi) {
12    if (std::isnan(value)) return lo;   // обработка NaN для вещественных
13    return value < lo ? lo : value > hi ? hi : value;
14}

Сообщения об ошибках SFINAE печально известны своей нечитаемостью:

error: no matching function for call to 'clamp(std::string, ...)'
note: template argument deduction/substitution failed
...15 строк трассировки инстанциирования шаблона...

Концепты C++20 заменяют SFINAE читаемыми ограничениями и читаемыми ошибками.


Концепты C++20 — читаемые ограничения

Концепт — именованный булев предикат на типах:

 1#include <concepts>
 2
 3// Встроенные концепты
 4std::integral<int>              // true
 5std::floating_point<float>      // true
 6std::copyable<std::vector<int>> // true
 7std::trivially_copyable<float>  // true
 8
 9// Определить собственные
10template <typename T>
11concept Numeric = std::integral<T> || std::floating_point<T>;
12
13template <typename T>
14concept DmaSafe = std::is_trivially_copyable_v<T>;

Ограничение шаблона с помощью концепта:

 1// Три эквивалентных синтаксиса
 2
 3// Предложение requires
 4template <typename T>
 5    requires Numeric<T>
 6T clamp(T value, T lo, T hi);
 7
 8// Концепт в списке параметров шаблона
 9template <Numeric T>
10T clamp(T value, T lo, T hi);
11
12// Сокращённый шаблон функции (C++20)
13auto clamp(Numeric auto value, Numeric auto lo, Numeric auto hi);

Сообщение об ошибке с концептами:

error: no matching function for call to 'clamp(std::string, ...)'
note: constraints not satisfied
note: 'std::string' does not satisfy 'Numeric'

Читаемо, понятно что исправить.


Ограничение интерфейса датчика

 1// Определить, что должен поддерживать "Sensor"
 2template <typename T>
 3concept Sensor = requires(T s) {
 4    { s.read()    } -> std::convertible_to<float>;
 5    { s.isReady() } -> std::convertible_to<bool>;
 6    { s.name()    } -> std::convertible_to<std::string_view>;
 7};
 8
 9// Компилируется только если T удовлетворяет Sensor
10template <Sensor S>
11class SamplingPipeline {
12    S& sensor_;
13    std::vector<float> history_;
14public:
15    explicit SamplingPipeline(S& s) : sensor_(s) {}
16
17    void sample() {
18        if (sensor_.isReady())
19            history_.push_back(sensor_.read());
20    }
21
22    std::span<const float> history() const { return history_; }
23};
24
25struct Bmp280 {
26    float read()            { return readTemp(); }
27    bool  isReady()         { return (readStatus() & 0x08) == 0; }
28    std::string_view name() { return "BMP280"; }
29};
30
31SamplingPipeline<Bmp280> pipeline(bmp);  // компилируется — Bmp280 удовлетворяет Sensor

Если в Bmp280 отсутствует метод, ошибка называет отсутствующее требование:

error: 'Bmp280' does not satisfy 'Sensor'
note: 'read()' is required but missing

if constexpr — ветвление на этапе компиляции

if constexpr выбирает ветку кода на этапе компиляции. Невыбранная ветка не компилируется — нет накладных расходов, нет ошибок компоновщика для веток, которые не работают с заданным типом:

 1template <typename T>
 2void serialize(T value, std::span<uint8_t> out) {
 3    if constexpr (std::is_same_v<T, float>) {
 4        uint32_t bits;
 5        std::memcpy(&bits, &value, 4);
 6        out[0] = bits >> 24; out[1] = bits >> 16;
 7        out[2] = bits >> 8;  out[3] = bits;
 8    } else if constexpr (std::is_integral_v<T>) {
 9        for (size_t i = sizeof(T); i > 0; --i)
10            out[i - 1] = value & 0xFF, value >>= 8;
11    } else {
12        static_assert(sizeof(T) == 0, "неподдерживаемый тип для serialize");
13    }
14}

static_assert(false) в ветке else — ошибка компиляции, срабатывающая только когда используется неподдерживаемый тип, а не при каждом инстанциировании.


Написание кастомного трейта типа

Определяйте трейты для собственных типов домена:

 1// Пометить типы как безопасные для DMA-передачи
 2template <typename T>
 3struct is_dma_safe : std::is_trivially_copyable<T> {};
 4
 5// Специализация для типа, не являющегося тривиально копируемым, но безопасного
 6// (например, тип с нетривиальным деструктором, не влияющим на данные)
 7template <>
 8struct is_dma_safe<MySpecialBuffer> : std::true_type {};
 9
10template <typename T>
11inline constexpr bool is_dma_safe_v = is_dma_safe<T>::value;
12
13// Использовать как концепт
14template <typename T>
15concept DmaSafe = is_dma_safe_v<T>;
16
17template <DmaSafe T>
18void startDma(std::span<T> buf, DMA_HandleTypeDef* hdma) {
19    HAL_DMA_Start(hdma, (uint32_t)buf.data(), dmaDest, buf.size() * sizeof(T));
20}

Вычисления на этапе компиляции

TMP может вычислять значения на этапе компиляции — вычисление происходит при компиляции, результат — константа:

 1// Проверка степени двойки на этапе компиляции (C++14)
 2template <size_t N>
 3constexpr bool isPowerOfTwo = N > 0 && (N & (N - 1)) == 0;
 4
 5static_assert(isPowerOfTwo<64>,  "буфер должен быть степенью двойки");
 6static_assert(!isPowerOfTwo<63>, "проверка вменяемости");
 7
 8// Логарифм по основанию 2 на этапе компиляции
 9constexpr size_t log2(size_t n) {
10    return n <= 1 ? 0 : 1 + log2(n / 2);
11}
12
13constexpr size_t mask = (1 << log2(64)) - 1;  // = 63

Свёрточные выражения C++17

Свёрточные выражения разворачивают пакеты параметров без рекурсивных шаблонов:

 1// Сумма любого числа аргументов
 2template <typename... Ts>
 3auto sum(Ts... args) {
 4    return (args + ...);  // свёрточное выражение: a1 + a2 + a3 + ...
 5}
 6
 7float total = sum(1.0f, 2.5f, 3.7f);  // 7.2
 8
 9// Условие "все"
10template <typename... Ts>
11bool allPositive(Ts... args) {
12    return ((args > 0) && ...);
13}
14
15bool ok = allPositive(1, 2, 3);   // true
16bool no = allPositive(1, -1, 3);  // false

Итоги

  • <type_traits> + static_assert: ловить злоупотребление типами на этапе компиляции — DMA-безопасные типы, ограничения размера
  • SFINAE: условное включение шаблонов в C++11/14/17 — читаемо только с практикой, некрасивые ошибки
  • Концепты C++20: именованные ограничения, читаемые ошибки, три варианта синтаксиса — предпочитайте template <MyConcept T>
  • if constexpr: ветвление по типу на этапе компиляции — сериализация по типу, выбор формата
  • Свёрточные выражения: развёртка пакетов без рекурсии — сумма, все, любой из пакетов аргументов
  • Кастомные трейты: is_dma_safe<T>, is_trivially_copyable<T> — проверки конкретного домена на этапе компиляции

Что дальше