Концепты
Проблемы обычных шаблонов
Иногда нам хочется чтобы шаблон инстанцировался только от определённых типов (например vector<int&> не имеет смысла).
Почему концепты могут быть полезны
Во-первых, упрощаются сообщения об ошибках при неправильном инстанцировании (пример - std::vector<int&>).
Во-вторых, концепты компилируются быстрее обычного SFINAE. Ещё есть не SFINAE-friendly типы, которые могут удовлетворить какому-нибудь std::is_nothrow_copyable_v но по факту бросать исключение. Ещё бывает, что типы параметры типов как-то связаны между собой, но эта связь не выразима трейтами (std::sort(begin, end) ничего не скажет, если мы дадим итераторы двух разных типов).
Заметки о мотивации за концептами
Пример
В std::vector<T> , метод assign имеет 2 перегрузки:
-
template<typename It> void assign(It begin, It end) -
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 : большой стектрейс подстановки
С концептами это могло бы выглядеть лучше: можно сразу написать что неудовлетворено конкретное требование к типу
Сложный предикат на типах
Если мы хотим выразить сложный предикат для нескольких перегрузок функции:
A<T>A<T> && B<T>A<T> && B<T> && C<T>
В enable_if это будет выглядеть вот так -- чтобы корректно выбиралась ровно одна перегрузка функции:
A<T> && !B<T>A<T> && B<T> && !C<T>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 , то эта функция будет нерасширяемая, не сможем сделать ещё одну специализацию не меняя исходную функцию
Итого концепты дают нам:
- SFINAE-friendly
- Расширяемость условий
- Сложные предикаты на концептах будут выглядеть проще
- Упрощают случаи когда нужно выбирать реализацию в зависимости от свойств типа
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)