Как стать автором
Обновить

Первое впечатление от концептов

Время на прочтение 8 мин
Количество просмотров 18K


Решил разобраться с новой возможностью С++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
Теги:
Хабы:
+48
Комментарии 34
Комментарии Комментарии 34

Публикации

Истории

Работа

Программист C++
121 вакансия
QT разработчик
13 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн