Стандарт C++20: обзор новых возможностей C++. Часть 3 «Концепты»

от автора

25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.

При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным и растянулся на почти 2,5 часа. Для вашего удобства текст мы разбили на шесть частей:

  1. Модули и краткая история C++.
  2. Операция «космический корабль».
  3. Концепты.
  4. Ranges.
  5. Корутины.
  6. Другие фичи ядра и стандартной библиотеки. Заключение.

Это третья часть, рассказывающая о концептах и ограничениях в современном C++.

Концепты

Мотивация

Обобщённое программирование — ключевое преимущество C++. Я знаю не все языки, но ничего подобного и на таком уровне не видел.

Однако у обобщённого программирования в C++ есть огромный минус: возникающие ошибки — это боль. Рассмотрим простую программу, которая сортирует вектор. Взгляните на код и скажите, где в нём ошибка:

#include <vector> #include <algorithm> struct X {     int a; }; int main() {     std::vector<X> v = { {10}, {9}, {11} };     // сортируем вектор     std::sort(v.begin(), v.end()); }

Я определил структуру X с одним полем int, наполнил вектор объектами этой структуры и пытаюсь его отсортировать.

Надеюсь, вы ознакомились с примером и нашли ошибку. Оглашу ответ: компилятор считает, что ошибка в… стандартной библиотеке. Вывод диагностики занимает примерно 60 строк и указывает на ошибку где-то внутри вспомогательного файла xutility. Прочитать и понять диагностику практически невозможно, но программисты C++ делают это — ведь пользоваться шаблонами всё равно нужно.

Компилятор показывает, что ошибка в стандартной библиотеке, но это не значит, что нужно сразу писать в Комитет по стандартизации. На самом деле ошибка всё равно в нашей программе. Просто компилятор недостаточно умный, чтобы это понять, и сталкивается с ошибкой, когда заходит в стандартную библиотеку. Распутывая эту диагностику, можно дойти до ошибки. Но это:

  • сложно,
  • не всегда возможно в принципе.

Сформулируем первую проблему обобщённого программирования на C++: ошибки при использовании шаблонов совершенно нечитаемые и диагностируются не там, где сделаны, а в шаблоне.

Другая проблема возникает, если есть необходимость использовать разные реализации функции в зависимости от свойств типа аргументов. Например, я хочу написать функцию, которая проверяет, что два числа достаточно близки друг к другу. Для целых чисел достаточно проверить, что числа равны между собой, для чисел с плавающей точкой — то, что разность меньше некоторого ε.

Задачу можно решить хаком SFINAE, написав две функции. Хак использует std::enable_if. Это специальный шаблон в стандартной библиотеке, который содержит ошибку в случае если условие не выполнено. При инстанцировании шаблона компилятор отбрасывает декларации с ошибкой:

#include <type_traits>  template <class T> T Abs(T x) {     return x >= 0 ? x : -x; }  // вариант для чисел с плавающей точкой template<class T> std::enable_if_t<std::is_floating_point_v<T>, bool> AreClose(T a, T b) {     return Abs(a - b) < static_cast<T>(0.000001); }  // вариант для других объектов template<class T> std::enable_if_t<!std::is_floating_point_v<T>, bool>  AreClose(T a, T b) {     return a == b; }

В C++17 такую программу можно упростить с помощью if constexpr, хотя это сработает не во всех случаях.

Или ещё пример: я хочу написать функцию Print, которая печатает что угодно. Если ей передали контейнер, она напечатает все элементы, если не контейнер — напечатает то, что передали. Мне придётся определить её для всех контейнеров: vector, list, set и других. Это неудобно и неуниверсально.

template<class T> void Print(std::ostream& out, const std::vector<T>& v) {     for (const auto& elem : v) {         out << elem << std::endl;     } }  // тут нужно определить функцию для map, set, list,  // deque, array…  template<class T> void Print(std::ostream& out, const T& v) {     out << v; }

Здесь SFINAE уже не поможет. Вернее, поможет, если постараться, но постараться придётся немало, и код получится монструозный.

Вторая проблема обобщённого программирования: трудно писать разные реализации одной шаблонной функции для разных категорий типов.

