Cómo hacer que SFINAE sea elegante y confiable

Hola de nuevo Estamos compartiendo con ustedes un artículo interesante, una traducción de la cual fue preparada específicamente para estudiantes del curso "Desarrollador C ++" .





Hoy tenemos una publicación invitada de dám Balázs. Adam es ingeniero de software en Verizon Smart Communities Hungary y desarrolla análisis de video para sistemas integrados. Una de sus pasiones es la optimización del tiempo de compilación, por lo que inmediatamente acordó escribir una publicación invitada sobre este tema. Puedes encontrar a Adam en línea en LinkedIn .

En una serie de artículos sobre cómo hacer que SFINAE sea elegante , vimos cómo hacer que nuestra plantilla de SFINAE sea bastante concisa y expresiva .

Solo eche un vistazo a su forma original:

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


Y compárelo con esta forma más expresiva:

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

Podemos creer razonablemente que ya es posible relajarse y comenzar a usarlo en la producción. Podríamos, funciona en la mayoría de los casos, pero, a medida que hablamos de interfaces, nuestro código debe ser seguro y confiable. Es asi? ¡Intentemos hackearlo!

Defecto # 1: SFINAE puede ser evitado


Normalmente SFINAE se usa para deshabilitar parte del código dependiendo de la condición. Esto puede ser muy útil si necesitamos implementar, por ejemplo, la función abs definida por el usuario por cualquier motivo (clase aritmética definida por el usuario, optimización para un equipo específico, para fines de capacitación, etc.):

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

Este programa muestra lo siguiente, que parece bastante normal:

 a: 2147483647 myAbs( a ): 2147483647 

Pero podemos llamar a nuestra función abs con argumentos sin signo T , y el efecto será catastrófico:

 nt main() { unsigned int a{ std::numeric_limits< unsigned int >::max() }; std::cout << "a: " << a << " myAbs( a ): " << myAbs( a ) << std::endl; } 

De hecho, ahora el programa muestra:

a: 4294967295 myAbs( a ): 1

Nuestra función no fue diseñada para trabajar con argumentos sin signo, por lo que debemos limitar el posible conjunto de T con 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 ); } 

El código funciona como se esperaba: una llamada a myAbs con un tipo sin signo provoca un error en tiempo de compilación:

candidate template ignored: requirement 'std::is_signed_v< unsigned int>' was not satisfied [with T = unsigned int]

Hackear el estado de SFINAE


Entonces, ¿qué tiene de malo esta función? Para responder a esta pregunta, debemos verificar cómo myAbs implementa SFINAE.

 template< typename T, typename = IsSigned<T> > T myAbs( T val ); 

myAbs es una plantilla de función con dos tipos de parámetros de plantilla de entrada. El primero es el tipo real del argumento de la función, el segundo es el tipo anónimo predeterminado IsSigned < T > (de lo contrario std::enable_if_t < std::is_signed_v < T > > o bien std::enable_if < std::is_signed_v < T>, void>::type , que es void o falla en la sustitución).

¿Cómo podemos llamar a myAbs ? Hay 3 formas:

 int a{ myAbs( -5 ) }; int b{ myAbs< int >( -5 ) }; int c{ myAbs< int, void >( -5 ) }; 

La primera y la segunda llamadas son sencillas, pero la tercera es interesante: ¿cuál es el argumento de la plantilla void ?

El segundo parámetro de plantilla es anónimo, tiene un tipo predeterminado, pero sigue siendo un parámetro de plantilla, por lo que puede especificarlo explícitamente. ¿Es esto un problema? En este caso, este es realmente un gran problema. Podemos usar el tercer formulario para evitar nuestro cheque SFINAE:

 unsigned int d{ myAbs< unsigned int, void >( 5u ) }; unsigned int e{ myAbs< unsigned int, void >( std::numeric_limits< unsigned int >::max() ) }; 

Este código se compila bien, pero conduce a resultados desastrosos, para evitar lo que usamos SFINAE:

 a: 4294967295 myAbs( a ): 1 

