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

bsuxzhf28lzu5gp_ju_iyetuin8.png

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

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

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

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

Концепты


z7lacf2kaxcebptusgyq7rzvo8c.png

Мотивация


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

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

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

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

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

3ps_qzsj3xay6epy8urfwoeeww0.jpeg

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

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

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

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

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

#include 

template 
T Abs(T x) {
    return x >= 0 ? x : -x;
}

// вариант для чисел с плавающей точкой
template
std::enable_if_t, bool>
AreClose(T a, T b) {
    return Abs(a - b) < static_cast(0.000001);
}

// вариант для других объектов
template
std::enable_if_t, bool> 
AreClose(T a, T b) {
    return a == b;
}

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

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

template
void Print(std::ostream& out, const std::vector& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

// тут нужно определить функцию для map, set, list, 
// deque, array…

template
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
concept Eq =
    requires(T a, T b) {
        { a == b } -> std::convertible_to;
        { a != b } -> std::convertible_to;
    };

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

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

Пример


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

template
concept IterToComparable = 
    requires(T a, T b) {
        {*a < *b} -> std::convertible_to;
    };
    
// обратите внимание на IterToComparable вместо слова class
template
void SortDefaultComparator(InputIt begin, InputIt end) {
    std::sort(begin, end);
}

struct X {
    int a;
};

int main() {
    std::vector v = { {10}, {9}, {11} };
    SortDefaultComparator(v.begin(), v.end());
}

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

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

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

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

  • Что случилось? Вызов функции с невыполненным ограничением.
  • Какое ограничение не удовлетворено? IterToComparable
  • Почему? Выражение ((* 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 >]' **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 >]':
main.cpp:24:45:   required from here
main.cpp:6:9:   **required for the satisfaction of 'IterToComparable'** [with InputIt = __gnu_cxx::__normal_iterator > >]
main.cpp:7:5:   in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator > >]
main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because
    8 |         {*a < *b} -> std::convertible_to;
      |          ~~~^~~~
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. Получим две функции: одну для чисел с плавающей точкой, другую для прочих объектов:
#include 

template 
T Abs(T x) {
    return x >= 0 ? x : -x;
}

// вариант для чисел с плавающей точкой
template
requires(std::is_floating_point_v)
bool AreClose(T a, T b) {
    return Abs(a - b) < static_cast(0.000001);
}

// вариант для других объектов
template
bool AreClose(T a, T b) {
    return a == b;
}

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

template
concept HasBeginEnd = 
    requires(T a) {
        a.begin();
        a.end();
    };

template
void Print(std::ostream& out, const T& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

template
void Print(std::ostream& out, const T& v) {
    out << v;
}

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

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

Теория


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

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

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

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

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

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

template concept C1 = true;

Для ограничений доступны булевы операции и комбинации других ограничений:
template 
concept Integral = std::is_integral::value;

template 
concept SignedIntegral = Integral &&
                         std::is_signed::value;
template 
concept UnsignedIntegral = Integral &&
                           !SignedIntegral;

В ограничениях можно использовать выражения и даже вызывать функции. Но функции должны быть constexpr — они вычисляются на этапе компиляции:
template
constexpr bool get_value() { return T::value; }
 
template
    requires (sizeof(T) > 1 && get_value())
void f(T); // #1
 
void f(int); // #2
 
void g() {
    f('A'); // вызывает #2.
}

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

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

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

Более сложный пример — вызов функций swap и forward. Ограничение выполнится тогда, когда этот код скомпилируется без ошибок:
template
concept Swappable = requires(T&& t, U&& u) {
    swap(std::forward(t), std::forward(u));
    swap(std::forward(u), std::forward(t));
};

Ещё один вид ограничений — проверка корректности типа:
template using Ref = T&;
template concept C =
requires {
    typename T::inner; 
    typename S;     
    typename Ref;   
};

Ограничение может требовать не только корректность выражения, но и чтобы тип его значения чему-то соответствовал. Здесь мы записываем:
  • выражение в фигурных скобках,
  • ->,
  • другое ограничение.
template concept C1 =
requires(T x) {
    {x + 1} -> std::same_as;
};

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

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

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

В нём два ограничения. Первое указывает, что:
  • выражение *x корректно;
  • тип T::inner корректен;
  • тип *x конвертируется к T::inner.

В одной строчке целых три требования. Второе указывает, что:
  • выражение x * 1 синтаксически корректно;
  • его результат конвертируется к T.

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

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

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

// Вместо слова class или typename в шаблонную декларацию.
// Поддерживаются только концепты.
template
void f(T arg);

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

template
void f(T arg) requires Incrementable;

И есть ещё четвёртый способ, который выглядит совсем магически:
void f(Incrementable auto arg);

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

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

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

template
class X {};
template
requires Incrementable
class Y {};

Антон Полухин, помогавший при подготовке этой статьи, заметил, что слово requires можно использовать не только при декларировании функций, классов и концептов, но и прямо в теле функции или метода. Например, оно пригодится, если вы пишете функцию, наполняющую контейнер неизвестного заранее типа:
template 
void ReadAndFill(T& container, int size) { 
    if constexpr (requires {container.reserve(size); }) { 
        container.reserve(size); 
    }

    // заполняем контейнер 
}

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

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

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

template
concept Derived = std::is_base_of::value;
 
template X>
void f(X arg);

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

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

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

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

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

fnkixiidmgkck_s7ece_lhq23dy.jpeg

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

h3tpptsx5yor01slxfdqfsayid0.jpeg

Статус


umotv2ecdzpuw2rtlw-glfwm8ta.png

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

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

Заключение


Во время трансляции мы опросили аудиторию, нравится ли ей эта функция. Результаты опроса:
  • Суперфича — 50 (92.59%)
  • Так себе фича — 0 (0.00%)
  • Пока неясно — 4 (7.41%)

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

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

© Habrahabr.ru