jueves, 21 de abril de 2016

La librería type_traits

Introducción

type_traits es una librería que se apareció en el estándar C++11, por lo que lleva poco tiempo entre nosotros. Desde mi punto de vista creo que es una librería que es fácil que pase desapercibida, puesto que no proporciona utilidades que sean indispensables a la hora de programar. Sin embargo, si se utiliza correctamente, se le puede llegar a sacar bastante partido.

La librería type_traits nos proporciona información en tiempo de compilación sobre los tipos de datos que estamos utilizando. Esto lo podemos utilizar tanto para acotar el uso que se les da a nuestro código como para programar optimizaciones de una forma muy sencilla.

A continuación tenemos un ejemplo bastante simple en el que hacemos uso de la librería type_traits:

#include <iostream>
#include <type_traits>

int main( )
{
  std::cout << "int tiene signo: "
            << std::is_signed< int >::value
            << std::endl;
  std::cout << "unsigned int tiene signo: "
            << std::is_signed< unsigned int >::value
            << std::endl;

  return 0;
}

Salida:

int tiene signo: 1
unsigned int tiene signo: 0

El ejemplo únicamente nos está indicando que el tipo int distingue positivos de negativos mientras que unsigned int no. A primera vista la información proporcionada por type_traits es trivial y puede hacer que nos cuestionemos la necesidad de usar la librería. Lo que sucede realmente es que esta librería está pensada para ser utilizada con templates y es ahí donde se le puede sacar todo el jugo.

Uso con templates

Si incluimos elementos de la librería type_traits en la declaración de nuestros templates conseguiremos una herramienta bastante potente que nos permitirá especializar nuestros templates con mucho menos esfuerzo del que teníamos que realizar hasta la fecha.

Imaginemos que necesitamos crear un template de función que nos indique si la variable pasada como argumento es una variable con signo o sin signo. Haciendo uso de las características tradicionales de los templates podríamos crear un programa como el siguiente:

#include <iostream>

template<typename T>
void
PrintValue( T value )
{
  std::cout << value << " is signed" << std::endl;
}

template< >
void
PrintValue( unsigned int value )
{
  std::cout << value << " is unsigned" << std::endl;
}

int main( )
{
  int intValue = 7;
  unsigned int uintValue = 200;

  PrintValue( intValue );
  PrintValue( uintValue );

  return 0;
}

Especializar un template de esta manera presenta numerosos inconvenientes:

  • Es necesario crear una especialización por cada tipo sin signo que necesitemos usar
  • La función dará un resultado incorrecto si la especializamos con un tipo no numérico
  • El código, debido a que tiende a ser demasiado largo y repetitivo, es difícil de mantener.

Para obtener un código funcional más o menos completo tendríamos que hacer tantas sobrecargas de PrintValue() como tipos sin signo necesitamos controlar. El código resultante será, por tanto, largo y complicado de mantener.

Ahora vamos a reescribir el código anterior haciendo uso de la librería type_traits. El código resultante es el siguiente:

#include <iostream>
#include <type_traits>

template<typename T>
typename std::enable_if<std::is_signed<T>::value, void>::type
PrintValue( T value )
{
  std::cout << value << " is signed" << std::endl;
}

template<typename T>
typename std::enable_if<std::is_unsigned<T>::value, void>::type
PrintValue( T value )
{
  std::cout << value << " is unsigned" << std::endl;
}

template<typename T>
typename std::enable_if<!std::is_integral<T>::value, void>::type
PrintValue( T value )
{
  std::cout << value << " is not a number!!!" << std::endl;
}

int main( )
{
  int intValue = 7;
  unsigned int uintValue = 200;

  PrintValue( intValue );
  PrintValue( uintValue );
  PrintValue( "test" );
}

Ahora con únicamente 3 versiones de la función hemos cubierto todo el espectro de posibilidades:

  • Números con signo
  • Números sin signo
  • Cualquier otra situación

Para entender el ejemplo anterior vamos a despiezar un poco una de las versiones del template:

template<typename T>
typename std::enable_if<std::is_signed<T>::value, void>::type
PrintValue( T value )
{
  std::cout << value << " is signed" << std::endl;
}

En este fragmento de código encontramos los siguientes elementos:

  • std::is_signed<T>::value: el miembro value de este template vale true cuando el tipo pasado equivale a un número con signo.
  • std::enable_if<..., void>::type: el tipo del miembro type de este template es idéntico al tipo pasado como segundo parámetro (void en este caso) sí y solo sí el primer parámetro es verdadero. Si el primer parámetro es falso, el miembro type de enable_if<> no estará definido.

Juntando estos dos elementos, resulta que el template que hemos puesto de ejemplo generará una especialización válida para aquellos tipos que sean números con signo y una función incorrecta (puesto que no habrá valor de retorno) en caso contrario. Espera, ¿una función incorrecta? ¿pero entonces no debería dar un error de compilación?

Pues bien, ejemplo funciona, no hay más que compilarlo y ejecutarlo para verificarlo. El motivo por el que funcione se debe a una regla de nueva aparición en C++11 conocida como SFINAE "Substitution Failure Is Not An Error". Esta regla viene a decir que si en la especialización de un template se produce un error de compilación, éste no se materializará si el compilador consigue encontrar otra versión del template que compile correctamente para el tipo dado.

Dicho en cristiano: Si un template tiene dos versiones y, para un tipo dado, la primera versión falla y la segunda no (o viceversa), el compilador no mostrará error alguno y compilará correctamente. En este caso, si intentamos especializar el template con un string, lo que sucede es que las dos primeras versiones del template darán error pero, al compilar correctamente la tercera versión, el compilador dará esa versión por buena y podrá continuar la compilación.

Revisando la lista de utilidades presentes en type_traits, descubrimos que podemos usarlas para adaptarnos a un sin fín de situaciones. Os expongo algunas que me han llamado la atención:

  • is_abstract: Verifica si el tipo se corresponde con una clase abstracta.
  • is_array: Permite identificar arrays crudos, es decir, en formato C.
  • is_pointer: Comprueba si el tipo se corresponde con un puntero.
  • is_const: Identifica tipos constantes.
  • is_copy_assignable: Nos indica si el operador de igualdad está disponible para el tipo dado.
  • ...

type_traits y static_assert

Otra característica nueva en C++11 es static_assert. Este nuevo elemento se presenta como un assert que se chequea en tiempo de compilación. Si el assert falla, provocará un fallo en la compilación y mostrará un mensaje de error elegido por nosotros mismos. La ventaja que supone realizar ciertos chequeos en tiempo de compilación es que podremos eliminar algunos if de nuestro código, con lo que mejoraremos el rendimiento de nuestros algoritmos.

Vamos con un ejemplo. Imaginemos que estamos creando un template para manejar un array de datos. El tamaño del array será fijo desde su creación y, por motivos de rendimiento, el tamaño del array no puede ser superior a 20:

template<typename T, int size>
class CustomArray
{
  static_assert(size < 20, "CustomArray length is too big");
};

int main( )
{
  CustomArray<char, 27> array;
  return 0;
}

Al probar el código del ejemplo, veremos que se produce un error en tiempo de compilación similar al siguiente:

..\main.cpp: In instantiation of 'class CustomArray<char, 27>':
..\main.cpp:9:27:   required from here
..\main.cpp:4:3: error: static assertion failed: CustomArray length is too big
   static_assert(size < 20, "CustomArray length is too big");

No hay comentarios:

Publicar un comentario