Концепты в современном C ++

от автора

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


Вадим Мишанин

Senior Software Engineer в Motional, ментор. 


Concepts как способ приручить шаблоны

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

Функция [Concepts] призвана сделать шаблонное метапрограммирование (TMP) более простым и читаемым. Концепты — это набор требований, которым должен соответствовать шаблонный тип. Например, следующий простой концепт Incrementable требует, чтобы объект поддерживал операцию инкремента:

template<typename T>

concept Incrementable = requires(T t) { ++t; };

Концепты предназначены для замены enable_if и решения всех связанных с ним проблем. Благодаря концептам компиляторы выдают более понятные ошибки. Насколько неудобным может быть использование [enable_if], вы можете увидеть на примерах по ссылке.


Зачем нужен новый концепт для контейнеров?

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

Для демонстрации концептов я использую этот код, доступный по ссылке. Кратко напомню, что этот код читает входные данные из файла и затем парсит их. Он использует два контейнера: std::array для вычислений на этапе компиляции и std::vector для выполнения во время выполнения. Методы solveFirstPart и solveSecondPart обрабатывают эти контейнеры. Поскольку методы должны принимать оба типа контейнеров, очевидным решением является шаблонизация типа контейнера. Простейшая реализация выглядит так:

template<typename Container>

constexpr size_t solveFirstPart(const Container& equations);

Тип Container является общим, и нам нужно ограничить его, чтобы гарантировать, что контейнер содержит правильный тип элементов и поддерживает forward iterator:

— Тип элемента должен быть Equation.

— Контейнер должен поддерживать итераторы std::begin и std::end для обработки объектов.

— Итераторы должны быть forward iterator.


Проверка типа элемента контейнера

Для первого условия нужен дополнительный концепт для проверки типа элемента Equation. Я использую trait std::is_same_v, который идеально подходит для этой цели:

template<typename T>

concept IsEquation = std::is_same_v<T, Equation>;

Для остальных условий в STL уже имеется концепт [std::forward_iterator], проверяющий итераторы на прямой доступ. Полный концепт выглядит следующим образом:

template<typename T>

concept EquationArray = requires(T a)

{

    requires IsEquation<std::iter_value_t<decltype(std::begin(a))>>;

    requires std::forward_iterator<decltype(std::begin(a))>;

    requires std::forward_iterator<decltype(std::end(a))>;

};

Он работает с контейнерами последовательностей, такими как std::array, std::vector, std::list, std::deque, но не с встроенными массивами:

static_assert(EquationArray<std::deque<Equation>>); //< OK

static_assert(EquationArray<std::vector<Equation>>); //< OK

static_assert(EquationArray<std::array<Equation, 100>>); //< OK

static_assert(EquationArray<std::list<Equation>>); //< OK

static_assert(EquationArray<Equation [100]>); //< Compile error

Причина в том, что для типа Equation*, которым становится a, не подходит функция std::begin. В то же время T остается Equation[100]. С точки зрения инженеров C++ это удобно, так как T соответствует типу, указанному в концепте. Однако несоответствие между типами T и a может запутать.


Что происходит с типами при выводе

При выводе типа функции T и a приводятся к Equation*. Такое поведение объясняется в книге [Скотта Мейерса «Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14»]. Вот пример вывода типов для функций:

template<typename T>

void foo(T a) {

    static_assert(std::is_same_v<T, Equation*>);

    static_assert(std::is_same_v<decltype(a), Equation*>);

}

int main() {

  Equation arr[100];

  foo(arr);

}


Как это обойти?

Есть два решения. Первое — использовать ссылку на тип T:

template<typename T>

concept EquationArray = requires(T& a)

Это сохраняет тип встроенного массива для параметра a, позволяя использовать std::begin.

Второе решение — создание фиктивного объекта T с использованием std::declval. Компилятор не скомпилирует: std::begin(std::declval<T>()), поскольку STL запрещает создание итератора из временных встроенных массивов. Это удобно, так как предотвращает трудноуловимые ошибки. Я нашёл решение в посте [Ховарда Хиннанта], (автора move-семантики), предлагающего использовать std::begin(std::declval<T&>()). Финальный концепт выглядит так:

template<typename T>

concept EquationArray = requires(T a)

{

    requires IsEquation<std::iter_value_t<decltype(std::begin(std::declval<T&>()))>>;

    requires std::forward_iterator<decltype(std::begin(std::declval<T&>()))>;

    requires std::forward_iterator<decltype(std::end(std::declval<T&>()))>;

};

Лично я выбрал второе решение по двум причинам:

— std::declval<T&>() часто встречается и в других местах.

— requires(T a) является общим определением, и отклонение от него может запутать.


Как это выглядит в сигнатуре функции

После реализации концепта, функцию solveFirstPart можно применять так:

constexpr size_t solveFirstPart(const EquationArray auto& equations);

Здесь больше нет ключевого слова template. Поскольку EquationArray не является типом, он используется вместе с ключевым словом auto. Эту сигнатуру можно прочитать так: аргумент equations — это обобщенный тип, который удовлетворяет правилам концепта EquationArray. В отличие от enable_if, концепты дают ясное и легко читаемое описание.


Ошибки компиляции: GCC vs Clang

Рассмотрим сообщения об ошибках компилятора при вызове функции с неверным типом:

const Equation* parr{nullptr}; // used to demonstrate compiler errors.

solveFirstPart(parr);

GCC выдаёт компактные, но малопонятные ошибки с сообщением «requirement is not satisfied». Clang генерирует более короткие и понятные сообщения:

— GCC: сообщение сложное и многословное.

— Clang: сообщение краткое и понятное, ясно указывающее, почему тип не удовлетворяет концепту.

Заключение

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


ссылка на оригинал статьи https://habr.com/ru/articles/896954/


Комментарии

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

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