Cómo compilar un decorador: C ++, Python y su propia implementación. Parte 1

Esta serie de artículos estará dedicada a la posibilidad de crear un decorador en C ++, las características de su trabajo en Python, y también consideraremos una de las opciones para implementar esta funcionalidad en nuestro propio lenguaje compilado, utilizando el enfoque general para crear cierres: conversión de cierre y modernización del árbol de sintaxis.



Descargo de responsabilidad
, Python — . Python , (). - ( ), - ( , ..), Python «» .

Decorador en C ++


Todo comenzó con el hecho de que mi amigo VoidDruid decidió escribir un pequeño compilador como diploma, cuya característica clave son los decoradores. Incluso durante la defensa previa, cuando describió todas las ventajas de su enfoque, que incluía cambiar el AST, me preguntaba: ¿es realmente imposible implementar estos mismos decoradores en el gran y poderoso C ++ y prescindir de términos y enfoques complicados? Buscando en Google este tema, no encontré ningún enfoque simple y general para resolver este problema (por cierto, solo encontré artículos sobre la implementación del patrón de diseño) y luego me senté a escribir mi propio decorador.


Sin embargo, antes de pasar a una descripción directa de mi implementación, me gustaría hablar un poco sobre cómo se organizan las lambdas y los cierres en C ++ y cuál es la diferencia entre ellos. Inmediatamente haga una reserva de que si no se menciona un estándar específico, por defecto me refiero a C ++ 20. En resumen, las lambdas son funciones anónimas y los cierres son funciones que utilizan objetos de su entorno. Entonces, por ejemplo, comenzando con C ++ 11, una lambda se puede declarar y llamar así:

int main() 
{
    [] (int a) 
    {
        std::cout << a << std::endl;
    }(10);
}

O asigne su valor a una variable y llámelo más tarde.

int main() 
{
    auto lambda = [] (int a) 
    {
        std::cout << a << std::endl;
    };
    lambda(10);
}

Pero, ¿qué sucede durante la compilación y qué es lambda? Para sumergirse en la estructura interna de la lambda, solo vaya al sitio web cppinsights.io y ejecute nuestro primer ejemplo. A continuación, adjunto una posible conclusión:

class __lambda_60_19
{
public: 
    inline void operator()(int a) const
    {
        std::cout.operator<<(a).operator<<(std::endl);
    }
    
    using retType_60_19 = void (*)(int);
    inline operator retType_60_19 () const noexcept
    {
        return __invoke;
    };
    
private: 
    static inline void __invoke(int a)
    {
        std::cout.operator<<(a).operator<<(std::endl);
    }    
};


Entonces, al compilar, el lambda se convierte en una clase, o más bien un functor (un objeto para el cual se define el operador () ) con un nombre único generado automáticamente que tiene un operador () , que toma los parámetros que pasamos a nuestro lambda y su cuerpo contiene El código que debe ejecutar nuestra lambda. Con esto, todo está claro, pero ¿qué pasa con los otros dos métodos, por qué son? El primero es el operador de conversión a un puntero de función, cuyo prototipo coincide con nuestro lambda, y el segundo es el código que debe ejecutarse cuando se llama a nuestro lambda en su asignación preliminar al puntero, por ejemplo así:

void (*p_lambda) (int) = lambda;
p_lambda(10);

Bueno, hay menos acertijos, pero ¿qué pasa con los cierres? Escribamos el ejemplo más simple de un cierre que captura la variable "a" por referencia y la aumenta en uno.

int main()
{
    int a = 10;
    auto closure = [&a] () { a += 1; };
    closure();
}

Como puede ver, el mecanismo para crear cierres y lambdas en C ++ es casi el mismo, por lo que estos conceptos a menudo se confunden y los lambdas y cierres simplemente se llaman lambdas.

Pero volvamos a la representación interna del cierre en C ++.

class __lambda_61_20
{
public:
    inline void operator()()
    {
        a += 1;
    }
private:
    int & a;
public:
    __lambda_61_20(int & _a)
    : a{_a}
    {}
};

Como puede ver, hemos agregado un nuevo constructor no predeterminado que toma nuestro parámetro como referencia y lo guarda como miembro de la clase. En realidad, es por eso que debe ser extremadamente cuidadoso al configurar [&] o [=], porque todo el contexto se almacenará dentro del cierre, y esto puede ser bastante subóptimo de la memoria. Además, perdimos el operador de conversión a un puntero de función, porque ahora se necesita su contexto de llamada normal. Y ahora el código anterior no se compilará:

int main()
{
    int a = 10;
    auto closure = [&a] () { a += 1; };
    closure();
    void (*ptr)(int) = closure;
}

Sin embargo, si aún necesita pasar el cierre en alguna parte, nadie ha cancelado el uso de la función std ::.

std::function<void()> function = closure;
function();

Ahora que hemos descubierto aproximadamente qué lambdas y cierres hay en C ++, pasemos a escribir el decorador directamente. Pero primero, debe decidir sobre nuestros requisitos.

