Требовательные концепции

В предыдущей статье мы рассмотрели базовые блоки, необходимые для построения C++-концепций. Теперь пришло время поговорить о самих концепциях[1] и как они соотносятся с концепциями окружающего нас мира.

Концепции?

Не будем долго ходить вокруг да около, а сразу рассмотрим определение концепции (temp.concept): это шаблон предиката, который создаётся с помощью ключевого слова concept, но, в отличие от других шаблонов, он не может быть ограничен с помощью requires-clause, его нельзя инстанциировать и специализировать. Пример:

template <typename T, typename U>
concept SameAs = std::is_same_v<T, U>;
static_assert(SameAs<int, int> == true);
static_assert(SameAs<int, int&> == false);
static_assert(SameAs<int, short> == false);

Выше мы создали предикат с именем SameAs, который при использовании вычисляется с помощью стандартной метафункции std::is_same_v. Вместо этой функции может быть какое угодно выражение, главное, чтобы оно вычислялось в bool. А раз любое выражение, то, значит, и requires-выражение подойдёт:

template <typename C>
concept Container = requires (C cont)
{
    typename C::value_type;
};
static_assert(Container<int> == false);
static_assert(Container<std::vector<int>> == true);

Этот пример взят из предыдущей статьи, только я заменил constexpr bool на concept. И так можно (и нужно) поступить с каждым constexpr bool из прошлой статьи, потому что концепции созданы как раз для таких целей и являются куда более мощным инструментом, чем банальные constexpr-объекты. В данном примере разницы не видно, но мы обязательно увидим её позднее. Пока что достаточно того, что мы увидели работу концепции в виде предиката, а также в виде именованного требования, или коллекции требований.

Помните, как мы были вынуждены повторять «шапку» шаблона? Если дать имя требованиям, то можно значительно сократить количество писанины:

template<typename T>
concept Nonsense = std::is_integral_v<T> &&
    (std::is_unsigned_v<T> || std::is_signed_v<T>);
template<typename T>
    requires Nonsense<T>
class Number
...

Шапку всё равно повторять нужно, но она теперь значительно проще. Правда, мы могли это сделать и с constexpr bool ранее, так что это не единственное изменение, которое мы внесём в эту «шапку», но позже.

Пока же предлагаю немного притормозить и ещё раз посмотреть на то, что мы увидели. А увидели мы то, как с помощью концепций мы можем заменить constexpr-объекты и дать имена требованиям. Но можем ли мы с помощью концепций описать концепции? Для начала давайте определимся с понятием концепции, пригодной для данной статьи, вот что может предложить «Большой толковый словарь»:

Система связанных между собой и вытекающих один из другого взглядов на то или иное явление; теоретическое построение. <...>

Совокупность предположений о свойствах и качествах данных, воспринимаемых и интерпретируемых кем-, чем-либо. <...>

Если мы заглянем в словарь синонимов, то обязательно обнаружим там слово «идея». Т. е. концепция должна описывать какую-то абстрактную идею, её свойства и т. п. В качестве примера можно рассмотреть концепцию числа или, если сузить немного понятие, то концепцию целого числа. Я не буду тут приводить все свойства целого числа — их слишком много, просто приведу парочку, чтобы посмотреть, можем ли мы с помощью концепций описать такую концепцию. Итак, свойства целого числа:

  1. Сумма целых чисел является целым числом.

  2. Ко��мутативность: a+b=b+a.

  3. Свойство единицы: a×1=a

  4. Упорядоченность: если a<b и c<d, то a+c<b+d

Первое же свойство легко прописывается в требованиях:

template<typename T>
concept Integral = requires (T a, T b)
{
    {a + b} -> std::convertible_to<T>;
    ...
};

Теперь осталось только преобразовать многоточие в конце в требования, которые будут удовлетворять свойствам 2–4. Но, как мы уже видели, сделать этого не удастся, потому что requires-выражение занимается проверкой синтаксиса, о поддержке аксиом речи не идёт, а без них мы не можем проверить тип на банальную коммутативность. А раз мы не можем сделать это, то о каких концепциях вообще речь?

В стандартной библиотеке есть концепция std::integral, которая определена очень просто:

template<class T>
concept integral = std::is_integral_v<T>;

std::is_integral_v, в свою очередь, просто проверяет T на принадлежность к заранее заданному списку типов (char, bool, int и т. д.). В стандартной библиотеке нет концепции std::arithmetic, зато есть метафункция is_arithmetic_v, которая может быть сформирована следующим образом:

template<class T>
constexpr bool is_arithmetic_v = std::is_integral_v<T> || std::is_floating_point_v<T>;

Из этого определения следуют следующие утверждения:

static_assert(std::is_arithmetic_v<bool> == true)
static_assert(std::is_arithmetic_v<char> == true)

Это имеет смысл, потому что оба этих типа относятся к интегральным C++-типам и к ним применимы арифметические операции. Только вот концептуально они арифметическими не являются, потому что один представляет логическое значение, а другой — символ. Сумма true и false имеет не больше смысла, чем сумма 'A' и 'k'. Так может, поэтому такой концепции нет, потому что она не имеет смысла? Нет, конечно, её нет просто потому, что никто не озаботился её добавлением, а она абсолютно точно имеет смысл с точки зрения C++, имеющего std::is_arithmetic_v.