Обе проблемы легко решить, если добавить в язык всего одну возможность — накладывать ограничения на шаблонные параметры. Например, требовать, чтобы шаблонный параметр был контейнером или объектом, поддерживающим сравнение. Это и есть концепт.

Что у других

Посмотрим, как дела обстоят в других языках. Я знаю всего один, в котором есть что-то похожее, — Haskell.

class Eq a where 	(==) :: a -> a -> Bool 	(/=) :: a -> a -> Bool

Это пример класса типов, который требует поддержки операции «равно» и «не равно», выдающих Bool. В C++ то же самое будет реализовано так:

template<typename T> concept Eq =     requires(T a, T b) {         { a == b } -> std::convertible_to<bool>;         { a != b } -> std::convertible_to<bool>;     };

Если вы ещё не знакомы с концептами, понять написанное будет трудно. Сейчас всё объясню.

В Haskell эти ограничения обязательны. Если не сказать, что будет операция ==, то использовать её не получится. В C++ ограничения нестрогие. Даже если не прописать в концепте операцию, ей всё равно можно пользоваться — ведь раньше вообще не было никаких ограничений, а новые стандарты стремятся не нарушать совместимость с предыдущими.

Пример

Дополним код программы, в которой вы недавно искали ошибку:

#include <vector> #include <algorithm> #include <concepts>  template<class T> concept IterToComparable =      requires(T a, T b) {         {*a < *b} -> std::convertible_to<bool>;     };      // обратите внимание на IterToComparable вместо слова class template<IterToComparable InputIt> void SortDefaultComparator(InputIt begin, InputIt end) {     std::sort(begin, end); }  struct X {     int a; };  int main() {     std::vector<X> v = { {10}, {9}, {11} };     SortDefaultComparator(v.begin(), v.end()); }

Здесь мы создали концепт IterToComparable. Он показывает, что тип T — это итератор, причём указывающий на значения, которые можно сравнивать. Результат сравнения — что-то конвертируемое к bool, к примеру сам bool. Подробное объяснение — чуть позже, пока что можно не вникать в этот код.

Кстати, ограничения наложены слабые. Здесь не сказано, что тип должен удовлетворять всем свойствам итераторов: например, не требуется, чтобы его можно было инкрементировать. Это простой пример для демонстрации возможностей.

Концепт использовали вместо слова class или typename в конструкции с template. Раньше было template<class InputIt>, а теперь слово class заменили на имя концепта. Значит, параметр InputIt должен удовлетворять ограничению.

Сейчас, когда мы попытаемся скомпилировать эту программу, ошибка всплывёт не в стандартной библиотеке, а как и должно быть — в main. И ошибка понятна, поскольку содержит всю необходимую информацию:

  • Что случилось? Вызов функции с невыполненным ограничением.
  • Какое ограничение не удовлетворено? IterToComparable<InputIt>
  • Почему? Выражение ((* a) < (* b)) некорректно.

Вывод компилятора читаемый и занимает 16 строк вместо 60.

main.cpp: In function 'int main()': main.cpp:24:45: error: **use of function** 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]' **with unsatisfied constraints**    24 |     SortDefaultComparator(v.begin(), v.end());       |                                             ^ main.cpp:12:6: note: declared here    12 | void SortDefaultComparator(InputIt begin, InputIt end) {       |      ^~~~~~~~~~~~~~~~~~~~~ main.cpp:12:6: note: constraints not satisfied main.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]': main.cpp:24:45:   required from here main.cpp:6:9:   **required for the satisfaction of 'IterToComparable<InputIt>'** [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >] main.cpp:7:5:   in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >] main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because     8 |         {*a < *b} -> std::convertible_to<bool>;       |          ~~~^~~~ main.cpp:8:13: error: no match for 'operator<' (operand types are 'X' and 'X')

Добавим недостающую операцию сравнения в структуру, и программа скомпилируется без ошибок — концепт удовлетворён:

struct X {     auto operator<=>(const X&) const = default;     int a; };

Точно так же можно улучшить второй пример, с enable_if. Этот шаблон больше не нужен. Вместо него используем стандартный концепт is_floating_point_v<T>. Получим две функции: одну для чисел с плавающей точкой, другую для прочих объектов:

