std::move_only_function: Type erasure para recursos únicos

El problema con std::function [C++11] es que es una abstracción “demasiado” completa: requiere que el callable almacenado sea CopyConstructible. Esto se debe a que el mecanismo de type erasure de std::function está diseñado para que el contenedor mismo pueda ser copiado, lo que obliga a que el objeto oculto también pueda clonarse. Si intentas capturar un std::unique_ptr en una lambda y guardarla en un std::function, el compilador fallará porque la lambda resultante no es copiable.

std::move_only_function [C++23] resuelve este cuello de botella. Es un envoltorio de type erasure que no exige la capacidad de copia, permitiendo almacenar lambdas que capturen recursos move-only como std::unique_ptr, std::packaged_task o cualquier objeto que haya anulado su operador de copia. Funciona mediante un puntero a función y un buffer de almacenamiento (a menudo mediante Small Object Optimization o SBO) que solo implementa la semántica de movimiento.

Debes usar std::move_only_function cuando tu arquitectura dependa de sistemas de callbacks, colas de tareas o máquinas de estado donde los objetos que ejecutan la acción sean dueños exclusivos de sus recursos. Si intentas usar std::function para esto, te enfrentarás a errores de compilación crípticos en la fase de instanciación de plantillas. Si intentas copiar un std::move_only_function, el código no compilará, ya que su semántica de copia está explícitamente eliminada para preservar la integridad de los recursos que contiene.

#include <iostream>
#include <functional> // Para std::move_only_function
#include <memory>
#include <vector>
#include <string>

// Un recurso que no se puede copiar, solo mover.
struct HeavyResource {
    std::string data;
    explicit HeavyResource(std::string value) : data(std::move(value)) {
        std::cout << "  [Resource] Creado: " << data << "\n";
    }
    ~HeavyResource() { std::cout << "  [Resource] Destruido: " << data << "\n"; }
    
    // Deshabilitamos la copia explícitamente para asegurar el comportamiento
    HeavyResource(const HeavyResource&) = delete;
    HeavyResource& operator=(const HeavyResource&) = delete;
    
    // Permitimos el movimiento
    HeavyResource(HeavyResource&&) noexcept = default;
    HeavyResource& operator=(HeavyResource&&) noexcept = default;
};

class TaskRunner {
public:
    // Almacenamos funciones que pueden capturar objetos move-only.
    void addTask(std::move_only_function<void()> task) {
        tasks_.push_back(std::move(task));
    }

    void executeAll() {
        std::cout << "Ejecutando tareas...\n";
        for (auto& task : tasks_) {
            if (task) { // Comprobación de validación (no es nulo)
                task();
            }
        }
        tasks_.clear();
    }

private:
    std::vector<std::move_only_function<void()>> tasks_;
};

int main() {
    TaskRunner runner;

    {
        // Capturamos un unique_ptr en la lambda. 
        // Esto haría que std::function fallara al compilar.
        auto resource = std::make_unique<HeavyResource>("Sensor_Data_01");
        
        runner.addTask([res = std::move(resource)]() {
            std::cout << "  [Task] Procesando: " << res->data << "\n";
        });
    } // El recurso se destruirá aquí, tras ejecutarse la tarea o al limpiar el runner.

    runner.executeAll();

    return 0;
}

Análisis técnico

En el ejemplo anterior, la clase TaskRunner utiliza std::vector<std::move_only_function<void()>> para gestionar una lista de tareas heterogéneas. Fíjate en la función addTask: acepta el wrapper por valor, lo que permite que el llamador mueva su lambda hacia dentro del contenedor mediante std::move(task).

La clave reside en la lambda definida en el main. Al hacer [res = std::move(resource)], estamos realizando un capture by move. La lambda resultante tiene un operator() que no es CopyConstructible porque contiene un std::unique_ptr. En versiones anteriores de C++, si intentáramos meter esta lambda en un std::function<void()>, el compilador intentaría instanciar el constructor de std::function que requiere que el objeto sea copiable para poder realizar el type erasure, lanzando un error masivo sobre la incapacidad de copiar el miembro res.

std::move_only_function evita este overhead. Internamente, la implementación no necesita gestionar una vtable que soporte operaciones de copia. Cuando runner.executeAll() invoca task(), se realiza un salto indirecto a través de un puntero a función (o una implementación de vtable simplificada si el objeto es grande) que opera sobre el almacenamiento donde reside el unique_ptr. Al llamar a tasks_.clear(), se invocan los destructores de las lambdas, lo que finalmente libera el HeavyResource de forma segura.

El error frecuente

Un error común es intentar usar std::move_only_function en un contenedor que requiere semántica de copia, como un std::vector que se intenta copiar.

// ERROR: std::vector no puede ser copiado si contiene move_only_function
std::vector<std::move_only_function<void()>> v1;
v1.push_back([](){});

std::vector<std::move_only_function<void()>> v2 = v1; // ERROR de compilación

Aunque std::vector puede crecer mediante reasignaciones (usando el constructor de movimiento de sus elementos), cualquier operación que requiera duplicar el contenedor fallará. Esto es un comportamiento esperado y necesario para garantizar la integridad de los recursos move-only. Si necesitas que un contenedor de funciones sea copiable, debes usar std::function, pero entonces estarás limitado a callables que no posean recursos únicos. Si el compilador te da un error de std::vector durante una reasignación de capacidad, asegúrate de que el vector tenga suficiente reserve() para evitar movimientos innecesarios o, más importante, verifica si estás intentando pasar el contenedor por valor en una función.

150

Dejar un comentario

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

Scroll al inicio