Кто-то может сказать, что я занимаюсь демагогией, мол, не надо смешивать математику и C++, в C++ это арифметические типы, и это совершенно нормально иметь такую концепцию. Я не буду с этим спорить, потому что это точка зрения совершенно точно не является неверной. Но давайте пойдём дальше (или обратно?) и посмотрим на другие концепции из стандартной библиотеки: same_as, common_reference_with, derived_from и т. д., и т. п. Какие концепции реализуют эти сугубо утилитарные предикаты?

С другой стороны, в стандарте есть такие концепции: floating_point, regular, predicate и т. д., и т. п. Очевидно, что эти сущности вполне можно считать концепциями, пусть они и не будут полностью математически полными. Страуструп, рассуждая о дизайне концепций, говорит, что следует избегать излишне простых и неоднозначных концепций, и приводит такой пример:

template<typename T>
concept Drawable = requires(T t) { t.draw(); };
class Shape 
{
    // Нарисовать фигуру
    void draw();
};
class Cowboy 
{
    // Достать оружие
    void draw(); 
};
static_assert(Drawable<Shape> == true);
static_assert(Drawable<Cowboy> == true);

Разумеется, что при написании Drawable мы имели в виду один из смыслов слова «draw», а в результате получили, что под нашу концепцию подходит и что-то иное. Очевидно, что это ещё одна функция, а не концепция. Я в целом совершенно согласен со Страуструпом и с удовольствием бы следовал этому совету, но есть одно но: функции бывают нужны, а отдельного синтаксиса их создания нет, поэтому нет никаких сомнений, что видению Страуструпа о применении ключевого слова concept не суждено сбыться (да он и сам всё прекрасно понимает), тем более что стандарт первый начал со своими same_as и прочими [2].

Исходя из всего вышесказанного, я считаю, что ключевое слово concept не стоит расценивать как определение концепции. Но что тогда определяют conceptы?

Ограничения!

Кому-то может показаться, что я занимаюсь ерундой, играя словами, которые, в сущности, не имеют значения; какая разница, называем ли мы нововведение концепциями или ограниченями? Для меня эта разница является существенной, потому что, исходя из того, чем я считаю эту сущность, я и буду писать код. К примеру, если принять, что concept определяет концепцию, то я буду крайне консервативен и вряд ли напишу такой код:

template <typename T>
concept HasFunctionSort = requires(T obj){ obj.sort(); };

Потому что, как правильно заметил Страуструп, это никакая не концепция. Принимая концепции концепциями, к их написанию нужно подходить вдумчиво и осторожно, чтобы не плодить сущностей, концепциями не являющихся. Но, принимая во внимание, что без аксиом произвольную концепцию определить с помощью concept невозможно, а также тот факт, что concept является уникальным инструментом, я просто считаю, что concept никогда не определяет концепцию, и это упрощает моё с ними взаимодействие — я больше не скован семантикой концепций, а просто определяю именованные требования или ограничения (англ. constraint). Для этого я использую два разных суффикса:

  1. _r — для именованных требований или просто требований (для краткости).

  2. _c — для ограничений. Удобно, что этот суффикс подходит также и для концепций; так что такая нотация будет не чужда и другим программистам, которых разница между «constraint» и «concept» совершенно не волнует.

Таким образом, я разделяю понятие концепции на два: ограничение и требование. Далее, если я явно буду упоминать концепцию, значит, что имеется в виду любой её вариант. В остальных случаях я буду явно указывать конкретную сущность.

Применив нотацию, пример выше станет таким:

template <typename T>
concept HasFunctionSort_r = requires(T obj){ obj.sort(); };

Что интересно, вы можете с таким же успехом считать требование выше ограничением, которое будет называться иначе:

template <typename T>
concept Sortable_c = requires(T obj){ obj.sort(); };

Но определение его останется прежним; поменялось лишь наше отношение к сущности: мы не проверяем на наличие функции-члена sort, мы проверяем, является ли тип T сортируемым. Тот факт, что одно неотличимо от другого, является лишь деталью реализации (которая для Sortable_c может легко измениться), а вот семантика двух этих шаблонов совершенно разная.

Ну хорошо, автор, допустим, Вы нас убедили, что семантика разная и есть смысл в двух разных суффиксах. Но ведь дальнейшее их использование в requires-clause сделает разное именование совершенно бессмысленным, оно ведь всё смешается в кучу!

Может заметить читатель и будет совершенно прав! В конце концов, если мы пишем так:

template <typename T>
    requires Formattable_c<T> && HasFunctionSort_r<T>;
void foo();

Тогда что нам дадут разные суффиксы? Абсолютно ничего. Они нужны для совсем другой нотации, доступной концепциям.

Модные шапки

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

Одним из вариантов применения ограничений является его прямое помещение в определение параметра шаблона. Для примера выше это может выглядеть так:

template <Formattable_c T>
    requires HasFunctionSort_r<T>;
void foo();

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

