В языке C++ перегрузки функций и шаблонов исторически были и остаются мощным инструментом для выражения различных реализаций одного и того же интерфейса. Многим перегрузки видятся как удобный способ дать одно имя разным функциям, но на практике понимание того, как компилятор выбирает нужную перегрузку, может стать источником ошибок и недоразумений. Компилятор же руководствуется сложным набором правил, которые мы ему предоставили, учитывает не только типы аргументов, но и порядок специализаций, преобразования типов, const-квалификаторы, шаблонные параметры и многое другое. А ошибки, возникающие при перегрузках, часто трудно диагностировать, поскольку сообщение компилятора может ссылаться на глубоко вложенные детали реализации вместо очевидного исходного кода. Об этом была предыдущая статья...

С введением концептов и ограничений (requires) язык получил возможность управлять этой сложностью на уровне интерфейса. Вместо того чтобы надеяться на магию перегрузки и изощрённые трюки вроде SFINAE, мы теперь можем прямо выражать намерения: какие свойства должен иметь тип, чтобы функция или шаблон были корректны, что позволило перейти от «магии разрешения перегрузок» к декларативному описанию требований к типам.

Давайте теперь поговорим о том, что именно делают ограничения (requires) в современном C++ и почему появление этого механизма стало таким важным шагом в развитии шаблонов. Тут надо сделать немного шаг в сторону и вспомнить, что исторически шаблоны в C++ были мощным, но довольно опасным инструментом, еще одним языком в языке, на котором можно было сделать почти всё, было бы желание.

В итоге компилятор позволял подставить или подхачить любой тип, а проверка того, «подходит ли он на самом деле», откладывалась до момента инстанцирования, что нередко приводило к ошибке далеко от места вызова, а сообщение о непосредственном месте ошибки превращалось в многостраничный отчёт о внутренней кухне компилятора и о том, как он работает с шаблонами. Теперь requires поменяли эту модель, позволяя описывать ожидания от типа явно и прямо в объявлении функции или класса.


По сути requires — это способ наложить ограничения на параметры шаблона средствами языка, формулируя некоторый контракт: «возьми такой T, ко��орый умеет вот это и вот это». Если тип не удовлетворяет этим условиям, шаблон даже не будет участвовать в разрешении перегрузок, когда мы исключаем выбранную ветку инстанцирования не через ошибку подстановки (SFINAE), а через исключение неподходящего варианта ещё на этапе выбора перегрузки. Помогает это не только компилятору ограничить множество возможных вариантов, но и нам самим, потому что теперь компилятор может выдать короткое и осмысленное сообщение, а не поток вторичных ошибок.

Кот != кот

Давайте рассмотрим простой пример, у нас есть функция, которая проверяет равенство двух объектов одного типа. Логично ожидать, что такой тип должен поддерживать оператор ==. Если до этого ошибку "неподдержки" операции сравнения мы получим, когда компилятор перебрал все возмо��ные варианты, пару разу свалился на SFINAE (мы этого не видим, это реальное время сборки бандла или бинарника) и только потом вывел бы простыню логов, то с помощью requires мы убираем эти накладные расходы и переносим все эти проверки "до", а не "во время":

template<typename T>
bool check_equality(const T& a, const T& b)
    requires std::equality_comparable<T>
{
    return a == b;
}

Здесь мы явно говорим, — эта функция существует только для тех типов T, которые удовлетворяют концепту std::equality_comparable, и если попытаться вызвать её с типом, у которого нет оператора ==, компилятор не будет пытаться «протащить» нас внутрь шаблона, а сразу сообщит: ограничение не выполнено — данный тип не является сравнимым на равенство. Это принципиально другой уровень обратной связи по сравнению с классическим C++, где аналогичная ошибка приводила бы к цепочке сообщений о том, что где-то глубоко не найден подходящий оператор.

Очень важным это становится, когда условий становится больше одного. В реальном коде функции редко предъявляют к типу только одно требование, и мы можем ожидать, что тип ведёт себя как итератор и при этом поддерживает полный порядок сравнения. Теперь такие условия можно комбинировать напрямую в объявлении функции:

template<typename T>
void resolve(const T& v)
    requires std::forward_iterator<T> && std::totally_ordered<T>;

В этом случае компилятор проверяет каждое условие по отдельности, и если тип не удовлетворяет хотя бы одному из них, в диагностике будет чётко указано, какое именно требование нарушено. Это резко контрастирует со старыми SFINAE-приёмами на базе enable_if, частичных специализаций и странных выражений с хаками через decltype, где ошибка часто выглядела как «нет подходящей перегрузки», без какого-либо объяснения, почему именно она не подходит.

