Концепты
Проблемы обычных шаблонов
Иногда нам хочется чтобы шаблон инстанцировался только от определённых типов (например 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)