std::function: Type erasure y costos de rendimiento

std::function es un contenedor polimórfico que permite almacenar cualquier objeto invocable —ya sea una función, un lambda, un functor o un puntero a función— que coincida con una firma específica. Utiliza una técnica conocida como type erasure (borrado de tipo) para ocultar la identidad real del objeto tras una interfaz uniforme, permitiendo que tipos totalmente distintos se almacenen en una misma colección, como un std::vector<std::function<void()>>.

Esta magia de diseño se logra mediante una capa de indirección. Internamente, std::function suele gestionar un puntero a una interfaz abstracta (similar a una vtable) que encapsula el comportamiento del objeto real. Si el objeto que intentas guardar es pequeño, la mayoría de las implementaciones modernas utilizan Small Buffer Optimization (SBO) para almacenarlo directamente dentro de la estructura de std::function, evitando una asignación en el heap. Sin embargo, si el objeto es grande (por ejemplo, un lambda con muchas capturas pesadas), se producirá una asignación dinámica en el montón.

Debes usar std::function cuando necesites almacenar callbacks de tipos heterogéneos o cuando la API de tu librería no pueda ser una plantilla (por ejemplo, en una interfaz de una clase base virtual). Sin embargo, evita su uso en el “hot path” de un algoritmo crítico; la indirección impide que el compilador realice inlining, lo que rompe optimizaciones vitales. Si tienes un template donde el callable es un parámetro, usa auto o un parámetro de plantilla en su lugar para mantener la resolución del tipo en tiempo de compilación. Si intentas invocar una std::function que no ha sido inicializada (está en un estado nulo), el programa lanzará una excepción std::bad_function_call.

#include <iostream>
#include <vector>
#include <functional>
#include <string>
#include <memory>

// Un functor clásico
struct Logger {
    void operator()(int nivel, const std::string& msg) const {
        std::cout << "[Log " << nivel << "] " << msg << "\n";
    }
};

// Una función global
void global_callback(int n, const std::string& s) {
    std::cout << "[Global] " << n << ": " << s << "\n";
}

class EventManager {
public:
    // Usamos std::function para permitir cualquier cosa que coincida con la firma
    void subscribe(std::function<void(int, const std::string&)> callback) {
        callbacks.push_back(std::move(callback));
    }

    void emit(int n, const std::string& s) const {
        for (const auto& cb : callbacks) {
            if (cb) { // Verificación de seguridad antes de invocar
                cb(n, s);
            }
        }
    }

    void trigger_empty_callback(int n, const std::string& s) const {
        // Esto lanzará std::bad_function_call si callbacks tiene elementos nulos
        // o si intentamos llamar a un elemento vacío directamente.
        for (const auto& cb : callbacks) {
            cb(n, s); 
        }
    }

private:
    std::vector<std::function<void(int, const std::string&)>> callbacks;
};

int main() {
    EventManager manager;

    // 1. Suscripción con una función global
    manager.subscribe(global_callback);

    // 2. Suscripción con un lambda que captura por valor (probablemente use SBO)
    int id = 42;
    manager.subscribe([id](int n, const std::string& s) {
        std::cout << "[Lambda ID " << id << "] " << n << " -> " << s << "\n";
    });

    // 3. Suscripción con un lambda que captura algo pesado (probablemente Heap Allocation)
    auto big_data = std::make_unique<std::string>("Datos pesados que evitan el SBO");
    manager.subscribe([data = std::move(big_data)](int n, const std::string& s) {
        std::cout << "[Lambda Heap] " << n << ": " << *data << " (" << s << ")\n";
    });

    // 4. Suscripción con un functor
    manager.subscribe(Logger{});

    // 5. Inyección de un callable nulo (para demostrar el error)
    std::function<void(int, const std::string&)> nulo;
    manager.subscribe(nulo);

    std::cout << "--- Emitiendo eventos ---\n";
    manager.emit(1, "Evento normal");

    std::cout << "\n--- Provocando error de llamada nula ---\n";
    try {
        manager.trigger_empty_callback(99, "Esto fallará");
    } catch (const std::bad_function_call& e) {
        std::cerr << "Error capturado: " << e.what() << "\n";
    }

    return 0;
}

Análisis del código

En el EventManager, el std::vector almacena objetos de tipo std::function<void(int, const std::string&)>. Aunque pasamos una función global, un lambda con un int capturado y un lambda que captura un std::unique_ptr (lo que implica una asignación en el heap para el control del objeto), el vector trata a todos por igual gracias al type erasure.

Cuando llamamos a cb(n, s) en el bucle de emit, el compilador no sabe qué código se va a ejecutar exactamente; solo sabe que debe seguir el puntero de función almacenado en la capa de abstracción de la std::function. Esto impide el inlining, ya que la resolución del destino de la llamada ocurre en tiempo de ejecución mediante un salto indirecto.

En el caso del tercer lambda, al capturar un std::unique_ptr, el objeto interno de la std::function crece. Si el tamaño de la captura excede el límite del buffer interno (SBO), la std::function reservará memoria en el heap para gestionar la vida útil de esa captura, añadiendo una sobrecarga de gestión de memoria que un lambda usado en un template no tendría.

Finalmente, el manager.subscribe(nulo) introduce un objeto std::function cuyo puntero interno es nullptr. Al intentar invocarlo en trigger_empty_callback, la librería estándar detecta la nulidad y lanza std::bad_function_call, a diferencia de un puntero a función de C que simplemente causaría un segmentation fault.

El error frecuente

Un error común es confundir la conveniencia de std::function con la eficiencia en código de alto rendimiento.

// CASO INEFICIENTE: El compilador no puede optimizar esto.
template <typename Func>
void procesar_datos_lento(Func f) {
    // f es un template parameter, el compilador puede hacer inlining
    f(42);
}

// El desarrollador usa esto por "comodidad" en un loop crítico
void procesar_datos_rapido(std::function<void(int)> f) {
    // f es una abstracción con indirección; NO hay inlining posible
    f(42);
}

Si usas std::function dentro de un bucle que se ejecuta millones de veces por segundo, el overhead de la indirección y la imposibilidad de realizar inlining pueden reducir el rendimiento drásticamente. Además, si el callable captura objetos con vida extendida, podrías estar provocando asignaciones en el heap de forma inadvertida en cada creación de la función. En C++23, si necesitas trabajar con callables que solo pueden moverse (como aquellos que capturan std::unique_ptr), la alternativa preferida es std::move_only_function, que es más eficiente al no requerir que el objeto sea copiable.

68

Dejar un comentario

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

Scroll al inicio