Parameter Packs: Sintaxis, Expansión y Fold Expressions

Un parameter pack es una abstracción que permite a las plantillas aceptar un número arbitrario de argumentos, ya sean tipos, valores o incluso otros templates. No es una colección en tiempo de ejecución como un std::vector; es una instrucción para el motor de instanciación del compilador. Cuando declaras typename... Args, creas un type pack; cuando usas Args... args en los parámetros de una función, creas un function parameter pack.

El diseño de esta característica se basa en la expansión de elipsis. El operador ... actúa de forma distinta según su posición: cuando acompaña a la declaración de un pack, lo define; cuando acompaña a un nombre (como args...), lo expande en una lista separada por comas. Esta expansión es lo que permite “desenrollar” la lógica de la plantilla durante la compilación. Para procesar estos packs, históricamente se recurría a la recursión de plantillas (definiendo un caso base y un caso recursivo que procesa la “cabeza” y pasa la “cola”), pero desde C++17, las fold expressions (expresiones de plegado) permiten operar sobre todo el pack en una sola línea, delegando la iteración al compilador.

Si intentas expandir un pack en un contexto que no permite múltiples argumentos (por ejemplo, intentar inicializar un solo int con un pack de tres elementos), el compilador lanzará un error de sintaxis. Si olvidas usar std::forward<Args>(args)... al pasar un pack a otra función, perderás la value category de los argumentos, convirtiendo los rvalues en lvalues y provocando copias innecesarias o errores de compilación, lo que anula el propósito de usar perfect forwarding.

#include <iostream>
#include <tuple>
#include <utility>
#include <string>
#include <type_traits>

// 1. Pack de templates (Template Template Parameters)
template<template<typename> class... Templates>
struct TemplateRegistry {};

// 2. Utilidad para indexar elementos de un pack mediante std::index_sequence
template<std::size_t I, typename Tuple>
auto& get_element_impl(std::size_t, Tuple&);

template<std::size_t I, typename Tuple>
auto& get_element_impl(std::index_t, Tuple& t) {
    // Usamos std::get para resolver la dirección de memoria en tiempo de compilación
    return std::get<I>(t);
}

// 3. Contenedor con variadic templates y fold expressions
template<typename... Args>
class DataVault<Args...> {
    std::tuple<Args...> data;

public:
    // Constructor con perfect forwarding y expansión de pack
    template<typename... Ts>
    explicit DataVault(Ts&&... args) 
        : data(std::forward<Ts>(args)...) {}

    // Uso de fold expressions para procesar elementos (C++17)
    template<typename F>
    void for_each(F&& func) {
        // Expansión con operador coma para ejecutar la función en cada elemento
        std::apply([&](auto&... element) {
            (func(element), ...); 
        }, data);
    }

    // Acceso al N-ésimo elemento usando técnicas de expansión de índices
    template<std::size_t I>
    auto& get() {
        static_assert(I < sizeof...(Args), "Índice fuera de rango del pack");
        return std::get<I>(data);
    }

    // Ejemplo de expansión en una lista de inicialización
    template<typename... OtherArgs>
    void append_if(OtherArgs&&... others) {
        // (Esto es una simplificación conceptual de lo que haría std::tuple_cat)
    }
};

// Especialización de DataVault para mostrar el uso de sizeof...
template<>
class DataVault<> {
    // Caso base: pack vacío
public:
    void print_size() const {
        std::cout << "Vault vacío. Sizeof: " << sizeof...(Args) << std::endl; 
    }
};

// Implementación real para el ejemplo
int main() {
    // Demostración de Template Template Parameter Pack
    using MyRegistry = TemplateRegistry<std::vector, std::list>;

    // Creación del vault con distintos tipos y valores
    DataVault<int, double, std::string> vault(42, 3.14159, "C++20");

    // Expansión de fold expression para imprimir
    std::cout << "Contenido: ";
    vault.for_each([](auto& item) {
        std::cout << item << " | ";
    });
    std::cout << std::endl;

    // Acceso indexado
    std::cout << "Elemento en índice 1: " << vault.get<1>() << std::endl;

    return 0;
}

Desglose del código

En la clase DataVault, el constructor utiliza Args&&... args para capturar una forwarding reference. La expresión std::forward<Ts>(args)... es crítica: la elipsis ... después de args indica al compilador que debe expandir cada argumento individualmente, aplicando std::forward a cada uno. Sin esta expansión, estaríamos pasando el pack completo como un único objeto, lo cual fallaría.

En el método for_each, aplicamos std::apply para desempaquetar el std::tuple. Dentro, la línea (func(element), ...); es una unary fold expression sobre el operador coma. El compilador descompone esto en algo similar a func(e1), func(e2), func(e3);. Es una abstracción de coste cero que sustituye a la antigua recursión de plantillas, reduciendo la carga de trabajo del compilador y la profundidad de la pila de instanciación.

El método get<I>() utiliza la lógica interna de std::get del std::tuple, pero el concepto subyacente es el uso de sizeof...(Args). Este operador devuelve el número de elementos en el pack en tiempo de compilación. En la especialización de DataVault<>, sizeof...(Args) es 0, permitiendo validar mediante static_assert que el acceso sea seguro antes de que se genere el código máquina.

El error frecuente

Un error clásico al trabajar con packs es la pérdida de la categoría de valor (value category) debido a una expansión incorrecta. Fíjate en este código erróneo:

template<typename... Args>
void process_data(Args&&... args) {
    // ERROR: Estamos expandiendo 'args' directamente, no 'std::forward<Args>(args)'
    some_function(args...); 
}

Si pasas un rvalue a process_data, los argumentos se vinculan como referencias de rvalue. Sin embargo, dentro del cuerpo de la función, el nombre args es un lvalue (las referencias tienen nombre). Al expandir args... sin std::forward, le estás pasando lvalues a some_function, lo que impide el move semantics. Si some_function espera un rvalue para mover un std::string, terminará realizando una copia costosa o, en casos de tipos no-copiables, el código simplemente no compilará. Para detectar esto, se recomienda usar AddressSanitizer o simplemente observar que los objetos no se mueven como se esperaba.

53

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio