std::invoke y la unificación de callables en C++

Cuando escribes código genérico en C++, a menudo te encuentras con el problema de la “dualidad de invocación”. No es lo mismo llamar a una función libre (f(x)) que llamar a un método de una clase ((obj.*ptr)(x) o (ptr->*func)(x)) o incluso acceder a un miembro de datos. Antes de C++17, si querías escribir una función de orden superior que aceptara “cualquier cosa que se pueda llamar”, tenías que sobrecargar tu función o usar plantillas extremadamente complejas para diferenciar entre punteros a función y punteros a función miembro.

std::invoke [C++17] soluciona esto proporcionando una interfaz única para invocar cualquier objeto callable. Internamente, std::invoke detecta el tipo de argumento que le pasas: si es un puntero a función miembro, realiza la desreferencia y la llamada al método con el primer argumento como objeto; si es una función normal, simplemente la llama. Esto permite que las librerías genéricas traten a un lambda, un std::function, una función clásica y un método de clase de la misma manera.

Para usar esto en meta-programación, contamos con std::is_invocable_v [C++17], que nos permite verificar en tiempo de compilación si un tipo F puede ser llamado con una lista de argumentos Args..., y std::invoke_result_t [C++17], que nos devuelve el tipo de retorno de esa llamada. Si intentas invocar algo que no es invocable, el compilador lanzará un error, pero si estás en un contexto de plantillas, lo ideal es usar if constexpr con std::is_invocable para evitar errores de compilación en ramas que no se ejecutan.

En el desarrollo moderno, aunque std::bind [C++11] existía para crear wrappers con argumentos pre-enlazados (usando std::placeholders), la recomendación actual es usar lambdas. Los lambdas son más fáciles de leer, más fáciles de entender para el compilador (lo que facilita el inlining) y evitan la complejidad sintáctica de los placeholders. Sin embargo, cuando necesitas pasar una función miembro a un contexto que solo acepta funciones libres, std::mem_fn [C++11] sigue siendo una herramienta útil para adaptar ese puntero a miembro.

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

struct Sensor {
    int id;
    // Un método que requiere un objeto para ser llamado
    void report(const std::string& mensaje) const {
        std::cout << "Sensor " << id << " reporta: " << mensaje << "\n";
    }
    // Un miembro de datos
    int status_code = 200;
};

void global_logger(const std::string& msg) {
    std::cout << "[Global] " << msg << "\n";
}

// Una función genérica que no sabe qué es 'F', solo sabe que se puede invocar
template <typename F, typename... Args>
auto generic_executor(F&& f, Args&&... args) {
    // Verificamos si la invocación es válida antes de intentar hacerlo
    if constexpr (std::is_invocable_v<F, Args...>) {
        // std::invoke unifica la sintaxis de la llamada
        // std::invoke_result_t obtiene el tipo de retorno para el valor de retorno
        using return_type = std::invoke_result_t<F, Args...>;
        
        if constexpr (std::is_void_v<return_type>) {
            std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
            return;
        } else {
            return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
        }
    } else {
        static_assert(std::is_invocable_v<F, Args...>, "El objeto no es invocable con estos argumentos");
    }
}

int main() {
    Sensor s{42};

    // 1. Llamada a función libre
    generic_executor(global_logger, "Sistema iniciado");

    // 2. Llamada a un método de clase (usando el objeto como primer argumento)
    generic_executor(&Sensor::report, s, "Todo OK");

    // 3. Llamada con un lambda
    auto lambda = [](int val) { std::cout << "Lambda con: " << val << "\n"; };
    generic_executor(lambda, 100);

    // 4. Uso de std::mem_fn para adaptar un método de clase
    auto member_caller = std::mem_fn(&Sensor::report);
    generic_executor(member_caller, s, "Usando mem_fn");

    // 5. Uso de std::not_fn [C++17] para negar un predicado
    auto is_even = [](int n) { return n % 2 == 0; };
    auto is_odd = std::not_fn(is_even);
    std::cout << "¿Es 3 impar? " << std::boolalpha << is_odd(3) << "\n";

    // 6. Uso de std::bind con std::ref para evitar copias innecesarias
    // std::bind captura por valor por defecto; std::ref es crítico aquí
    auto bound_func = std::bind(global_logger, std::ref(s.id)); // Error conceptual: id es int, no es función
    // Corregido para el ejemplo: bindamos una función que reciba el objeto
    auto bound_report = std::bind(&Sensor::report, &s, std::placeholders::_1);
    generic_executor(bound_report, "Desde bind");

    return 0;
}

Desglose del código

En el generic_executor, la magia reside en std::invoke. Fíjate que no importa si F es &Sensor::report o global_logger. Si F es un puntero a miembro, std::invoke descompone la llamada internamente para tratar a s (el primer argumento) como el objeto sobre el cual ejecutar el método.

He utilizado std::is_invocable_v y std::invoke_result_t para hacer el código robusto. La instrucción if constexpr es vital: permite que el compilador descarte la rama que no cumple la condición durante la fase de instanciación de la plantilla. Si intentáramos compilar std::invoke con un tipo incompatible sin este chequeo, el compilador fallaría al intentar generar el código para esa instanciación específica, aunque el programa parezca “correcto” en otras partes.

En el caso de std::bind, es crucial observar cómo funciona la captura. std::bind crea un nuevo objeto que almacena los argumentos proporcionados. Si pasas un objeto por valor, se guardará una copia dentro del objeto devuelto por bind. Por eso, en sistemas de alto rendimiento, si necesitas que el callable trabaje con el objeto original, debes envolverlo en std::ref.

El error frecuente

Un error clásico al usar std::bind o pasar funciones a hilos con std::async es olvidar que los argumentos se copian por defecto.

// ERROR: El objeto 'data' se copia dentro del binder
int data = 10;
auto bad_bind = std::bind([](int& n) { n += 1; }, data); 
bad_bind(); // 'data' sigue valiendo 10, porque bind operó sobre una copia

// SOLUCIÓN: Usar std::ref
auto good_bind = std::bind([](int& n) { n += 1; }, std::ref(data));
good_bind(); // 'data' ahora vale 11

Este error es especialmente peligroso porque no siempre lanza un error de compilación; a menudo simplemente produce un comportamiento lógico incorrecto que es un dolor de cabeza depurar. Además, si intentas pasar una referencia a un objeto temporal a std::bind sin std::ref, estarás creando un dangling reference dentro del objeto binder, lo que resultará en un comportamiento indefinido en cuanto el binder sea invocado. Herramientas como AddressSanitizer (usando -fsanitize=address) son esenciales para detectar estos casos de uso de memoria tras la vida del objeto.

132

Dejar un comentario

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

Scroll al inicio