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

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

8bbbf61944e06a3fea9c3769c1c64fc1.jpegВадим Мишанин

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>); //< OK

static_assert(EquationArray>); //< OK

static_assert(EquationArray>); //< OK

static_assert(EquationArray>); //< OK

static_assert(EquationArray); //< 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

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