Как я уже упоминал в предыдущем разделе, проблема эта старая, известная, и работы над её решением тоже идут довольно давно. Ещё до C++11 шли разговоры о концепциях (англ. concepts), но в стандарт всё это не попало, потому что предложение оказалось слишком сложным. Его решено было отложить, а вместо него реализовать то, что стали называть облегчёнными концепциями (англ. concepts-lite), но даже в облегчённой форме им не суждено было добраться до стандарта раньше 2020 года.
requires-clause
Начнём с конструкции requires-clause, которая используется в «шапках» шаблонов и имеет следующий вид:
template<typename T>
requires A && B || C
Requires-clause применяется к «шапке» в любом контексте: функции, классы, специализация шаблонов, лямбды и т. д. Я не буду рассматривать каждый случай отдельно, потому что работает это везде одинаково, с поправкой на контекст. В данном разделе по большей части используются функции, потому что с ними можно показать все основные возможности с минимальным синтаксисом.
Где A, B и C — это потенциально конвертируемые в bool первичные выражения (англ. primary expressions) (expr.prim), а && и || — операции конъюнкции (⋀) и дизъюнкции (⋁) соответственно. Разумеется, может быть сколь угодно много различных выражений и операций ⋀/⋁ между ними — это ничем не ограничено, но это всё, что может быть использовано в этом контексте.
Теперь давайте разберёмся, почему я использовал понятия операций ⋀/⋁, тогда как обычно в C++ говорят об операторах &&/||. Всё дело в том, что, хотя в контексте requires-clause эти операторы работают так, как от них ожидает C++-программист, это всё же не простые бинарные логические операторы. Логические операторы в C++ исполняются по короткой схеме (англ. short-circuit) (это свойство сохраняется и для ⋀/⋁) и работают исключительно с операндами типа bool. Выше я написал, что наши выражения должны быть потенциально конвертируемы в bool, но это не означает, что они всегда будут типа bool. Исходя из этого, мы не можем использовать эти операторы, поэтому, хотя синтаксически мы используем логические операторы &&/||, реальное вычисление (англ. evaluation) выражений происходит несколько иначе, поэтому здесь и далее при использовании логических операторов будут подразумеваться логические операции .
Кстати, в C++ операторы &&/||/! могут быть записаны в альтернативной словесной форме: and/or/not (больше можно посмотреть в стандарте lex.digraph). Видимо, чтобы подчеркнуть разницу, некоторые программисты взяли за правило использовать вторую форму в requires-clause. Код выше у них бы выглядел так:
template<typename T>
requires A and B or C
Чтобы распутать этот семантический клубок, нужно разобраться с тем, как работает requires-clause, а также что значит слово «потенциально» в предыдущем абзаце. Начнём с простого примера:
template<typename T>
requires true
void fun(T t)
{}
Здесь вся requires-clause состоит из одного выражения true. Если requires-clause истинна, тогда функция fun будет инстанциирована и будет участвовать в разрешении перегрузки (англ. overload resolution), в противном случае она отбраковывается. Происходящее очень похоже на то, как работает наше «решение» с std::enable_if из предыдущего подраздела, но они не эквивалентны: requires-clause является гораздо более мощным инструментом, но об этом мы поговорим позже. Сейчас достаточно понимать, что с помощью этой конструкции мы можем «включать»/«выключать» шаблоны (или их части).
Что значит для requires-clause быть истинной? Это означает, что в результате вычисления всех подвыражений и логических операций между ними, полученное выражение будет истинным. Истинным подвыражение является тогда и только тогда, когда оно является первичным, корректным (англ. valid) и может быть неявно преобразовано в bool. В примере выше всё выражение состоит из одного подвыражения: true. Очевидно, что true является первичным, корректным C++-выражением, и оно имеет тип bool. Отсюда следует, что выражение — истинно, requires-clause — истинна, и функция fun будет участвовать в разрешении перегрузки.
Давайте чуть-чуть усложним пример:
template <typename... Ts>
struct pack_size
{
static constexpr std::size_t value = sizeof...(Ts);
};
template <typename... Ts>
requires pack_size<Ts...>
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
void fun(Ts... vs) { std::println("Free!"); }
int main()
{
fun(42);
}
Что выведет скомпилированная программа в консоль? Ничего, она не скомпилируется, потому что pack_size<Ts...> не является первичным выражением. Но это легко исправить:
template <typename... Ts>
requires pack_size<Ts...>::value
Здесь мы используем значение типа size_t, как многие привыкли в условных операторах: если значение ненулевое, значит, true, в противном случае — false. И хотя pack_size<Ts...>::value является корректным первичным выражением, код всё равно откажется компилироваться, потому что требование первичности необходимо, но не достаточно: pack_size<Ts...>::value должно неявно конвертироваться в тип bool. Но мы же C++-программисты, мы можем заставить нас слушаться так:
template <typename... Ts>
requires (static_cast<bool>(pack_size<Ts...>::value))
Или так:
template <typename... Ts>
requires (pack_size<Ts...>::value > 0)
Теперь код будет успешно скомпилирован, и на экране мы увидим: Constrained ☠️. Если же мы заменим вызов функции fun(42) на fun(), то на экране мы уже увидим Free!, потому что ограниченная fun работает только с ненулевым количеством аргументов.
Заметьте, что недостаточно просто добавить static_cast<bool>(...) вокруг нашего выражения, нужно ещё и скобки вокруг самого static_cast, потому что оператор приведения типа не является первичным выражением (то же самое относится и к сравнению). Унарные операторы, кстати, тоже формируют непервичные выражения, поэтому нельзя написать так:
template<typename T>
requires !true
void fun(T t)
{}
Отрицание нужно обязательно оборачивать скобками, формируя тем самым первичное выражение:
template<typename T>
requires (!true)
void fun(T t)
{}
Это ограничение и синтаксис выглядит притянутым за уши, но у него есть причина: несовершенство парсера (temp.pre/p9). Если позволить произвольные выражения в requires-clause, то некоторые последовательности получаются двусмысленными. В результате было принято решение максимально ужесточить разрешённые выражения внутри конструкции requires-clause, оставив всё остальное на откуп скобкам. Это, на мой взгляд, наиболее неинтуитивная часть нового синтаксиса, потому что отрицание — это очень часто используемый оператор, и наличие ошибки после его добавления может ввести в ступор.
До настоящего момента все примеры использовали корректные и существующие C++-идентификаторы, пусть в некоторых случаях и не на своих местах. Давайте теперь используем несуществующий идентификатор и посмотрим, что будет:
template <typename... Ts>
requires pack_size<Ts...>::
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
void fun(Ts... vs) { std::println("Free!"); }
Как думаете, какой результат можно получить из этого кода? Правильный ответ: Free!.
И здесь мы наконец-то добрались до свойства, которое не позволяет использовать обычные логические операторы. Очевидно, что pack_size<Ts...>:: ссылается на несуществующий идентификатор , но, находясь в зависимом контексте (от параметра шаблона T), компилятор не считает этот код некорректным до тех пор, пока не будет представлен аргумент шаблона. Когда же он появляется, срабатывает пресловутое SFINAE, делая выражение pack_size<Ts...>:: ложным вместо прерывания компиляции ошибкой. Если мы немного видоизменим пример и добавим дизъюнкцию:
template <typename... Ts>
requires pack_size<Ts...>:: || true
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
void fun(Ts... vs) { std::println("Free!"); }
То код выведет уже Constrained ☠️, потому что только первое подвыражение является ложным, а всё выражение получается истинным за счёт второго подвыражения и дизъюнкции.
Интересно, что если мы попытаемся инвертировать ложное подвыражение оператором отрицания:
template <typename... Ts>
requires (!pack_size<Ts...>::)
То это не даст на выходе истинное подвыражение, потому что отрицание является частью подвыражения и будет применено только к корректному C++-выражению, либо же будет отброшено вместе с некорректным. С помощью отрицания, но без помощи концепций нельзя превратить некорректное C++-выражение в истинное.
А пока предлагаю продолжить усложнять код и добавить ещё одну функцию с ограничением, но более строгим, чем ранее:
template <typename... Ts>
requires (pack_size<Ts...>::value > 0)
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
requires (pack_size<Ts...>::value > 3)
void fun(Ts... vs) { std::println("Restrained "); }
template <typename... Ts>
void fun(Ts... vs) { std::println("Free!"); }
int main()
{
fun(1, 2, 3, 4);
}
Как думаете, что выведет этот код? Если вы считаете, что будет выведено Restrained , то я искренне завидую вашей вере в разработчиков и их возможности. Действительно, ограничение у второй функции математически более строгое, но только вот компиляторы не в состоянии анализировать произвольные математические утверждения и выстраивать по ним ограничения. С точки зрения компилятора, ни одно ограничение не строже другого, когда в функцию передаётся больше трёх аргументов, а значит результатом будет ошибка компиляции. Хорошо, раз компилятор не может сложную математику анализировать, может, простую сможет:
template <typename... Ts>
requires (pack_size<Ts...>::value > 0) &&
(pack_size<Ts...>::value > 3)
void fun(Ts... vs) { std::println("Restrained ️"); }
Теперь у нас есть два ограничения, в противовес одному, 2 > 1, а значит, сейчас мы получим Restrained ! Нет, снова ошибка компиляции. Можно добавить сколько угодно ограничений подобного вида, но это ничего не изменит: компилятор не анализирует произвольные выражения в requires-clause на предмет их строгости, всё, что он делает, это анализирует наличие или отсутствие ограничений. Если они есть, любые, то функция побеждает в перегрузке; если у двух функций есть ограничения и вызов функции подпадает под оба, то компилятор просто пасует — он не может выбрать. Ситуация несколько меняется только с применением концепций, но о них, опять же, позже.
Интересно, что, прочитав стандарт, я не нашёл там никаких доказательств того, что написанное в предыдущем абзаце не должно работать без применения концепций. Есть неплохой пост от Barry Revzin на StackOverflow, в котором в одном месте собрана вся релевантная информация. И с этой информацией можно трактовать стандарт так, как того желали писавшие его, но не имея этих знаний, на мой взгляд, понять данную часть стандарта в таком ключе просто невозможно. Стандарт часто бывает сложно понять, но в данном случае он написан просто отвратительно.
Итак, посмотрев на функционал requires-clause, можно с уверенностью сказать, что с его помощью можно заменить std::enable_if из предыдущего раздела; давайте так и сделаем:
template<typename T>
constexpr bool IsRandomIter = std::is_same_v<typename std::iterator_traits<T>::iterator_category,
std::random_access_iterator_tag>;
template<class Random>
requires IsRandomIter<Random>
void Sort(Random first, Random last)
{
std::sort(first, last);
}
template<class Iter>
void Sort(Iter first, Iter last)
{
static_assert(false, "Random iterators required!");
}
В этом коде два ключевых отличия: мы заменили std::enable_if на requires (добавив возвращаемый тип, который формировался из метафункции ранее), а также полностью удалили std::enable_if из второй функции. Здесь мы воспользовались свойством, которое только что рассмотрели, но которое важно подчеркнуть в контексте преимуществ requires-clause перед std::enable_if: при использовании requires-clause в процессе определения лучшей (англ. best viable) функции (для вызова, получения адреса и т. д.), при прочих равных, побеждает более ограниченная функция. Полные правила определения «более ограниченной» описаны в temp.constr.order, но в коде выше это очевидно: одна функция имеет requires-clause, а другая — нет. Подобное поведение невозможно с std::enable_if, потому что компилятор ничего не знает об этой метафункции, а следовательно, на каж��ый std::enable_if нужно добавлять его отрицание, чтобы убрать двусмысленность выбора.
Но и это ещё не всё. С помощью std::enable_if мы могли «включать»/«выключать» шаблоны функций, в том числе шаблоны функций-членов, но с его помощью нельзя было этого сделать для нешаблонных функций-членов шаблона класса. Выше я писал, что requires-clause применяется в «шапках» шаблонов, но это не единственное место; его также можно использовать и в объявлении функции внутри шаблона класса:
template <typename T>
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) requires false;
};
Таким вот нехитрым способом мы «отключили» конструктор копирования в шаблоне класса. Исходя из всего вышенаписанного, можно сделать простой вывод: имея requires-clause в языке, нет никакой необходимости содержать std::enable_if в библиотеке. Мы получили в своё распоряжение куда более мощный инструмент, который позволяет ограничивать интерфейсы шаблонов, имея при этом достойный синтаксис, который, при определённой сноровке, будет понятен большинству программистов на C++.
Ну и раз уже я затронул достоинства синтаксиса requires-clause, нужно поговорить и о его недостатках. Про «проблему» скобок мы уже поговорили выше, но она, к сожалению, не единственная. Если мы захотим реализовать NonCopyable(const NonCopyable&) вне класса, мы должны будем полностью продублировать requires-clause:
template<typename T>
NonCopyable<T>::NonCopyable(const NonCopyable &) requires false
{
}
В нашем примере она довольно компактная, но можете себе представить, что они могут быть значительно больше, и, меняя трёхэтажные требования в одном месте, вам нужно будет менять их и в другом.
Давайте возьмём класс Number из первого раздела, добавим к нему ограничений и уберём определение функций-членов из него:
template<typename T>
requires std::is_integral_v<T> &&
(std::is_unsigned_v<T> || std::is_signed_v<T>)
class Number
{
public:
explicit Number(T value);
Number operator+(T rhs) const;
private:
T m_Value;
};
Эти ограничения носят исключительно иллюстративную цель, поэтому они несколько нелепые. Главное их достоинство — количество символов. Теперь давайте реализуем функции члены:
template<typename T>
requires std::is_integral_v<T> &&
(std::is_unsigned_v<T> || std::is_signed_v<T>)
Number<T>::Number(T value):
m_Value{value}
{
}
template<typename T>
requires std::is_integral_v<T> &&
(std::is_unsigned_v<T> || std::is_signed_v<T>)
Number<T> Number<T>::operator+(T rhs) const
{
return Number{m_Value + rhs};
}
У каждого вынесенного определения мы обязаны полностью повторить всю «шапку» (temp.class.general/p3 и temp.mem/p1), в которую в том числе входит require-clause (temp.over.link/p6). Разумеется, обычно классы имеют куда больше функций-членов, да и наложенные ограничения могут быть значительно более многословные. Всё это приводит к огромному количеству визуального шума, который трудно читать и сопровождать. Очевидно, что люди не захотят использовать require-clause в подобных случаях. Решение у этой проблемы есть, и оно заключается в использовании концепций.
requires-выражение
Но пока до концепций мы не добрались, предлагаю рассмотреть ещё одно применение ключевого слова requires: создание requires-выражения. В минимальной форме это выражение выглядит так: requires {}, в полной — так: requires () {}. Где внутри круглых скобок идут «параметры функции», а внутри фигурных — его тело. Определение requires-выражения синтаксически очень похоже на определение функции, у которой может быть полностью опущен блок параметров (прямо как у лямбд). Это очень удобный и знакомый синтаксис, но requires-выражение не является функцией и схожи они исключительно внешне. Requires-выражение — это prvalue-выражение типа bool. Давайте рассмотрим простой пример:
#include <print>
int main()
{
int a = 10;
int b = 25;
auto res = requires
{
a + b;
};
std::println("Result: {}", res);
}
Первое, на что стоит обратить внимание, — это область видимости: мы имеем дело с обычным выражением, а значит, и a, и b могут быть использованы внутри нашего requires-выражения. Их не надо «захватывать», как в лямбдах, или передавать в качестве аргументов, как в функциях. В результате выполнения этого кода на экране мы увидим Result: true. Requires-выражение является истинным, когда все требования внутри его тела удовлетворены, в противном случае оно является ложным. Изменим тело requires-выражения на такое:
auto res = requires
{
a + b;
(a + b) < 5;
};
Хотя очевидно, что (a + b) < 5 является ложным утверждением, в результате мы всё равно получим Result: true. Дело в том, что тело requires-выражения значительно отличается от тела функции, и то, что в нём написано, интерпретируется совсем иначе. Безусловно, у вас уже должны были возникнуть вопросы по поводу банального a + b;, что это вообще значит? Это же просто сложение двух переменных, без сохранения результата. Так вот, тело requires-выражения является жёстко ограниченным и может состоять только из определённого набора требований (англ. requirements).
Требование a + b означает, что должна существовать возможность сложения a и b, а вот требование (a + b) < 5 означает, что должна существовать возможность сложения a и b, а также результат этого сложения можно сравнить с 5. Заметьте, что не «результат их сложения должен быть меньше 5», а «результат сложения можно сравнить с 5». Т. е., имея ложное утверждение (a + b) < 5, мы имеем истинное (удовлетворённое) требование (a + b) < 5. Эта разница является ключевой для requires-выражения, потому что его назначением является проверка корректности синтаксиса, оно ничего не знает о сути выражений, из которых формируются требования, потому что эти выражения никогда не вычисляются (англ. evaluate).
Можно переписать пример выше на такой, и он будет означать то же самое:
auto res = requires
{
int{} + int{};
int{} + int{} > int{};
};
Мы просто обезличили наши требования, сведя их к своей сути: проверке синтаксиса. Чтобы сделать requires-выражение ложным, достаточно хотя бы одного неудовлетворённого требования. Причём первое же встреченное такое требование делает всё выражение ложным, и дальнейший анализ не производится.
Осталось только разобраться с тем, как требование может быть неудовлетворённым. Очевидно, что использовать отрицание, как мы это делали в requires-clause, не выйдет, потому что вычисления выражений не происходит, и всё, что сделает отрицание, — это добавит требование существования отрицания для того или иного выражения. Поэтому остаётся только одно: некорректное выражение. Давайте попробуем:
auto res = requires
{
a.b;
};
Однако на выходе мы получим не вожделенный Result: false, а ошибку компиляции, потому что выражение некорректно. Но не этого ли мы и пытались добиться? Этого, правда, мы хотели не ошибку компиляции, а false-выражение. Всё дело в том, что, как и requires-clause, функционал проверки синтаксиса в requires-выражении, дающий отрицательный результат, построен на SFINAE, а значит, применим исключительно к шаблонам. Компилятор знает, что a.b — это всегда некорректное выражение, нужно ему предоставить то, что может быть корректным . Поэтому давайте перепишем наш пример на шаблоны:
#include <print>
template <typename T>
constexpr bool Res = requires(T a, T b)
{
a + b;
};
int main()
{
std::println("Result: {}", Res<int>);
}
Этот пример эквивалентен самому первому, но теперь мы используем шаблон константы Res, а в requires-выражение мы добавили параметры (T a, T b). Res<int>, очевидно, будет true, но не менее очевидно, что Res<std::mutex> даст нам false.
Итак, мы написали первое requires-выражение, которое действительно что-то делает: оно определяет наличие возможности сложения объекта произвольного типа T с объектом того же типа. Это может показаться мелочью, но пусть вас не обманывает простота этого примера, ведь в коде выше буквально в одну строку мы определили наличие/отсутствие operator+(T) у произвольного типа!
Давайте вспомним лучший метод определения наличия функции в классе, о котором я писал ранее:
#include <type_traits>
template <typename Type, typename = void>
struct HasFunctionSort : std::false_type {};
template <typename Type>
struct HasFunctionSort<Type,
std::void_t<decltype(std::declval<Type>().sort())>>
: std::true_type {};
template <typename T>
constexpr bool HasFunctionSort_v = HasFunctionSort<T>::value;
А вот как это будет выглядеть с помощью requires-выражения:
template <typename T>
constexpr bool HasFunctionSort = requires(T obj){ obj.sort(); };
И всё! Больше никаких громоздких метафункций, «магических инкантаций» типа std::void_t и прочих костылей, которые мы были вынуждены использовать и даже радовались тому, как стало проще заниматься метапрограммированием. Если такие вещи не заставляют вас почувствовать, насколько проще С++ становится с каждым новым стандартом, то я не знаю, что вообще способно это сделать.
Требование требований
Все примеры выше используют простые (англ. simple) требования. Такие требования состоят из набора невычисляемых выражений (англ. unevaluated expressions), которые перечислены в [expr]. Но, как известно, враг не дремлет, и комитет по стандартизации об этом осведомлён как никто другой, поэтому не все выражения, перечисленные в вышеупомянутом разделе стандарта, могут быть использованы в качестве требований. Так, к примеру, requires-выражение является выражением, но внутри другого requires-выражения не может быть использовано в том виде, в котором мы его рассмотрели. Оно должно «переодеться» в «requires-clause», и только тогда его можно будет использовать. Пример:
template <typename T>
constexpr bool FancyType = requires (T obj)
{
T{};
obj = T{};
requires !std::is_integral_v<T> && !std::is_floating_point_v<T>;
};
static_assert(FancyType<int> == false);
static_assert(FancyType<std::vector<int>> == true);
Как вы можете видеть, последнее требование очень похоже на requires-clause, но использует отрицания подвыражений без скобок. В то же время, в отличие от простых требований, здесь вычисление выражений происходит, т. е. результат !std::is_integral_v<T> используется для анализа удовлетворения требования (прямо как requires-clause). Эта «химера» называется вложенное требование (англ. nested-requirement) и является отдельным видом требований внутри requires-выражений. И что интересно, она проваливает классический «утиный тест». На что только не пойдёшь, лишь бы ключевые слова в язык не добавлять.
Кстати, раз уж заговорили о «химерах» и ключевых словах, requires-выражение является первичным, а это значит, что оно вполне может быть использовано в requires-clause:
template <typename C>
constexpr bool СanSort = false;
template <typename C>
requires requires (C cont) { cont.sort(); }
constexpr bool СanSort<C> = true;
static_assert(СanSort<std::vector<int>> == false);
static_assert(СanSort<std::list<int>> == true);
Конструкция выше может показаться сложной, но на деле всё просто: первое requires начинает requires-clause, а второе — requires-выражение. Это занятная особенность синтаксиса, которую в реальном коде вы скорее всего не увидите (хочется верить). Но так умеет не только requires-clause, наша «химера» тоже:
template <typename T>
constexpr bool FancyType = requires (T obj)
{
T{};
requires requires
{
obj = T{};
requires !std::is_integral_v<typename T::value_type>;
};
};
Ума не приложу, кому это может быть нужно, но мы всё-таки можем засунуть requires-выражение внутрь другого requires-выражения!
В примере с CanSort я использовал частичную специализацию для демонстрации requires requires, но если бы мне реально нужно было написать такую «функцию», то я бы сделал это так:
template <typename C>
constexpr bool canSort()
{
if constexpr (requires (C cont) { cont.sort(); })
return true;
return false;
}
Тот факт, что requires-выражение является обычным выражением, делает возможным очень много интересных однострочников. Это не всегда хорошо для ясности кода, но знать и уметь пользоваться этими возможностями, безусловно, нужно. К примеру, банальную проверку на наличие функции у шаблона лучше локализовать в такой проверке, чем городить для этого отдельные глобальные сущности.
И да, if constexpr (как и if в целом) здесь избыточен, но я специально его добавил, чтобы подчеркнуть технику ветвления на основе наличия функции-члена, которая чаще будет использоваться вне constexpr-функций.
И последнее, с помощью вложенного требования можно реализовать проверку утверждения, о котором мы говорили ранее:
#include <print>
int main()
{
constexpr int a = 10;
constexpr int b = 25;
auto res = requires
{
a + b;
requires (a + b) < 5;
};
std::println("Result: {}", res);
}
На выходе получим искомый Result: false. Необходимость использования constexpr вносит существенные ограничения на то, что мы можем проверить подобным образом, но потенциал у таких проверок, я полагаю, есть.
Типичные требования
Ранее мы уже использовали «параметры функции» в requires-выражении, но я ни слова не сказал про то, чем они на самом деле являются, ведь, очевидно, они будут отличаться от реальных параметров функции. Действительно, составители стандарта просто заимствовали синтаксис у функций, убрали из него разрешенное там многоточие ... и аргументы по умолчанию, после чего получилось то, что выглядит ровно так же, но именно что выглядит. Вся суть параметров requires-выражения заключается в удобстве написания требований. Мы уже видели, как можно написать обезличенное требование, но люди привыкли работать с именованными сущностями, поэтому мы их и получили.
Тем не менее их функционал не является чисто косметическим, и они могут заходить на территорию требований. Рассмотрим такой пример:
template <typename C>
constexpr bool ContainerWithNums = requires (C cont, C::value_type value)
{
value * value;
};
static_assert(ContainerWithNums<int> == false);
static_assert(ContainerWithNums<std::vector<int>> == true);
static_assert(ContainerWithNums<std::list<float>> == true);
Обратите внимание, что в качестве параметра используется C::value_type, который превращает всё выражение в false для типов, у которых нет такого внутреннего типа. Таким образом, в данном случае параметр выступает как в своей роли, так и в роли требования.
В общем случае, если бы нам нужно было потребовать от типа наличия внутреннего типа, мы бы воспользовались требованием типа (англ. type-requirement):
template <typename C>
constexpr bool Container = requires (C cont)
{
typename C::value_type;
};
static_assert(Container<int> == false);
static_assert(Container<std::vector<int>> == true);
Такое требование всегда начинается с ключевого слова typename, а дальше указывается тип, существование которого мы проверяем. Если нам нужно проверить только наличие типа, то, разумеется, прямое указание требования предпочтительнее. Но если мы хотим проверить и наличие типа, и объекты этого типа, то удобнее воспользоваться параметрами, а не указывать требование типа явно. Это интересное свойство параметров, которое проистекает просто из того, что никак иначе их реализовать было всё равно нельзя. Типичный SFINAE, в общем.
Жёсткие требования
Хотя я мужественно держался и только ссылался на концепции ранее, в данном подразделе без них, к сожалению, обойтись не удастся. Но для этого текста будет достаточно грубого определения концепции, при котором она является предикатом, поведение которого эквивалентно соответствующей метафункции. К примеру, концепция std::same_as<bool, int> эквивалентна метафункции std::is_same_v<bool, int>.
Когда мы рассматривали простые требования, мы могли только проверить, что такие выражения возможны. Позднее мы увидели, что с помощью вложенных требований и constexpr можно даже подобие аксиом реализовать. Наконец, с помощью ещё одного вида требований, которые называются составными (англ. compound-requirement), можно оценить тип выражения, а также является ли оно noexcept. Выглядят эти требования следующим образом: {выражение} noexcept -> concept, где выражение — это всё тот же представитель [expr], а всё, что идёт после } является необязательным. Давайте перепишем ранее рассмотренный пример с помощью этого синтаксиса:
template <typename T>
constexpr bool FancyType = requires (T obj)
{
{ T{} };
{ obj = T{} };
// Так писать нельзя!
//{requires !std::is_integral_v<T> && !std::is_floating_point_v<T>};
};
Заметьте, что для простых требований просто добавились фигурные скобки, больше ничего. А вот строку с requires пришлось закомментировать, потому что она формирует вложенное требование, которое не может использоваться внутри составного. Внутри фигурных скобок может содержаться только то, что может быть простым требованием. Ещё пример:
struct LabRat
{
LabRat();
LabRat& operator=(const LabRat&) noexcept;
short operator+(const LabRat&);
};
template <typename T>
constexpr bool SimpleType = requires (T a, T b)
{
{ T{} } noexcept;
{ a + b } -> std::convertible_to<int>;
{ a = b } noexcept -> std::same_as<T&>;
};
static_assert(SimpleType<int> == true);
static_assert(SimpleType<float> == true);
static_assert(SimpleType<std::vector<int>> == false);
static_assert(SimpleType<LabRat> == false);
Оба int и float подходят под наши требования, std::vector<int> проваливает как минимум второе требование и выходит из гонки, а LabRat проваливает самое первое, потому что конструктор не помечен noexcept. Обратите внимание, что у концепций std::same_as и std::convertible_to два пара��етра, а передаём мы только один. Это потому, что компилятор сам передаёт тип выражения первым аргументом концепции, т. е. это часть синтаксиса, и это объясняет тот факт, что без концепций в составных требованиях не обойтись.
Кстати, изначальный синтаксис, который предлагали в стандарт, выглядел иначе: { a + b } -> int;, т. е. никаких концепций, просто тип. Хотя он выглядит куда проще и привлекательнее, на деле он значительно уступает принятому. Что означает int после стрелки? Что выражение будет типа int? Что оно будет в него конвертироваться? Как выбрать из этих двух вариантов тот, который будет применяться во всех случаях? Очевидно, что никакого правильного ответа на все вопросы быть не может — ситуации разные, поэтому в правую часть были добавлены концепции, и теперь программист явно указывает, какие свойства типа выражения ему нужны. Да, выглядит это более многословным, но зато требования читаются однозначно.
Интересным свойством составных требований является их избыточность: если первые три разновидности требований нельзя реализовать друг через друга , то составные легко реализуются с помощью трёх предыдущих. К примеру, требования выше можно было бы переписать так:
template <typename T>
constexpr bool SimpleType = requires (T a, T b)
{
requires noexcept(T{});
a + b;
requires std::convertible_to<decltype(a + b), int>;
a = b;
requires noexcept(a = b);
requires std::same_as<decltype(a = b), T&>;
};
И мы получили бы тот же самый результат (можно было и от концепций избавиться, только смысла в этом нет, раз уж всё равно пришлось использовать), но писанины тут куда больше и запутаться легче, поэтому, на мой взгляд, наличие отдельного синтаксиса вполне оправдано.