Концептуальные требования

Шаблоны и построенное на них метопрограммирование являются одним из мощнейших инструментов, доступных C++-программисту. К сожалению, доступность метопрограммирования на всём протяжении жизни C++ определялась не столько тем, что позволяет сделать язык, сколько тем, что конкретный программист на C++ способен выжать из него. Потому что даже С++98 позволял уже очень многое, а с добавлением variadic templates и expression SFINAE в C++11 C++ обрёл, наверное, все необходимые инструменты метапрограммирования. Но проблема того, что им могут заниматься только избранные индивиды, никуда с C++11 не ушла. Надо сказать, что не ушла она и поныне, но практически в каждой итерации языка мы получали что-то, что позволяло его упростить, сделать метапрограммирование более понятным и доступным рядовому C++-программисту. Одной из наиболее важных вех на этом пути стал стандарт C++20, в котором появились долгожданные ограничения параметров шаблона (англ. constraints), и именно о них пойдёт речь в настоящей статье.

Всеядные шаблоны

Шаблоны существуют в C++ с самого зарождения языка, и, как утверждает его создатель Bjarne Stroustrup, он с самого начала хотел иметь возможность ограничения параметров шаблона. Давайте посмотрим на базовое определение шаблона:

template<typename T>
class Number
{
    //...
};

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

template<typename T>
class Number
{
public:
    explicit Number(T value):
        m_Value{value}
    {}
    Number operator+(T rhs) const
    {
        return Number{m_Value + rhs};
    }
private:
    T m_Value;
};

template class Number<int>;
template class Number<std::vector<int>>;

Что будет, если скомпилировать этот код? В MSVC 2022, к примеру, это будет 133 строки ошибок! Можно ли понять источник проблемы из них? Зависит от вашего опыта с C++: если он обширен — безусловно, если нет, то это наверняка будет длительным приключением. А ведь это минимальный, крайне простой пример, обычно текст ошибок куда больше, а источник куда менее очевиден.

Итак, после тщательного анализа текста ошибок мы находим проблему — operator+. Отлично! А дальше что? А дальше, собственно, ничего, потому что operator+ — это не проблема, а следствие. Настоящей проблемой является то, что мы пытаемся моделировать число (англ. number), принимая любой тип в качестве типа-хранилища. Очевидно, что std::vector<int> не может является типом-хранилищем для числа (в общем случае). Но если std::vector<int> в теории может представлять собой число, то какой-нибудь std::mutex совершенно точно нет, а вот в качестве аргумента шаблона мы его передать можем.

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

void* addNumbers(void* lhs, void* rhs);

Можно передавать всё, что угодно, и, может быть, даже какой-то предсказуемый результат получится. Не думаю, что найдётся тот, кто скажет, что addNumbers — это нормальная C++-функция, но также считаю, что, увидев Number, ни один C++-программист даже глазом не моргнёт.

Хотя оба этих интерфейса отвратительны: их очень легко использовать неверно, а корректное использование требует знания внутреннего устройства, т. е. реализации. Так почему такая разница в отношении? Отчасти потому, что все проблемы с шаблонами должны быть решены на этапе компиляции, что исключает долгоживущие скрытые сюрпризы, но ещё и потому, что язык очень давно представляет все инструменты для удобного создания понятных интерфейсов, когда речь идёт о нешаблонном коде, а значит, оправдать существование addNumbers было бы затруднительно. С шаблонами же ситуация сложнее.

Так как проблема существует давно, то и определённые «решения» всплывали и ранее. Так, к примеру, стандартная библиотека имеет иерархию итераторов, знания о которой используется в различных алгоритмах. Возьмём, скажем, std::sort из последнего на данный момент стандарта C++23:

template<class RandomAccessIterator>
void sort(RandomAccessIterator first, RandomAccessIterator last);

Хотя стандарт и пытается явно выразить свои намерения путём именования параметра шаблона RandomAccessIterator, параметр всё равно остаётся тем же T, что мы видели раньше. В этом легко убедиться, попробовав собрать такой код:

std::list<int> list;
std::sort(list.begin(), list.end());

MSVС 2022 выдаст около 60 строк ошибок, ни одна из которых не содержит никаких отсылок к тому, что передаются итераторы, которые не поддерживаются функцией sort. Мы можем написать функцию Sort, которая будет лишена этого недостатка:

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>
std::enable_if_t<IsRandomIter<Random>>
Sort(Random first, Random last)
{
    std::sort(first, last);
}

