Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Решил разобраться с новой возможностью С++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