Ещё одним интересным моментом является то, что я оставил requires-clause с HasFunctionSort_r<T>. Почему я не занёс её в угловые скобки и не избавился от requires-clause? На это у меня есть две причины:

  1. Согласно ранее рассмотренной таксономии, требованию HasFunctionSort_r нечего делать там, где прописывается ограничение типа. Т. е. здесь как раз и проявляют себя разные суффиксы: сущностям с _r нечего делать внутри template <...>, их удел — определение других ограничений или требований, а также прямое указание в requires-clause.

  2. До C++26 нет возможности указать несколько concept-сущностей для одного типа внутри template <...>. Как это сделать в C++26, я покажу позже.

Оба этих ограничения можно обойти, создав, как водится, ещё один уровень косвенности:

template<typename T>
concept Amalgamation_c = Formattable_c<T> && HasFunctionSort_r<T>;
template <Amalgamation_c T>
void foo();

Это снова абсолютно эквивалентный код, который полностью лишён requires-clause. Его основной проблемой является именование — что ещё за «amalgamation»? А всё потому, что в этом простом примере я попытался скрестить «ежа» с «удавом», в результате чего получилось это невнятное имя[3]. К сожалению, я полагаю, что подобного в реальном коде будет много, потому что тщательно продумать иерархию ограничений может быть довольно сложно, а вот настрочить промежуточных требований и ограничений, объединив их потом в какие-то ограничения с невыразительными именами, очень просто.

Если вы видите в коде подобное имя (из которого непонятно, в чём заключается ограничение), или вам при написании общего ограничения не приходит в голову ничего конкретного, то это означает, что с кодом что-то не так и, возможно, стоит задуматься над иным решением. Ограничения — это новый функционал, но в его написании нужно придерживаться тех же принципов, что вы придерживаетесь в написании классов и функций. Принцип единой ответственности (англ. single responsibility principle) — это ведь вовсе не про ООП.

Но вернёмся к технической стороне и рассмотрим ещё один пример:

template <std::integral T, std::same_as<int> U>
bool foo(T, U);
template <typename T, typename U>
int foo(T, U);
static_assert(std::same_as<bool, decltype(foo(false, 42))> == true);
static_assert(std::same_as<int, decltype(foo(true, false))> == true);

std::integral является ограничением с одним параметром, а вот std::same_as[4] — с двумя, поэтому один из них мы указали явно — второй, а вот U неявно будет первым. Применение ограничений в параметрах шаблона никак не меняет манеру использования самих параметров; вот как мы можем записать набор параметров Us, ограниченных с помощью std::same_as:

template <std::integral T, std::same_as<int>... Us>
bool foo(T, Us...);

Эквивалентная запись с requires будет выглядеть так:

template <typename T, typename... Us>
    requires std::integral<T> && (std::same_as<Us, int> && ...)
bool foo(T, Us...);

Хочу заострить внимание на том, что с помощью упрощённого синтаксиса можно получить только конъюнкцию концепций; если же нужна дизъюнкция, то придётся использовать расширенный синтаксис, т. е. либо requires, либо concept.

Избавление от requries-clause — это то, к чему стоит стремиться при написании C++-кода, потому что лишние строки в «шапке» сильно затрудняют её чтение. Правда, не стоит это делать механически, создавая ограничения с бессмысленными именами, ещё больше затрудняя чтение кода. Requires-clause существует как раз для случаев, когда требованию не находится места в ограничении, но такие случаи должны быть редки.

Итак, применяя упрощённый синтаксис, мы можем решить проблему «большой шапки» из предыдущей статьи:

template<typename T>
concept Nonsense = std::is_integral_v<T> &&
    (std::is_unsigned_v<T> || std::is_signed_v<T>);
template<Nonsense T>
class Number
<...>
template<Nonsense T>
Number<T>::Number(T value):
    m_Value{value}
{}
<...>

Теперь повторяющаяся «шапка» шаблона по размеру не будет отличаться от обычного неограниченного шаблона.

Но на этом упрощение синтаксиса не заканчивается, стандарт позволяет вообще убрать «шапку» шаблона из шаблонов функций:

bool foo(std::integral auto, std::same_as<int> auto);
template <typename T, typename U>
int foo(T, U);

С помощью имени ограничения и ключевого слова auto мы получаем шаблон функции, «шапка» которого формируется неявно! Эта запись полностью эквивалентна предыдущей, с ограничениями в параметрах, которая, в свою очередь, эквивалентна этой:

template <typename  T, typename U>
    requires std::integral<T> && std::same_as<U, int>
bool foo(T, U);
template <typename T, typename U>
int foo(T, U);

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

Неуниверсальные ограничения

До этого момента все примеры использовали передачу аргументов в функцию по значению, потому что я не хотел отвлекаться на то, что влечёт за собой использование «обвешанных» типов, а также не хотел отвлекать читателя лишними символами там, где это не несёт никакой смысловой нагрузки. Но реальный код отличается от рафинированных примеров, и в нём зачастую используются универсальные ссылки. Давайте напишем шаблон функции Size, который принимает контейнер в качестве аргумента и возвращает его размер[5]:

#include <print>
#include <vector>
template <typename C>
concept Container = requires (C cont)
{
    typename C::value_type;
};
template<Container T>
size_t Size(T&& cont)
{
    return cont.size();
}
int main()
{
    std::vector<int> v{1, 2, 3};
    std::println("Vec size: {}", Size(v));
}