Упрощение "перегруженных"

Ещё один важный аспект — это разделение перегрузок по категориям типов. Например, одна версия функции работает только с целыми числами, а другая — только с числами с плавающей точкой. Используя enable_if можем написать их так:

template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
resolve(T x) {
   // Реализация для целых чисел
}

template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
resolve(T x) {
   // Реализация для вещественных чисел
}

При таком подходе мы получаем замусоренный техническими деталями возвращаемый тип. Не имея практики работы с таким кодом, может быть непонятно без комментариев, что делает enable_if. Нам все равно приходится полагаться на шаблонную магию ::value и ::type, и в любом случае логика «что требуется» спрятана в коде функции.

Либо через SFINAE + trailing return, те же решения — вид сбоку:

template<typename T>
auto resolve(T x) -> std::enable_if_t<std::is_integral_v<T>> {
   // Реализация для целых чисел
}

template<typename T>
auto resolve(T x) -> std::enable_if_t<std::is_floating_point_v<T>> {
   // Реализация для вещественных чисел
}

Применив requires мы можем написать несколько функций с одинаковым именем, но с разными ограничениями, и тем самым выразить разные намерения для разных семейств типов:

template<typename T>
void resolve(T x) requires std::integral<T>;

template<typename T>
void resolve(T x) requires std::floating_point<T>;

Здесь нет трюков c enable_if или скрытых условий и читая такой код, даже без глубокого знания шаблонных механизмов, вы легко поймете замысел автора о том, что поведение функции зависит от того, к какой математической категории относится тип. Для разработчика это особенно ценно, потому что requires по сути учит четко формулировать требования к абстракциям, создавая в коде явный контракт или, если хотите ТЗ, а не прятать контракт в деталях реализации “бизнес-логики”.

Еще немного о сигнатурах

C requires если попытаться вызвать resolve() с типом, который не относится ни к целочисленным, ни к вещественным, компилятор окажется в честной и понятной ситуации: подходящей перегрузки просто не существует. Теперь компилятор не будет гадать, не станет пытаться «подогнать» тип под одну из версий и не уйдёт в дебри шаблонных подстановок, а прямо скажет, что ни одно из ограничений requires std::integral<T> и requires std::floating_point<T> не выполнено. Для разработчика это выглядит как нормальная, логичная ошибка интерфейса.

Почему же всё это вообще работает? Дело в том, что с точки зрения компилятора функции с одинаковым именем и формально одинаковыми параметрами, но с разными условиями requires, считаются разными функциями. Это может показаться неожиданным, если мы привыкли думать о перегрузке только через список параметров, но в стандарте C++ явно зафиксировано правило: две функции считаются одной и той же сущностью только в том случае, если у них эквивалентные параметры и эквивалентные условия requires.

Иначе говоря, ограничения теперь — это часть сигнатуры функции на уровне языка. Но при этом важно понимать тонкий, но принципиальный момент: requires является частью интерфейса функции, но "не участвует в манглинге/не влияет на манглинг". Это означает, что с точки зрения линковки никакого конфликта имён не возникает, а с точки зрения компилятора на этапе выбора перегрузки возникают разные кандидаты, каждый со своими условиями применимости, поэтому такой код абсолютно легален (логические имена функций внутри компилятора будут разные):

template<typename T>
void resolve(T t) requires (sizeof(T) > 4)
   -> resolve_t_sizeof_gr_4

template<typename T>
void resolve(T t) requires (sizeof(T) <= 4)
   -> resolve_t_sizeof_ls_4 

Когда мы вызываем resolve с конкретным типом, компилятор сначала подставляет этот тип, затем проверяет ограничения и просто выбирает ту версию, для которой логическое выражение в requires истинно и здесь нет никакой магии, как это было с шаблонами, теперь это обычный отбор перегрузок, но с дополнительным фильтром в виде условий.

На этом месте важно не забыть о типичной ловушке ограничений — если условия requires не являются взаимоисключающими, мы можем легко получить неоднозначность. Можем представить себе такой пример:

template<typename T>
void resolve(T t) requires (sizeof(T) >= 3);

template<typename T>
void resolve(T t) requires (sizeof(T) <= 4);

В этом случае для типа int оба условия будут истинны одновременно, и с точки зрения компилятора обе перегрузки одинаково хороши. Ни одна из перегрузок не является более специализированной, что в результате приводит нас к ошибке неоднозначного вызова. Неоднозначный вызов — это не баг и не странность реализации, а следствие того, что мы сами описали пересекающиеся контракты, поэтому при проектировании интерфейсов с requires нужно составлять их максимально четкими: либо условия должны быть строго разделены, либо одна версия должна быть более строгой и явно доминировать над другой.