Resolveremos este problema, pero primero: ¿hay alguna otra desventaja? Bueno ...

Defecto # 2: No podemos tener implementaciones específicas


Otro uso común de SFINAE es proporcionar implementaciones específicas para condiciones específicas de tiempo de compilación. ¿Qué sucede si no queremos prohibir completamente la llamada de myAbs con valores myAbs y proporcionar una implementación trivial para estos casos? Podemos usar if constexpr en C ++ 17 (discutiremos esto más adelante), o podemos:

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

Pero que es eso?

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

Oh, el estándar C ++ (C ++ 17; §17.1.16) establece lo siguiente :

"Los argumentos predeterminados no deben proporcionarse al parámetro de plantilla mediante dos declaraciones diferentes en el mismo alcance".

Vaya, esto es exactamente lo que hicimos ...

¿Por qué no usar regular si?


Podríamos usar if en tiempo de ejecución en su lugar:

 template< typename T > T myAbs( T val ) { if( std::is_signed_v< T > ) { return ( ( val <= -1 ) ? -val : val ); } else { return val; } } 

El compilador optimizaría la condición porque if (std::is_signed_v < T>) convierte en if (true) o if (false) después de crear la plantilla. Sí, con nuestra implementación actual de myAbs esto funcionará. Pero en general, esto impone una gran limitación: las else if y else deben ser válidas para cada T ¿Qué pasa si cambiamos un poco nuestra implementación?

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

Nuestro código se bloqueará de inmediato:

 error: call of overloaded 'abs(unsigned int&)' is ambiguous 

Esta restricción es lo que SFINAE elimina: podemos escribir código que sea válido solo para un subconjunto de T (en myAbs es válido solo para tipos sin signo o válido solo para tipos con signo).

Solución: otra forma para SFINAE


¿Qué podemos hacer para superar estas deficiencias? Para el primer problema, debemos forzar nuestra verificación SFINAE independientemente de cómo los usuarios invoquen nuestra función. Actualmente, nuestra prueba se puede eludir cuando el compilador no necesita el tipo predeterminado para el segundo parámetro de plantilla.

¿Qué sucede si usamos nuestro código SFINAE para declarar un tipo de parámetro de plantilla en lugar de proporcionar un tipo predeterminado? Probemos

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

Necesitamos que IsSigned sea un tipo distinto de nulo en casos válidos, porque queremos proporcionar un valor predeterminado para este tipo. No hay ningún valor para el tipo de vacío, por lo que deberíamos usar algo más: bool, int, enum, nullptr_t, etc. Por lo general, uso bool, en este caso las expresiones parecen significativas:

 template< typename T, IsSigned< T > = true > 