Что выведет этот код? Он не скомпилируется.

Дело в том, что при попытке найти функцию Size, компилятор рассматривает наш шаблон функции; подставляя аргумент, получается, что T = std::vector<int>&, далее этот тип подставляется в наше ограничение, где пытается удовлетворить требование: typename std::vector<int>&::value_type. Очевидно, что ссылка на std::vector<int> не имеет внутреннего типа value_type, требование не удовлетворено, ограничение — нарушено, а значит, функция не включается в список на рассмотрение. Другой функции компилятор найти не может и потому сдаётся.

Разумеется, решение этой проблемы заключается в том, чтобы очистить тип от всего того, что нам неинтересно. Мне известно три метода[6], с помощью которого мы можем этого добиться. Первый, использовать requires-clause в Size:

template<typename C>
    requires Container<std::remove_cvref_t<C>>
size_t Size(C&& cont)
{
    return cont.size();
}

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

template <typename C>
concept Container = requires (C cont)
{
    typename std::remove_cvref_t<C>::value_type;
};

Третий способ требует C++26, который по примеру шаблонов в качестве параметров шаблонов (англ. template template parameter) позволяет передавать концепции в качестве параметров шаблона (англ. concept template parameter). С помощью нового синтаксиса мы создадим концепцию-функцию, которая будет очищать переданный ей тип для последующей передачи его указанной концепции:

template <typename T, template <typename> concept C>
concept RemoveCVRef = C<std::remove_cvref_t<T>>;
template<RemoveCVRef<Container> T>
size_t Size(T&& cont)
{
    return cont.size();
}

Ну и раз уж зашла речь о C++26, сразу отдам долг и покажу, как можно применить несколько концепций к одному типу, не прибегая к requires-clause:

template <typename T, template <typename> concept... Cs>
concept AnyOf = (Cs<T> || ...);
template <typename T, template <typename> concept... Cs>
concept AllOf = (Cs<T> && ...);
template <AllOf<Formattable_c, HasFunctionSort_r> T>
void foo();

Здесь приведен шаблон функции foo, который ранее приходилось ограничивать с помощью requires. AnyOf приведена просто в качестве примера того, что можно и дизъюнкцию использовать в параметрах шаблона, не прибегая к requires-clause. Это довольно интересное нововведение, которое, несомненно, найдёт своё применение в коде шаблонов. Кроме того, подобная запись очень похожа на то, как в других языках ограничивают обобщения (англ. generics), поэтому весьма вероятно, что в будущем мы увидим много подобных функций.

Рассмотрев три метода, какой из них является предпочтительным? Я считаю, что если у вас есть возможность изменять код исходной концепции, то, безусловно, второй способ является безальтернативным: неумение обрабатывать ссылки — это недостаток реализации концепции, не дело пользователя заниматься исправлением этого недостатка в своих интерфейсах.

Если же менять исходную концепцию нельзя, то, имея под рукой компилятор, поддерживающий нововведение из C++26, я бы предпочёл третий вариант, и только в случае, когда никак иначе сделать нельзя — первый. По возможности, я не хочу видеть requires-clause в коде.

Сила ограничений

Всё, что мы рассмотрели к настоящему моменту, выглядит исключительно косметической надстройкой над пресловутыми constexpr-объектами, к которым мы давно привыкли. Безусловно, одной этой «косметики» достаточно, чтобы предпочесть использовать концепции. Но я не зря ранее называл концепции уникальным функционалом в C++; пришло время рассмотреть то, что делает их таковыми.

Нормализация

Давайте рассмотрим следующую группу ограничений:

template <typename T>
concept Integral_с = std::is_integral_v<T>;
template <typename T>
concept Floating_с = std::is_floating_point_v<T>;
template <typename T>
concept Arithmetic_с = Integral_с<T> || Floating_с<T>;
void printVal(Arithmetic_с auto val)
{
    std::println("Arithmetic: {}", val);
}
//...
printVal(11);

Когда мы вызываем ограниченную функцию printVal(11), то первым, что делает компилятор, является нормализация. Нормализация концепции заключается в подставлении аргументов шаблонов в выражения и концепции до тех пор, пока в результирующем выражении не останется шаблонов и концепций. Формальное определение можно найти в temp.constr.normal. Т. е. для вызова printVal(11) произойдёт следующая цепочка раскрытия: Integral_с<T> || Floating_с<T> → Integral_с<int> || Floating_с<int> → std::is_integral_v<int> ∨ std::is_floating_point_v<int>.

Давайте рассмотрим ещё один пример:

void printVals(Integral_с auto... vals)
{   
    std::print("Integrals: ");
    (std::print("{} ", vals), ...);
    std::print("\n");
}
//...
printVals(1, 2, 3, 45, 5);

Давайте напишем нормальную форму для этого вызова согласно правилам стандарта C++23 (без полного раскрытия пачки): (Integral_c<T> && ...) → (Integral_c<int> && ...) → (std::is_integral_v<T> && ...). Предполагая, что я не ошибся, вы заметили существенную разницу между этими двумя нормальными формами?