#include <type_traits>  template <class T> T Abs(T x) {     return x >= 0 ? x : -x; }  // вариант для чисел с плавающей точкой template<class T> requires(std::is_floating_point_v<T>) bool AreClose(T a, T b) {     return Abs(a - b) < static_cast<T>(0.000001); }  // вариант для других объектов template<class T> bool AreClose(T a, T b) {     return a == b; }

Модифицируем и функцию печати. Если вызов a.begin() и a.end() допусти́м, будем считать a контейнером.

#include <iostream> #include <vector>  template<class T> concept HasBeginEnd =      requires(T a) {         a.begin();         a.end();     };  template<HasBeginEnd T> void Print(std::ostream& out, const T& v) {     for (const auto& elem : v) {         out << elem << std::endl;     } }  template<class T> void Print(std::ostream& out, const T& v) {     out << v; }

Опять же, это неидеальный пример, поскольку контейнер — не просто что-то с begin и end, к нему предъявляется ещё масса требований. Но уже неплохо.

Лучше всего использовать готовый концепт, как is_floating_point_v из предыдущего примера. Для аналога контейнеров в стандартной библиотеке тоже есть концепт — std::ranges::input_range. Но это уже совсем другая история.

Теория

Пришло время понять, что такое концепт. Ничего сложного тут на самом деле нет:

Концепт — это имя для ограничения.

Мы свели его к другому понятию, определение которого уже содержательно, но может показаться странным:

Ограничение — это шаблонное булево выражение.

Грубо говоря, приведённые выше условия «быть итератором» или «являться числом с плавающей точкой» — это и есть ограничения. Вся суть нововведения заключается именно в ограничениях, а концепт — лишь способ на них ссылаться.

Самое простое ограничение — это true. Ему удовлетворяет любой тип.

template<class T> concept C1 = true;

Для ограничений доступны булевы операции и комбинации других ограничений:

template <class T> concept Integral = std::is_integral<T>::value;  template <class T> concept SignedIntegral = Integral<T> &&                          std::is_signed<T>::value; template <class T> concept UnsignedIntegral = Integral<T> &&                            !SignedIntegral<T>;

В ограничениях можно использовать выражения и даже вызывать функции. Но функции должны быть constexpr — они вычисляются на этапе компиляции:

template<typename T> constexpr bool get_value() { return T::value; }   template<typename T>     requires (sizeof(T) > 1 && get_value<T>()) void f(T); // #1   void f(int); // #2   void g() {     f('A'); // вызывает #2. }

И список возможностей этим не исчерпывается.

Для ограничений есть отличная возможность: проверка корректности выражения — того, что оно компилируется без ошибок. Посмотрите на ограничение Addable. В скобках написано a + b. Условия ограничения выполняются тогда, когда значения a и b типа T допускают такую запись, то есть T имеет определённую операцию сложения:

template<class T> concept Addable = requires (T a, T b) {     a + b; };

Более сложный пример — вызов функций swap и forward. Ограничение выполнится тогда, когда этот код скомпилируется без ошибок:

template<class T, class U = T> concept Swappable = requires(T&& t, U&& u) {     swap(std::forward<T>(t), std::forward<U>(u));     swap(std::forward<U>(u), std::forward<T>(t)); };

Ещё один вид ограничений — проверка корректности типа:

template<class T> using Ref = T&; template<class T> concept C = requires {     typename T::inner;      typename S<T>;          typename Ref<T>;    };

Ограничение может требовать не только корректность выражения, но и чтобы тип его значения чему-то соответствовал. Здесь мы записываем:

  • выражение в фигурных скобках,
  • ->,
  • другое ограничение.

template<class T> concept C1 = requires(T x) {     {x + 1} -> std::same_as<int>; };

Ограничение в данном случае — same_as<int>
То есть тип выражения x + 1 должен быть в точности int.

Обратите внимание, что после стрелки идёт ограничение, а не сам тип. Посмотрите ещё один пример концепта:

template<class T> concept C2 = requires(T x) {     {*x} -> std::convertible_to<typename T::inner>;     {x * 1} -> std::convertible_to<T>; };

В нём два ограничения. Первое указывает, что:

  • выражение *x корректно;
  • тип T::inner корректен;
  • тип *x конвертируется к T::inner.

