Русский
Русский
English
Статистика
Реклама

Стандарт C20 обзор новых возможностей 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, arraytemplate<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 вместо слова classtemplate<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 satisfiedmain.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]':main.cpp:24:45:   required from heremain.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%)

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

Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.
Источник: habr.com
К списку статей
Опубликовано: 12.05.2021 14:11:23
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании яндекс.практикум

Программирование

C++

It-стандарты

Яндекс.практикум

C++20

Концепты

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru