Fold Expressions: Expansiones de Parameter Packs sin recursión

Las fold expressions [C++17] son una syntactic sugar que permite expandir un parameter pack dentro de una expresión de forma directa, eliminando la necesidad de recurrencia mediante plantillas para realizar operaciones sobre los argumentos. En versiones anteriores de C++, para sumar un pack de argumentos o iterar sobre ellos, necesitábamos definir una función base (con un tipo base o sin argumentos) y una función recursiva que redujera el pack elemento a elemento. Esto no solo ensucia el código, sino que dispara la complejidad de la tabla de símbolos del compilador y puede agotar el límite de profundidad de la recursión de plantillas.

Las fold expressions resuelven esto permitiendo al compilador generar la expansión de forma iterativa y eficiente durante la fase de análisis sintáctico. Funcionan mediante cuatro variantes dependiendo de la dirección de la operación y si se proporciona un valor inicial (identidad): las unary left fold ((... op pack)), las unary right fold ((pack op ...)) y las binary left fold ((init op ... op pack)) y binary right fold ((pack op ... op init)).

Debes usarlas siempre que necesites reducir un pack de argumentos a un único valor (como sumas, productos o validaciones lógicas) o cuando quieras aplicar un efecto secundario (como imprimir o llamar a una función) a cada elemento de un pack sin el overhead de la recursión. Si intentas usar una unary fold con un pack vacío, el compilador lanzará un error, ya que las expresiones de plegado unario requieren al menos un elemento. Por otro lado, si utilizas una operación no asociativa (como la resta - o la división /) y te equivocas en la dirección del plegado, el resultado matemático será incorrecto debido a cómo se agrupan los paréntesis.

#include <iostream>
#include <string_view>
#include <concepts>
#include <utility>

// Un motor de procesamiento para demostrar las 4 formas de plegado
template <typename... Args>
class DataStream {
private:
    // Usamos un array estático para simular un almacén de datos sin heap
    // En un caso real, esto podría ser una tupla o un buffer.
    // Para este ejemplo, asumimos que los tipos son compatibles con la lógica.
    struct {
        Args args...;
    } data;

public:
    // Constructor para inicializar los elementos
    explicit DataStream(Args... args) : data{args...} {}

    // 1. Unary Left Fold (Binary op con identidad)
    // Se expande como: (((init + arg1) + arg2) + arg3)
    // Ideal para operaciones asociativas como suma.
    template <typename T>
    auto sum_all(T init) const {
        return (init + ... + args); 
    }

    // 2. Unary Right Fold
    // Se expande como: (arg1 + (arg2 + (arg3 + init)))
    // En suma es equivalente a la izquierda, pero en multiplicación importa la identidad.
    template <typename T>
    auto product_all(T init) const {
        return (args * ... * init);
    }

    // 3. Unary Left Fold con operador coma (Side-effect pattern)
    // Útil para ejecutar una función o efecto secundario por cada elemento.
    void log_all() const {
        (std::cout << args << " | " , ...); 
        std::cout << "END\n";
    }

    // 4. Binary Right Fold (Operación no asociativa)
    // Se expande como: (arg1 - (arg2 - (arg3 - init)))
    // Fundamental para entender la importancia de la dirección del plegado.
    template <typename T>
    auto complex_reduction(T init) const {
        return (args - ... - init);
    }

    // 5. Combinación con Predicados (Logical fold)
    // Verifica si todos los elementos cumplen una condición.
    template <typename Pred>
    bool all_satisfy(Pred&& pred) const {
        return (pred(args) && ...); // Unary left fold con &&
    }
};

// Helper para facilitar la creación sin llamar explícitamente al constructor de la clase
template <typename... Args>
auto make_stream(Args... args) {
    return DataStream<Args...>(args...);
}

int main() {
    // Caso de uso con números reales
    auto stream = make_stream(10, 5, 2);

    std::cout << "Elementos: 10, 5, 2\n";
    
    // Suma: 0 + 10 + 5 + 2 = 17
    std::cout << "Suma (Binary Left): " << stream.sum_all(0) << "\n";

    // Producto: 10 * 5 * 2 * 1 = 100
    std::cout << "Producto (Unary Right): " << stream.product_all(1) << "\n";

    // Log: Imprime cada elemento usando el operador coma
    std::cout << "Log de datos: ";
    stream.log_all();

    // Verificación lógica: ¿Son todos positivos?
    bool todos_positivos = stream.all_satisfy([](auto n) { return n > 0; });
    std::cout << "Todos positivos: " << std::boolalpha << todos_positivos << "\n";

    // El peligro de la asociación:
    // Left Fold (en la implementación de sum_all con init): ((10 - 5) - 2) - 0 = 3
    // Right Fold (complex_reduction): (10 - (5 - (2 - 0))) = 10 - 3 = 7
    std::cout << "Reducción compleja (Right Fold): " << stream.complex_reduction(0) << "\n";

    return 0;
}

Análisis del código

En sum_all, utilizamos una binary left fold (init + ... + args). El compilador toma el valor inicial init, le aplica la suma con el primer elemento del pack, y el resultado se usa como operando izquierdo para la siguiente suma. Esto es altamente eficiente y evita la sobrecarga de la recursión de plantillas.

En log_all, aplicamos una unary left fold usando el operador coma ,. Esta es una técnica muy potente para ejecutar expresiones que devuelven void o que simplemente se usan por sus efectos secundarios, como el std::cout en este caso. El compilador expande esto como ((std::cout << 10 << " | "), (std::cout << 5 << " | "), ...) eliminando la necesidad de un bucle o recursión.

El método all_satisfy emplea una unary left fold con el operador lógico &&. Lo interesante aquí es que el compilactor puede optimizar la expansión aplicando el cortocircuito (short-circuiting) de los operadores lógicos: si un elemento no cumple la condición, la expansión puede detenerse ahí, algo que una implementación recursiva tradicional podría no lograr de forma tan directa si no se programa cuidadosamente.

Finalmente, complex_reduction ilustra el comportamiento de las binary right folds. Al expandirse hacia la derecha (arg1 - (arg2 - (arg3 - init))), el orden de las operaciones cambia drásticamente en comparación con un plegado hacia la izquierda. Esto es crítico cuando trabajamos con tipos no asociativos como double (debido a la precisión de punto flotante) o tipos personalizados donde el orden de los operandos altera el resultado.

El error frecuente

Un error común al trabajar con variadic templates es intentar usar una unary fold en un contexto donde el pack de argumentos podría estar vacío. Por ejemplo, si defines:

template<typename... Ts>
auto sum(Ts... args) {
    return (... + args); // Error de compilación si args es vacío
}

Si llamas a sum<>(), el compilador fallará porque las expresiones de plegado unario requieren al menos un elemento para tener un operando inicial. Para hacer este código robusto y permitir packs vacíos, se debe utilizar siempre un binary fold con un valor de identidad:

template<typename... Ts>
auto sum(Ts... args) {
    return (0 + ... + args); // Seguro: si args está vacío, devuelve 0
}

Además, ten cuidado con la asociación en operaciones de resta o división. Un programador que espera ((10 - 5) - 2) = 3 pero utiliza accidentalmente un plegado hacia la derecha (10 - (5 - 2)) = 7 introducerá errores de lógica matemática extremadamente difíciles de depurar en sistemas de alta precisión.

54

Dejar un comentario

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

Scroll al inicio