
Хотя концепты действительно являются мощным и выразительным инструментом, у них есть принципиальные ограничения, о которых важно знать, чтобы не пытаться использовать их не по назначению. Эти ограничения не случайны и не являются «недоделками» языка, а отражают осознанное архитектурное решение, принятое комитетом C++.
Первое фундаментальное ограничение это запрет прямой и косвенной рекурсии в определении концепта. Проще говоря, концепт не может ссылаться сам на себя ни напрямую, ни через цепочку других концептов. Например, такое определение является недопустимым
template<typename T>
concept Recurse = Recurse<T>;
Даже если попытаться замаскировать рекурсию через промежуточные концепты, результат будет тем же, и код все равно не скомпилируется. Это ограничение введено для того, чтобы исключить возможность бесконечных циклов на этапе проверки ограничений, но в отличие от обычных шаблонов, концепты должны проверяться быстро и предсказуемо, без риска зациклиться в процессе компиляции.
Нескучное программирование: Обобщения (WIP)
И снова ограничения <= Вы тут
Как компилятор видит код
Проблемы с именами
Ночью все кошки серы, а типы одинаковы
Тот, кто путается в именах
Простой поиск имен
Непослушный using
Правила выведения типа
От вывода типов к проблемам с ко[д/т]ом
Выражения в ко[д/т]е
Схлопывание ссылок
Специализация шаблонов
. . .
Второе важное ограничение заключается в том, что нельзя накладывать requires на само определение концепта, и, если внутри тела концепта можно писать проверки, использовать requires-выражения, логические операции и другие концепты, то нельзя дополнительно ограничивать шаблон концепта снаружи ещё одним requires, потому что концепт всегда должен оставаться самодостаточным логическим предикатом, �� не еще одним вариантом шаблонов с собственными ограничениями.
Оба этих ограничения тесно связаны с философией, которой придерживался комитет C++, чтобы не превратить концепты в еще один полноценный язык метапрограммирования на уровне типов, как это произошло с шаблонами. История уже знает немало примеров, где система типов становится настолько выразительной, что на ней начинают писать целые программы, как это произошло с type classes в Haskell.
Поэтому в C++ сознательно решили пойти другим путём и решили пожертвовать выразительностью ради предсказуемости, ограниченной сложности и понятной модели компиляции. Концепты должны описывать требования к типам, а не быть средством вычислений или рекурсивных абстракций.
template<typename T>
concept CopyableConcept = std::copyable<T> && std::movable<T>;Такой подход полностью укладывается в модель языка: он ясен, читаем и хорошо работает с механизмом частичного порядка, когда компилятор без труда понимает, что CopyableConcept строже каждого из составляющих его концептов по отдельности, и может корректно использовать это при выборе перегрузок.
Формы написания requires
В C++ предусмотрено несколько синтаксических вариантов, и каждый из них удобен в своей ситуации. Самая явная форма будет прямым указание requires перед объявлением функции:
template<typename T>
requires std::integral<T>
void resolve(T x);Этот вариант хорошо читается, особенно когда условия простые и короткие. Тут сразу бросается в глаза ограничение, которое подчёркивает, что функция существует только для определённой категории типов. Вторая форма — это сокращенная трейлинг-форма (trailing form), когда requires пишется после объявления функции:
template<typename T>
void resolve(T x) requires std::integral<T>;Эту удобно писать в тех случаях, когда в условиях нужно использовать имена аргументов функции, или когда сигнатура сама по себе уже достаточно длинная, и хочется визуально отделить ограничения от основной части объявления. И, наконец, самая компактная и часто самая читаемая форма — использование концепта напрямую:
void print(std::integral auto x);Несмотря на внешний вид, это всё ещё шаблонная функция, просто шаблонный параметр не выписывается явно, а ограничивается концептом на месте использования. Такой синтаксис делает код приближенным к обычным функциям, создавая меньше «технического шума», но при этом сохра��яет все преимущества статической проверки требований к типам. В совокупности эти формы позволяют выбрать баланс между явностью, компактностью и выразительностью, а ограничения, наложенные на сами концепты, помогают удерживать кодовую базу в разумных рамках шаблоностроения, не превращая её в еще один слой метапрограммирования.
Применение к классам и переменным
Теперь посмотрим, как ограничения и концепты применяются не только к свободным функциям, но и к классам, методам и даже локальным переменным, потому что здесь становится видно, что концепты это не «фича для шаблонных гуру», а инструмент повседневной разработки для разработчика уровня джуна и ниже.
Начну с классов и шаблонов, когда концепты можно использовать прямо в списке шаблонных параметров класса, ограничивая допустимые типы ещё на этапе объявления. Например, если мы хотим, чтобы наш движковый класс Vec3 работал только с вещественными типами, это можно сделать напрямую:
template<std::floating_point T>
class Vec3 {
// ...
};Такое объявление сразу фиксирует наши намерения, и разные поползновения написать Vec3<Vasia> или Vec3<bool> приведут нас к ошибке невалидных типов и компилятор не будет даже пытаться инстанцировать класс и выдавать ошибки где-то глубоко в реализации, а остановится на границе интерфейса, сделав шаблонные классы гораздо ближе к обычным “строгим” типам. Ещё надо сказать, что концепты работают не только на уровне шаблонов, но и на уровне локального кода и можно использовать auto с концептами даже для локальных переменных, например даже так:
std::integral auto EntityNumber = 0;
std::floating_point auto EntitySpeed = 5.2;Выглядит как «синтаксический сахар», пока вы не полезли дебажить систему анимаций, где тип параметров сплошь из auto и его концы вообще через десять классов только видны, а студия такая: извини дружище я фиг знает, что за тип тут. На практике такой приём делает код самодокументирующимся, когда читающий явно видит не просто «здесь какое-то число», а «здесь целочисленный счётчик» или «здесь вещественная скорость».
Следующий шаг - это применение requires к методам классов. Хотя тут, в отличие от многих других языков, интерфейс класса может зависеть от параметров шаблона, и requires позволяет выразить это формально, когда можно объявить метод так, чтобы он существовал только для тех параметров шаблона, которые удовлетворяют определённым требованиям.
template<typename T>
struct Entity {
float mass() requires IsObjectHasMass<T> {
// реализация только для объектов с массой
}
};Т.е. метод mass() существует не всегда, а только если T удовлетворяет концепту IsObjectHasMass, и, с точки зрения языка, такого метода просто нет для некоторых классов. а попытка вызвать его приведёт не к ошибке внутри реализации, а к обычному сообщению о том, что у типа нет такого члена. Это позволяет нам создавать «умные» интерфейсы, где набор доступных операций зависит от свойств типов, а не от неявных договорённостей на этапе проектирования классов. Аналогичным образом можно ограничивать и конструкторы, и любые другие функции, что особенно полезно для обобщённых обёрток и контейнеров, где возможность создания объекта зависит от особенностей параметрического типа:
template<typename T>
struct EntityDamagable {
EntityDamagable() requires HasDamageFunction<T> = default;
EntityDamagable(const T&) requires HasHealth<T>;
};В этом случае конструктор по умолчанию существует только тогда, когда T имеет некоторый признак-функцию, которая позволяет наносить урон, а конструктор, принимающий const T&, существует только если T имеет «хелсу», т.е. когда мы сможем скопировать её значение, позволяя нам через интерфейс класса автоматически адаптироваться к возможностям типа, но оставаться при этом строго формализованным и проверяемым компилятором кодом. В итоге приходим к тому, что концепты пронизывают весь язык, начиная от шаблонных классов и методов, и заканчивая переменными класса и конструкторов, позволяя проектировать интерфейсы, которые точно отражают возможности типов, но остаются строгими и предсказуемыми и при этом хорошо читаются.
Немного про частичный порядок перегрузок
В предыдущей статье об иерархии концептов я уже показал, что компилятор умеет выстраивать частичный порядок (partial ordering) между шаблонными функциями на основе того, какие концепты используются в их ограничениях, и при наличии нескольких подходящих перегрузок выбирает не «первую попавшуюся», а наиболее специализированную.
Вернусь у примеру с resolve из прошлой статьи и немного его изменю, пусть у нас есть две функции resolve(): одна принимает любой целочисленный тип, удовлетворяющий std::integral, а вторая только знаковые целые, удовлетворяющие std::signed_integral. Теперь при вызове resolve(-5) обе перегрузки формально подходят, потому что тип int является и std::integral, и std::signed_integral.
Но компилятор выбирает вторую версию, потому что std::signed_integral логически строже: и каждый тип, который является std::signed_integral, автоматически является и std::integral, но не наоборот. Такое упорядочивание перегрузок и называется частичным порядком, но «частичным», потому что не для любых двух ограничений можно однозначно сказать, какое из них более специализированное.
template <std::integral T>
void resolve(T value) {
std::cout << "integral version: " << value << '\n';
}
template <std::signed_integral T>
void resolve(T value) {
std::cout << "signed integral version: " << value << '\n';
}
int main() {
resolve(42); // int → signed_integral
resolve(-5); // int → signed_integral
resolve(42u); // unsigned int → integral
}При компиляции этого кода происходит следующее: для вызовов resolve(42) и resolve(-5) компилятор видит две подходящие перегрузки и обе принимают тип int, который удовлетворяет и std::integral, и std::signed_integral. Затем он сравнивает ограничения и обнаруживает, что std::signed_integral<T> является более специализированным, поскольку каждый тип, удовлетворяющий этому концепту, автоматически удовлетворяет и std::integral<T>, поэтому выбирается версия для знаковых целых.
resolve(-5); // int → signed_integralВ случае resolve(42u) ситуация иная, потому что тип unsigned int удовлетворяет std::integral, но не удовлетворяет std::signed_integral, что приводит к ситуации, когда вторая перегрузка отбрасывается ещё на этапе проверки ограничений, и остаётся только одна версия. Механизм частичного порядка основан на сравнении ограничений, записанных в requires, для этого компилятор приводит их к набору так называемых атомарных ограничений — минимальных логических выражений, которые уже не разложить на более простые дальше, и именно на этом уровне происходит сравнение.
Пройду еще раз этот момент, потому он может выпасть из внимания: атомарные ограничения считаются одинаковыми только при буквальном синтаксическом совпадении, и даже логически эквивалентные выражения вроде (sizeof(T) > 4) и (sizeof(T) > 4 && true) для компилятора будут разными ограничениями, но такое правило делает поведение системы предсказуемым и реализуемым.
Поэтому повторю вывод из прошлой статьи - если вы хотите, чтобы один концепт считался более специализированным, чем другой, он должен явно включать его ограничения в том же синтаксическом виде (логическая прямая), а не просто быть логически эквивалентным логически (логическая кривая), иначе вы можете получить ситуацию, где две перегрузки окажутся несравнимыми, и вызов станет неоднозначным.
На этом в целом я заканчиваю вводную часть про концепты, и начну разбирать собствено "мясло" про то, как компилятор находит имена сущностей в коде и какие есть с этим проблемы, как определяет из какого неймспейса взять перегрузку, причем здесь ADL и как его сломать, и почему манглинг сожрал все ваши типы, приходите - будет страшно интересно.
P.S. Это все теория, но если есть желание узнать больше про управление памятью, скрытые аллокации в стандартной библиотеке и неосторожное обращение с языком, особенно когда речь заходит про игрострой без кучи, то приходите на мой курс по C++ без динамической памяти на Stepik, где всё это разложено по полочкам. Промокод как обычно HABR50, если комуто нужно больше или тестовый доступ - напишите в личку, найду лишний инвайт.
Нескучное программирование. С++ без аллокаций памяти

