Funciones variádicas permiten un número arbitrario de parámetros de tipo arbitrario. La más conocida en C y C++ (y otros lenguajes) es printf. Aquí tratamos de cómo declarar e implementar una función variádica. También comparamos estas funciones con otros conceptos relacionados como sobrecarga de una función y patrones variádicos (variadic templates).

Cómo declarar una función variádica

Cómo ejemplo de declaración usamos la signatura de printf.

int printf(const char* formato, ...);

La signatura de una función variádica tiene como parámetro una elipsis .... Esta elipsis quiere decir: y unos cuántos parámetros más de tipo desconocido. A continuación llamaremos los valores que el elipsis representa “parámetros anónimos”. Son parámetros que existen, pero para cuales no existe un nombre con que los podemos referenciar.

Una elipsis siempre tiene que ser el último parámetro con excepción de parámetros con valor de defecto.

int mi_función_variádica_1(int a, ..., int b);  // Error de compilación
int mi_función_variádica_2(int a, ..., int b = 0);  // Ok

Los parámetros con nombre y sin valor por defecto son parámetros obligatorios. Obligan al usuario de especificar al menos uno, dos o más valores. Vamos a ver en la implementación que un parámetro obligatorio como mínimo es útil.

Cómo implementar una función variádica

Cuando se llama a una función variádica, el compilador guarda todos los parámetros en el stack. El problema es como accedemos a estos parámetros ya que no tienen nombre. La solución viene por unas macros cuya implementación depende tanto de cada compilador que recomiendo no intentar entenderla a menos que sea este tu destino.

Explicamos las macros en un ejemplo de uso. Nota que las macros vienen sin el namespace. Macros nunca tienen namespace.

// Incluye la definición de las macros
#include <stdarg.h>

// Una función variádica
void trata_valores(const std::list<id_de_tipo> lista_de_tipos, ...)
{
    // Una referencia para aceder a los parámetros anónimos
    va_list parámetros_desconocidos;
    
    // Asigna el puntero de lectura al primer parámetro anónimo.
    va_start(parámetros_desconocidos, lista_de_tipos);
    // El segundo parámetro es el último parámetro con nombre.
    // Es una de las dos buenas razones de tener al menos un
    // parámetro con nombre, porque sin él no se puede inicializar
    // la referencia de parámetros anónimos.
    
    while (/* ver explicación abajo */)
    {
        // Obtiene un parámetro. ¿Cómo sabemos su tipo?
        un_tipo i = va_arg(parámetros_desconocidos, un_tipo);
    }
    
    // Limpia cualquier recurso reservado por va_start y va_arg
    va_end(parámetros_desconocidos);
}

En corto:

  1. Define un nombre con va_list.
  2. Inicializa el acceso a los parámetros anónimos con va_start.
  3. Obtén un nuevo parámetro con cada llamada a va_arg.
  4. Recoge la basura con va_end.

La dificultad es ¿cómo saber los tipos de los parámetros? Pues, no se puede por las macros de acceso. La signatura de una función variádica no asegura ningún tipo específico para los parámetros anónimos. De hecho, una implementación puede obtener diez valores de tipo int y tres de tipo float con éxito aunque el usuario no haya llamado la función variádica con estos parámetros. La macro va_arg simplemente devuelve un valor del tipo especificado en el segundo parámetro y mueve el puntero de lectura tantos bytes como el tamaño de este tipo. Es genial para leer memoria basura, pero ¿cómo podemos obtener los parámetros reales?

Pues, ahí viene la segunda razón para el primer parámetro obligatorio: el primer parámetro contiene una información sobre los tipos de los parámetros anónimos. Por esto lo hemos definido como una lista de identificadores de tipos en el ejemplo arriba. Podemos imaginarnos que la lista contiene tantos elementos como parámetros anónimos y que cada valor de id_de_tipo corresponde a un tipo posible.

En la función printf, esta lista de tipos de parámetros es el parámetro de formato. El bucle while dentro de la implementación de printf imprime cada letra a menos que viene el símbolo %. Entonces sabe que viene la especificación de un tipo que entonces puede usar para obtener uno de los parámetros anónimos. Obviamente debe haber tantos símbolos % en el texto de formato como hay parámetros anónimos – y los tipos especificados deben corresponder a los reales. Sino printf imprime una basura que cualquier programador seguramente ya ha visto en sus vida.