Funciona! Para myAbs (5u) compilador arroja un error, como antes:

 candidate template ignored: requirement 'std::is_signed_v<unsigned int>' was not satisfied [with T = unsigned int 

La segunda llamada, myAbs < int> (5u) sigue siendo válida, le decimos al compilador tipo T explícitamente, por lo que convierte 5u en int .

Finalmente, ya no podemos rastrear myAbs alrededor del dedo: myAbs < unsigned int, true> (5u) arroja un error. No importa si proporcionamos un valor predeterminado en la llamada o no, parte de la expresión SFINAE se evalúa de todos modos, porque el compilador necesita un tipo de argumento de un valor de plantilla anónimo.

Podemos pasar al siguiente problema, ¡pero espere un minuto! Creo que ya no anulamos el argumento predeterminado para el mismo parámetro de plantilla. ¿Cuál era la situación original?

 template< typename T, typename = IsUnsigned< T > > T myAbs( T val ); template< typename T, typename = IsSigned< T > > T myAbs( T val ); 

Pero ahora con el código actual:

 template< typename T, IsUnsigned< T > = true > T myAbs( T val ); template< typename T, IsSigned< T > = true > T myAbs( T val ); 

Se ve muy similar al código anterior, por lo que podríamos pensar que esto tampoco funcionará, pero de hecho, este código no tiene el mismo problema. ¿Qué es IsUnsigned < T> ? Bool o búsqueda fallida. ¿Y qué es IsSigned < T> ? Lo mismo, pero si uno de ellos es Bool, el otro es una búsqueda fallida.

Esto significa que no anulamos los argumentos predeterminados, ya que solo hay una función con el argumento de plantilla bool, la otra es una sustitución fallida, por lo que no existe.

Azúcar sintáctica


UPD Este párrafo fue eliminado por el autor debido a errores encontrados en él.

Versiones anteriores de C ++


Todo lo anterior funciona con C ++ 11, la única diferencia es la verbosidad de las definiciones de restricciones entre versiones estándar:

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

Pero la plantilla sigue siendo la misma:

 template< typename T, IsSigned< T > = true > 

En el antiguo C ++ 98, no hay alias de plantilla, además, las plantillas de función no pueden tener tipos o valores predeterminados. Podemos insertar nuestro código SFINAE en el tipo de resultado o solo en la lista de parámetros de función. Se recomienda la segunda opción porque los constructores no tienen tipos de resultados. Lo mejor que podemos hacer es algo como esto:

 template< typename T > T myAbs( T val, typename my_enable_if< my_is_signed< T >::value, bool >::type = true ) { return( ( val <= -1 ) ? -val : val ); } 

Solo para comparar: la versión moderna de C ++:

 template< typename T, IsSigned< T > = true > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } 

La versión de C ++ 98 es fea, introduce un parámetro sin sentido, pero funciona; puede usarla si es absolutamente necesario. Y sí: my_enable_if y my_is_signed deberían implementarse ( std :: enable_if std :: is_signed eran nuevos en C ++ 11).

Estado actual


C ++ 17 introdujo if constexpr , un método para descartar código basado en condiciones en tiempo de compilación. Las instrucciones if y else deben ser sintácticamente correctas, pero la condición se evaluará en tiempo de compilación.

 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." ); }*/ } } 

Como podemos ver, nuestra función de abdominales se ha vuelto más compacta y fácil de leer. Sin embargo, el manejo de tipos no conformes no es sencillo. El static_assert incondicional static_assert hace que esta declaración sea poco coherente, lo cual está prohibido por el estándar, independientemente de si se descarta o no.

Afortunadamente, hay una laguna: en los objetos de plantilla, los operadores descartados no se crean si la condición es independiente del valor. Genial

Entonces, el único problema con nuestro código es que se bloquea durante la definición de la plantilla. Si pudiéramos aplazar la evaluación de static_assert hasta el momento en que se creó la plantilla, el problema se resolvería: se crearía si y solo si todas nuestras condiciones son falsas. Pero, ¿cómo podemos diferir static_assert hasta que se static_assert la plantilla? ¡Haga que su condición dependa del tipo!

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

Sobre el futuro


Ya estamos muy cerca, pero debemos esperar un poco hasta que C ++ 20 ofrezca la solución final: ¡conceptos! Esto cambiará por completo la forma en que se usan las plantillas (y SFINAE).

En pocas palabras: los conceptos se pueden usar para limitar el conjunto de argumentos que se aceptan para los parámetros de la plantilla. Para nuestra función abs, podríamos usar el siguiente concepto:

 template< typename T > concept bool Arithmetic() { return std::is_arithmetic_v< T >; } 

¿Y cómo podemos usar conceptos? Hay tres formas:

 //   template< typename T > requires Arithmetic< T >() T myAbs( T val ); //   template< Arithmetic T > T myAbs( T val ); //  Arithmetic myAbs( Arithmetic val ); 

¡Tenga en cuenta que la tercera forma todavía declara una función de plantilla! Aquí está la implementación completa de myAbs en 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" ) }; } 

Una llamada comentada da el siguiente error:

 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 

Insto a todos a usar audazmente estos métodos en el código de producción; el tiempo de compilación es más barato que el tiempo de ejecución. ¡Feliz SFINAEing!

Source: https://habr.com/ru/post/466915/


All Articles