Разница в том, что в первой нормальной форме используется операция ∨, а во второй — оператор &&. Это очень важное различие (в следующем подразделе мы увидим, почему), и я, честно говоря, не понимаю, почему эту очевидную ошибку не исправили сразу же в C++20, а потребовалось аж шесть лет, чтобы уже в C++26 нормальная форма второго примера стала выглядеть так: (std::is_integral_v<T> ∧ ...). За это можно сказать спасибо авторам этого предложения.

Интересно заметить, что если бы мы переписали Arithmetic_с без использования концепций, а напрямую через метафункции:

template <typename T>
concept Arithmetic_с = std::is_integral_v<T> || std::is_floating_point_v<T>;

То чисто визуально нормальная форма при вызове printVal(11) осталась бы прежней: std::is_integral_v<int> ∨ std::is_floating_point_v<int>, но код бы перестал компилироваться. Пришло время разобраться, почему.

Поглощение

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

Для установления порядка у компилятора есть ряд правил, которые описаны в temp.constr.order. Я не вижу смысла переписывать здесь то, что там написано, как и не хочу расписывать булеву алгебру, поясняя, что такое конъюнктивная и дизъюнктивная нормальные формы — всё это можно прочитать, например, в «Википедии».

Ниже я представлю менее формальную модель процесса поглощения, с помощью которой, как мне кажется, понять происходящее несколько проще. Тем более, что в конечном итоге процесс должен быть интуитивно понятен; программист не должен выстраивать модели, результат поглощения должен быть виден на глаз.

Возьмём пример из предыдущего подраздела и немного его дополним:

template <typename T>
concept Integral_с = std::is_integral_v<T>;
template <typename T>
concept Floating_с = std::is_floating_point_v<T>;
template <typename T>
concept Arithmetic_с = Integral_с<T> || Floating_с<T>;
template <typename T>
concept GoodIntegral_c = Integral_с<T> &&
    (!std::same_as<bool, T> && !std::same_as<char, T>);
void printVal(Arithmetic_с auto val)
{
    std::println("Arithmetic: {}", val);
}
void printVal(Integral_с auto val)
{
    std::println("Integral: {}", val);
}
void printVal(GoodIntegral_c auto val)
{
    std::println("Proper int: {}", val);
}

Если мы возьмём такой вызов функции: printVal(12.), то однозначно получим вывод: Arithmetic: 12, потому что ограничения двух других не позволяют работать с числами с плавающей запятой. Если же мы вызовем функцию так: printVal(11), то ситуация становится интереснее. Что выведет этот код? Proper int: 11. А как насчёт printVal(true)? Integral: true. Почему получились именно такие результаты, как компилятор смог разрешить неоднозначность?

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

I: std::is_integral_v<T>
F: std::is_floating_point_v<T>
NB: !std::same_as<bool, T>
NC: !std::same_as<char, T>

Теперь давайте рассмотрим вызов printVal(11). Очевидно, что 11 подходит для всех трёх функций, а значит, мы получим три нормальных формы:

  1. Arithmetic_с: I∨F

  2. Integral_с: I

  3. GoodIntegral_c: I∧NB∧NC

Я надеюсь, никто не забыл, как сокращать дроби, потому что мы сейчас воспользуемся школьными знаниями и запишем отношения ограничений в виде трёхэтажной (от количества функций в рассмотрении) дроби; для каждой из рассматриваемых функций нормальная форма её ограничений будет на самом верху (в числителе). Правила сокращения практически такие же, как в обычной алгебре, но с несколькими нюансами, вызванными тем, что мы имеем дело с алгеброй булевой, поэтому будем называть их не сокращением, а поглощением:

  • A поглощает A и A∧A=A.

  • A поглощает A∨B, потому что если A истинно, то A∨B тоже. Но A∨B не поглощает A, потому что истинность A∨B не означает истинности A.

  • Поглощение происходит на всём протяжении дроби, вне зависимости от количества «этажей», т. е. A∧AA∧BA=∅B, а не ABA, как это было бы в алгебре.

  • ∅ означает, что все ограничения были поглощены, аналог 1 в алгебре. Т. е. если числитель состоит из ∅, то его убрать нельзя, а если знаменатель представляет собой ∅, то его можно отбросить. Условимся, что у неограниченной функции ограничения пусты (∅).

  • Поглощение происходит исключительно сверху вниз, т. е. AA∨B=∅, но A∨BA — это последняя форма, дальнейшее поглощение невозможно.

Разобравшись с нюансами, давайте рассмотрим процесс поглощения для всех трёх функций и начнём с Arithmetic_с:

I∨FII∧NB∧NC=I∨FII∧NB∧NC=I∨FNB∧NC

Integral_с:

II∨FI∧NB∧NC=II∨FI∧NB∧NC=∅NB∧NC

GoodIntegral_c:

I∧NB∧NCI∨FI=I∧NB∧NCI∨FI=NB∧NC

Итак, в процессе поглощения мы получили как дробные, так и целый результаты. Целый результат означает, что существует ограничение, которое может поглотить любое другое из рассматриваемого списка. Дробный результат означает, что существует как минимум два ограничения, отношение между которыми установить невозможно, т. е. невозможно определить, какое из них является более ограниченным.

