25 февраля автор курса Разработчик C++ в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.
При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным и растянулся на почти 2,5 часа. Для вашего удобства текст мы разбили на шесть частей:
- Модули и краткая история C++.
- Операция космический корабль.
- Концепты.
- Ranges.
- Корутины.
- Другие фичи ядра и стандартной библиотеки. Заключение.
Это третья часть, рассказывающая о концептах и ограничениях в современном 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%)
Подавляющее большинство проголосовавших оценило концепты. Я тоже считаю это крутой фичей. Спасибо Комитету!
Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.