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

qdjakcxd4173w_juzv5fx3bf0_o.jpeg

Решил разобраться с новой возможностью С++20 — концептами.

Концепты (или концепции, как пишет русскоязычная Вики) — очень интересная и полезная фича, которой давно не хватало.

По сути это типизация для аргументов шаблонов.

Основная проблема шаблонов до С++20 — в них можно было подставить все что угодно, в том числе то, на что они совершенно не рассчитаны. То есть система шаблонов была совершенно нетипизирована. В результате, при передаче в шаблон неверного параметра возникали невероятно длинные и совершенно нечитаемые сообщения об ошибках. С этим пытались бороться с помощью разных языковых хаков, которые я даже упоминать не хочу (хотя приходилось сталкиваться).

Концепты призваны исправить это недоразумение. Они добавляют в шаблоны систему типизации, причем весьма мощную. И вот, разбираясь с особенностями этой системы, я стал изучать доступные материалы в интернете.

Скажу честно, я немножко в шоке:) С++ и без того сложный язык, но тут хотя-бы есть оправдание: так получилось. Метапрограммирование на шаблонах именно открыли, а не заложили при проектировании языка. А дальше, при разработке следующих версий языка, были вынуждены подстраиваться под это «открытие», так как в мире было написано очень много кода. Концепты же — принципиально новая возможность. И, как мне кажется, в их реализации уже присутствует некоторая непрозрачность. Возможно, это следствие необходимости учесть огромный объем унаследованных возможностей? Попробуем разобраться…

Общие сведения


Концепт — новая языковая сущность на основе синтаксиса шаблонов. У концепта есть имя, параметры и тело — предикат, возвращающий константное (т.е. вычисляемое на этапе компиляции) логическое значение, зависящее от параметров концепта. Вот так:

template 
concept Even = I % 2 == 0;  

template
concept FourByte = sizeof(T)==4;


Технически, концепты очень похожи на шаблонные constexpr-выражения типа bool:

template
constexpr bool EvenX = I % 2 == 0; 

template
constexpr bool FourByteX = sizeof(T)==4;


Можно даже использовать концепты в обычных выражениях:

bool b1 = Even<2>; 


Использование


Основная идея концептов — их можно использовать вместо ключевых слов typename или class в шаблонах. Как метатипы («типы для типов»). Тем самым в шаблоны привносится статическая типизация.

template
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
constexpr bool Shiftable = requires(T i) { i>>1; };


И будет работать:

bool b1 = Shiftable; // true
bool b2 = Shiftable; // false


Основное применение requires expression — создание концептов. Например, вот так можно проверить наличие полей и методов в типе. Весьма востребованный кейс.

template 
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:

requires(T v, int i) {
  { v.f(i) } -> std::same_as; 
}  


(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;
 
  // проверяем на истинность - с трейтом
  requires std::is_convertible_v;
}


При этом допускаются и вложенные 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 
  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
	requires Sortable
void sort(Cont& container);

// хвостовая декларация require (только для функций)
template
void sort(Cont& container) requires Sortable;

// имя концепта вместо typename
template
void sort(Cont& container)  


В декларации requires могут использоваться несколько предикатов, объединенных логическими операторами.

template 
  requires is_standard_layout_v && is_trivial_v
void fun(T v); 
 
int main()
{
  std::string s;
 
  fun(1);  // ok
  fun(s);  // compiler error
}


Однако, cтоит только инвертировать одно из условий, как возникнет ошибка компиляции:

template 
  requires is_standard_layout_v && !is_trivial_v
void fun(T v); 


Вот такой пример тоже не будет компилироваться

template 
  requires !is_trivial_v
void fun(T v);	


Причина этого — неоднозначности, возникающие при разборе некоторых выражений. Например в таком шаблоне:

template  
  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 
  requires (!is_trivial_v)
void fun(T v);


Теперь об особенностях предикатов в requires clause


Рассмотрим другой пример.

template 
  requires is_trivial_v 
void fun(T v); 


В этом примере в requires используется трейт, зависящий от вложенного типа value_type. Заранее неизвестно, есть ли такой вложенный тип у произвольного типа, который можно передать в шаблон. Если передать в такой шаблон например простой тип int, будет ошибка компиляции, однако если у нас есть две специализации шаблона — то ошибки не будет; просто будет выбрана другая специализация.

template 
  requires is_trivial_v 
void fun(T v) { std::cout << "1"; } 
 
template 
void fun(T v) { std::cout << "2"; } 
 
int main()
{
  fun(1);  // displays: "2"
}


Таким образом, специализация отбрасывается не только когда предикат require clause возвращает false, но и тогда, когда он оказывается некорректным.

Круглые скобки вокруг предиката являются важным напоминанием того, что в requires clause инверсия предиката не является противоположностью самого предиката. Так,

requires is_trivial_v 


означает что «трейт корректый и возвращает true». При этом

!is_trivial_v 


означало бы «трейт корректный и возвращает false»
Настоящая логическая инверсия первого предиката — НЕ («трейт корректый и возвращает true») == «трейт НЕкорректный или возвращает false» — достигается чуть более сложным образом — через явное определение концепта:

template 
concept value_type_valid_and_trivial 
  = is_trivial_v; 
 
template 
  requires (!value_type_valid_and_trivial)
void fun(T v); 


Конъюнкция и дизъюнкция


Операторы логической конъюнкции и дизъюнкции выглядят как обычно, но на самом деле работают немного иначе, чем в обычном С++.

Рассмотрим два очень похожих фрагмента кода.

Первый — предикат без скобок:

template 
  requires std::is_trivial_v
		|| std::is_trivial_v
void fun(T v, U u); 


Второй — со скобками:

template 
  requires (std::is_trivial_v
		 || std::is_trivial_v)
void fun(T v); 


Разница только в скобках. Но из-за этого во втором шаблоне не два ограничения, объединенных «requires-дизъюнкцией», а одно, объединенное обычным логическим ИЛИ.

Эта разница проявляется следующим образом. Рассмотрим код

std::optional 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

© Habrahabr.ru