Как сделать SFINAE изящным и надежным

в 14:20, , рубрики: c++, sfinae, Блог компании OTUS. Онлайн-образование, Программирование

И снова здравствуйте. Делимся с вами интересной статьёй, перевод которой подготовлен специально для студентов курса «Разработчик C++».

Как сделать SFINAE изящным и надежным - 1


Сегодня у нас гостевой пост Адама Балаша (Ádám Balázs). Адам является инженером-программистом в Verizon Smart Communities Hungary и занимается разработкой видеоаналитики для встраиваемых систем. Одна из его страстей — оптимизации времени компиляции, поэтому он сразу согласился написать гостевой пост на эту тему. Вы можете найти Адама в онлайне на LinkedIn.

В серии статей о том, как сделать SFINAE изящным, мы увидели, как сделать наш SFINAE-шаблон довольно лаконичным и выразительным.

Просто взгляните на его оригинальную форму:

template<typename T>
class MyClass
{
public:
void MyClass(T const& x){}
template<typename T_ = T>
void f(T&& x,
        typename std::enable_if<!std::is_reference<T_>::value,
        std::nullptr_t>::type = nullptr){}
};

И сравните ее с этой более выразительной формой:

template<typename T>
using IsNotReference = std::enable_if_t<!std::is_reference_v<T>>;
template<typename T>
class MyClass
{
public:
    void f(T const& x){}
    template<typename T_ = T, typename = IsNotReference <T_>>
    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<code></code> 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>::type, который является void или неудавшейся подстановкой).

Как мы можем вызвать 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<unsigned int>' was not satisfied [with T = unsigned int

Второй вызов — myAbs <int> (5u) — все еще действителен, мы сообщаем компилятору тип T явно, поэтому он преобразует 5u в int.

Наконец, мы больше не можем обводить myAbs вокруг пальца: myAbs <unsigned int, true> (5u) вызывает ошибку. Неважно, если мы предоставляем в вызове значение по умолчанию или нет, часть выражения SFINAE оценивается в любом случае, потому что компилятору нужен тип аргумента анонимного значения шаблона.

Мы можем перейти к следующей проблеме — но погодите минуту! Я думаю, что мы больше не переопределяем аргумент по умолчанию для того же параметра шаблона Какова была исходная ситуация?

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 );

Он выглядит очень похоже на предыдущий код, поэтому мы можем подумать, что это тоже не сработает, но на самом деле этот код не имеет той же проблемы. Что такое IsUnsigned <T>? Bool или неудавшаяся подстановка. А что такое IsSigned <T>? То же самое, но если одно из них Bool, другое — неудавшаяся подстановка.

Это означает, что мы не переопределяем аргументы по умолчанию, так как есть только одна функция с аргументом шаблона bool, другая — неудавшаяся подстановка, поэтому она не существует.

Синтаксический сахар

Если вы предпочитаете enable_if с void, а не bool-версию, то у нас для вас есть хорошие новости — вы можете использовать следующий синтаксис:

template<code><</code> typename T >
using IsSigned = std::enable_if_t<code><</code> std::is_signed_v<code><</code> T > >;
 
template< typename T, IsSigned<code><</code> T >... >
T myAbs( T val );

Как это работает? Это шаблонная функция с бестиповой частью с переменным количеством аргументов, которая принимает в качестве аргументов шаблона ноль или более значений типа IsSigned <T> (void или неудавшаяся подстановка).

Компилятор должен знать тип значений с переменным количеством аргументов, чтобы часть 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<class T> concept bool Arithmetic() [with T = const char*]'
concept bool Arithmetic(){
^~~~~~~~~~
'std::is_arithmetic_v' evaluated to false

Я призываю всех смело использовать эти методы в производственном коде, время компиляции дешевле, чем время выполнения. Happy SFINAEing!

Автор: MaxRokatansky

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js