Шаблоны.
Мотивация.
Часто очень хочется делать типизированный класс - например, какую-то структуру данных для разных типов. Здесь и применяются шаблоны.
Способы жить без шаблонов.
В C шаблонов не было и было два способа жить без них
void*
.
В C есть void*
— тип указателя, который (в C, не C++) неявно преобразуется куда угодно и откуда угодно. И тогда всё выглядело бы так:
struct vector {
void push_back(void*);
void*& operator[](size_t index);
const void*& operator[](size_t index) const;
};
int main() {
point* p;
vector v;
v.push_back(p);
static_cast<point*>(v[0]);
}
У этого есть следующие проблемы:
- Это не типобезопасно. Мы можем достать из вектора не то что туда положили. И компилятор ничего не скажет. А если мы можем выявить ошибку на этапе компиляции, сто́ит это делать.
- Количество аллокаций. Если мы хотим хранить целые числа, а не указатели, в
std::vector<int>
мы тупо выделяем большой блок памяти, а в нашемvector
'е мы сначала выделяем большой блок под указатели, а потом выделяем память под каждый. К тому же подобную штуку не получится prefetch'ить, потому что память под каждый объект выделена в разных местах, а значит лишний indirection. - Следующий код не exception-safe.
vector u;
u.push_back(new point());
Макросы.
#define DEFINE_VECTOR(vector_name, type) \
struct vector_name { \
void push_back(type) { /*...*/ } \
type operator[](size_t index) const{ /*...*/ } \
};
Тут уже явно лучше, можно написать это типобезопасно, без проблем с памятью, но тоже имеются проблемы. Например, вот:
DEFINE_VECTOR(vector_int, int);
DEFINE_VECTOR(vector_int32_t, int32_t);
int main() {
vector_int v;
vector_int32_t u;
}
И теперь мы имеем две одинаковые структуры, а хотелось бы иметь одну там, где int
32-битный.
Второе — когда мы имеем DEFINE_VECTOR(int)
в двух разных местах. Потому что вам и какому-то человеку из Австралии понадобилось одно и то же. А потом кто-то подключает и то, и другое, и он проиграл.
Базовый синтаксис.
template <typename T>
// template <class T> — это то же самое
struct vector {
void push_back(T const &) { /*...*/ }
T const& operator[](size_t index) const { /*...*/ }
};
После этого пишем vector<int>
, и не получаем ни одну из проблем двух способов выше. Шаблоны можно навесить не только на класс, но и на переменную (since C++14) или функцию:
template <typename T>
void swap(T& a, T& b) {
T tmp = a;
a = b;
b = tmp;
}
При этом для функций вы не обязаны писать swap<int>(a, b)
, а можете написать просто swap(a, b)
, если переменные a
и b
уже имеют тип int
. При этом если вы подставите в эту функцию long long
и int
, вам явно напишут, что нельзя так. Более того:
template <typename Dst, typename Src>
Dst my_cast(Src s) {
return static_cast<Dst>(s);
}
int main() {
int x = 42;
my_cast(x); // Непонятно, чему равно `Dst`, ошибка.
my_cast<float>(x); // `Dst` указан явно, `Src` можно вывести, зная тип `x`.
}
Специализации.
Иногда для каких-то типов хочется сделать отдельную реализацию. Например, vector<bool>
. Это называется специализацией и делается так:
template <>
struct vector<bool> {
// ...
};
И у вас для всех типов, кроме bool
будет то, что вы написали изначально, а для bool
— специализация. При этом когда вы пишете специализацию, вы пишете целиком новый класс.
vector<T>
называется primary template, vector<bool>
— explicit (или full) specialization. А ещё есть
partial specialization — специализировать необязательно все параметры. И ещё можно особым образом специализировать. Например, мы решили, что мы можем каким-то особым образом хранить указатели:
template <typename U>
struct vector<U*> {
// ...
};
(То что тут параметр называется U
, а не тоже T
, ничего не значит.)
То есть partial specialization — это специализация, сама являющаяся шаблоном. Теперь, когда вы напишете vector<int*>
, вам дадут специализацию vector<U*>
.
Примечание: в шаблоны вы можете передавать всё что угодно (хоть void
, хоть int(int, int)
(функцию, даже не указатель на неё), хоть char[]
). Но не любой класс обязан корректно работать с любым классом. Так std::vector
не компилируется, если дать ему не перемещаемый тип, и не работает вполне корректно, если дать ему, ну, например, тип, который кидает исключение в деструкторе.
Впрочем, у вас есть возможность явно указать, какие типы вы принимать не хотите. Двумя способами: SFINAE и концепты.
Выбор специализации.
template <typename A, template B>
struct my_type {}; // 1
template <typename A>
struct my_type<A, int> {}; // 2
template <typename B>
struct my_type<int, B> {}; // 3
int main() {
my_type<float, double> fd; // Выбирается 1.
my_type<float, int> fi; // Выбирается 2.
my_type<int, double> id; // Выбирается 3.
my_type<int, int> ii; // Компилятор не может выбрать между 2 и 3. Не компилируется.
}
Хм-м-м-м, тут возникает резонный вопрос: видимо, компилятор как-то выбирает «самую хорошую» специализацию, но непонятно, как определяет, какая лучше. А давайте вот на такой пример посмотрим:
template <typename T>
struct bar {}; // Произвольный тип.
template <typename U>
struct bar<U*> {}; // Указатель на что-то.
template <typename R, typename A, typename B>
struct my_type<R (*)(A, B)> {}; // Указатель на функцию.
Здесь есть «указатель на что-то» и «указатель на функцию». Кажется, что второе более специализированно. Но как бы это формализовать? Да легко! Является ли произвольный указатель на функцию указателем? Да. А является ли произвольный указатель указателем на функцию? Нет. То есть если мы всегда можем корректно подставить одну специализацию в другую, но не наоборот, то первая более специализированна.
Вопрос: что делать, если шаблон от нескольких параметров зависит? Тут первая специализация более специализированна, чем вторая, если хотя бы по одному параметру она строго более специализированна, а по остальным — не менее.
Специализация функций.
Во-первых, у функций нет partial-специализаций. Во-вторых, есть перегрузки, и непонятно, как они со специализациями взаимодействуют.
template <typename T>
void baz(T*) {}
#if ENABLE_TEMPLATE
template <>
void baz<int>(int*) {}
#else
void baz(int*) {}
#endif
Чем отличаются две последних функции? Например, на таком коде:
int main() {
foo(nullptr);
}
Давайте подумаем, работает ли это, если мы включим ENABLE_TEMPLATE
. А вот не работает, потому что непонятно, чему равно T
. А вот с перегрузкой всё работает (выбирается перегрузка). Почему это так работает, хочется спросить?
Есть перегрузки функции. Мы их проходили, и одну видим тут: void baz(int*)
. Так вот, шаблон (весь целиком) считается ещё одной перегрузкой. При этом, когда вы вызываете функцию, происходит вот что:
- Выбирается перегрузка.
- Если выбрана шаблонная перегрузка, выбирается специализация.
Подробнее про Template argument deduction на cppreference.
Поэтому когда мы подставляем nullptr
, то из него нельзя понять, на какой тип он указывает, поэтому deduction провалится, и мы получаем ошибку. Если же есть void foo(int*)
, то выбирается он, как единственный подходящий.
Кстати, можно немного изменить работу с перегрузками. Можно вызывать функции не как foo(...)
, а как foo<>(...)
. В таком случае вы явно отбросите всё, что не является шаблоном, а значит выбирать будете только из специализаций.
Non-type template parameter.
Помимо типов в параметры шаблона можно пихать чиселки. Или любой другой примитивный тип либо enum
. Простым примером является std::array
— обёртка над C-шным массивом, который принимает два шаблонных параметра: тип, что хранить, и количество, сколько хранить:
template <typename T, size_t N>
struct array {
private:
T data[N];
public:
// ...
};
Всё также можно писать специализации:
template <typename T>
struct array<T, 0> { /*...*/ };
array<int, 10> a;
array<int, 0> a;
То же самое можно написать и для функций:
template <typename T, size_t N>
size_t size(T (&arr)[N]) {
return N;
}
Важный момент в non-type параметрах шаблона: всё, что вы подставляете в шаблон, должно быть известно на этапе компиляции. Потому что только на этапе компиляции существуют типы, в частности, шаблонные типы.
Template template parameter.
Хочется обёртку над контейнером. Зачем-то.
template </* container */ V>
struct container_wrapper {
V<int> container;
};
container_wrapper<vector> wrapper;
Это пишется вот так:
template <template <typename> class V>
struct container_wrapper {
V<int> container;
};
Используется эта штука очень редко. Правила работы с ней те же самые, что и обычно.
Параметры по умолчанию.
Шаблонные параметры могут иметь дефолтные значения, они работают как и у функций:
struct default_comparer { /*...*/ }; // Вообще он называется `std::less`.
template <typename T, typename C = default_comparer>
struct set { /*...*/ };
set<int> a; // `C` = `default_comparer`.
Зависимые имена.
Начнём немного издалека: если вы видели шаблонный код, то вам может показаться, что в случайных местах по нему раскиданы слова typename
и template
. Например, вот в таких
примерах:
typename std::vector<T>::iterator it;
// Вместо std::vector<T>::iterator it;
typename foo<T>::template bar<int> y;
// Вместо foo<T>::bar<int> y;
Зачем это? А давайте рассмотрим некоторые строки в вакууме:
(a)-b
.int b(a)
.a < b && c > d
.
Что в них написано? А вот непонятно. В зависимости от того, что такое a
, есть варианты:
(a)-b
.- Вычитание
b
из(a)
. - Приведение
-b
к типуa
.
- Вычитание
int b(a)
.- Определение переменной
b
типаint
с конструктором отa
. - Объявление функции
b
, которая принимает типa
и возвращаетint
.
- Определение переменной
a < b && c > d
.- Логическое выражение
(a < b) && (c > d)
. - Определение переменной
d
типаa<b && c>
(шаблон с non-type параметром типаbool
).
- Логическое выражение
Если a
— это тип, то одно, если не тип — то другое. И обычно компилятор это знает. Проблема в том, что в шаблонах мы можем сделать что-то такое:
template <typename T>
void foo(int x) {
(T::nested) - x;
}
Вы не узнаете, что такое T::nested
до подстановки. А очень хотите это знать, чтобы отлавливать ошибки раньше, чем подстановка (например, написав (T::nested) - y
, вы получили бы ошибку о том, что не существует y
, сразу). Поэтому вы должны явно указать, что происходит:
template <typename T>
void foo(int x) {
(T::nested) - x; // Вычитание.
(typename T::nested)-x; // Каст.
}
Аналогично с двумя другими примерами:
template <typename T>
void foo(int x) {
int b(T::nested); // Конструктор переменной.
int b(typename T::nested); // Объявление функции.
}
template <typename T>
void foo(int x) {
T::nested < b && c > d; // Логическое выражение.
T::template nested<b && c> d; // Переменная шаблонного типа.
}
При этом когда у вас есть что-то, что не зависит от шаблона (и имеет в себе nested
), компилятор сам определит, тип ли это, писать template
и typename
необязательно. То, что зависит от шаблона, называется dependent. И вот в dependent-штуках обязательно писать typename
'ы и template
'ы, а non-dependent — нет.
MSVC, кстати, долгое время делал не так (а полностью разбирал шаблонную функцию при подстановке), за что его загнобили, и больше он так не делает, а делает как все: разбирает dependent выражения при подстановке, а non-dependent — сразу. Это называется «two-phase name lookup».
Зависимые имена в базовых классах.
struct arg1 {
struct type {}; // Нельзя складывать.
};
template <typename T>
struct base {};
template <typename T>
struct derived : base<T> {
void f() {
typename T::type() + 1; // Ошибка компиляции про подстановке (dependent).
arg1::type() + 1; // Ошибка компиляции при разборе (non-dependent).
x = 5; // Непонятно.
}
};
Почему непонятно? Потому что мы можем создать специализацию base
, у которой будет x
, и будет корректно. А ещё этот x
может быть глобальной переменной. Поэтому тут происходит что-то непонятное.., хотелось бы сказать, но нет.
По стандарту компилятор ищет имя, игнорируя базовые классы (иначе он не мог бы откидывать любые неизвестные имена). Если хотим ссылаться на x
из базового класса, нужно писать явно base<T>::x
или this->x
. Тогда он, очевидно, будет dependent в обоих случаях.
В dependent-именах компилятор откладывает разбор на момент инстанцирования, даже если раньше очевидно, что есть ошибка:
template <typename T>
struct derived : base<T> {
void* x;
void f() {
this->x = 5;
}
};
Как это устроено внутри?
На лекции очень много godbolt, поэтому посмотрите запись или сами покомпилируйте.
Начнём с шаблонных функций:
template <typename T>
void swap(T& a, T& b) {
T tmp = a;
a = b;
b = tmp;
}
auto p = &swap<int>;
auto q = &swap<char>;
Для каждого типа код функции генерируется отдельно. При этом, например, чтобы сделать sizeof(swap(a, b))
, компилятору необязательно подставлять тело функции.
Немного про разные единицы трансляции.
// swap.h.
template<typename T>
int swap(T& a, T& b);
// swap.cpp.
template <typename T>
void swap(T& a, T& b) {
T tmp = a;
a = b;
b = tmp;
}
// main.cpp.
#include "swap.h"
int main(){
int a, b;
swap(a, b);
}
Такое не скомпилируется. Почему? Каждая единица трансляции транслируется отдельно, а потом всё линкуется.
Инстанцирование шаблонов (подстановка) происходит до линковки. По этой причине в swap.cpp
мы не можем сгенерить swap<int>
, потому что не знаем, что он будет использоваться, а в main.cpp
не может сгенерить, потому что нет её тела.
Можно определить тело шаблонной функции прямо в swap.h
и инклудить в разные файлы. Казалось бы, получим ошибку из-за нескольких определений, но нет. Шаблонные функции помечаются компилятором как inline
и не выдаёт ошибку, считая, что они все одинаковые.
В стандарте прописано, что инстанцирование происходит только когда необходимо. При этом компилятор может делать это в конце единицы трансляции:
template <typename T>
struct foo {
T* bar;
void baz(){
T qux;
}
};
int main() {
foo<void> a; // Так скомпилируется.
a.baz(); // Так нет, ошибка из-за `void qux;`.
}
В примере выше видно, что если в коде нет вызова функции baz
, то всё компилируется, так как она не инстанцируется.
С классами работает аналогично: полное тело класса не подставляется, если не требуется:
template <typename T>
struct foo {
T bar;
};
int main() {
foo<void>* a; // Так скомпилируется.
a->bar; // Так нет, опять ошибка из-за `void bar;`.
}
Incomplete type.
Помните incomplete type? Его упоминали в конце раздела про forward-декларации. Так вот, почему важно понимать то, что написано выше? Ну, например, мы не можем использовать std::unique_ptr
на incomplete типе. Точнее, можем, но получим ошибку, если в коде есть вызов деструктора или другие обращение к объекту.
// mytype.h
#include <memory>
struct object;
struct mytype {
std::unique_ptr<object> obj;
};
// mytype.cpp
#include "mytype.h"
struct object {
object(int, int, int) {};
};
mytype::mytype() : obj(new object(1, 2, 3)) {}
// main.cpp
#include "mytype.h"
int main(){
mytype a;
return 0;
}
Без main.cpp компилируется, так как у a
не вызывался деструктор, поэтому он не инстанцировался. С main.cpp компилятор генерирует деструктор, который вызывает деструкторы всех членов класса, а там unigue_ptr<object>
, у которого при компиляции будет инстанцироваться деструктор. В unique_ptr
есть специальная проверка, что если удаляется incomplete type (а у нас object
именно таковой), то это ошибка.
Как решить проблему? Сделать объявление деструктора в mytype.h, а определить его там, где object
— complete тип (то есть в mytype.cpp).
Ещё пример:
template <typename T>
struct base {
typename T::mytype a;
};
template <typename T>
struct derived : base<derived<T>> {
typedef T mytype;
};
derived<int> a;
Почему это не скомпилируется? Посмотрим на пример попроще:
template <typename T>
struct base {
typename T::mytype a;
};
struct derived : base<derived> {
typedef int mytype;
};
Тоже не скомпилируется с ошибкой про incomplete type derived
. Почему? Ну потому что derived
является incomplete типом, когда инстанцируется base<derived>
.
В предыдущем примере тот же самый эффект: так как derived
шаблонный, то он не инстанцируется сразу, но когда мы инстанцируем derived
, то он создаётся как incomplete (complete он станет после подстановки базовых классов), происходит подстановка base и получаем ошибку.
В конексте обсуждённого выше может быть интересно прочитать про идиому CRTP.
Явное инстанцирование шаблонов.
Есть у нас стандартный нерабочий пример:
// string.h
template <class CharT>
struct basic_string {
// ...
const CharT* c_str();
// ...
};
// string.cpp
#include "string.h"
// ...
template <class CharT>
const CharT* basic_string<T>::c_str() { /*...*/ }
// ...
// main.cpp
#include "string.h"
int main() {
basic_string<char> str("abacaba");
const char* c_str = str.c_str();
}
Мы уже знаем, что этот пример не скомпилируется, и знаем, почему. Но не знаем пока, как его можно поправить. А поправить его можно так:
// string.cpp
#include "string.h"
template const char* basic_string<char>::c_str();
// ...
template <class CharT>
const CharT* basic_string<T>::c_str() { /*...*/ }
// ...
Это явное инстанцирование шаблона, и является оно командой «прямо тут мне инстанцируйте то, что я попросил».
Подавление инстанцирования (since C++11).
Подавление явного инстанцирования, если знаем, что функции уже где-то инстанцированы и мы не хотим лишних:
extern template void foo<int>(int);
extern template void foo<float>(float);
"Выдаём тело наружу и говорим, что уже проинстанцировано, main
не будет пытаться инстанцировать функцию, так как увидит extern
и будет работать соответствующе."
Type-based dispatch.
Иногда необходимо узнавать какое-то свойство у типа. Если мы пишем обобщённое возведение в степень, нужно спрашивать, что считается единицей. Или если мы пишем операции с числами, хочется взять максимум данного типа. Очень много из такого делает стандартная библиотека: например, есть std::advance
— функция, которая делает итератору +=
, даже если он так не умеет, а умеет только ++
. И тут мы либо делаем +=
, либо ++
много раз, в зависимости от типа. Надо спросить, умеет ли итератор в +=
.
<numeric_limits>
Самые простые свойства типов — std::numeric_limits
. Это шаблонный класс, в который вы даёте численный тип, а он содержит миллион статических полей, которые для данного типа дают информацию о минимуме, максимуме или чём-то ещё.
<type_traits>
.
Более сложные запросы к типу можно найти в заголовочном файле <type_traits>
, где есть бесконечное количество шаблонных констант: is_trivially_destructible_v
, is_empty_v
, и прочих других. Какие-то из встроены в компилятор, какие-то вы можете реализовать сами (is_signed_v
, например, можете запросто).
Как работают штуки из type_traits
? И почему оканчиваются на _v
? Дело в том, что до C++14 у вас не было шаблонных переменных (а по сути is_empty_v
— шаблонная переменная и есть). Поэтому создали шаблонный класс is_empty
со статическим полем value
, в котором то, что вам нужно. А когда в С++14 такое появилось, вы смогли писать is_empty_v
, и это уже реальная bool
'евая константа, которую можно использовать.
Наивный способ делать type-based dispatch. if constexpr
.
Пример использования <type_traits>
: хотим мы вызвать деструкторы всех элементов на отрезке:
#include <type_traits>
template <class T>
void destroy(T* first, T* last) {
if (!std::is_trivially_destructible_v<T>)
for (T* p = first, p != last; p++)
p->~T();
}
Работает! Компилятор поймёт, что if
можно на этапе компиляции посчитать, и посчитает. Но такое, увы, работает не всегда. Напишем свой std::advance
:
#include <type_traits>
#include <iterator_traits>
template <class It>
void advance(It& it, ptrdiff_t n) {
using category = typename std::iterator_traits<It>::iterator_category;
// Если итератор — RandomAccess, сделаем ему +=.
if (std::is_base_of_v<std::random_access_iterator_tag, category>) {
it += n;
} else {
// Если не RandomAccess, сделаем ++ или -- несколько раз.
while (n > 0) {
--n;
++it;
}
while (n < 0) {
++n;
--it;
}
}
}
Проблема тут очевидная — компилируются всё равно обе ветки, и первая не компилируется для std::list<T>::iterator
, потому что он не умеет в +=
. В C++17 есть простое решение этой проблемы: if constexpr
— работает как if
, но только с compile-time константами, и при этом компилируется только нужная ветка. Но так сделать у вас есть возможность не всегда.
Iterator dispatch.
А давайте вот как схитрим:
#include <iterator_traits>
template <class It>
void advance_impl(It& it, ptrdiff_t n, std::random_access_iterator_tag) {
it += n;
}
template <class It>
void advance_impl(It& it, ptrdiff_t n, std::input_iterator_tag) {
while (n > 0) {
--n;
++it;
}
while (n < 0) {
++n;
--it;
}
}
template <class It>
void advance(It& it, ptrdiff_t n) {
using category = typename std::iterator_traits<It>::iterator_category;
advance_impl(it, n, category());
}
То есть мы передаём лишний параметр — одну их двух пустых структур, в зависимости от которой выбирается правильная перегрузка. Это называется iterator dispatch, и работает также хорошо, как и if constexpr
, несмотря на передачу
лишнего параметра (поскольку параметр — пустая структура, его в реальной жизни никто никуда не передаёт).
Tag dispatch.
Хорошо, что у итераторов есть теги. А что делать, если тегов нет (например, в массовом деструкторе)? Тогда их можно разве что самим создать:
struct trivially_destructible_tag {};
struct not_trivially_destructible_tag {};
template <class T>
void destroy_impl(T* first, T* last, trivially_destructible_tag) {}
template <class T>
void destroy_impl(T* first, T* last, not_trivially_destructible_tag) {
if (!std::is_trivially_destructible_v<T>)
for (T* p = first; p != last; p++)
p->~T();
}
template <class T>
void destroy(T* first, T* last) {
// Хочется как-то выбрать одну структуру-тег из двух на этапе компиляции.
}
Как выбрать одну структуру из двух на этапе компиляции? Да тривиально вообще:
template <bool Cond, typename IfTrue, typename IfFalse>
struct conditional {
using type = IfFalse;
};
template <typename IfTrue, typename IfFalse>
struct conditional<true, IfTrue, IfFalse> {
using type = IfTrue;
};
template <class T>
void destroy(T* first, T* last) {
using tag = typename conditional<is_trivially_destructible_v<T>,
trivially_destructible_tag,
not_trivially_destructible_tag>::type;
destroy_impl(first, last, tag());
}
такое уже есть, и называется std::conditional
. А typename std::conditional</*...*/>::type
также сокращается до std::conditional_t
. . Итого наш пример выглядит так:
#include <type_traits>
struct trivially_destructible_tag {};
struct not_trivially_destructible_tag {};
template <class T>
void destroy_impl(T* first, T* last, trivially_destructible_tag) {}
template <class T>
void destroy_impl(T* first, T* last, not_trivially_destructible_tag) {
if (!std::is_trivially_destructible_v<T>)
for (T* p = first; p != last; p++)
p->~T();
}
template <class T>
void destroy(T* first, T* last) {
using tag = std::conditional_t<is_trivially_destructible_v<T>,
trivially_destructible_tag,
not_trivially_destructible_tag>;
destroy_impl(first, last, tag());
}
Такая техника называется tag-dispatching, и она, несомненно, работает. Но есть у неё крупная проблема: когда у нас функции были как перегрузки, мы могли свободно добавлять в список перегрузок новые классы с новыми свойствами. А когда мы делаем это if
'ами (хоть if constexpr
, хоть std::conditional_t
), новые классы с новыми свойствами не добавить.
SFINAE.
Есть другой способ сделать похожее, основанный на поведении компилятора при выведении шаблона:
template <typename C>
void foo(C&, typename C::iterator); // 1.
template <typename T, size_t N>
void foo(T (&)[N], T*); // 2.
int main() {
std::vector<int> v;
foo(v, v.begin());
int w[10];
foo(w, w + 2);
}
С виду всё хорошо, но давайте разберём, как работает компилятор на таком коде. Сначала производится вывод параметра, а потом — подстановка. В коде выше он видит, что v
- это vector&
, а параметр - C&
, поэтому C
— это vector
. Он как бы декомпозирует типы и запускается от частей, а когда доходит до шаблонных параметров, понимает, какой тип здесь имелся в виду.
Из dependent имён выводить не можем:
template <typename T>
struct mytype {
typedef T type;
};
template <>
struct mytype<int> {
typedef char type;
};
template <typename T>
void bar(typename mytype<T>::type);
Сложность возникла из-за специализаций. Если приходит char
, то из такого не понятно, откуда он пришёл (могло из mytype<int>
, а могло из mytype<char>
), поэтому deduction не пытается выводить.
Вернёмся к первому примеру. У нас есть два вызова: от std::vector
и от C-шного массива. Рассмотрим, что с ними делает компилятор.
- Когда мы подставляем
vector
, первый шаблон не имеет проблем, а во втором даже параметры шаблона вывести не получается. - Когда мы подставляем
int[10]
, в первом шаблоне вывестиC
получается (C
равноint[10]
), но возникает ошибка при подстановке — нельзя сделатьint[10]::iterator
.
Но в обоих случаях мы не получаем ошибку компиляции, и дело тут в принципе SFINAE — substitution failure is not an error: если в процессе вывода или подстановки шаблона произошла ошибка, это не ошибка компиляции, просто данный шаблон не подходит.
std::enable_if
.
Теперь, вооружившись SFINAE, сделаем так, чтобы наш destroy
работал без if
'ов:
template <bool>
struct enable_if {};
template <>
struct enable_if<true> {
using type = void;
};
template <class T>
typename enable_if<std::is_trivially_destructible_v<T>>::type // Это возвращаемое значение.
destroy(T* first, T* last) {}
template <class T>
typename enable_if<!std::is_trivially_destructible_v<T>>::type
destroy(T* first, T* last) {
if (!std::is_trivially_destructible_v<T>)
for (T* p = first, p != last; p++)
p->~T();
}
Опять же, подобная штука в стандартной библиотеке есть, и называется std::enable_if
. Для typename enable_if</*...*/>::type
также создана короткая версия: std::enable_if_t
.
На практике SFINAE применимо где-нибудь в таком месте:
template <class T>
struct vector {
void assign(size_t count, T const& value);
template <class InputIt>
void assign(InputIt first, InputIt last);
};
int main() {
vector<size_t> v;
v.assign(10, 0); // Выбирается шаблонная перегрузка.
}
Исправляется вот так:
#include <iterator_traits>
#include <type_traits>
template <class T>
struct vector {
void assign(size_t count, T const& value);
template <class InputIt>
std::enable_if_t<
std::is_base_of_v<
std::input_iterator_tag,
std::iterator_traits<InputIt>::category
>
> assign(InputIt first, InputIt last);
};
int main() {
vector<size_t> v;
v.assign(10, 0);
}
Пара слов о концептах.
SFINAE — это длинно и неудобно, как можно было заметить. И если вам очень не нравится, в C++20 есть концепты. Пример выше с их использованием вообще пишется на ура:
#include <iterator>
template <class T>
struct vector {
void assign(size_t count, T const& value);
template <std::input_iterator InputIt>
void assign(InputIt first, InputIt last);
/* Также можно вот так:
template <class InputIt>
requires std::input_iterator<InputIt>
void assign(InputIt first, InputIt last);
После requires можно и что-то более сложное писать. */
};
int main() {
vector<size_t> v;
v.assign(10, 0);
}
У концептов есть ещё одно преимущество, помимо размера. Когда мы пользуемся SFINAE, нам необходимо перебрать все случаи перегрузок. Если вы в destroy
написали перегрузку под std::trivially_destructible
, напишите под !std::trivially_destructible
. А если вы хотите расширять, будьте добры изменить предикаты. А концепты умеют понимать, что один концепт расширяет другой, как с шаблонами. И выбирать наиболее специализированный вариант.