Limitaciones de funciones variádicas

Ya hemos visto que funciones variádicas no aseguran el tipo de los parámetros anónimos y que esto puede llegar a no obtener los valores de los parámetros correctamente. Esto, desde luego, no es aceptable para un buen estilo de programación y, por eso, mucho se ha ideado en C++ para evitar tener que usar funciones variádicas. No obstante la función printf es junto con las macros de C una de las chapuzes más populares que se siguen usando porque simplemente permiten hacer algo complicado de una forma simple.

Otra limitación es el número de tipos que una función variádica puede manejar. Cada implementación contiene un bucle while, y dentro de esto habrá un switch que llama para cada caso a va_arg con un tipo diferente. Lógicamente no se puede manejar un tipo si no está programado en este switch. Esta es la razón porque printf sólo acepta tipos simples, nativos del lenguaje. Los autores de printf simplemente no podían saber qué estructuras a imprimir inventarás.

Alternativas a funciones variádicas

Con tanto peligro de leer mal los parámetros, C++ tenía que ofrecer nuevas maneras de pasar un número de parámetros arbitrario. No obstante, todos tienen limitaciones.

Sobrecarga y valores por defecto

Si el número de posibles permutaciones de parámetros no es demasiado alto, se pueden definir sobrecargas de una función para cada una. Esto se hace, por ejemplo, en el

operator<<(std::ostream& out, const un_tipo& valor)

Hay una definición para cada tipo nativo del compilador y cada clase de biblioteca estándar cuyo contenido tiene sentido de imprimir como para std::string.

Aunque estés depuesto a escribir mil sobrecargas de una función, no llegarás a sustituir printf con ellas. No obstante, puedes usar algunos trucos.

  • Si lo que varía es más el número de los parámetros y no su tipo, entonces te basta una función con muchos parámetros del mismo tipo y todos estos parámetros tienen valores por defecto. El valor por defecto entonces dirá algo como “parámetro no especificado”. Sin embargo, esta versión puede ser más lenta que una función variádica, porque el programa guarda todos los parámetros en el stack – también aquellos que no se usan.
  • El truco del operator<< consiste en sólo permitir un parámetro de tipo variado y luego encadenar un número arbitrario de operadores. De esta forma sólo hay que definir tantas funciones como hay tipos a manejar.
  • Si lo que varía son los tipos y no el número de parámetros, entonces se pueden usar templates de funciones.
Templates

Un template permite definir una función con tipos variados. Esto permite en principio crear una implementación que vale para cualquier tipo, porque se puede usar el nombre del tipo variable en la macro va_arg.

Un template convencional tiene un número de parámetros fijo. Desde el estándar C++11, templates pueden también tener un número variado de tipos. Esto se llama variadic template o parameter pack. Cuando se tiene un número arbitrario de tipos, entonces la función puede aceptar también un número arbitrario de parámetros. De esta manera, sí, se puede escribir un sustituto de printf que asegura los tipos de los parámetros. (Para facilitar el manejo de una lista de parámetros de tipo arbitrario, la biblioteca estándar provee el tipo std::tuple.)

En una función de tipos y parámetros arbitrarios habrá, igual como en una implementación de una función variádica convencional, un bucle, que hace algo para cada parámetro: típicamente llama a una función con sólo un parámetro y luego vuelve a llamar a si misma con los parámetros restantes. Es decir, funciones de variadic template suelen ser a menudo recursivos. Esto les hace lento, ya que tienen copiar los parámetros varias veces. Además, se genera una instancia de la función para cada conjunto de tipos. Por eso, una función con templates variádicos provee la misma forma de uso, pero no ejecutará tan rápida como una función variádica sin templates.

Conclusión

Funciones variádicas como printf permiten implementar funciones con un número de parámetros arbitrario de forma eficaz. Desgraciadamente dependen de que el usuario provee una lista con los tipos de los parámetros suministrados. Al hacerlo mal, una función variádica puede leer o incluso escribir en la memoria de forma no controlada.

C++ provee los variadic templates como alternativa que asegura los tipos de los parámetros, pero sus implementaciones suelen traer desventajas a la hora de ejecución. Además, su implementación tampoco suele ser más fácil. Por eso, las funciones variádicas tienen su derecho de existencia, aunque un buen programador siempre pensará en alternativas antes de implementarlas.

Lectura adicional