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, 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%)
Подавляющее большинство проголосовавших оценило концепты. Я тоже считаю это крутой фичей. Спасибо Комитету!
Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.
TargetSan
Если вы про именно обобщённое программирование, то какой-нибудь Haskell уделывает С++ и по удобству, и по продуктивности. И появление концептов пусть и улучшит ситуацию, но ненамного. Как минимум потому что они необязательные.
Если же вы про метапрограммирование, то примеров гораздо лучших решений хватает. Был такой язык Nemerle. Который позволял полноценное метапрограммирование с использованием квазицитат и паттерн-матчинга по частям AST. Ещё из недавно виденного — Zig и его comptime, который позволяет, к примеру, генерить обобщённые типы в отсутствии дженериков. Тот же Template Haskell, если не ошибаюсь.
Вообще же, шаблоны С++ как инструмент обобщённого или метапрограммирования далеко не предел. И далеко не идеал.
xjossy Автор
Спасибо! Да, стоило добавить, что имеются ввиду императивные строго типизированные языки и именно обобщенное, а не метапрограммирование. В функциональном мире слишком много всего интересного.
Что касается второго, то в кругах C++ активно обсуждается введение рефлексии.
Да, то что я пишу, что я другого такого языка не знаю, не значит, что его нет. Я знаю далеко не все языки)
anonymous
Речь, наверное, о реализации в мейнстримовых языках, а не в академической и маргинальной экзотике.