Templates con número variable de argumentos en C++0x

marzo 9, 2010

Allá por el año 1999 se publicaba el nuevo y flamante estándar de C, C99. Una de las muchas de novedades que incluye es la de macros variadic, es decir, con un número variable de argumentos. Sin embargo, siendo programador de C++ me resisto a utilizar macros para cualquier cosas que pueda resolverse con funciones inline y con templates. Esto me deja en una situación de desventaja, pues la única manera que que una función en C++ reciba un número variable de argumentos es con (…) y (como la función printf(const char *, …)), pero es una técnica incómoda y totalmente insegura, no es el estilo de C++.
¡Hasta ahora! Porque C++0x ha conseguido lo que parecía imposible: templates variadic, es decir templates con número variable de argumentos.

El ejemplo más sencillo de un template con un número variable de argumentos es:

template <typename ... Tipos> void fun()
{
}

El uso es trivial, aunque no muy útil (de momento):

{
    fun<int>();
    fun<int, double, char, void>();
    fun<>();
    fun(); //igual que la anterior

Para que estos templates hagan algo con sentido, hay que utilizar el argumento variable Tipos. Esto se llama pack de parámetros template, y para utilizarlo hay que expandirlo utilizando . La expansión del pack equivale a la lista de tipos que representa, separados por coma, por lo que solo puede utilizarse en aquellos contextos en los que dicha lista tiene sentido:

template <typename ... Tipos> void foo()
{
}
template <typename ... Tipos> void bar()
{
    foo<Tipos...>();
}

La expansión de un pack no tiene por qué ser en un template variadic, pero obviamente la instanciación debe tener sentido:

template <typename ... Tipos> void foo()
{
    std::pair<Tipos...> x; //solo para 2 argumentos
}
//...
{
    foo<int, char>(); //bien
    foo<int>(); //error
}

Uno de los sitios más interesantes para expandir un pack de parámetros template es en los parámetros de la función:

template <typename ... Tipos> void foo(Tipos ... par)
{
}

En este caso se crea un nuevo pack llamado par, pero este es un pack de parámetros a función, y equivale a una lista de expresiones en lugar de una lista de tipos. Puede utilizarse en (casi) cualquier contexto en el que se admita una lista de expresiones separadas por coma:

template <typename ... Tipos> void miprintf(const char *fmt, Tipos ... par)
{
    printf(fmt, par...);
}

Además, como puede verse en el ejemplo, la deducción automática de tipos para los argumentos de funciones template funciona perfectamente.

La verdadera potencia de la expansión de packs reside en el hecho de que se puede hacer con expresiones compuestas, y el compilador replica la expresión:

template <typename ... Tipos> void miprintf_doble(const char *fmt, Tipos ... par)
{
    printf(fmt, (par+par)...);
}
//...
{
    miprintf_doble("%d %ld %f", 1, 2L, 3.0);
//equivale a:
    printf("%d %ld %f", 1+1, 2L+2L, 3.0+3.0);
}

Una expansión de pack puede ser todo lo complicada que haga falta, e incluso puede implicar a varios packs simultáneamente, siempre que tengan el mismo número de elementos. ¡Incluso se pueden hacer expansiones anidadas!

Claro que printf no es un muy buen ejemplo de C++, y hemos caído en el antiguo . Si quisiéramos hacer la función equivalente a printf con std::cout tenemos un problema, y es que no se puede iterar sobre un pack, solo puede expandirse. Para resolver este problema hay que utilizar técnicas similares a la programación funcional, aprovechando la especialización de templates. Suena lioso, y a veces lo es. Veamos un ejemplo sencillo, pero útil:

//introducción del template, sin definición
template<typename ... Tipos> 
    std::ostream &super_print(std::ostream &os, const Tipos& ... args);
 
//sobrecarga terminal (no tiene que ser template)
inline std::ostream &super_print(std::ostream &os)
{
    return os;
}
 
//especialización general
template<typename Primero, typename ... Resto> 
    std::ostream &super_print(std::ostream &os, const Primero &p, const Resto& ... r)
{
    os << p;
    super_print(os, r...);
    return os;
}

Es una función template variadic con especialización parcial recursiva y sobrecarga (¡hala!). Puede parecer lioso, pero si lo miras bien no tiene por qué no funcionar, y de hecho funciona.

Usos avanzados

Naturalmente también se pueden utilizar con clases template, aunque utilizar los packs en este caso resulta un tanto más complicado. O lo sería si no fuera porque la librería estándar incluye el trabajo difícil con la clase std::tuple:

#include <tuple>
 
template<typename ... Tipos> class Varios
{
private:
    std::tuple<Tipos...> m_vars;
public:
    Varios(const Tipos &... args)
        :m_vars(args...)
    {
    }
};

Y aun así, darle uso a la variable m_vars puede no ser trivial.

Si queremos saber el número total de elementos de un pack podemos utilizar el nuevo operador sizeof…:

template <typename ... Tipos> void miprintf(const char *fmt, Tipos ... par)
{
    printf("%d: " fmt, sizeof...(Tipos), par...);
}

Uno de los usos más importantes de esta tecnología es para implementar el forwarding perfecto:

template <typename T, typename ... Tipos> T *CreaObjeto(Tipos && ... args)
{
    return new T(std::forward<Tipos>(args)...);
}

Nótese la posición de los respecto a los paréntesis: debe ser así para que se llame a forward con cada uno de los elementos constituyentes de ambos packs.

Esto es tan útil que a partir de ahora los tipos contenedores estándar tienen, por cada función que añade un elemento, otra equivalente que crea el elemento directamente dentro de la secuencia, recibiendo como parámetros los parámetros del constructor. Esto se entiende mejor con un ejemplo:

class Cosa
{
public:
    Cosa(int a, const std::string &n);
    //...
};
 
//...
{
    std::vector<Cosa> x;
 
    x.push_back(Cosa(3, "tres"));
    x.emplace_back(3, "tres");
}

La diferencia entre las dos últimas líneas es que la última no crea un objeto temporal, sino que utiliza el forwarding perfecto para crear el objeto directamente en la memoria del vector. Existe una variante de emplace para cada función que inserta un elemento en el contenedor.

Dónde expandir el pack

Como referencia incluyo a continuación una lista con los principales contextos en los que se puede expandir un pack, tanto de tipos como de expresiones:

  • En la declaración de argumentos de una función. En este caso se declara un nuevo pack.
  • Los argumentos en la llamada a una función.
  • Los argumentos en la inicialización de una clase base (en realidad es lo mismo que la anterior).
  • Los argumentos de un template.

Otros contextos menos útiles, en mi opinión, son:

  • En la lista de clases base de una clase.
  • En la lista de inicializaciones de miembros de una clase.
  • En la especificación de excepciones de una función (¿para qué puede servir esto?).

Artículos relacionados:

  1. Deducción de tipos en C++0x con decltype El otro día veíamos la deducción automática de tipos usando...
  2. Tipos automáticos en C++0x con auto La palabra reservada auto es casi con toda seguridad la...
  3. Referencias a R-valor (2ª parte) En el post anterior describía cómo las referencias a R-valor...
  4. Bucle foreach en C++0x A menudo se dice que C++ es un lenguaje complicado,...
  5. std::unique_ptr Si alguna vez has utilizado (o intentado utilizar) la clase...

Deja un comentario