Обсудим еще одну важную сторону requires, который умеет быть не только простым логическим фильтром, как в примерах выше, но и инструментом проверки корректности выражений. Особенно, когда нас интересует не числовые условия вроде sizeof(T) > 4, а сам факт существования операции или выражения для заданного типа. Для этого в языке предусмотрены так называемые requires-выражения, которые чаще всего используются при определении концептов: мы можем описать концепт сравнимости на равенство следующим образом:

template<typename T, typename U>
concept entity_comparable = requires(T a, U b) {
    { a == b } -> std::convertible_to<bool>;
};

Больше похоже на псевдокод, чем на обычные плюсы. Но тут просто условие компилятору, что для типов T и U должны существовать объекты a и b такие, что выражение a == b компилируется, и его результат можно привести к bool, и если хотя бы одно из этих требований не выполняется (оператор == не определён или возвращает странный тип) контракт считается невыполненным.

Как и в случаях выше ошибка будет локальной и точечной, и компилятор укажет именно на то выражение, которое не удалось проверить, что позволяет формулировать требования к типам в терминах языка, а не в терминах побочных эффектов компиляции, вроде подстановки void или SFINAE. Мы описываем не то, «что сломается, если тип неподходящий», а то, «каким должен быть подходящий тип», и именно поэтому requires и концепты так хорошо подходят для замены шаблонов на уже работающих проектах, уточняя область работы и делая её строже, вместо старого подхода «попробуем и посмотрим, что скажет компилятор».

Простые и сложные requires

Если вы только начинаете работать с requires, то в простейшем варианте это будет выглядеть как обычное логическое выражение, вычисляемое во время компиляции, которое выглядит и ведёт себя так же, как constexpr bool. Мы проверяем некоторое свойство типа, получаем истину или ложь, и в зависимости от этого функция либо участвует в перегрузке, либо нет. Большинство замещений условий шаблонов сводится к таким случаям: проверки размеров, выравнивания, принадлежность к категории типов, различные std::is_* и концепты стандартной библиотеки, которые сами по себе сводятся к логическим условиям.

Но, поработав какое-то время, вы заметите, что есть и более мощная, «сложная», форма requires, когда нас интересует не просто значение некоторого предиката, а корректность самой операции. Тогда мы спрашиваем компилятор: «а можно ли вообще написать вот такое выражение для этого типа?»; «если можно, то что оно возвращает?»; «обладает ли оно дополнительными свойствами, например, гарантией отсутствия исключений?». Это уже не абстрактная логика, а прямая проверка синтаксиса и семантики кода на этапе компиляции.

Конструкция requires { ++a; } означает: для данного типа должна существовать операция префиксного инкремента, и выражение ++a должно быть ��орректным. Мы ничего не говорим о том, что оно возвращает, и ничего не говорим о побочных свойствах в простом случае. Нас просто интересует сам факт применимости операции ++, но если же мы пишем requires { ++a } noexcept; то добавляем ещё одно, более тонкое требование: операция не только должна существовать, но и быть помеченной как не выбрасывающая исключений, приводя нас таким образом к тому факту, что формулировать требования к логике работы можно на уровне контрактов.

Когда требований становится много, писать их прямо в объявлении функции становится неудобно и плохо читаемо. Длинные цепочки условий в requires быстро превращаются в шум, за которым сложно понять, что же в итоге хотел донести автор, поэтому в язык были введены концепты. Concept — это, по сути, именованный набор требований, которому мы даём осмысленное имя и затем используем его как строительный блок уже для других вычислений. Пусть мы хотим выразить идею «тип T может быть неявно преобразован к типу U». Мы можем оформить это как концепт:

template<typename T, typename U>
concept CanConvertTo = std::is_convertible_v<T, U>;

После этого имя CanConvertTo становится частью нашего словаря концептов, и мы можем использовать его в шаблонных параметрах, не раскрывая каждый раз детали проверки.

template<CanConvertTo<int> T>
void resolve(T value);

А можем пойти ещё дальше и воспользоваться упрощенным синтаксисом с auto, который делает код почти обычным и почти понятным, даже не сильно искушенному в языке человеку:

void foo(CanConvertTo<int> auto value);

