Концепты

Проблемы обычных шаблонов

Иногда нам хочется чтобы шаблон инстанцировался только от определённых типов (например vector<int&> не имеет смысла).

Почему концепты могут быть полезны

Во-первых, упрощаются сообщения об ошибках при неправильном инстанцировании (пример - std::vector<int&>).

Во-вторых, концепты компилируются быстрее обычного SFINAE. Ещё есть не SFINAE-friendly типы, которые могут удовлетворить какому-нибудь std::is_nothrow_copyable_v но по факту бросать исключение. Ещё бывает, что типы параметры типов как-то связаны между собой, но эта связь не выразима трейтами (std::sort(begin, end) ничего не скажет, если мы дадим итераторы двух разных типов).

Заметки о мотивации за концептами

Пример

В std::vector<T> , метод assign имеет 2 перегрузки:

  1. template<typename It> 
    void assign(It begin, It end)
    
  2. void assign(size_t n, T elem)
    

Рассмотрим ситуацию когда имеем T = size_t

vector.assign(10, 42) -- T выведется в int и выберется первая перегрузка, но она упадет на попыткее разыменовать int

Когда человек писал такой код, понятно что он хотел вторую перегрузку

В стандартной библиотеке эту перегрузку зарезают при помощи enable_if

Тут, отсутствие требований к шаблонным параметрам приводит к таким проблемам

В целом, enable_if это прием которым тяжело пользоваться и тяжело объяснять людям, это непрямой способ наложить ограничения на тип С концептами это будет выглядеть так: template <requires is_iterator It>

Иногда подход с enable_if оказывается дороже

Более того, библиотека ranges-v3 которая в итоге вошла в C++20, имела переключатель использовать сфинае или использовать концепты, с последними она собиралась быстрее, поэтому иногда сфинае оказывается нам дороже

Как будет выглядеть ошибка

Помимо этого, представим как выглядит типичная ошибка с enable_if : большой стектрейс подстановки С концептами это могло бы выглядеть лучше: можно сразу написать что неудовлетворено конкретное требование к типу

Сложный предикат на типах

Если мы хотим выразить сложный предикат для нескольких перегрузок функции:

  1. A<T>
  2. A<T> && B<T>
  3. A<T> && B<T> && C<T>

В enable_if это будет выглядеть вот так -- чтобы корректно выбиралась ровно одна перегрузка функции:

  1. A<T> && !B<T>
  2. A<T> && B<T> && !C<T>
  3. A<T> && B<T> && C<T>

Видно, что сюда тяжело добавлять требования, нужно менять логическое выражение в каждой перегрузке

Ещё один пример

template<typaneme It>
void advance(It it&, ptrdiff_t diff) 

Эта функция делает += diff для итераторов, но к ней нет требования работать за O(1). Ее можно было бы реализовать по-наивному, делать в while сдвиг.

Но по хорошему мы могли бы рассмотреть отдельно случаи для RandomAccessIterator -- просто делаем +=, иначе -- инкрементим по единичке.

Могли бы сделать это через enable_if или tag_dispatching, В С++ 17 могли бы реализовать через if constexpr .

Но с концертами могли бы сделать максимально прямолинейно, две перегрузки, одна для RandomAccessIterator, другая для остальных.

В общем случае, если будем пользоваться SFINAE, то сначала выполнится подстановка, но внутри функция может не скомпилироваться -- это понятие называется SFINAE-friendly

Если будем пытаться делать if constexpr , то эта функция будет нерасширяемая, не сможем сделать ещё одну специализацию не меняя исходную функцию

Итого концепты дают нам:

  1. SFINAE-friendly
  2. Расширяемость условий
  3. Сложные предикаты на концептах будут выглядеть проще
  4. Упрощают случаи когда нужно выбирать реализацию в зависимости от свойств типа

Explicit vs implicit концепты

Достаточно ли написать деструктор, чтобы тип считался destructible? И не всегда можно по итератору определить, какого он вида. Тут не обойтись без explicit концептов.