Используя два типа результата, правила определения более ограниченной шаблона можно сформулировать следующим образом:

  1. Целый результат лучше дробного.

  2. Дробные результаты несравнимы между собой.

  3. Два пустых (∅) результата несравнимы между собой.

  4. Целый непустой результат лучше пустого, но, т. к. ситуация, в которой может получится и целый пустой, и целый непустой, или несколько целых непустых, невозможна, то это правило можно свести к: целый непустой результат всегда является лучшим.

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

template <GoodIntegral_c T>
    requires GoodIntegral_c<T>
void printVal(T val)
{
    std::println("Most Proper int: {}", val);
}

То получим два целых пустых результата (приведу лишь один):

I∧NB∧NCI∨FNB∧NC∧NB∧NCI=I∧NB∧NCI∨FNB∧NC∧NB∧NCI=∅

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

Я надеюсь, что своими объяснениями я вас ещё больше не запутал, потому что за исключением каких-то сложных случаев подобный подробный анализ, естественно, никто проводить не будет. Поглощение является интуитивно понятным свойством в подавляющем большинстве случаев. Просто, когда вам нужно усилить ограничение на одной из функций, вы используете конъюнкцию с уже существующим. К примеру, есть у нас ForwardIter_c, а мы хотим сделать RandomIter_c, который будет связан с ForwardIter_c. Тогда мы просто делаем так:

template <typename T>
concept RandomIter_c = ForwardIter_c<T> && RequireRandom_r<T>;

И всё, теперь RandomIter_c всегда будет поглощать ForwardIter_c, т. к. является более ограниченным.

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

Здесь вам не тут

Ещё одной важной деталью, которую до этого момента я опускал, является то, что для поглощения важна полная идентичность в записи концепции. Рассмотрим следующий некорректный C++-код:

template <unsigned N>
concept MoreThan10_r = N > 10;
template <unsigned N>
    requires MoreThan10_r<N + 3>
void printer()
{
    std::print("An avg printer.");
}
template <unsigned N>
    requires MoreThan10_r<3 + N> && true
void printer()
{
    std::print("True printer!");
}
//...
printer<8>();

Этот код некорректен, потому что для разрешения перегрузки нам нужно установить, поглощает ли MoreThan10_r<3 + N> MoreThan10_r<N + 3>, а для этого нам нужно убедиться в том, что они эквивалентны. И хотя они выглядят эквивалентными, из-за разницы в порядке N и 3, они таковым не являются (temp.over.link/p5). Это очень простой пример, на котором тем не менее хорошо видно, насколько аккуратным нужно быть при написании шаблонного кода. И если этот пример может показаться вам надуманным, я покажу другой, куда менее очевидный, но похожий по существу.

Давайте попробуем реализовать свою функцию std::same_as. Наивная реализация могла бы выглядеть так:

template <typename T, typename U>
concept SameAs = std::is_same_v<T, U>;

Но когда мы попробуем её использовать в коде:

template <typename T, typename U>
    requires SameAs<T, U>
void sameStuff(T, U)
{
    std::print("An avg sameness.");
}
template <typename T, typename U>
    requires SameAs<U, T> && true
void sameStuff(T, U)
{
    std::print("True sameness!");
}
//...
sameStuff(10, 8);

То получим ошибку компиляции, потому что, хотя нормальные формы для SameAs<T, U> и SameAs<U, T> выглядят одинаково, на деле являются разными, потому что intы у них «разные», т. к. идут из разных параметров функции. Следовательно, SameAs<T, U> не может поглотить SameAs<U, T>. Чтобы исправить эту ситуацию, мы должны сделать так, чтобы для SameAs порядок аргументов не играл роли; этого довольно легко добиться:

template <typename T, typename U>
concept SameAsImpl = std::is_same_v<T, U>;
template <typename T, typename U>
concept SameAs = SameAsImpl<T, U> && SameAsImpl<U, T>;

С такой функцией код соберётся и выдаст ожидаемый результат.

Но, автор, зачем нам SameAsImpl, почему бы не сделать так:

template <typename T, typename U>
concept SameAs = std::is_same_v<T, U> && std::is_same_v<U, T>;

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

Не всё то то, что тем кажется

Разобравшись с поглощением, пришла пора разобраться с тем, почему оно не работает с измененным Arithmetic_с из подраздела о нормализации. Для этого ещё раз повторим нашу иерархию ограничений:

template <typename T>
concept Integral_с = std::is_integral_v<T>;
template <typename T>
concept Floating_с = std::is_floating_point_v<T>;
template <typename T>
concept Arithmetic_с = Integral_с<T> || Floating_с<T>;
template <typename T>
concept Arithmetic2_с = std::is_integral_v<T> || std::is_floating_point_v<T>;

А теперь дадим короткие имена оконечным метафункциям:

I: std::is_integral_v<T> из Integral_с
F: std::is_floating_point_v<T> из Floating_с
I2: std::is_integral_v<T> из Arithmetic2_с
F2: std::is_floating_point_v<T> из Arithmetic2_с

Видите разницу? В первый раз, когда я давал короткие имена, я опустил вторую часть, которая показывает, к какой концепции относится метафункция, потому что каждая из них была в единственном экземпляре. Но, создав Arithmetic2_с, ситуация поменялась, так что пришлось использовать полную запись. А из этой записи видно, что каждое написание метафункции[7] будет иметь уникальное имя, и только через использование концепции можно в двух местах использовать одну и ту же метафункцию, сохраняя одно имя.