С точки зрения читающего этот код, м�� избавились даже от объявления шаблонов и свели ограничения к тому, что функция принимает не «какой-то шаблонный тип», а значение, которое можно преобразовать к int. То, что раньше приходилось выражать через двухэтажный шаблон, теперь выражено прямо в сигнатуре, без необходимости читать реализацию или комментарии.

За всеми этими кульбитами с шаблонами, ограничениями и новым синтаксисом важно помнить, что концепты — это не какой-то особый вид типов и не новая категория сущностей. Concept, как до этого часть обработки шаблонов, — это просто предикат времени компиляции, то есть фактически это булево выражение, зависящее от параметров шаблона. Именно поэтому его можно использовать не только в списке параметров функции, но и в самых разных местах кода, например, в static_assert, чтобы зафиксировать важное условие выполнения логики:

static_assert(CanConvertTo<int> T);

Таким утверждением мы явно заявляем: дальнейший код имеет смысл только в том случае, если T может быть конвертирован к int и если это перестанет быть верным, например, после чьего-то рефакторинга, то ошибка возникнет сразу и в понятном месте. Точно так же концепт можно использовать в if constexpr, чтобы выбирать разные ветки реализации в зависимости от свойств типа, или сохранить результат проверки в constexpr bool, если это улучшает читаемость. И во всех этих случаях концепты работают как именованные, проверяемые компилятором условия, а не как неформальные договорённости между разработчиками.

Сложные requires и посложнее

Наконец, стоит рассмотреть более сложные случаи, когда requires используются не для одной-двух-трех проверок, а как полноценный язык описания требований, но тут важно понимать, что requires умеют проверять не только корректность выражений, но и само существование типов, вложенных объявлений и связанных с ними операций. Кроме того, внутри одного requires-блока можно объединять несколько условий, и все они должны быть выполнены одновременно. Сложно? Давайте разберемся.

Допустим нас интересует не просто тип T, а то, что он ведёт себя как контейнер с определённым интерфейсом. Мы можем проверить, что у типа существует вложенный тип iterator, и что разыменование такого итератора даёт значение, приводимое к int:

requires {
    typename T::iterator;
    { *std::declval<T::iterator>() } -> CanConvertTo<int>;
}

Cтрока — typename T::iterator; — это требование к типу. Она означает: у T должен существовать вложенный тип с таким именем. Если его нет, условие requires не выполнено, и соответствующая функция или шаблон просто не рассматриваются компилятором. Вторая строка уже проверяет выражение: мы берём воображаемый итератор этого типа, разыменовываем его и проверяем, что результат можно привести к int. Таким образом, мы одновременно накладываем архитектурное требование (должен быть класс, которые содержит итератор), структурное требование (итератор должен поддерживать операцию *) и семантическое (результат должен конвертиться к int).

Такие requires-блоки уже можно рассматривать как компактное описание мини-интерфейса, когда «этот контейнер» не абстрактный, а с точно фиксированным поведением, какие элементы интерфейса нам нужны и в каком виде, причём все требования объединяются логическим И: если не выполнено хотя бы одно, весь блок считается ложным.

К тем же составным требованиям относятся проверки возвращаемых типов и свойств операций, как в примере с -> std::convertible_to<...>, который задаёт ограничения на результат выражения. Там же можно указывать noexcept, получив другое поведение и другой контракт, если для нас принципиально, чтобы операция не выбрасывала исключений. В конечном итоге можно формулировать очень точные контракты (тут тоже важно не перестараться): не просто «операция существует», а «она существует, возвращает нужный тип и не выбрасывает исключения».

Это приводит нас к тому, что такие вложенные и составные requires-блоки выносятся в отдельные концепты с говорящими именами, после чего используются в сигнатурах функций и классов, позволяя сохранить баланс между выразительностью языка и читаемостью кода: сложная логика проверки остаётся в одном месте, а интерфейс и логика работы там, где была.

Итог

Если попробовать подвести итог, то requires и концепты дают программисту язык для разговора с компилятором на уровне четких условий, а не размытых образов в стиле, "если этот не подошел, пробуй дальше". Также как мы можем управлять перегрузками, точно описывать ожидания от типов и проверять их автоматически во время компиляции, мы теперь можем, не прибегая к громоздким и сложным шаблонным приёмам вроде SFINAE, описывать какие функции и с каким результатом нам нужны.

В результате код становится одновременно строже и яснее: строже, потому что требования проверяются формально, и яснее, потому что эти требования теперь видны прямо в интерфейсе, а не спрятаны в деталях реализации. А вот насколько строже и насколько яснее разберем в следующей статье...

upd: благодарю @Serpentineза редактуру и вычитку