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

Автор оригинала: Ádám Balázs
  • Перевод
И снова здравствуйте. Делимся с вами интересной статьёй, перевод которой подготовлен специально для студентов курса «Разработчик C++».





Сегодня у нас гостевой пост Адама Балаша (Á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<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, другая — неудавшаяся подстановка, поэтому она не существует.

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


UPD. Данный абзац был удален автором из-за обнаружившихся в нем ошибок.

Старые версии 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!
OTUS. Онлайн-образование
799,11
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Комментарии 17

    +1
    template< typename T, IsSigned< T > = true >
    T myAbs( T val );
    Классно выглядит, но "= true" выглядит опасным, т.к. уставший разработчик может написать
    IsSigned<T> = false
    и это скомпилируется не так, как он ожидал.

    Как по мне, легче использовать trailing return type:
    template <typename T>
    using EnsureUnsigned = std::enable_if_t< !std::is_signed_v<T>, T >;
    
    template <typename T>
    auto myAbs(T val) -> EnsureUnsigned<T>
    {
        return val;
    }

    В этом случае никто не попытается написать "=false", и никому не надо думать о том, можно ли так написать.
      0

      С возвращаемым значением есть одна проблемка. В приведенном выше простом примере так можно делать. Однако если тип возвращаемого значения auto, то такой вариант не очень подходит.

        +1
        В этом случае EnsureUnsigned можно использовать так:
        template <typename T, EnsureUnsigned<T>* dummy = nullptr>
        auto myAbs(T val)
        {
            return val;
        }
        И это снова на одну граблю меньше.

        UPD: на всякий случай расшифрую, что я поднимаю под граблями в этом случае.

        Выражение «X x = val» интуитивно воспринимается, как будто туда можно впихнуть любое эквивалентное значение val: true/false, или цифру, или строку, или указатель.

        При этом, увидев указатель, программист либо подумает, что параметр — это хак, либо не поймет ничего и станет разбираться что, как и зачем туда можно запихнуть. Но скорее всего, не станет разбираться и скопипастит как есть.

        А увидев «IsUnsigned = true» может показаться, что можно написать «IsUnsigned = false». И это скомпилируется.
          0

          Можно и так, только это случай не trailing return type, а лишь улучшение описанной идеи. Опять же, это не защищает от хаков типа myAbs<int, nullptr>(v) от слова совсем. Т.е. если пользователь хочет докопаться, то он это сделает и его ничего не остановит.

            0
            Т.е. если пользователь хочет докопаться, то он это сделает и его ничего не остановит.

            Но зачем ему это делать? Если человек делает так — то он либо точно знает, зачем это делает и осознаёт последствия, либо вообще не понимает что такое программирование, и никогда не открывал cppreference

              0

              Я к тому, что это решение по сути ничем не отличается от того, что приведено в статье.

                0
                Кроме того, что оно страхует от непреднамеренного неправильного использования)
        0

        Можете пояснить непроснувшемуся мозгу (ака мне) почему вариант с =false чем-то отличается?
        Ведь там просто выводится тип bool в случае успеха, и каким дефолтным значением переменную в параметрах шаблона инициализировать (true/false) без разницы, разве нет?

          0
          В том-то и дело, что он ничем не отличается и это мне не нравится, т.к. можно непреднамеренно написать IsSigned = false и ожидать, что эта специализация будет работать для unsigned типов.

          Я считаю, что шаблонный код в продакшене часто используют те, кто не умеет его читать, и с этим приходится жить: писать так, чтобы никто с мыслью «я ничего не понял, просто поменяю true на false, вдруг сработает» не добавил еще один баг в проект.

          Там еще с enum class прикольный вариант подсказали. Только я бы опять же не yes использовал, а dummy. «yes» подразумевает, что бывает «no», когда «dummy» отражает суть — заглушка.
            0
            «yes» подразумевает, что бывает «no»

            Интересно, о чём будет думать программист, который напишет свой enum class no, в расчёте на то, что с ним будет другой результат? Или Вы имеете в виду, что символ no может чисто случайно оказаться в том же scope, и программист воспользуется им, не посмотрев?

              0
              Он будет думать про std::true_type, std::fasle_type и то, зачем здесь yes_type, который на первый взгляд похож на переизобретенный true_type.
          0
          Я для таких целей использовал enum class:

          enum class yes_t {} inline constexpr yes{};
          
          template< typename T, IsSigned< T > = yes >
          T myAbs( T val );
          
          0

          Концепты — отличная штука. Это как раз то, что нужно разработчику, чтобы определять зависимости и условия для типов. Можно накладывать условия на сами типы через concept, а можно условие на все типы в функции через requires

            0
            (не туда)
              –1
              Я правильно понял — вы придумали какую-то compile-time конструкцию, а потом в compile-же time её обошли? Ну, наверное, круто.
              Но как мы можем отложить static_assert до создания шаблона? Сделайте его условие зависимым от типа!

              А в gcc этот static_assert под if contexpress() работает?
                0
                А какой смысл в
                ( val <= -1 ) ? -val : val
                вместо классического
                ( val < 0 ) ? -val : val
                  0
                  Ну да, это даже не «какой смысл вместо», это вообще неправильно. Например, double подходит и под std::is_arithmetic и под std::is_signed, угадайте что будет, если вызвать эту myAbs() с аргументом double = -0.1.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое