Подходит к концу 2019 год. Уже скоро C++20 станет доступен во всей своей красе. Но совсем не скоро он плотно войдет в мир промышленной разработки (сюда еще даже C++14 не везде дошел).
В С++20 появятся концепции/типажи (concepts) -- долгожданная горсть синтаксического сахара, призванная дать разработчикам возможность писать шаблоны (templates), накладывая ограничения на их параметры так, чтоб потом не было мучительно больно.
Допустим, у нас есть такой простенький шаблон
template<typename T>
T sum(T a, T b) { return a + b; }
Зачем в нем навешивать какие-то ограничения? Ведь и так понятно, что над типом T должна быть определена операция сложения. А если не определена, код просто не скомпилируется. Коплилятор сам знает, где и какие ограничения. Так зачем писать больше кода, если можно писать меньше? Действительно, в таких тривиальных примерах писать больше нет никакого смысла. Если мы подставим неправильный тип, компилятор выдаст две строчки ошибки, и мы сразу все поймем... Или нет? В примере по ссылке, конечно, сразу все ясно, ведь первой строчкой указана причина наших неудач, однако, сразу за этой строчкой идет сотня других. И именно эту сотню вы скорее всего увидите первой в своем прекрасном терминале/логе IDE, если у вас не выключена автопрокрутка, конечно.
Умный компилятор до последнего перебирал все известные ему перегрузки и шаблоны для оператора +, пока они не закончились. После чего он сдался и подробно рассказал о своих тщетных попытках. Примерно так, если говорить в двух словах, работает принцип SFINAE, согласно которому, компилятор должен пробовать подставлять аргументы в разные версии шаблонов до тех пор, пока ему не удастся подставить все аргументы, либо шаблоны не закончатся.
Допустим у нас есть некий способ проверки, что типы удовлетворяют некому условию. В случае выше, будем считать, что это некоторая (мета)функция is_addable<T1, T2>
, которая проверяет, можно ли сложить a
и b
типов T1
и T2
соответственно. Посмотрим, что мы можем с ней сделать.
SFINAE работает до тех пор, пока подстановка фейлиться в заголовке функции (класса). Если компилятор смог расставить аргументы и вывести типы, он останавливается и идет внутрь тела функции/класса. Дальше SFINAE для этого шаблона не работает. Отсюда происходят два способа, как можно поставить органичение на используемые типы.
Мы можем поместить проверку в заголовке, либо в теле функции.
Начиная с C++11 в языке есть static_assert для проверок на этапе компиляции. Попробуем с ним.
template<typename T>
T sum(T a, T b) {
static_assert(is_addable<T, T>::value, "can't apply +");
return a + b;
}
Как видно лучше не стало. static_assert вызывает ошибку компиляции, но не заставляет компилятор остановить дальнейшие проверки.
В C++17 появилась дополнительная возможность условной компиляции, похожая на использование директив #if #else #endif.
template <int x>
void foo() {
if constexpr (x == 5) {
std::cout << "Hello!\n";
}else {
std::cout << "World!\n";
}
}
В зависимости от того, какое значение у x
(которое должно быть известно на этапе компиляции),
будет компилироваться только одна из веток if constexpr. (но есть нюансы).
Также, в отличие от #if деректив препроцессора, содержимое обеих веток должно соответствовать синтаксису C++, обращаться к существующим именам и т.д.
С использованием if constexpr становится значительно лучше:
template <class T>
struct always_false : std::false_type {};
template<typename T>
T sum(T a, T b) {
if constexpr (is_addable<T, T>::value) {
return a + b;
}else{
static_assert(always_false<T>::value, "+ is not supported");
}
}
Правда, нам понадобилась вспомогательная метафункция always_false
. Мы не можем написать в static_assert просто false. Иначе мы будем получать ошибку компиляции даже если наш шаблон ни разу не инстанциировался. Поэтому нам нужен false, зависящий от параметра шаблона.
Строк с отчетом об ошибке стало значительно меньше, правда появились предупреждения, что по одной из веток отсутствует return
. Что не очень хорошо. Нужно что-то вернуть. Но что? Мы не можем просто написать return T{}
. У типа может не быть конструктора по умолчанию, и компилятор заботливо накидает нам дополнительных ненужных строк в отчете об ошибке.
Можем использовать std::declval. Чтобы убрать предупреждение... Но его можно корректно использовать только в невычисляемом контексте. Конечно, в нашем случае в этой ветке кода всегда будет происходить ошибка компиляции, потому можно делать что угодно...
Кажется, пытаясь решить одну проблему, мы придумали себе множество других...
Второй вариант позволяет в принципе выкинуть из рассмотрения шаблон sum<T>(T, T)
, если тип не будет удовлетворять условию.
И тут у нас также есть несколько вариантов, как это сделать.
Если вы открывали ссылки из примеров выше, вы видели как реализована метафункция is_addable
:
template <class T1, class T2, class = void>
struct is_addable : std::false_type {};
template <class T1, class T2>
struct is_addable <T1, T2,
decltype(void(std::declval<T1>() + std::declval<T2>()))> : std::true_type {};
Довольно жутко, но ничего страшного тут нет!
Функция is_addable
объявляется как шаблон с тремя типами-параметрами. Причем третий параметр устанавливается по умолчанию в void (можно любой другой тип, можно даже вместо типа-параметра использовать значение-параметр).
Значения двух произвольных типов вообще говоря не обязаны поддерживать сложение. Поэтому общая (general) версия шаблон реализуется просто с помощью наследования от false_type
;
template <class T1, class T2, class = void>
struct is_addable : std::false_type {};
Далее идет частичная специализация нашей метафункции. Частичная, потому что специализированный шаблон продолжает зависеть от параметров (при полной специализации список параметров в угловых скобках после ключевого слова template
становится пустым).
Но теперь шаблон зависит от двух параметров вместо трех. Третий параметр зафиксирован и равен...
а чему он равен?
Рассмотрим эту штуку поближе:
decltype(void(std::declval<T1>() + std::declval<T2>()))
decltype -- получить тип выражения в скобках.
Дальше видно, что последним действием идет приведение к void. Значит, результат всегда должен быть void, делов-то! Да? А вот и нет. Приведение к void -- самая последняя операция, которая будет производиться внутри decltype
. А это значит, что у компилятора тут еще много возможностей, чтобы упереться в ошибку... А если произойдет ошибка... SFINAE! Специализированная версия шаблона будет выброшена и останется только общая версия. Это замечательно, но зачем нам это надо? Давайте посмотрим на то, что именно приводится к void:
std::declval<T1>() + std::declval<T2>()
Это как раз попытка применить сложение к объектам наших переданных типов.
Получается, если сложение можно применить, специализированная версия шаблона останется. Если нет, будет отброшена. Если сложение можно применить, у компилятора будет выбор между общей версией шаблона и специализированной. Если нет, то только общая версия.
А специализированная версия всегда имеет больший приоритет, чем неспециализированная.
Таким образом, если сложение можно применить, компилятор остановится на специализированной версии шаблона. Если нельзя -- на общей версии. А специализированную версию мы предусмотрительно унаследовали от true_type
. И так мы получили возможность различать, применимо ли сложение, либо не применимо.
Остается лишь два вопроса:
- У нас
std::declval
. Но тут его можно вполне законно применять и ничего с нами никто не сделает. Потому что внутри скобок decltype как раз невычисляемый контекст. - Зачем было приведение к void? Тут основная хитрость. У нашего шаблона не два параметра, а три. Так как в общей версии шаблона мы в качестве третьего параметра по умолчанию указали void, чтобы наша метафункция правильно работала при указании только первых двух параметров, мы в специализации должны выставить третий параметр также в тип void. Никто не запрещает использовать любой другой тип или значение. Главное, чтоб оно совпало с выставленным по умолчанию.
Возвращаемcя к нашему примеру с функцией sum
.
Мы могли бы использовать механизам частичной специализации и тут... но не можем, потому что он не работает с функциями. А только с классами и структурами. Попробуем по-другому.
Мы не можем засунуть проверку в аргументы шаблона, как мы это сделали при реализации is_addable
. Но мы можем засунуть проверку в аргументы функции! А почему бы и нет? Это все еще не тело, так что будет работать.
Для этого нам понадобится какая-нибудь хитрость, которая приведет к ошибке, если условие не выполнится. В качестве такой хитрости воспользуемся std::enable_if_t<bool Cond, typename T = void>
. Если Cond
истинно, возвращается переданный тип T
. А если ложно... то ничего не возвращается. Ошибка.
Попробуем:
template<typename T>
T sum(T a, T b, std::enable_if_t<is_addable<T,T>::value>* = nullptr) {
return a + b;
}
Мы добавили третий параметр (со заначением по-умолчанию). Имеющий тип void*
, если сложение поддерживается. И приводащий к ошибке, если нет.
Получилось более мерзко, чем было с if constexpr
, но лучше, чем в самом начале.
Мы можем это немного исправить, сделав свой собственный enable_if, который дополнительно будет выдавать сообщение. Меньше строк не станет, но в отчете обо ошибке появится static_assert
по которому обычно проще искать, где произошла ошибка.
template <class T>
struct always_false : std::false_type {};
template <bool condition, class T>
struct enable_if;
template <class T>
struct enable_if<true, T> {
using type = T;
};
template <class T>
struct enable_if<false, T> {
static_assert(always_false<T>::value, "requirements are not satisfied");
};
template <bool condition, class T = void>
using enable_if_t = typename enable_if<condition, T>::type;
Вариант с дополнительным параметром в аргументы функции плох тем, что, собственно, появляется этот параметр. Через который можно передать что-то ненужное. И получить вместо сообщения о несовпадении числа аргументов, странные сообщения о неудачных попытках приведения типов. Но у нас есть еще одно место, куда можно запихнуть проверку -- в возвращаемый тип!
template<typename T>
auto sum(T a, T b) -> enable_if_t<is_addable<T,T>::value, T> {
return a + b;
}
Число строк в отчете об ошибке не поменялось, но выглядеть это стало чуть лучше. И нет лишних параметров.
Мы можем еще переделать enable_if (и саму нашу метафункцию), чтоб получить чуть более читаемый синтаксис.
- C++14 позволяет нам объявлять шаблонные переменные. И, чтобы не писать везде ::value, переделаем нашу метафункцию следующим образом.
template <class T1, class T2, class = void>
struct is_addable_impl : std::false_type {};
template <class T1, class T2>
struct is_addable_impl <T1, T2,
decltype(void(std::declval<T1>() + std::declval<T2>()))> : std::true_type {};
template <class T1, class T2>
constexpr auto is_addable = is_addable_impl<T1, T2>::value;
- Добавим небольшую обертку
template <class T>
struct Result {
template <bool Condition>
using Requires = typename enable_if<Condition, T>::type;
};
Позволяющую нам писать такие прекрасные вещи
template<typename T>
auto sum(T a, T b) -> typename Result<T>::template Requires<is_addable<T,T>> {
return a + b;
}
- Поскольку красота этой конструкции не вызывает никаких сомнений, добавим два простеньких макроса:
#define RESULT typename Result
#define REQUIRES template Requires
И мы восхитительны!
template<typename T>
auto sum(T a, T b) -> RESULT<T>::REQUIRES<is_addable<T,T>> {
return a + b;
}
Полная версия тут