Концепты в C++20: как устроено внутри и что меняет для перегрузок
Концепты — не синтаксический сахар над enable_if. Они меняют порядок разрешения перегрузок, дают частичное упорядочивание по ограничениям и ломаются на неатомарных выражениях. Разбираем механизм.

Возьмите шаблон с std::enable_if и перепишите его на requires. Скомпилируется одинаково, но поведёт себя по-разному в одном месте: когда две перегрузки подходят под один вызов, компилятор решает, какая «более ограничена». У SFINAE этого механизма нет вовсе — там кандидат либо в наборе, либо выброшен из него. Концепты в C++20 добавляют между шаблонами частичный порядок по их ограничениям, и именно этот порядок объясняет большинство неочевидных ошибок вида ambiguous overload, которые появляются при миграции. Разбираем, что происходит внутри компилятора, а не как писать concept.
Материал про механику: атомарные ограничения, нормализация, subsumption и порядок проверки. Синтаксис объявления концептов и их применение в шаблонах — отдельная тема, здесь не пересказываем. Всё написано для C++20 и проверялось на GCC 13 и Clang 17.
Из чего состоит ограничение
Ограничение (constraint) — это не сам концепт, а логическое выражение, которое компилятор должен свести к истине или лжи для конкретных аргументов. Концепт — именованный кирпич, из которого ограничения собираются через &&, || и requires-выражения.
Внутри стандарт (раздел [temp.constr]) выделяет три вида ограничений:
- конъюнкция —
A && B; - дизъюнкция —
A || B; - атомарное ограничение — всё остальное: вызов концепта,
requires-выражение, любое булево выражение вrequires-клаузе.
Ключевой момент: && и || внутри requires-клаузы — это не операторы, а структурные узлы дерева ограничений. Компилятор их видит и раскладывает. А вот && внутри обычного скобочного выражения — просто оператор, и всё выражение целиком становится одним атомарным ограничением.
Разница практическая. Вот две записи, которые выглядят похоже:
// C++20, GCC 13 / Clang 17
template<typename T>
concept Integral = std::is_integral_v<T>;
template<typename T>
concept Signed = std::is_signed_v<T>;
// Вариант A: два атомарных ограничения, соединённых конъюнкцией
template<typename T>
requires Integral<T> && Signed<T>
void f(T);
// Вариант B: одно атомарное ограничение — скобки склеили всё в выражение
template<typename T>
requires (std::is_integral_v<T> && std::is_signed_v<T>)
void g(T);
Для проверки истинности разницы нет. Для упорядочивания перегрузок — есть, и она проявится ниже.
Концепт даёт не просто «да/нет» на этапе подстановки. Он даёт компилятору структуру, по которой одно ограничение можно признать строже другого. Без этой структуры вы получаете либо жёсткую ошибку компиляции, либо неоднозначность.
Нормализация: как ограничение раскладывается на атомы
Перед тем как сравнивать два ограничения, компилятор приводит каждое к нормальной форме — раскрывает все именованные концепты до атомарных ограничений и строит дерево из конъюнкций и дизъюнкций. Это называется нормализацией ([temp.constr.normal]).
Возьмём концепт, собранный из других:
// C++20
template<typename T>
concept Number = std::is_arithmetic_v<T>;
template<typename T>
concept SignedNumber = Number<T> && std::is_signed_v<T>;
Нормальная форма SignedNumber<T> — это конъюнкция двух атомов: is_arithmetic_v<T> и is_signed_v<T>. Имя Number исчезает, остаётся его тело. Именно поэтому компилятор может сказать, что SignedNumber строже, чем Number: множество атомов первого включает атомы второго.
При нормализации важна одна тонкость — идентичность атомов. Два атомарных ограничения считаются одним и тем же, только если они возникли из одного и того же выражения в исходном коде (с точностью до отображения параметров). Скопированное дословно выражение из другого места программы не равно ему для нормализации. Это причина, по которой «одинаковые на вид» проверки не сокращаются, и упорядочивание не работает так, как ожидаешь.
Subsumption: кто кого поглощает
Когда две перегрузки прошли обычное разрешение и остались одинаково хорошими по типам, включается частичное упорядочивание по ограничениям. Ограничение P поглощает (subsumes) ограничение Q, если из P логически следует Q при рассмотрении атомов как булевых переменных.
Формально алгоритм переводит P в дизъюнктивную нормальную форму, Q — в конъюнктивную, и проверяет логическое следование. На практике достаточно интуиции: перегрузка с ограничением A && B строже, чем перегрузка с A, поэтому первая выигрывает.
// C++20
template<typename T>
requires std::integral<T>
void process(T); // (1)
template<typename T>
requires std::integral<T> && std::signed_integral<T>
void process(T); // (2)
process(42); // выбирается (2): её ограничение поглощает ограничение (1)
А теперь вернёмся к варианту B из первого раздела. Если бы signed_integral у второй перегрузки был записан как единое скобочное выражение (std::is_integral_v<T> && std::is_signed_v<T>), а первая — через отдельный атом std::integral<T>, то компилятор не увидел бы общего атома. Subsumption не сработал бы, и вызов стал бы неоднозначным. Отсюда практическое правило: составляйте ограничения из именованных концептов, а не из склеенных руками булевых выражений — иначе теряете упорядочивание.
Порядок проверки атомов и ленивость
Атомы конъюнкции проверяются слева направо, и проверка останавливается на первом ложном. Это не оптимизация, а гарантия стандарта, и она важна для случаев, где второй атом инстанцируется корректно только при истинном первом.
// C++20
template<typename T>
concept HasValueType = requires { typename T::value_type; };
template<typename T>
requires HasValueType<T> && std::default_initializable<typename T::value_type>
void inspect(T);
inspect(5); // T = int: HasValueType<int> ложно, второй атом не вычисляется
Если бы порядок нарушался, typename int::value_type дал бы жёсткую ошибку вместо тихого исключения перегрузки из набора. Порядок в конъюнкции — часть контракта, а не деталь реализации.
Где механизм ломается
Три граничных случая, на которых спотыкаются даже те, кто уже переписал код на концепты.
Идентичность атомов через разные псевдонимы
Если один концепт проверяет sizeof(T) == 4 в файле A, а другой — тот же sizeof(T) == 4, но написанный отдельным выражением в файле B, для нормализации это два разных атома. Логического следования между ними нет, и перегрузки не упорядочатся. Лечится выносом проверки в один именованный концепт, который используют обе перегрузки.
Дизъюнкции и комбинаторный взрыв
Проверка поглощения на выражениях с большим числом || раскладывается в нормальную форму, размер которой растёт экспоненциально от числа операндов. На практике это редко всплывает, но глубоко вложенные дизъюнкции ограничений способны заметно замедлить компиляцию. Держите ограничения плоскими.
requires-выражение не проверяет семантику
Ограничение requires { a + b; } проверяет только то, что выражение a + b корректно компилируется. Оно не проверяет, что сложение осмысленно, не проверяет тип результата и не запускает вычисление. Концепт — синтаксический и типовой фильтр, а не контракт поведения. Это ловушка для тех, кто ждёт от requires проверки инвариантов.
Чем это отличается от enable_if
| Свойство | enable_if / SFINAE | Концепты (C++20) |
|---|---|---|
| Упорядочивание перегрузок по строгости | нет | да, через subsumption |
| Диагностика при несоответствии | «no matching function», без причины | указывает, какой атом ложен |
| Раскладка на составные части | монолитное выражение | нормализация до атомов |
| Короткое замыкание проверок | зависит от структуры выражения | гарантирован порядок в конъюнкции |
| Читаемость сигнатуры | тонет в шаблонных параметрах | ограничение в requires-клаузе |
Главное отличие не в синтаксисе. SFINAE отвечает на вопрос «подходит ли кандидат». Концепты дополнительно отвечают на «какой из подходящих кандидатов строже» — и это меняет исход вызова там, где раньше вы получали неоднозначность.
Что смотреть дальше
Если мигрируете с enable_if: сначала оберните все точечные проверки в именованные концепты, потом стройте из них ограничения через && в requires-клаузе, а не через скобочные выражения. Так вы сохраните упорядочивание. Проверяйте разрешение перегрузок на пограничных типах (знаковые/беззнаковые, с вложенными типами и без) — именно там проявляется subsumption.
Для отладки неожиданных ambiguous overload сравните нормальные формы ограничений: включите подробную диагностику компилятора и найдите, какие атомы не совпали. Раздел стандарта [temp.constr] описывает нормализацию и поглощение построчно — при спорах об ожидаемом поведении сверяйтесь с ним, а не с интуицией.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: cppreference — Constraints and concepts, C++ Standard draft, [temp.constr] — Constraints
Частые вопросы
Работают ли концепты в C++17 или нужен C++20?
У меня перегрузки не упорядочились и вызов неоднозначен, хотя одна явно строже. Что не так?
Проверяет ли requires-выражение, что операция действительно работает правильно?
В каком порядке проверяются части ограничения A && B && C?
Замедляют ли концепты компиляцию по сравнению с enable_if?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.