- PVSM.RU - https://www.pvsm.ru -

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

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


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

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

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


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

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

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

template<typename T>

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

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


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

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

Для демонстрации концептов я использую этот код [3], доступный по ссылке [4]. Кратко напомню, что этот код читает входные данные из файла и затем парсит их. Он использует два контейнера: 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] [5], проверяющий итераторы на прямой доступ. Полный концепт выглядит следующим образом:

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»] [6]. Вот пример вывода типов для функций:

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 запрещает создание итератора из временных встроенных массивов. Это удобно, так как предотвращает трудноуловимые ошибки. Я нашёл решение в посте [7] [Ховарда Хиннанта], (автора move-семантики [8]), предлагающего использовать 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: сообщение краткое и понятное, ясно указывающее, почему тип не удовлетворяет концепту.

Заключение

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

Автор: AlinaObIT

Источник [9]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/razrabotka/415524

Ссылки в тексте:

[1] Функция [Concepts]: https://en.cppreference.com/w/cpp/language/constraints

[2] [enable_if],: https://en.cppreference.com/w/cpp/types/enable_if

[3] этот код: https://vadimmi.substack.com/p/solving-an-advent-of-code-task-at?r=234t1q

[4] по ссылке: https://github.com/blacktea/adventofcode_2024/blob/main/src/day7.cpp

[5] [std::forward_iterator]: https://en.cppreference.com/w/cpp/iterator/forward_iterator

[6] [Скотта Мейерса «Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14»]: https://www.amazon.com/Effective-Modern-Specific-Ways-Improve/dp/1491903996

[7] посте: https://groups.google.com/a/isocpp.org/g/std-discussion/c/qFFol3PivUw

[8] автора move-семантики: https://github.com/howardhinnant

[9] Источник: https://habr.com/ru/articles/896954/?utm_source=habrahabr&utm_medium=rss&utm_campaign=896954