В одной строчке целых три требования. Второе указывает, что:

  • выражение x * 1 синтаксически корректно;
  • его результат конвертируется к T.

Перечисленными способами можно формировать любые ограничения. Это очень весело и приятно, но вы быстро наигрались бы с ними и забыли, если бы их нельзя было использовать. А использовать ограничения и концепты можно для всего, что поддерживает шаблоны. Конечно, основное применение — это функции и классы.

Итак, мы разобрали, как нужно писать ограничения, теперь я расскажу, где можно их писать.

Ограничение для функции можно написать в трёх разных местах:

// Вместо слова class или typename в шаблонную декларацию. // Поддерживаются только концепты. template<Incrementable T> void f(T arg);  // Использовать ключевое слово requires. В таком случае их можно вставить  // в любое из двух мест. // Годится даже неименованное ограничение. template<class T> requires Incrementable<T> void f(T arg);  template<class T> void f(T arg) requires Incrementable<T>;

И есть ещё четвёртый способ, который выглядит совсем магически:

void f(Incrementable auto arg);

Тут использован неявный шаблон. До C++20 они были доступны только в лямбдах. Теперь можно использовать auto в сигнатурах любых функций: void f(auto arg). Более того, перед этим auto допустимо имя концепта, как в примере. Кстати, в лямбдах теперь доступны явные шаблоны, но об этом позже.

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

Для класса возможностей меньше — всего два способа. Но этого вполне хватает:

template<Incrementable T> class X {}; template<class T> requires Incrementable<T> class Y {};

Антон Полухин, помогавший при подготовке этой статьи, заметил, что слово requires можно использовать не только при декларировании функций, классов и концептов, но и прямо в теле функции или метода. Например, оно пригодится, если вы пишете функцию, наполняющую контейнер неизвестного заранее типа:

template<class T>  void ReadAndFill(T& container, int size) {      if constexpr (requires {container.reserve(size); }) {          container.reserve(size);      }      // заполняем контейнер  }

Эта функция будет одинаково хорошо работать как с vector, так и с list, причём для первого будет вызываться нужный в его случае метод reserve.

Полезно использовать requires и в static_assert. Так можно проверять выполнение не только обычных условий, но и корректность произвольного кода, наличие у типов методов и операций.

Интересно, что концепт может иметь несколько шаблонных параметров. При использовании концепта нужно задать все, кроме одного — того, который мы проверяем на ограничение.

template<class T, class U> concept Derived = std::is_base_of<U, T>::value;   template<Derived<Other> X> void f(X arg);

У концепта Derived два шаблонных параметра. В декларации f один из них я указал, а второй — класс X, который и проверяется. Аудитории был задан вопрос, какой параметр я указал: T или U; получилось Derived<Other, X> или Derived<X, Other>?

Ответ неочевиден: это Derived<X, Other>. Указывая параметр Other, мы указали второй шаблонный параметр. Результаты голосования разошлись:

  • правильных ответов — 8 (61.54%);
  • неправильных ответов — 5 (38.46%).

При указании параметров концепта нужно задавать все, кроме первого, а первый будет проверяться. Я долго думал, почему Комитет принял именно такое решение, предлагаю подумать и вам. Ваши идеи пишите в комментариях.

Итак, я рассказал как определять новые концепты, но это нужно не всегда — в стандартной библиотеке их уже предостаточно. На этом слайде приведены концепты, которые находятся в заголовочном файле <concepts>.

Это ещё не всё: концепты для проверки разных типов итераторов есть в <iterator>, <ranges> и других библиотеках.

Статус

«Концепты» есть везде, но в Visual Studio пока что не полностью:

  • GCC. Хорошо поддерживается с версии 10;
  • Clang. Полная поддержка в версии 10;
  • Visual Studio. Поддерживается VS 2019, но не полностью реализован requires.

Заключение

Во время трансляции мы опросили аудиторию, нравится ли ей эта функция. Результаты опроса:

  • Суперфича — 50 (92.59%)
  • Так себе фича — 0 (0.00%)
  • Пока неясно — 4 (7.41%)

Подавляющее большинство проголосовавших оценило концепты. Я тоже считаю это крутой фичей. Спасибо Комитету!

Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.

ссылка на оригинал статьи https://habr.com/ru/company/yandex_praktikum/blog/556816/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *