Всё, что мы рассмотрели к настоящему моменту, выглядит исключительно косметической надстройкой над пресловутыми constexpr-объектами, к которым мы давно привыкли. Безусловно, одной этой «косметики» достаточно, чтобы предпочесть использовать концепции. Но я не зря ранее называл концепции уникальным функционалом в C++; пришло время рассмотреть то, что делает их таковыми.
Поглощение
После того как компилятор провёл процесс нормализации для каждой ограниченной шаблонной функции, которую он нашёл для вызова, приходит время, когда ему нужно выбрать лучшую. Одним из критериев выбора является более жёсткая ограниченность функции, и чтобы определить оную, ему нужно сначала упорядочить существующие ограничения.
Для установления порядка у компилятора есть ряд правил, которые описаны в 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 подходит для всех трёх функций, а значит, мы получим три нормальных формы:
Arithmetic_с:
Integral_с:
GoodIntegral_c:
Я надеюсь, никто не забыл, как сокращать дроби, потому что мы сейчас воспользуемся школьными знаниями и запишем отношения ограничений в виде трёхэтажной (от количества функций в рассмотрении) дроби; для каждой из рассматриваемых функций нормальная форма её ограничений будет на самом верху (в числителе). Правила сокращения практически такие же, как в обычной алгебре, но с несколькими нюансами, вызванными тем, что мы имеем дело с алгеброй булевой, поэтому будем называть их не сокращением, а поглощением:
поглощает и .
поглощает , потому что если истинно, то тоже. Но не поглощает , потому что истинность не означает истинности .
Поглощение происходит на всём протяжении дроби, вне зависимости от количества «этажей», т. е. , а не , как это было бы в алгебре.
означает, что все ограничения были поглощены, аналог в алгебре. Т. е. если числитель состоит из , то его убрать нельзя, а если знаменатель представляет собой , то его можно отбросить. Условимся, что у неограниченной функции ограничения пусты ().
Поглощение происходит исключительно сверху вниз, т. е. , но — это последняя форма, дальнейшее поглощение невозможно.
Разобравшись с нюансами, давайте рассмотрим процесс поглощения для всех трёх функций и начнём с Arithmetic_с:
Integral_с:
GoodIntegral_c:
Итак, в процессе поглощения мы получили как дробные, так и целый результаты. Целый результат означает, что существует ограничение, которое может поглотить любое другое из рассматриваемого списка. Дробный результат означает, что существует как минимум два ограничения, отношение между которыми установить невозможно, т. е. невозможно определить, какое из них является более ограниченным.
Используя два типа результата, правила определения более ограниченной шаблона можно сформулировать следующим образом:
Целый результат лучше дробного.
Дробные результаты несравнимы между собой.
Два пустых () результата несравнимы между собой.
Целый непустой результат лучше пустого, но, т. к. ситуация, в которой может получится и целый пустой, и целый непустой, или несколько целых непустых, невозможна, то это правило можно свести к: целый непустой результат всегда является лучшим.
Можно было бы ограничиться только последним пунктом, но три других нужны для понимания случаев, когда у компилятора нет победителя. Также, если при анализе массы функций у вас получился целый непустой результат, то анализ можно заканчивать — победитель найден. Т. к. у нас целый непустой результат получился у GoodIntegral_c, то эта функция побеждает и будет вызвана. Если же мы добавим ещё вот такую функцию:
template <GoodIntegral_c T>
requires GoodIntegral_c<T>
void printVal(T val)
{
std::println("Most Proper int: {}", val);
}То получим два целых пустых результата (приведу лишь один):
Оба будут пустыми, поэтому определить лучший среди них нельзя, а следовательно, вызов функции становится неоднозначным (англ. 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_с, ситуация поменялась, так что пришлось использовать полную запись. А из этой записи видно, что каждое написание метафункции будет иметь уникальное имя, и только через использование концепции можно в двух местах использовать одну и ту же метафункцию, сохраняя одно имя.
И хотя нормальные формы обеих 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> получим , а для SameAs<int_2, int_1> — . Очевидно, что эти нормальные формы не могут поглотить друг друга, а значит, этот код не является решением. С другой стороны, реализация через 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>: , для SameAs<int_2, int_1>: . Происходит полное поглощение, и мы ещё раз убеждаемся, насколько важно минимизировать использование метафункций при написании концепций. В идеале, при написании концепции не должно использоваться более одной неконцепции.
Но на этом наши злоключения не заканчиваются, есть ещё примеры! Например, вот такой:
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++-программистов функционал шаблонов с переменным количеством параметров, не работал с концепциями.
Таким образом, вы могли воочию убедиться, что концепции обладают уникальными свойствами, которые невозможно повторить никакими другими языковыми конструкциями, и именно поэтому я для себя выработал эту таксономию и разделил их на ограничения, требования и, возможно, функции, перестав даже пытаться думать о них как о концепциях.