template<class Iter>
std::enable_if_t<!IsRandomIter<Iter>>
Sort(Iter first, Iter last)
{
    static_assert(false, "Random iterators required!");
}

Хотя в коде выше я использовал функционал C++17, всё, что там написано, можно спокойно реализовать на C++98. Я уже писал об этом, поэтому подробно останавливаться не стану — понимание кода выше приветствуется, но не обязательно для понимания остальной части статьи.

Используя её в предыдущем коде:

std::list<int> list;
Sort(list.begin(), list.end());

Мы получим одну строку ошибки, которая явно указывает на проблему:

error C2338: static_assert failed: 'Random iterators required!'

Стандарт мог бы пойти по этому пути, но по какой-то причине решили, что оно того не стоит, и в результате интерфейсные ограничения стандартных алгоритмов так и остались лишь на бумаге, а инструкции, как известно, никто не читает.

Это неидеальное решение, но оно к нему довольно близко: мы улучшили интерфейс функции Sort, ограничив параметр шаблона, и сделали так, чтобы ошибка была понятной и однозначной. Проблема с таким решением в том, что его тяжело писать и ещё тяжелее читать. Безусловно, код выше довольно прост, но он будет куда более многословным, если мы добавим поддержку всех типов итераторов, да и особенность SFINAE, при которой мы обязаны рассматривать все случая явно, вместо того чтобы добавить ограниченные интерфейсы и один неограниченный, не добавляют ему очков: std::enable_if это хороший костыль, но не более того.

Укрощение рациона

Как я уже упоминал в предыдущем разделе, проблема эта старая, известная, и работы над её решением тоже идут довольно давно. Ещё до C++11 шли разговоры о концепциях (англ. concepts), но в стандарт всё это не попало, потому что предложение оказалось слишком сложным. Его решено было отложить, а вместо него реализовать то, что стали называть облегчёнными концепциями (англ. concepts-lite), но даже в облегчённой форме им не суждено было добраться до стандарта раньше 2020 года.

В C++20 было добавлено новое ключевое слово requires, которое лежит в основе облегчённых концепций, или, если быть более точным, ограничений (англ. constraints). Оно используется в двух разных контекстах: requires-выражении (expr.prim.req) и requires-clause (temp.pre).

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) выражений происходит несколько иначе, поэтому здесь и далее при использовании логических операторов будут подразумеваться логические операции [1].

Кстати, в 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 в языке, нет никакой необходимости [2] содержать 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 — это всегда некорректное выражение, нужно ему предоставить то, что может быть корректным [3]. Поэтому давайте перепишем наш пример на шаблоны:

#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? Что оно будет в него конвертироваться? Как выбрать из этих двух вариантов тот, который будет применяться во всех случаях? Очевидно, что никакого правильного ответа на все вопросы быть не может — ситуации разные, поэтому в правую часть были добавлены концепции, и теперь программист явно указывает, какие свойства типа выражения ему нужны. Да, выглядит это более многословным, но зато требования читаются однозначно.

Интересным свойством составных требований является их избыточность: если первые три разновидности требований нельзя реализовать друг через друга [4], то составные легко реализуются с помощью трёх предыдущих. К примеру, требования выше можно было бы переписать так:

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&>;
};

И мы получили бы тот же самый результат (можно было и от концепций избавиться, только смысла в этом нет, раз уж всё равно пришлось использовать), но писанины тут куда больше и запутаться легче, поэтому, на мой взгляд, наличие отдельного синтаксиса вполне оправдано.

Заключение

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

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

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


[1] Этот педантизм с терминами, строго говоря, не нужен для понимания и применения ограничений, но, с другой стороны, если не упоминать эту разницу, то полного понимая достичь тоже не удастся.

[2] Кроме поддержки старого кода, разумеется.

[3] Контекст шаблона необходим, но не достаточен для превращения ошибки компиляции в false. Если внутри requires-выражения содержится требование, которое не может быть удовлетворено ни при каких аргументах шаблона, то на выходе получите не false, а некорректный код.

[4] В теории можно обойтись без требований типа, но это потребовало бы создания фиктивных простых требований или параметров. Да и функционал простых можно реализовать через вложенные, но это не совсем реализация одного через другое. Это просто очередное (зло)употребление SFINAE, но с точки зрения стандарта — это не будет полным эквивалентом.