И хотя нормальные формы обеих Arithmetic выглядят как std::is_integral_v<int> ∨ std::is_floating_point_v<int>, если переписать их в укороченной нотации, то сразу будет видна разница:

Arithmetic_с: I ∨ F
Arithmetic2_с: I2 ∨ F2

Исходя из этой записи, Arithmetic_с связана с Integral_с, так что поглощение будет проходить успешно, и компилятор сможет выбрать нужную перегрузку. А вот между Arithmetic2_с и Integral_с связи уже нет никакой, и совершенно не важно, что Arithmetic_с и Arithmetic2_с — близнецы-братья, важно то, что связи нет → поглощения нет → определить отношения между двумя концепциями невозможно.

Именно поэтому пример из предыдущей статьи не работал:

#include <print>
template <typename... Ts>
struct pack_size
{
    static constexpr std::size_t value = sizeof...(Ts);
};
template <typename... Ts>
    requires (pack_size<Ts...>::value > 0)
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
    requires (pack_size<Ts...>::value > 0) &&
        (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);
}

Но, вооружившись новыми знаниями, мы легко можем его исправить:

template <typename... Ts>
concept NonEmptyPack_r = pack_size<Ts...>::value > 0;
template <typename... Ts>
    requires NonEmptyPack_r<Ts...>
void fun(Ts... vs) { std::println("Constrained ☠️"); }
template <typename... Ts>
    requires NonEmptyPack_r<Ts...> && (pack_size<Ts...>::value > 3)
void fun(Ts... vs) { std::println("Restrained "); }

Теперь код скомпилируется и напечатает Restrained .

Кстати, заметьте, что в этом примере мы вынуждены использовать requires-clause, потому что нам нужно применить требование, исходя из всей пачки параметров, а не отдельного её члена. Без «высокоранговых» концепций из C++26, с помощью одних концепций этого сделать нельзя.

И с SameAs проблема та же; давайте построим таблицу коротких имён для двух реализаций. Условимся, что мы строим их на основании вызова функции с двумя int, а также дадим типам новые имена, чтобы подчеркнуть, из какого параметра функции они получились: int_1 и int_2. Для «читательской» реализации:

template <typename T, typename U>
concept SameAs = std::is_same_v<T, U> && std::is_same_v<U, T>;

Получим:

I1: std::is_same_v<int_1, int_2> слева от &&
I2: std::is_same_v<int_1, int_2> справа от &&
I3: std::is_same_v<int_2, int_1> слева от &&
I4: std::is_same_v<int_2, int_1> справа от &&

И тогда для SameAs<int_1, int_2> получим I1∧I4, а для SameAs<int_2, int_1> — I3∧I2. Очевидно, что эти нормальные формы не могут поглотить друг друга, а значит, этот код не является решением. С другой стороны, реализация через SameAsImpl:

template <typename T, typename U>
concept SameAsImpl = std::is_same_v<T, U>;
template <typename T, typename U>
concept SameAs = SameAsImpl<T, U> && SameAsImpl<U, T>;

Даст нам следующее:

I1: std::is_same_v<int_1, int_2>
I2: std::is_same_v<int_2, int_1>

Нормальная форма для SameAs<int_1, int_2>: I1∧I2, для SameAs<int_2, int_1>: I2∧I1. Происходит полное поглощение, и мы ещё раз убеждаемся, насколько важно минимизировать использование метафункций при написании концепций. В идеале, при написании концепции не должно использоваться более одной неконцепции.

Но на этом наши злоключения не заканчиваются, есть ещё примеры! Например, вот такой:

template <typename T>
    requires (!Integral_с<T>)
void printVal(T val)
{
    std::println("Non-integral: {}", val);
}
template <typename T>
    requires (!Integral_с<T> && true)
void printVal(T val)
{
    std::println("Truly non-integral: {}", val);
}
//...
printVal(13.);

Что получим в результате? Ошибку компиляции.

Хотя это совершенно неочевидно, но здесь мы имеем ситуацию абсолютно равносильную той, что только что рассмотрели: !Integral_с<T> не может поглотить !Integral_с<T>, потому что это совершенно разные выражения с разными именами. Что их делает разными? Восклицательный знак! У нас два разных восклицательных знака применяются к одной концепции, что на выходе даёт нормальные формы с двумя разными короткими именами. Поэтому будьте аккуратнее с requires-clause в целом и скобками в частности; если видите скобки, то весьма вероятно, что поглощение в опасности.

Чинится это очень просто:

template <typename T>
concept NonIntegral_c = !Integral_с<T>;
void printVal(NonIntegral_c auto val)
{
    std::println("Non-integral: {}", val);
}
void printVal(NonIntegral_c auto val) requires true
{
    std::println("Truly non-integral: {}", val);
}

«Всего-то» понадобилось создать промежуточную концепцию, и на выходе мы получаем Truly non-integral: 13. Этот пример, на мой взгляд, очень хорошо демонстрирует, почему я не могу относится к концепциям как к концепциям, а считаю их просто инструментом ограничения шаблонных параметров, для которых было неудачно выбрано ключевое слово concept.