Entonces, el decorador debe tomar nuestra función o método como entrada, agregarle la funcionalidad que necesitamos (por ejemplo, esto se omitirá) y devolver una nueva función cuando se le llame, que ejecuta nuestro código y el código de función / método. En este punto, cualquier pitonista respetuoso dirá: “¡Pero cómo es eso! ¡El decorador debe reemplazar el objeto original y cualquier llamada al mismo debe llamar a una nueva función! Esta es la principal limitación de C ++, no podemos evitar que el usuario invoque la función anterior. Por supuesto, hay una opción para obtener su dirección en la memoria y molerla (en este caso, acceder a ella dará lugar a una terminación anormal del programa) o reemplazar su cuerpo con una advertencia de que no debe usarse en la consola, pero esto está lleno de graves consecuencias. Si la primera opción parece bastante difícil,luego, el segundo, cuando se utilizan varias optimizaciones de compilador, también puede provocar un bloqueo y, por lo tanto, no las utilizaremos. Además, el uso de cualquier macro magia aquí lo considero redundante.

Entonces, pasemos a escribir nuestro decorador. La primera opción que me vino a la mente fue esta:

namespace Decorator
{
    template<typename R, typename ...Args>
    static auto make(const std::function<R(Args...)>& f)
    {
        std::cout << "Do something" << std::endl;
        return [=](Args... args) 
        {
            return f(std::forward<Args>(args)...);
        };
    }
};

Supongamos que es una estructura con un método estático que toma std :: function y devuelve un cierre que tomará los mismos parámetros que nuestra función y, cuando se llame, simplemente llamará a nuestra función y devolverá su resultado.

Creemos una función simple que queremos decorar.

void myFunc(int a)
{
    std::cout << "here" << std::endl;
}

Y nuestro principal se verá así:

int main()
{
    std::function<void(int)> f = myFunc;
    auto decorated = Decorator::make(f);
    decorated(10);
}


Todo funciona, todo está bien y en general Hurra.

En realidad, esta solución tiene varios problemas. Comencemos en orden:

  1. Este código solo se puede compilar con la versión C ++ 14 y superior, ya que no es posible conocer el tipo devuelto de antemano. Desafortunadamente, tengo que vivir con esto y no encontré otras opciones.
  2. make requiere que se le pase std :: function, y pasar una función por su nombre conduce a errores de compilación. ¡Y esto no es tan conveniente como nos gustaría! No podemos escribir código como este:

    Decorator::make([](){});
    Decorator::make(myFunc);
    void(*ptr)(int) = myFunc;
    Decorator::make(ptr);

  3. Además, no es posible decorar un método de clase.

Por lo tanto, después de una breve conversación con colegas, se inventó la siguiente opción para C ++ 17 y superior:

namespace Decorator
{
    template<typename Function>
    static auto make(Function&& func)
    {
        return [func = std::forward<Function>(func)] (auto && ...args) 
        {
            std::cout << "Do something" << std::endl;
            return std::invoke(
                func,
                std::forward<decltype(args)>(args)...
            );
        };
    }
};

Las ventajas de esta opción en particular son que ahora podemos decorar absolutamente cualquier objeto que tenga un operador () . Entonces, por ejemplo, podemos pasar el nombre de una función libre, un puntero, una lambda, cualquier functor, función std :: y, por supuesto, un método de clase. En el caso de este último, también será necesario pasarle un contexto al llamar a la función decodificada.

Opciones de aplicación
int main()
{
    auto decorated_1 = Decorator::make(myFunc);
    decorated_1(1,2);

    auto my_lambda = [] (int a, int b) 
    { 
        std::cout << a << " " << b <<std::endl; 
    };
    auto decorated_2 = Decorator::make(my_lambda);
    decorated_2(3,4);

    int (*ptr)(int, int) = myFunc;
    auto decorated_3 = Decorator::make(ptr);
    decorated_3(5,6);

    std::function<void(int, int)> fun = myFunc;
    auto decorated_4 = Decorator::make(fun);
    decorated_4(7,8);

    auto decorated_5 = Decorator::make(decorated_4);
    decorated_5(9, 10);

    auto decorated_6 = Decorator::make(&MyClass::func);
    decorated_6(MyClass(10));
}


Además, este código se puede compilar con C ++ 14 si hay una extensión para usar std :: invoke, que debe reemplazarse con std :: __ invoke. Si no hay una extensión, tendrá que renunciar a la capacidad de decorar métodos de clase, y esta funcionalidad no estará disponible.

¡Para no escribir el engorroso "std :: forward <decltype (args)> (args) ...", puede usar la funcionalidad disponible con C ++ 20 y hacer nuestra placa lambda!

namespace Decorator
{
    template<typename Function>
    static auto make(Function&& func)
    {
        return [func = std::forward<Function>(func)] 
        <typename ...Args> (Args && ...args) 
        {
            return std::invoke(
                func,
                std::forward<Args>(args)...
            );
        };
    }
};

Todo es perfectamente seguro e incluso funciona de la manera que queremos (o al menos finge). Este código está compilado para las versiones gcc y clang 10-x y puede encontrarlo aquí . También habrá implementaciones para varios estándares.

En los siguientes artículos, pasaremos a la implementación canónica de decoradores usando el ejemplo de Python y su estructura interna.

All Articles