Explicit concepts:

  • Плюсы:
    • Есть некоторые концепты, которые нельзя никак отличить по синтаксическим требованиям(например: InputIterator и ForwardIterator, набор операций над ними один и тот же, но ForwardIterator может несколько раз пройти по одному месту, а InputIterator - нет). Т.е в таких случаях без explicit concepts не обойтись, потому что хочется уметь различать эти два концепта.
    • Консистентность с наследованием - при наследовании явно пишем от какой базы наследуемся, и в explicit concepts будем так же явно указывать концепт типа.
  • Минусы:
    • При попытке внедрения концептов в стандартную библиотеку explicit concepts казались все большим и большим бойлерплейтом.

Плюс Implicit концептов соответственно в том что они ликвидируют бойлерплейты.

В Concept GCC пытались делать definition-checking. Это проверка того, какими свойствами должен обладать шаблонный тип (наличие оператора > и т.п.). Помимо этой проверки еще проверялось что в определении функции мы работаем только с теми свойствами, которые потребовали.

И это работало очень медленно.

concept_map

Например, vector может реализовывать концепт stack и в соответствие операциям push и pop мы могли бы поставить push_back и pop-back. Или мы могли бы сделать T* итератором, объявив для него value_type равным T и т.п. Вот это и называется concept_map.

Проблемы ранних концептов

Изначально хотели сделать систему концептов с наследованием:

concept C1 : C2 {
    ...
};

Но вскоре поняли, что какой-то безобидный концепт мог потащить за собой кучу других и нам пришлось бы реализовывать всю ненужную функциональность. Ещё одна проблема - концептуализация стандартной библиотеки. Например, std::sort использует оператор < и через него выражает все остальные пять.

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

Concepts Lite

  • Proposal Концепты вошли в язык из пропозала выше, concept_map решили убрать.

Синтаксис:

template <typename T>
concept destructible = std::is_nothrow_destructible_v<T>;

Полная форма:

template <typename T>
requires destructible<T>
void f(T&);

Короткая форма:

template <destructible T>
void f(T&);

Можно даже ещё короче:

void f(destructible auto&);

В общем случае объявление выглядит так:

// Запись:
template<C X>
// Эквивалентна
template<typename X>
requires C<X>

// А запись
template<C auto X>
// Эквивалентна
template<auto X>
requires C<X>

Выражение вида requires expr называется requires clause.

Как проверить, что тип поддерживает какую-то операцию? Для этого существует requires expression:

template <typename T>
concept comparable = requires(T a)
// compound requirement
{
 {a < a} -> std::convertible_to<bool>;
};

Внутри requires expression можно проверить валидность и свойства какого-то выражения (как в примере выше - compound requirement), наличие функции (begin(a) - simple requirement) либо наличие типа (typename T::value_type - т.н. type requirement).

Специализации (или как это здесь называется)

Рассмотрим варианты функции advance:

void advance(input_iterator auto&, ptrdiff_t);
void advance(random_access_iterator auto&, ptrdiff_t);

Как компилятор понимает, какая специализация "уже"? Для этого в стандарте определяется специальный алгоритм (см. partial ordering of constraints).

Применение

С 34 минуты второй лекции начинается godbolt (ничего не видно, но можно послушать): примеры с comparable, свой same_as. Потом обсуждается презентация Андрея Давыдова по концептам (пример с дефолтным конструктором pair). Проблема enable_if в конструкторах - выключать их не просто. Например, мы решали эту проблему в optional наследованием + conditional и т.д. Зато с помощью концептов эта проблема решается тривиально:

template<typename T> class optional {
    ...
    optional(optional const&) requires(!CopyConstructible<T>) = delete;
    optional(optional const&) requires(TriviallyCopyConstructible<T>) = default;
    optional(optional const&) noexcept(NothrowCopyConstructible<T>) { ... }
    ...
}

Поддержка у компиляторов

gcc-10, clang-10, msvc (2019.09-2021.03)