Последним, что нам осталось рассмотреть, является ещё один пример из подраздела о нормализации, а точнее, вот такая нормальная форма: (std::is_integral_v<T> && ...). Из-за того, что в нормальную форму пришло всё выражение (std::is_integral_v<T> && ...), а не конъюнкция метафункций (std::is_integral_v<T> ∧ ...), означает, что поглощение происходить не может, потому что, как мы видели, оно происходит только между связанными выражениями (посредством концепций), и никогда не происходит между произвольными. И совершенно не важно, что в короткой нотации это будет (I && ...), важно здесь то, что нет ∧. Т. е. должны быть и совпадающее имя, и дизъюнкция/конъюнкция. При отсутствии любого из двух, поглощения не происходит.

Я вот сейчас пишу эти строки и понимаю, насколько бредово звучит то, что написано в предыдущем абзаце, и в очередной раз вопрошаю: «Как это можно было пропустить?». Рассмотрим такой пример:

#include <print>
template <typename T>
concept Integral_с = std::is_integral_v<T>;
template <typename T>
concept GoodIntegral_c = (Integral_с<T>) &&
    (!std::same_as<bool, T> && !std::same_as<char, T>);
void printVals(Integral_с auto... vals)
{
    std::print("Integrals: ");
    (std::print("{} ", vals), ...);
    std::print("\n");
}
void printVals(GoodIntegral_c auto... vals)
{
    std::print("Proper integrals: ");
    (std::print("{} ", vals), ...);
    std::print("\n");
}
int main()
{
    // C++26
    // printVals(1, 2, 3);
    printVals(1, 2, 3, 45, 5, false);
}

Из-за этой недоработки вполне невинный вызов printVals(1, 2, 3) даёт ошибку компиляции. Хорошо, что в C++26 это исправлено, но очень плохо, что целых шесть лет код, использующий абсолютно привычный для C++-программистов функционал шаблонов с переменным количеством параметров, не работал с концепциями.

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

Заключение

Итак, многострадальные концепции наконец-то добрались до C++. Да, в пути их сильно потрепало, и смогли дойти лишь ограничения, но не менять же из-за этого имя, право слово! Думаю, что появлению ограничений мы должны быть благодарны другому нововведению C++20: диапазонам (англ. ranges), потому что они сильно зависели от наличия ограничений в языке. Да, библиотека ranges-v3, которая является прототипом диапазонов, написана без их использования, но комитету, очевидно, хватило ума всё-таки дать возможность реализовать библиотеку с помощью мощного инструмента, а не набора костылей.

Основные достоинства ограничений можно свести к следующим пунктам:

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

  2. Короткие и понятные ошибки. Это, конечно, идеал, который пока что не достигнут. Ошибки стали значительно лучше, в основном за счёт того, что теперь они указывают на интерфейс, а не в «кишки» реализации. Но тут есть куда двигаться разработчикам компиляторов: уменьшать визуальный шум вокруг непосредственно ошибки, а также улучшать конкретику, чтобы при нарушении требования внутри requires-выражения компилятор указывал на то, что было нарушено. Достигнут ли ошибки такого уровня когда-то? Понятия не имею, но очень хотелось бы.

  3. Ограничения значительно менее затратны для компилятора. Инстанциация шаблона класса — это очень дорого, а они часто используются при написании метафункций, которые, следовательно, тоже очень дороги. Ограничения могут вполне обойтись без шаблонов-классов, используя метафункции, написанные через объекты, а не классы (или вообще обойтись без метафункций для определённых задач). Поэтому код с их использованием может собираться значительно быстрее. Вишенкой на торте можно считать то, что и в декорир��вание имён (англ. name mangling) ограничения дают результат лучше, что может ускорить компоновку программы.

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

В общем и целом же, я нахожу ограничения главным функционалом C++20, который находится в тени только модулей, но т. к. модули всё ещё находятся в анабиозе, тень эта не видна. Поэтому, лично для меня, минимальным стандартом C++-проекта является C++20, потому что уж слишком большой вехой стали ограничения. И пусть некоторые стенают, что концепции не дотянули до Rust traits, и пытаются их эмулировать, я в этом никакого смысла не вижу. Я получил очень мощный инструмент ограничения шаблонных интерфейсов, а концепции, как и раньше, лучше всего писать с использованием старых добрых C++-классов.


[1] В настоящем тексте курсивом будут выделяться исключительно C++-концепции. Если нет выделения, то разговор идёт не о них.

[2] same_as не противоречит этим словам Страуструпа, но, с моей точки зрения, этот предикат противоречит словам о том, что концепция должна быть реализацией фундаментальной концепции.

[3] Нет, FormattableWithSort_c было бы ничем не лучше.

[4] Я считаю, что std::same_as является требованием (или функцией), а не ограничением, но его очень удобно использовать в простых примерах, поэтому я его и взял. Тем более, что у него нет суффикса, а значит, _r в запрещённое место не пробрался .

[5] Этот пример тоже надуманный, потому что для получения размера достаточно получать контейнер по const&, но для экспозиции сойдёт.

[6] Четвёртым можно считать создание специального требования под эту задачу, но я его даже не рассматриваю — обобщённые конструкции не должны решать частные случаи.

[7] На самом деле речь идёт о выражении, метафункция — это лишь частный случай.