Заметки по языку с и с++

Концепты (concepts) в C++20: ограничение шаблонных параметров

1 просмотров

Введение

Одна из главных болей шаблонного программирования в C++ — нечитаемые сообщения об ошибках, когда шаблон используется с неподходящим типом. До C++20 единственным способом ограничить допустимые типы и улучшить диагностику было использование SFINAE (Substitution Failure Is Not An Error) — техники, формально работающей, но крайне громоздкой и непонятной для новичков. C++20 вводит концепты (concepts) — именованные, явно читаемые ограничения на шаблонные параметры.

Суть концепции

Концепт — это предикат компиляции: набор требований к типу (наличие операций, других концептов, выражений), который можно проверить и которому можно дать осмысленное имя. Если тип не удовлетворяет концепту, компилятор сразу укажет, какое именно требование не выполнено, вместо того чтобы выводить стену текста из недр инстанцирования шаблона.

#include <concepts>
#include <iostream>
#include <string>

// Собственный концепт: тип поддерживает оператор < и оператор +
template <typename T>
concept Addable = requires(T a, T b) {
    a + b;
};

template <typename T>
concept Comparable = requires(T a, T b) {
    a < b;
};

// Используем концепт как ограничение на параметр шаблона
template <Comparable T>
T myMax(T a, T b)
{
    return (a < b) ? b : a;
}

// Альтернативный синтаксис с requires-выражением
template <typename T>
requires Addable<T> && Comparable<T>
T sumIfGreater(T a, T b)
{
    if (a < b) {
        return a + b;
    }
    return a;
}

// Стандартные концепты из <concepts>
template <std::integral T>
T square(T value)
{
    return value * value;
}

int main()
{
    std::cout << myMax(3, 7) << std::endl;
    std::cout << myMax(std::string("abc"), std::string("abd")) << std::endl;

    std::cout << sumIfGreater(2, 5) << std::endl;
    std::cout << square(6) << std::endl; // OK: int — integral

    // square(3.14); // ОШИБКА КОМПИЛЯЦИИ: double не удовлетворяет std::integral
    // Сообщение об ошибке прямо укажет на нарушенное требование концепта.

    return 0;
}

Разбор примера

Addable и Comparable — собственные концепты, описанные через requires-выражение: внутри фигурных скобок мы просто пишем код, который должен «уметь скомпилироваться» для типа T. Если выражение a + b или a < b не компилируется для конкретного типа, концепт считается не удовлетворённым.

template <Comparable T> T myMax(...) — это сокращённый синтаксис: концепт используется прямо вместо typename в списке параметров шаблона. Эквивалентная, более явная форма — template <typename T> requires Comparable<T>.

std::integral — один из стандартных концептов из заголовка <concepts>, который проверяет, является ли тип целочисленным (int, long, char, bool и т. д.). Если попытаться вызвать square(3.14), компилятор немедленно сообщит, что double не удовлетворяет std::integral, указав на конкретное несоответствие, а не печатая десятки строк инстанцирования шаблонов, как было бы с обычным template <typename T> без концептов.

Где использовать концепты

  • Документирование интерфейса шаблона. Имя концепта (Comparable, Addable, std::ranges::range, std::invocable) само по себе объясняет, что требуется от типа, не заглядывая в реализацию функции.
  • Перегрузка по семантике типа. Можно определить несколько версий функции для разных категорий типов и позволить компилятору выбрать подходящую через requires-ограничения — это явная и читаемая альтернатива SFINAE и тегам-диспетчерам.
  • Constrained auto. Концепты можно использовать прямо при объявлении переменных и параметров: void process(std::integral auto value) — это сокращённая форма шаблонной функции с ограничением.
void process(std::integral auto value)
{
    std::cout << "Целое число: " << value << std::endl;
}

Подводные камни и советы

  • Концепт — это не runtime-проверка, вся проверка происходит во время компиляции. Не пытайтесь использовать концепты для валидации пользовательского ввода в runtime.
  • Не переусложняйте собственные концепты. Если в стандартной библиотеке уже есть подходящий концепт (std::integral, std::floating_point, std::same_as, std::derived_from, std::invocable, std::ranges::range и десятки других) — используйте его вместо изобретения своего.
  • requires-выражение и requires-предложение — разные вещи. requires(T a) { ... } внутри concept — это requires-выражение (проверка компилируемости кода). requires Comparable<T> после списка шаблонных параметров — это requires-предложение (накладывает ограничение). Их легко спутать по синтаксису, но назначение разное.
  • Композиция концептов через && и || работает естественно: requires Addable<T> && Comparable<T> — тип должен удовлетворять обоим требованиям.
  • Концепты особенно ценны в сочетании с шаблонами классов и фабричными функциями — ограничения можно накладывать как на сам класс, так и на отдельные его методы независимо.

Концепты делают шаблонное программирование в C++ значительно более доступным новичкам: вместо изучения SFINAE-трюков можно явно и читаемо сформулировать, что требуется от типа, и получить понятную ошибку компиляции при нарушении этого требования.

================================================================================