[Перевод] Как сделать SFINAE изящным и надежным
И снова здравствуйте. Делимся с вами интересной статьёй, перевод которой подготовлен специально для студентов курса «Разработчик C++».
Сегодня у нас гостевой пост Адама Балаша (Ádám Balázs). Адам является инженером-программистом в Verizon Smart Communities Hungary и занимается разработкой видеоаналитики для встраиваемых систем. Одна из его страстей — оптимизации времени компиляции, поэтому он сразу согласился написать гостевой пост на эту тему. Вы можете найти Адама в онлайне на LinkedIn.
В серии статей о том, как сделать SFINAE изящным, мы увидели, как сделать наш SFINAE-шаблон довольно лаконичным и выразительным.
Просто взгляните на его оригинальную форму:
template
class MyClass
{
public:
void MyClass(T const& x){}
template
void f(T&& x,
typename std::enable_if::value,
std::nullptr_t>::type = nullptr){}
};
И сравните ее с этой более выразительной формой:
template
using IsNotReference = std::enable_if_t>;
template
class MyClass
{
public:
void f(T const& x){}
template>
void f(T&& x){}
};
Мы можем разумно полагать, что уже можно расслабиться и начать использовать его в производстве. Мы могли бы, он работает в большинстве случаев, но — как мы говорим об интерфейсах — наш код должен быть безопасным и надежным. Так ли это? Давайте попробуем взломать его!
Недостаток № 1: SFINAE можно обойти
Обычно SFINAE используется для отключения части кода в зависимости от условия. Это может быть очень полезно, если нам нужно реализовать, например, пользовательскую функцию abs по какой-либо причине (пользовательский арифметический класс, оптимизация для конкретного оборудования, в учебных целях и т. д.):
template< typename T >
T myAbs( T val ) {
return( ( val <= -1 ) ? -val : val );
}
int main()
{
int a{ std::numeric_limits< int >::max() };
std::cout << "a: " << a << " myAbs( a ): " << myAbs( a ) << std::endl;
}
Эта программа выводит следующее, что выглядит вполне нормально:
a: 2147483647 myAbs( a ): 2147483647
Но мы можем вызвать нашу функцию abs
с беззнаковыми аргументами T
, и эффект будет катастрофическим:
nt main()
{
unsigned int a{ std::numeric_limits< unsigned int >::max() };
std::cout << "a: " << a << " myAbs( a ): " << myAbs( a ) << std::endl;
}
Действительно, теперь программа выводит:
a: 4294967295 myAbs( a ): 1
Наша функция не была предназначена для работы с беззнаковыми аргументами, поэтому мы должны ограничить возможный набор T
с помощью SFINAE:
template< typename T >
using IsSigned = std::enable_if_t< std::is_signed_v< T > >;
template< typename T, typename = IsSigned< T > >
T myAbs( T val ) {
return( ( val <= -1 ) ? -val : val );
}
Код работает должным образом: вызов myAbs с беззнаковым типом вызывает ошибку времени компиляции:
candidate template ignored: requirement 'std::is_signed_v<
unsigned int>' was not satisfied [with T = unsigned int]
Взлом SFINAE состояния
Тогда что не так с этой функцией? Чтобы ответить на этот вопрос, мы должны проверить, как myAbs реализует SFINAE.
template< typename T, typename = IsSigned
T > >
T myAbs( T val );
myAbs
— это шаблон функции с двумя типами параметров шаблона для ввода. Первый является фактическим типом аргумента функции, второй является анонимным типом назначенным по умолчанию
(иначе IsSigned <
T>
или иначе Std::enable_if_t <
std: is_signed_v <
T>
>
, который является Std::enable_if <
std: is_signed_v <
T>, void>:: typevoid
или неудавшейся подстановкой).
Как мы можем вызвать myAbs
? Есть 3 способа:
int a{ myAbs( -5 ) };
int b{ myAbs< int >( -5 ) };
int c{ myAbs< int, void >( -5 ) };
Первый и второй вызовы незамысловаты, но третий вызывает интерес: что это за аргумент шаблона void
?
Второй параметр шаблона является анонимным, имеет тип по умолчанию, но он все еще является параметром шаблона, поэтому его можно явно указать. Является ли это проблемой? В этом случае это действительно огромная проблема. Мы можем использовать третью форму, чтобы обойти нашу SFINAE-проверку:
unsigned int d{ myAbs< unsigned int, void >( 5u ) };
unsigned int e{ myAbs< unsigned int, void >( std::numeric_limits< unsigned int >::max() ) };
Этот код прекрасно компилируется, но приводит к катастрофическим результатам, для избежания которых, мы использовали SFINAE:
a: 4294967295 myAbs( a ): 1
Мы решим эту проблему —, но сначала: есть ли другие недостатки? Что ж…
Недостаток № 2: У нас не может быть конкретных реализаций
Другое распространенное использование SFINAE — предоставление конкретных реализаций для определенных условий времени компиляции. Что, если мы не хотим полностью запретить вызов myAbs
со значениями без знака и предоставляем тривиальную реализацию для этих случаев? Мы можем использовать if constexpr в C ++ 17 (мы рассмотрим это позже), или же мы можем:
template< typename T >
using IsSigned = std::enable_if_t< std::is_signed_v< T > >;
template< typename T >
using IsUnsigned = std::enable_if_t< std::is_unsigned_v< T > >;
template< typename T, typename = IsSigned< T > >
T myAbs( T val ) {
return( ( val <= -1 ) ? -val : val );
}
template< typename T, typename = IsUnsigned< T > >
T myAbs( T val ) {
return val;
}
Но что это?
error: template parameter redefines default argument
template< typename T, typename = IsUnsigned< T > >
note: previous default template argument defined here
template< typename T, typename = IsSigned< T > >
Ой-ой, стандарт C++ (C++ 17; §17.1.16) гласит следующее:
«Аргументы по умолчанию не должны предоставляться параметру шаблона двумя разными объявлениями в одной и той же области видимости».
Упс, это именно то, что мы сделали…
Почему бы не использовать обычный if?
Мы могли бы просто использовать if во время выполнения вместо этого:
template< typename T >
T myAbs( T val ) {
if( std::is_signed_v< T > ) {
return ( ( val <= -1 ) ? -val : val );
} else {
return val;
}
}
Компилятор оптимизировал бы условие, потому что
становится if (std::is_signed_v <
T>)if (true)
или if (false)
после создания шаблона. Да, с нашей текущей реализацией myAbs
это будет работать. Но в целом это накладывает огромное ограничение: операторы if
и else
должны быть действительными для каждого T
. Что если мы немного изменим нашу реализацию:
template< typename T >
T myAbs( T val ) {
if( std::is_signed_v< T > ) {
return std::abs( val );
} else {
return val;
}
}
int main() {
unsigned int a{ myAbs( 5u ) };
}
Наш код сразу даст сбой:
error: call of overloaded ‘abs(unsigned int&)’ is ambiguous
Это ограничение — то, что устраняет SFINAE: мы можем написать код, который действителен только для подмножества T (в myAbs действителен только для беззнаковых типов или действителен только для знаковых типов).
Решение: еще одна форма для SFINAE
Что мы можем сделать, чтобы преодолеть эти недостатки? Для первой проблемы мы должны принудительно проводить нашу SFINAE-проверку независимо от того, как пользователи вызывают нашу функцию. В настоящее время нашу проверку можно обойти, когда компилятору не нужен тип по умолчанию для второго параметра шаблона.
Что если мы используем наш SFINAE-код для объявления типа параметра шаблона вместо предоставления типа по умолчанию? Давайте попробуем:
template< typename T >
using IsSigned = std::enable_if_t< std::is_signed_v< T >, bool >;
template< typename T, IsSigned< T > = true >
T myAbs( T val ) {
return( ( val <= -1 ) ? -val : val );
}
int main() {
//int a{ myAbs( 5u ) };
int b{ myAbs< int >( 5u ) };
//int c{ myAbs< unsigned int, true >( 5u ) };
}
Нам нужно, чтобы IsSigned был типом, отличным от void в допустимых случаях, потому что мы хотим предоставить значение по умолчанию для этого типа. Для типа void нет значения, поэтому мы должны использовать что-то другое: bool, int, enum, nullptr_t и т. д. Обычно я использую bool — в этом случае выражения выглядят осмысленно:
template< typename T, IsSigned< T > = true >
Оно работает! Для myAbs (5u)
компилятор выдает ошибку, как и раньше:
candidate template ignored: requirement 'std::is_signed_v' was not satisfied [with T = unsigned int
Второй вызов —
— все еще действителен, мы сообщаем компилятору тип myAbs <
int> (5u)T
явно, поэтому он преобразует 5u
в int
.
Наконец, мы больше не можем обводить myAbs
вокруг пальца:
вызывает ошибку. Неважно, если мы предоставляем в вызове значение по умолчанию или нет, часть выражения SFINAE оценивается в любом случае, потому что компилятору нужен тип аргумента анонимного значения шаблона.myAbs <
unsigned int, true> (5u)
Мы можем перейти к следующей проблеме —, но погодите минуту! Я думаю, что мы больше не переопределяем аргумент по умолчанию для того же параметра шаблона Какова была исходная ситуация?
template< typename T, typename = IsUnsigned< T > >
T myAbs( T val );
template< typename T, typename = IsSigned< T > >
T myAbs( T val );
Но теперь с текущим кодом:
template< typename T, IsUnsigned< T > = true >
T myAbs( T val );
template< typename T, IsSigned< T > = true >
T myAbs( T val );
Он выглядит очень похоже на предыдущий код, поэтому мы можем подумать, что это тоже не сработает, но на самом деле этот код не имеет той же проблемы. Что такое
? Bool или неудавшаяся подстановка. А что такое IsUnsigned <
T>
? То же самое, но если одно из них Bool, другое — неудавшаяся подстановка.IsSigned <
T>
Это означает, что мы не переопределяем аргументы по умолчанию, так как есть только одна функция с аргументом шаблона bool, другая — неудавшаяся подстановка, поэтому она не существует.
Синтаксический сахар
Если вы предпочитаете enable_if с void
, а не bool
-версию, то у нас для вас есть хорошие новости — вы можете использовать следующий синтаксис:
template<
typename T >
using IsSigned = std::enable_if_t<
std::is_signed_v<
T > >;
template< typename T, IsSigned<
T >... >
T myAbs( T val );
Как это работает? Это шаблонная функция с бестиповой частью с переменным количеством аргументов, которая принимает в качестве аргументов шаблона ноль или более значений типа
(void или неудавшаяся подстановка).IsSigned <
T>
Компилятор должен знать тип значений с переменным количеством аргументов, чтобы часть SFINAE нельзя было обойти. Фактический вызов остается таким же простым, как и раньше, потому что переменная часть принимает ноль фактических аргументов.
Старые версии C ++
Все вышеперечисленное работает с C++11, единственное отличие — многословность определений ограничений между стандартными версиями:
//C++11
template< typename T >
using IsSigned = typename std::enable_if< std::is_signed< T >::value, bool >::type;
//C++14 - std::enable_if_t
template< typename T >
using IsSigned = std::enable_if_t< std::is_signed< T >::value, bool >;
//C++17 - std::is_signed_v
template< typename T >
using IsSigned = std::enable_if_t< std::is_signed_v< T >, bool >;
Но шаблон остается прежним:
template< typename T, IsSigned< T > = true >
В старом добром C++98 нет псевдонимов шаблонов, кроме того, шаблоны функций не могут иметь типы или значения по умолчанию. Мы можем вставить наш SFINAE-код в тип результата или только в список параметров функции. Рекомендуется второй вариант, потому что конструкторы не имеют типов результатов. Лучшее, что мы можем сделать, это что-то вроде этого:
template< typename T >
T myAbs( T val, typename my_enable_if< my_is_signed< T >::value, bool >::type = true ) {
return( ( val <= -1 ) ? -val : val );
}
Просто для сравнения — современная версия C++:
template< typename T, IsSigned< T > = true >
T myAbs( T val ) {
return( ( val <= -1 ) ? -val : val );
}
Версия C++98 уродлива, вводит бессмысленный параметр, но она работает — вы можете использовать ее, если это крайне необходимо. И да: my_enable_if
и my_is_signed
должны быть реализованы (std :: enable_if и std :: is_signed
были новыми в C++11).
Современное состояние
C++17 ввел if constexpr
— способ для отбрасывания кода на основе условий во время компиляции. Оба оператора if и else должны быть синтаксически корректны, но условие будет оцениваться во время компиляции.
template< typename T >
T myAbs( T val ) {
if constexpr( std::is_signed_v< T > ) {
return( ( val <= -1 ) ? -val : val );
} else {
if constexpr( std::is_unsigned_v< T > ) {
return val;
} /*else {
static_assert( false, "T must be signed or unsigned arithmetic type." );
}*/
}
}
Как мы видим, наша функция abs стала более компактной и удобной для чтения. Однако обработка несоответствующих типов не является однозначной. Закомментированный безусловный static_assert
делает это утверждение плохо согласованным, что запрещено стандартом, независимо от того, будет оно отброшено или нет.
К счастью, существует лазейка: в шаблонных объектах отброшенные операторы не создаются, если условие не зависит от значения. Отлично!
Таким образом, единственная проблема с нашим кодом состоит в том, что он дает сбой во время определения шаблона. Если бы мы могли отложить оценку static_assert
до времени создания шаблона, проблема была бы решена: он был бы создан тогда и только тогда, когда все наши условия false. Но как мы можем отложить static_assert
до создания шаблона? Сделайте его условие зависимым от типа!
template< typename >
inline constexpr bool dependent_false_v{ false };
template< typename T >
T myAbs( T val ) {
if constexpr( std::is_signed_v< T > ) {
return( ( val <= -1 ) ? -val : val );
} else {
if constexpr( std::is_unsigned_v< T > ) {
return val;
} else {
static_assert( dependent_false_v< T >, "Unsupported type" );
}
}
}
О будущем
Мы действительно уже близки, но нужно еще немного подождать, пока C++20 принесет окончательное решение: концепции (concept)! Это полностью изменит способ использования шаблонов (и SFINAE).
В двух словах: концепции могут быть использованы для ограничения набора аргументов, которые принимаются для параметров шаблона. Для нашей функции abs мы могли бы использовать следующую концепцию:
template< typename T >
concept bool Arithmetic() {
return std::is_arithmetic_v< T >;
}
И как мы можем использовать концепции? Есть три способа:
// Многословная версия
template< typename T >
requires Arithmetic< T >()
T myAbs( T val );
// Укороченная версия
template< Arithmetic T >
T myAbs( T val );
// ОГО
Arithmetic myAbs( Arithmetic val );
Обратите внимание, что третья форма все еще объявляет функцию шаблона! Вот полная реализация myAbs в C++20:
template< typename T >
concept bool Arithmetic() {
return std::is_arithmetic_v< T >;
}
Arithmetic myAbs( Arithmetic val ) {
if constexpr( std::is_signed_v< decltype( val ) > ) {
return( ( val <= -1 ) ? -val : val );
} else {
return val;
}
}
int main()
{
unsigned int a{ myAbs( 5u ) };
int b{ myAbs< int >( 5u ) };
//std::string c{ myAbs( "d" ) };
}
Закомментированный вызов дает следующую ошибку:
error: cannot call function 'auto myAbs(auto:1) [with auto:1 = const char*]'
constraints not satisfied
within 'template concept bool Arithmetic() [with T = const char*]'
concept bool Arithmetic(){
^~~~~~~~~~
'std::is_arithmetic_v' evaluated to false
Я призываю всех смело использовать эти методы в производственном коде, время компиляции дешевле, чем время выполнения. Happy SFINAEing!