Решил разобраться с новой возможностью С++20 — концептами.
Концепты (или концепции, как пишет русскоязычная Вики) — очень интересная и полезная фича, которой давно не хватало.
По сути это типизация для аргументов шаблонов.
Основная проблема шаблонов до С++20 — в них можно было подставить все что угодно, в том числе то, на что они совершенно не рассчитаны. То есть система шаблонов была совершенно нетипизирована. В результате, при передаче в шаблон неверного параметра возникали невероятно длинные и совершенно нечитаемые сообщения об ошибках. С этим пытались бороться с помощью разных языковых хаков, которые я даже упоминать не хочу (хотя приходилось сталкиваться).
Концепты призваны исправить это недоразумение. Они добавляют в шаблоны систему типизации, причем весьма мощную. И вот, разбираясь с особенностями этой системы, я стал изучать доступные материалы в интернете.
Скажу честно, я немножко в шоке:) С++ и без того сложный язык, но тут хотя бы есть оправдание: так получилось. Метапрограммирование на шаблонах именно открыли, а не заложили при проектировании языка. А дальше, при разработке следующих версий языка, были вынуждены подстраиваться под это «открытие», так как в мире было написано очень много кода. Концепты же — принципиально новая возможность. И, как мне кажется, в их реализации уже присутствует некоторая непрозрачность. Возможно, это следствие необходимости учесть огромный объем унаследованных возможностей? Попробуем разобраться…
Общие сведения
Концепт — новая языковая сущность на основе синтаксиса шаблонов. У концепта есть имя, параметры и тело — предикат, возвращающий константное (т.е. вычисляемое на этапе компиляции) логическое значение, зависящее от параметров концепта. Вот так:
template<int I>
concept Even = I % 2 == 0;
template<typename T>
concept FourByte = sizeof(T)==4;
Технически, концепты очень похожи на шаблонные constexpr-выражения типа bool:
template<int I>
constexpr bool EvenX = I % 2 == 0;
template<typename T>
constexpr bool FourByteX = sizeof(T)==4;
Можно даже использовать концепты в обычных выражениях:
bool b1 = Even<2>;
Использование
Основная идея концептов — их можно использовать вместо ключевых слов typename или class в шаблонах. Как метатипы («типы для типов»). Тем самым в шаблоны привносится статическая типизация.
template<FourByte T>
void foo(T const & t) {}
Теперь, если мы используем в качестве шаблонного параметра int, то код в подавляющем большинстве случаев скомпилируется; а если double, то будет выдано краткое и понятное сообщение об ошибке. Простая и понятная типизация шаблонов, пока все ок.
requires
Это новое «контекстное» ключевое слово С++20, имеющее двойное назначение: requires clause и requires expression. Как будет показано далее, эта странная экономия на ключевых словах приводит к некоторой путанице.
requires expression
Сначала рассмотрим requires expression. Идея весьма неплоха: это слово имеет блок в фигурных скобках, код внутри которого оценивается на компилируемость. Правда, код там должен быть написан не на С++, а на специальном языке, близком к С++, но имеющем свои особенности (это первая странность, вполне можно было сделать и просто С++ код).
Если код корректный — requires expression возвращает true, иначе false. Сам код разумеется не попадает на кодоненерацию никогда, примерно как выражения в sizeof или decltype.
К сожалению, слово контекстное и работает только внутри шаблонов, то есть вне шаблона вот такое не скомпилируется:
bool b = requires { 3.14 >> 1; };
А в шаблоне — пожалуйста:
template<typename T>
constexpr bool Shiftable = requires(T i) { i>>1; };
И будет работать:
bool b1 = Shiftable<int>; // true
bool b2 = Shiftable<double>; // false
Основное применение requires expression — создание концептов. Например, вот так можно проверить наличие полей и методов в типе. Весьма востребованный кейс.
template <typename T>
concept Machine =
requires(T m) { // любая переменная `m` типа, удовлетворяющего концепту Machine
m.start(); // должна иметь метод `m.start()`
m.stop(); // и метод `m.stop()`
};
Кстати, все переменные, которые могут потребоваться в тестируемом коде (не только параметры шаблона), нужно объявлять в круглых скобках requires expression. Просто так объявить переменную почему-то нельзя.
Проверка типов внутри requires
Здесь начинаются отличия requires-кода от стандартного С++. Для проверки возвращаемых типов используется специальный синтаксис: объект берется в фигурные скобки, ставится стрелка и после нее пишется концепт, которому должен удовлетворять тип. Причем использование непосредственно типов не допускается.
Проверяем, что возврат функции может быть сконвертирован к int:
requires(T v, int i) {
{ v.f(i) } -> std::convertible_to<int>;
}
Проверяем, что возврат функции в точности равен int:
requires(T v, int i) {
{ v.f(i) } -> std::same_as<int>;
}
(std::same_as и std::convertible_to это концепты из стандартной библиотеки).
Если не заключить выражение, тип которого проверяется, в фигурные скобки, компилятор не поймет что от него хотят и интерпретирует всю строку как единое выражение, которое нужно проверить на компилируемость.
requires внутри requires
Ключевое слово requires имеет специальное значение внутри выражений requires. Вложенные requires-выражения (уже без фигурных скобок) проверяются уже не на компилируемость, а на равенство true или false. Если такое выражение окажется false, то и объемлющее выражение немедленно окажется false (и дальнейший анализ компилируемости прерывается). Общий вид:
requires {
expression; // expression is valid
requires predicate; // predicate is true
};
В качестве предиката могут использоваться например ранее определенные концепты или свойства типов (type traits). Пример:
requires(Iter it) {
// проверяем код на валидность (что для типа Iter допустимы операции * и ++)
*it++;
// проверяем на истинность - с концептом
requires std::convertible_to<decltype(*it++), typename Iter::value_type>;
// проверяем на истинность - с трейтом
requires std::is_convertible_v<decltype(*it++), typename Iter::value_type>;
}
При этом допускаются и вложенные requires-выражения с кодом в фигурных скобках, который проверяется именно на валидность. Однако если записать просто одно requires-выражение внутри другого, то вложенное выражение (всё в целом, включая вложенное ключевое слово requires) будет просто проверено на валидность:
requires (T v) {
requires (typename T::value_type x) { ++x; }; // это ВЫРАЖЕНИЕ а не предикат,
// оно просто проверяется на валидность!
};
Поэтому возникла странная форма с двойным requires:
requires (T v) {
requires requires (typename T::value_type x) { ++x; }; // вот теперь на валидность будет проверено "++x"
};
Вот такая вот забавная escape-последовательность из «requires».
Кстати, еще одно сочетание двух requires — на этот раз clause (см. далее) и expression:
template <typename T>
requires requires(T x, T y) { bool(x < y); }
bool equivalent(T const& x, T const& y)
{
return !(x < y) && !(y < x);
};
requires clause
Теперь перейдем к еще одному использованию слова requires — для декларации ограничений шаблонного типа. Это альтернатива использованию имен концептов вместо typename. В следующем примере все три способа эквивалентны:
// декларация require
template<typename Cont>
requires Sortable<Cont>
void sort(Cont& container);
// хвостовая декларация require (только для функций)
template<typename Cont>
void sort(Cont& container) requires Sortable<Cont>;
// имя концепта вместо typename
template<Sortable Cont>
void sort(Cont& container)
В декларации requires могут использоваться несколько предикатов, объединенных логическими операторами.
template <typename T>
requires is_standard_layout_v<T> && is_trivial_v<T>
void fun(T v);
int main()
{
std::string s;
fun(1); // ok
fun(s); // compiler error
}
Однако, cтоит только инвертировать одно из условий, как возникнет ошибка компиляции:
template <typename T>
requires is_standard_layout_v<T> && !is_trivial_v<T>
void fun(T v);
Вот такой пример тоже не будет компилироваться
template <typename T>
requires !is_trivial_v<T>
void fun(T v);
Причина этого — неоднозначности, возникающие при разборе некоторых выражений. Например в таком шаблоне:
template <typename T>
requires (bool)&T::operator short unsigned int foo();
непонятно к чему отнести unsigned — к оператору или к прототипу функции foo(). Поэтому разработчиками было принято решение, что без круглых скобок в качестве аргументов requires clause могут использоваться только очень ограниченный набор сущностей — литералы true или false, имена полей типа bool вида value, value, T::value, ns::trait::value, имена концептов вида Concept и requires expressions. Все остальное следует заключать в круглые скобки:
template <typename T>
requires (!is_trivial_v<T>)
void fun(T v);
Теперь об особенностях предикатов в requires clause
Рассмотрим другой пример.
template <typename T>
requires is_trivial_v<typename T::value_type>
void fun(T v);
В этом примере в requires используется трейт, зависящий от вложенного типа value_type. Заранее неизвестно, есть ли такой вложенный тип у произвольного типа, который можно передать в шаблон. Если передать в такой шаблон например простой тип int, будет ошибка компиляции, однако если у нас есть две специализации шаблона — то ошибки не будет; просто будет выбрана другая специализация.
template <typename T>
requires is_trivial_v<typename T::value_type>
void fun(T v) { std::cout << "1"; }
template <typename T>
void fun(T v) { std::cout << "2"; }
int main()
{
fun(1); // displays: "2"
}
Таким образом, специализация отбрасывается не только когда предикат require clause возвращает false, но и тогда, когда он оказывается некорректным.
Круглые скобки вокруг предиката являются важным напоминанием того, что в requires clause инверсия предиката не является противоположностью самого предиката. Так,
requires is_trivial_v<typename T::value_type>
означает что «трейт корректый и возвращает true». При этом
!is_trivial_v<typename T::value_type>
означало бы «трейт корректный и возвращает false»
Настоящая логическая инверсия первого предиката — НЕ(«трейт корректый и возвращает true») == «трейт НЕкорректный или возвращает false» — достигается чуть более сложным образом — через явное определение концепта:
template <typename T>
concept value_type_valid_and_trivial
= is_trivial_v<typename T::value_type>;
template <typename T>
requires (!value_type_valid_and_trivial<T>)
void fun(T v);
Конъюнкция и дизъюнкция
Операторы логической конъюнкции и дизъюнкции выглядят как обычно, но на самом деле работают немного иначе, чем в обычном С++.
Рассмотрим два очень похожих фрагмента кода.
Первый — предикат без скобок:
template <typename T, typename U>
requires std::is_trivial_v<typename T::value_type>
|| std::is_trivial_v<typename U::value_type>
void fun(T v, U u);
Второй — со скобками:
template <typename T, typename U>
requires (std::is_trivial_v<typename T::value_type>
|| std::is_trivial_v<typename U::value_type>)
void fun(T v, U u);
Разница только в скобках. Но из-за этого во втором шаблоне не два ограничения, объединенных «requires-дизъюнкцией», а одно, объединенное обычным логическим ИЛИ.
Эта разница проявляется следующим образом. Рассмотрим код
std::optional<int> oi {};
int i {};
fun(i, oi);
Здесь шаблон инстанцируется типами int и std::optional.
В первом случае тип int::value_type невалидный, и первое ограничение тем самым не удовлетворяется.
Но тип optional::value_type валидный, второй трейт возвращает true, а поскольку между ограничениями стоит оператор ИЛИ, то весь предикат в целом удовлетворяется.
Во втором случае это единое выражение, содержащее невалидный тип, из-за чего оно оказывается невалидно в целом и предикат не удовлетворяется. Вот так простые скобки незаметно меняют смысл происходящего.
В завершение
Конечно здесь показаны далеко не все особенности концептов. Я просто не стал углубляться дальше. Но в качестве первого впечатления — очень интересная идея и несколько странная путаная реализация. И забавный синтаксис с повторяющимися requires, который реально путает. Неужели в английском языке так мало слов, что пришлось использовать одно слово для совершенно разных целей?
Идея с кодом, проверяемым на компилируемость — однозначно хорошая. Это даже чем-то похоже на «квази-цитирование» в синтаксических макросах. Но стоило ли замешивать туда особый синтаксис проверки возвращаемых типов? ИМХО, для этого просто следовало бы сделать отдельное ключевое слово.
Неявное смешивание понятий «истинно/ложно» и «компилируется/не компилируется» в одну кучу, и как следствие приколы со скобочками — тоже неправильно. Это разные понятия, и они должны существовать строго в разных контекстах (хотя я понимаю откуда это пришло — из правила SFINAE, где некомпилируемый код просто молча исключал специализацию из рассмотрения). Но если уж цель концептов — сделать код как можно более явным, то стоило ли тащить все эти неявности в новые возможности?
Статья написана в основном по материалам
akrzemi1.wordpress.com/2020/01/29/requires-expression
akrzemi1.wordpress.com/2020/03/26/requires-clause
(там рассмотрено гораздо больше примеров и интересных особенностей)
с моими добавлениями из других источников
все примеры можно проверить на wandbox.org
YChebotaev
А я вот не совсем пойму. Зачем эти шаблоны вообще нужны? По-сути, это просто особый вид параметризации, который вычисляется в момент компиляции.
Особенность такой параметризации, что там параметры имеют один тип — ссылка на класс и эта ссылка не меняется динамически (в рантайме).
Но не лучше ли просто сделать более умный компилятор, который будет оптимизировать код, опираясь на константы, и тогда «автоматически» получится и «шаблонизация». Ну, шаблонизации уже не получится, просто будет решена та же задача, которую решает шаблонизация — получение абстрактных алгоритмов и структур данных.
Кроме умного компилятора, потребуется еще одно: умение использовать rvalue в качестве декларации типа:
Типа такого. Все остальное может разрулить компилятор.
Я где-то заблуждаюсь?
NeoCode Автор
А что такое «умение использовать rvalue в качестве декларации типа»?
В вашем примере по сути тот же шаблон, только шаблонные аргументы объединены с обычными.
Я кстати давно задумывался над тем, лучше был бы такой синтаксис (и следующая из него семантика) или хуже. Пока не придумал:)
func foo(type T, const int N, int x)
против
func foo<type T, int N>(int x)
YChebotaev
Ну там, где обычно указывается тип, можно указать выражение.
Там, конечно, нужны еще дополнительные ограничения. Не любое выражения. Выражение с побочными эффектами — точно нет. Выражение должно быть возможно вычислить на этапе компиляции. Но, в целом, это все решаемо.
Да. Это то, что по-сути шаблоны и есть. Только я показал, что для них не нужен специальный синтаксис, который бы столь разительно отличался от остального синтаксиса.
Это ровно та же самая идея, что и у меня. 1 в 1.
NeoCode Автор
auto или decltype не подойдет? Внутри decltype по идее можно писать хоть с побочными эффектами, этот код все равно не попадает на кодогенерацию.
Или лучше приведите пример как бы это должно выглядеть.
YChebotaev
Ну, что-то типа этого. Только это должно автоматически делаться, без необходимость руками прописывать.
Я же привел:
DrPass
Когда-то давно-давно, разбирая исходники PHP4 из-за необходимости внести пару мелких правок в пул odbc-коннектов, я задавался тем же вопросом, разбирая шаблоны семиуровневой вложенности. Я тогда искренне возненавидел и тех, кто их придумал, и тех, кто открыл метапрограммирование с их помощью, и тех, кто это написал :)
В итоге быстрее оказалось написать собственное расширение РНР, с собственной реализацией odbc_pconnect
gecube
golang'исты с вами всеми согласны. Генерализация плохо — проще заняться кодогенерацией по шаблону )))
TargetSan
Не надо ёрничать. Проблема не в обобщениях как таковых, а в применении их не по назначению. Есть языки с работающими type traits, в которых не возникает этой каши. И есть языки с вменяемым метапрограммированием через квази-цитирование.
sborisov
Шаблоны придумал Страуструпп, кстати, по словам математиков очень круто сделал.
habr.com/ru/post/166849
habr.com/ru/post/167257
А дальше, получилось, что «джина выпустили из бутылки»…
У меня бывало, что разобраться с библиотекой на Си, гораздо проще, чем с аналогичной реализацией на С++.
Одна из цитат Линуса
0xd34df00d
Шаблоны (вернее, параметрический полиморфизм, с которым в плюсах грустно, но концепты это пытаются починить хоть как-то) по-хорошему нужны для того, чтобы некорректный код определялся и отбрасывался на этапе компиляции. Даже очень умный компилятор не сможет прочитать ваши мысли.
AnthonyMikh
Это, конечно, не про C++, но вот Zig так может.
Overlordff
Всё просто. Шаблоны имеют свои особые эффекты:
1) Увеличение ресурсов компиляции
2) Каждая новая инстанция шаблона это отдельная полноценная сущность (функция или класс). Это приводит например к тому, что адреса двух инстанций одной шаблонной функции неравны.
3) Следствие из п.2: раздувание бинарника.
Если вы уберёте все эти эффекты в неявный синтаксис, то огребёте потом отлаживать код (особенно п.2).
DrPass
Во многих других языках задачу параметризации типов решают с помощью дженериков сиречь обобщений. Как по мне, более практичный способ, который
а) не раздувает бинарник
б) успешно решает задачу параметризации
в) не даёт лишней возможности сделать это нечитабельным
mayorovp
Не раздувается бинарник разве что на управляемых языках, где в "бинарнике" хранится промежуточный байт-код.
Тут основной "затык" — именно в возможности взять адрес у функции.
YChebotaev
С шаблонами и так код не слишком просто отлаживается.
А как п.2 ведет к проблемам с отладкой я не понял. Это же по-сути типа обычная функция. А обычные функции хорошо отлаживаются и с ними нет проблем.
Если кому-то правда нужно брать указатели на функции и понимать разные они или нет — он всегда может сделать функцию-обертку.