- PVSM.RU - https://www.pvsm.ru -
C++ шаблоны — мощный инструмент, но работать с ними бывает больно: многословные ошибки, путаница с типами и enable_if, который все усложняет. Concepts в C++20 появились, чтобы упростить жизнь разработчикам и сделать шаблонный код понятнее. В этой статье — разбор конкретного кейса: как с помощью концептов задать корректные ограничения на контейнеры, избежать ловушек с массивами и получить внятные ошибки от компилятора.
Senior Software Engineer в Motional, ментор.
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, концепты дают ясное и легко читаемое описание.
Рассмотрим сообщения об ошибках компилятора при вызове функции с неверным типом:
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
Нажмите здесь для печати.