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

Senior Software Engineer в Motional, ментор.
Concepts как способ приручить шаблоны
C++ известен своей склонностью генерировать многословные и загадочные ошибки компиляции, связанные с шаблонами. Длина таких ошибок может занимать несколько экранов. Это похоже на снежный ком — одна ошибка запускает другую, та — следующую, и так далее. На поиск корневой причины могут уйти часы. Моя рекомендация по расшифровке таких ошибок — сосредоточиться на самой первой.
Функция [Concepts] призвана сделать шаблонное метапрограммирование (TMP) более простым и читаемым. Концепты — это набор требований, которым должен соответствовать шаблонный тип. Например, следующий простой концепт Incrementable требует, чтобы объект поддерживал операцию инкремента:
template
concept Incrementable = requires(T t) { ++t; };
Концепты предназначены для замены enable_if и решения всех связанных с ним проблем. Благодаря концептам компиляторы выдают более понятные ошибки. Насколько неудобным может быть использование [enable_if], вы можете увидеть на примерах по ссылке.
Зачем нужен новый концепт для контейнеров?
В этой статье мы рассмотрим концепт, задающий требования к контейнерам последовательностей и встроенным массивам. Замечание: основы концептов здесь не рассматриваются. Я предполагаю, что читатель уже знаком с ними.
Для демонстрации концептов я использую этот код, доступный по ссылке. Кратко напомню, что этот код читает входные данные из файла и затем парсит их. Он использует два контейнера: std: array для вычислений на этапе компиляции и std: vector для выполнения во время выполнения. Методы solveFirstPart и solveSecondPart обрабатывают эти контейнеры. Поскольку методы должны принимать оба типа контейнеров, очевидным решением является шаблонизация типа контейнера. Простейшая реализация выглядит так:
template
constexpr size_t solveFirstPart(const Container& equations);
Тип Container является общим, и нам нужно ограничить его, чтобы гарантировать, что контейнер содержит правильный тип элементов и поддерживает forward iterator:
— Тип элемента должен быть Equation.
— Контейнер должен поддерживать итераторы std: begin и std: end для обработки объектов.
— Итераторы должны быть forward iterator.
Проверка типа элемента контейнера
Для первого условия нужен дополнительный концепт для проверки типа элемента Equation. Я использую trait std: is_same_v, который идеально подходит для этой цели:
template
concept IsEquation = std::is_same_v
Для остальных условий в STL уже имеется концепт [std: forward_iterator], проверяющий итераторы на прямой доступ. Полный концепт выглядит следующим образом:
template
concept EquationArray = requires(T a)
{
requires IsEquation
requires std::forward_iterator
requires std::forward_iterator
};
Он работает с контейнерами последовательностей, такими как std: array, std: vector, std: list, std: deque, но не с встроенными массивами:
static_assert(EquationArray
static_assert(EquationArray
static_assert(EquationArray
static_assert(EquationArray
static_assert(EquationArray
Причина в том, что для типа 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
void foo(T a) {
static_assert(std::is_same_v
static_assert(std::is_same_v
}
int main() {
Equation arr[100];
foo(arr);
}
Как это обойти?
Есть два решения. Первое — использовать ссылку на тип T:
template
concept EquationArray = requires(T& a)
Это сохраняет тип встроенного массива для параметра a, позволяя использовать std: begin.
Второе решение — создание фиктивного объекта T с использованием std: declval. Компилятор не скомпилирует: std: begin (std: declval
template
concept EquationArray = requires(T a)
{
requires IsEquation
requires std::forward_iterator
requires std::forward_iterator
};
Лично я выбрал второе решение по двум причинам:
— std: declval
— 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: сообщение краткое и понятное, ясно указывающее, почему тип не удовлетворяет концепту.
Заключение
Подводя итог, эта статья демонстрирует работу концептов на практике, показывая, как они упрощают работу с шаблонами и улучшают качество сообщений об ошибках.
Habrahabr.ru прочитано 6802 раза