
В языке C++ перегрузки функций и шаблонов исторически были и остаются мощным инструментом для выражения различных реализаций одного и того же интерфейса. Многим перегрузки видятся как удобный способ дать одно имя разным функциям, но на практике понимание того, как компилятор выбирает нужную перегрузку, может стать источником ошибок и недоразумений. Компилятор же руководствуется сложным набором правил, которые мы ему предоставили, учитывает не только типы аргументов, но и порядок специализаций, преобразования типов, const-квалификаторы, шаблонные параметры и многое другое. А ошибки, возникающие при перегрузках, часто трудно диагностировать, поскольку сообщение компилятора может ссылаться на глубоко вложенные детали реализации вместо очевидного исходного кода. Об этом была предыдущая статья...
С введением концептов и ограничений (requires) язык получил возможность управлять этой сложностью на уровне интерфейса. Вместо того чтобы надеяться на магию перегрузки и изощрённые трюки вроде SFINAE, мы теперь можем прямо выражать намерения: какие свойства должен иметь тип, чтобы функция или шаблон были корректны, что позволило перейти от «магии разрешения перегрузок» к декларативному описанию требований к типам.
Давайте теперь поговорим о том, что именно делают ограничения (requires) в современном C++ и почему появление этого механизма стало таким важным шагом в развитии шаблонов. Тут надо сделать немного шаг в сторону и вспомнить, что исторически шаблоны в C++ были мощным, но довольно опасным инструментом, еще одним языком в языке, на котором можно было сделать почти всё, было бы желание.
В итоге компилятор позволял подставить или подхачить любой тип, а проверка того, «подходит ли он на самом деле», откладывалась до момента инстанцирования, что нередко приводило к ошибке далеко от места вызова, а сообщение о непосредственном месте ошибки превращалось в многостраничный отчёт о внутренней кухне компилятора и о том, как он работает с шаблонами. Теперь requires поменяли эту модель, позволяя описывать ожидания от типа явно и прямо в объявлении функции или класса.
Нескучное программирование: Обобщения (WIP)
Нескучное программирование: Концепты и ограничения <= Вы тут
Нескучное программирование. И снова ограничения
. . .
По сути 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за редактуру и вычитку