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() {
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:
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; } } }
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:
¡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 ) };
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!