Концепты
До C++20 ограничивать шаблоны по типам можно было только через SFINAE и enable_if — механизм мощный, но ужасающий в использовании. Ошибка в коде приводила к многостраничным простыням сообщений компилятора, а сам код превращался в загадки вроде std::enable_if_t<std::is_integral_v<T> && !std::is_same_v<T, bool>, T>. Концепты появились в C++20 именно для того, чтобы сделать ограничения на типы читаемыми, диагностируемыми и семантически явными.
Концепт — это именованный предикат времени компиляции: булево условие, которое тип либо удовлетворяет, либо нет. Представьте его как контракт: функция с концептом std::integral<T> сообщает компилятору и человеку — "я принимаю только целые числа". Если передать std::string, компилятор скажет именно это, а не выдаст стену косвенных ошибок подстановки.
Определение концепта
#include <concepts>
#include <iostream>
template<typename T>
concept Printable = requires(T val) {
{ std::cout << val }; // T должен поддерживать operator<<
};
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>
concept HasSize = requires(T t) {
{ t.size() } -> std::convertible_to<std::size_t>;
};
Концепт определяется ключевым словом concept после template<typename T>. Правая часть — булево выражение времени компиляции. Оно может быть составным (&&, ||), ссылаться на другие концепты или содержать requires-выражение.
requires — два разных зверя
Слово requires в C++20 выполняет две совершенно разные роли. Их легко перепутать.
requires clause — ограничение шаблона
requires clause стоит снаружи определения и говорит, при каком условии шаблон участвует в перегрузке:
// Способ 1: requires clause после списка параметров шаблона
template<typename T>
requires std::integral<T>
T gcd(T a, T b) {
while (b) { a %= b; std::swap(a, b); }
return a;
}
// Способ 2: концепт прямо в template-параметре (сокращённый синтаксис)
template<std::integral T>
T gcd(T a, T b) { /* ... */ }
// Способ 3: trailing requires (после сигнатуры функции)
template<typename T>
T gcd(T a, T b) requires std::integral<T> { /* ... */ }
Все три варианта семантически идентичны. Сокращённый синтаксис (template<std::integral T>) — самый читаемый и предпочтительный для одного концепта.
requires expression — проверка набора выражений
requires expression стоит внутри определения концепта (или прямо в requires clause) и описывает, какие операции должны быть валидны для типа:
template<typename T>
concept Sortable = requires(T& container) {
container.begin(); // выражение должно компилироваться
container.end();
typename T::value_type; // вложенный тип должен существовать
{ container.size() } -> std::unsigned_integral; // тип результата выражения
};
Внутри requires { ... } каждая строка — статическая проверка, не выполняемый код. { expr } -> Concept означает: выражение expr должно компилироваться, а его тип — удовлетворять Concept. Тело requires-выражения никогда не выполняется в рантайме.
Можно использовать requires-выражение прямо в requires clause, без объявления отдельного концепта:
// Ад-хок ограничение: не называем концепт, просто требуем
template<typename T>
requires requires(T a, T b) { a + b; }
T add(T a, T b) { return a + b; }
Двойное requires — первое это clause, второе — expression. Выглядит странно, но законно.
Четыре способа применить концепт
// 1. Концепт прямо в template-параметре (наиболее читаемо)
template<Printable T>
void print(T val) { std::cout << val; }
// 2. requires clause
template<typename T> requires Printable<T>
void print(T val) { std::cout << val; }
// 3. Trailing requires (после сигнатуры)
template<typename T>
void print(T val) requires Printable<T> { std::cout << val; }
// 4. Сокращённые auto-параметры (abbreviated function templates)
void print(Printable auto val) { std::cout << val; }
Четвёртый способ — abbreviated function templates — самый компактный. Компилятор автоматически превращает Printable auto val в шаблонный параметр. Каждый auto-параметр вводит независимый шаблонный параметр:
// Эти две записи эквивалентны:
void swap_values(std::integral auto a, std::integral auto b);
// ≡
template<std::integral T, std::integral U>
void swap_values(T a, U b);
// T и U — независимые типы! Если нужен один тип, используйте явный шаблон.
Составные концепты
Концепты комбинируются через && и ||:
template<typename T>
concept SignedIntegral = std::integral<T> && std::signed_integral<T>;
template<typename T>
concept StringLike = std::same_as<T, std::string>
|| std::same_as<T, std::string_view>
|| std::convertible_to<T, std::string_view>;
template<typename T>
concept Container = requires(T c) {
c.begin(); c.end(); c.size();
typename T::value_type;
typename T::iterator;
} && std::copyable<T>;
Важная тонкость с && в концептах: логические операторы && и || в концептах — это не те же операторы, что в обычном коде. Они не имеют семантики короткого замыкания в общем смысле, однако для свёртки концептов (subsumption) компилятор их понимает структурно. Подробнее — в разделе о subsumption ниже.
Стандартные концепты (``)
Типы и преобразования
std::same_as<T, U> // T и U — один и тот же тип
std::derived_from<D, B> // D публично наследует от B
std::convertible_to<From, To> // From неявно конвертируется в To
std::common_with<T, U> // у T и U есть общий тип (std::common_type_t)
std::common_reference_with<T, U>
Объектная семантика
std::destructible<T> // ~T() не бросает исключений
std::constructible_from<T, Args...>
std::default_initializable<T>
std::move_constructible<T>
std::copy_constructible<T>
std::movable<T> // перемещаем + swap
std::copyable<T> // копируем + movable
std::semiregular<T> // copyable + default_initializable
std::regular<T> // semiregular + equality_comparable
std::regular<T> — минимальный набор для "нормального" типа данных: можно копировать, перемещать, создавать по умолчанию и сравнивать на равенство. Именно это стандарт ожидает от типов, которые ведут себя как "значения".
Сравнения
std::equality_comparable<T>
std::equality_comparable_with<T, U>
std::totally_ordered<T>
std::totally_ordered_with<T, U>
Callable
std::invocable<F, Args...> // F можно вызвать с Args...
std::regular_invocable<F, Args...>// invocable + чистая функция (без побочных эффектов)
std::predicate<F, Args...> // invocable, возвращает bool
std::relation<R, T, U> // бинарное отношение
std::strict_weak_order<R, T, U> // строгий слабый порядок (нужен для std::sort)
Числовые типы
std::integral<T> // целочисленный тип (включая bool, char)
std::signed_integral<T> // знаковый целочисленный
std::unsigned_integral<T> // беззнаковый целочисленный
std::floating_point<T> // float, double, long double
Обратите внимание: std::integral<bool> — истина, std::signed_integral<bool> — ложь.
Ranges (``)
std::ranges::range<R> // R имеет begin() и end()
std::ranges::sized_range<R> // + size() за O(1)
std::ranges::bidirectional_range<R>
std::ranges::random_access_range<R>
std::ranges::contiguous_range<R> // элементы лежат в памяти подряд
std::ranges::viewable_range<R> // R можно передать в views::all()
Иерархия итераторов (``)
std::input_iterator<I>
↓
std::forward_iterator<I> // многопроходный, сравниваемый
↓
std::bidirectional_iterator<I> // + operator--
↓
std::random_access_iterator<I> // + operator[], +n, -n, <, >, <=, >=
↓
std::contiguous_iterator<I> // элементы в непрерывной памяти (std::to_address)
template<std::random_access_iterator It>
void my_sort(It begin, It end) {
// Алгоритм требует произвольного доступа — std::list не пройдёт
std::sort(begin, end);
}
Subsumption — более ограниченный концепт побеждает
Subsumption — правило разрешения неоднозначности перегрузок: если один шаблон накладывает более строгие ограничения, чем другой, то более строгий побеждает без ambiguous overload.
template<typename T>
concept Integral = std::integral<T>;
template<typename T>
concept SignedIntegral = Integral<T> && std::signed_integral<T>;
// Два кандидата:
template<Integral T> void process(T x) { /* общий случай */ }
template<SignedIntegral T> void process(T x) { /* для signed */ }
process(42); // SignedIntegral более специализирован → вызывается вторая перегрузка
process(42u); // unsigned — удовлетворяет только Integral → первая перегрузка
Subsumption работает только если более специальный концепт определён через более общий с помощью &&. Произвольные эквивалентные концепты компилятор не сравнивает:
// Два независимых концепта с одинаковым смыслом — subsumption не работает:
template<typename T>
concept MyIntegral = std::is_integral_v<T>; // через type trait
template<typename T>
concept AlsoIntegral = std::integral<T>; // через стандартный концепт
// Компилятор не знает, что они эквивалентны → ambiguous overload
Концепты проверяют синтаксис, не семантику
Это важное ограничение, которое легко упустить. requires { expr; } проверяет, что выражение компилируется, но не что оно делает то, что ожидается.
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
struct Weird {
Weird operator+(const Weird&) const {
throw std::runtime_error("не работает!"); // компилируется, но...
}
};
// Weird удовлетворяет Addable — синтаксически всё верно
// Семантика (работает ли сложение осмысленно) концепт не проверяет
Поэтому std::regular_invocable и std::invocable — синтаксически одинаковы: стандарт документирует семантическое различие (чистота функции), но компилятор его не проверяет. Это архитектурное решение: семантические гарантии — ответственность разработчика.
Концепты vs type traits — в чём разница
На первый взгляд std::integral<T> и std::is_integral_v<T> делают одно и то же. Разница — в поведении при перегрузке:
// Type trait — не участвует в subsumption:
template<typename T>
requires std::is_integral_v<T>
void foo(T x);
// Concept — участвует в subsumption:
template<std::integral T>
void foo(T x);
Компилятор понимает иерархию концептов структурно и может выбирать более специализированную перегрузку автоматически. С type traits это невозможно — они непрозрачны для системы перегрузки.
Кроме того, концепты дают читаемые сообщения об ошибках, а сигнатуры с концептами IDE может отображать как документацию. Стандартные концепты из <concepts> — предпочтительный выбор; std::is_integral_v<T> остаётся полезным внутри if constexpr и шаблонной метапрограммирования.
Практические предупреждения
&& в концептах и subsumption: чтобы subsumption работал, более специальный концепт должен явно включать более общий через && в своём определении. Переписать SignedIntegral как просто requires(...) без ссылки на Integral — и subsumption сломается.
Рекурсия и самоссылка: концепты не могут напрямую ссылаться на самих себя — это ошибка компиляции.
Концепт auto в возвращаемом типе: пока не поддерживается напрямую. std::integral auto foo() — не валидный C++20, нужен явный шаблон.
Концепты и explicit: концепты не влияют на explicit. std::convertible_to<From, To> проверяет неявную конвертацию; std::constructible_from<To, From> — явную (через прямую инициализацию).
Значение для собеседований
Концепты набирают популярность на собеседованиях по мере того, как C++20 становится стандартом де-факто в новых кодовых базах. Вот что обычно проверяет интервьюер:
Что спрашивают:
- Чем концепт отличается от SFINAE и
enable_if? (Ответ: не только удобство — это другая модель диагностики и subsumption) - В чём разница между
requires clauseиrequires expression? - Что такое subsumption и когда оно работает?
- Как концепты влияют на разрешение перегрузок?
- Какие концепты из стандартной библиотеки вы знаете?
Популярные направления вопросов:
- Написать концепт для произвольного требования (например, тип поддерживает
+и<) - Объяснить, почему два семантически эквивалентных концепта могут дать
ambiguous overload - Разобрать abbreviated function template с
autoи объяснить, что происходит с типами параметров - Объяснить ограничения концептов: они проверяют синтаксис, не семантику
Частая ошибка: "Концепты — это просто красивый enable_if". Это неверно. Концепты — первоклассная часть системы типов C++: они структурно понятны компилятору (subsumption), документируют намерение в сигнатуре и дают принципиально другую диагностику. enable_if — хак над системой вывода типов; концепты — встроенный механизм ограничений.