Como compilar um decorador - C ++, Python e sua própria implementação. Parte 1

Esta série de artigos será dedicada à possibilidade de criar um decorador em C ++, os recursos de seu trabalho em Python, e também consideraremos uma das opções para implementar essa funcionalidade em nossa própria linguagem compilada, usando a abordagem geral para criar closures - conversão de fechamento e modernização da árvore de sintaxe.



aviso Legal
, Python — . Python , (). - ( ), - ( , ..), Python «» .

Decorador em C ++


Tudo começou com o fato de meu amigo VoidDruid ter decidido escrever um pequeno compilador como diploma, cuja principal característica são os decoradores. Mesmo durante a pré-defesa, quando ele destacou todas as vantagens de sua abordagem, que incluíam a alteração do AST, eu me perguntava: é realmente impossível implementar esses mesmos decoradores no grande e poderoso C ++ e fazer sem termos e abordagens complicados? Pesquisando neste tópico, não encontrei nenhuma abordagem simples e geral para resolver esse problema (a propósito, encontrei apenas artigos sobre a implementação do padrão de design) e depois me sentei para escrever meu próprio decorador.


No entanto, antes de passar para uma descrição direta da minha implementação, gostaria de falar um pouco sobre como as lambdas e os fechamentos em C ++ são organizados e qual é a diferença entre eles. Faça imediatamente uma reserva de que, se não houver menção a um padrão específico, por padrão, quero dizer C ++ 20. Em resumo, lambdas são funções anônimas e encerramentos são funções que usam objetos de seu ambiente. Por exemplo, começando com C ++ 11, um lambda pode ser declarado e chamado assim:

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

Ou atribua seu valor a uma variável e chame-a mais tarde.

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

Mas o que acontece durante a compilação e o que é lambda? Para mergulhar na estrutura interna do lambda, basta ir ao site cppinsights.io e executar nosso primeiro exemplo. Em seguida, anexei uma conclusão possível:

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);
    }    
};


Portanto, ao compilar, o lambda se transforma em uma classe, ou melhor, em um functor (um objeto para o qual o operador () está definido ) com um nome exclusivo gerado automaticamente que possui um operator () , que aceita os parâmetros que passamos para o lambda e seu corpo contém o código que nosso lambda deve executar. Com isso, tudo fica claro, mas e os outros dois métodos, por que eles são? O primeiro é o operador de conversão para um ponteiro de função, cujo protótipo coincide com nosso lambda, e o segundo é o código que deve ser executado quando nosso lambda é chamado em sua atribuição preliminar ao ponteiro, por exemplo,

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

Bem, há menos enigmas, mas e os fechamentos? Vamos escrever o exemplo mais simples de fechamento que captura a variável “a” por referência e a aumenta em um.

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

Como você pode ver, o mecanismo para criar closures e lambdas em C ++ é quase o mesmo; portanto, esses conceitos costumam ser confusos e lambdas e closures são simplesmente chamados de lambdas.

Mas voltando à representação interna do fechamento em C ++.

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

Como você pode ver, adicionamos um novo construtor não padrão que pega nosso parâmetro por referência e o salva como membro da classe. Na verdade, é por isso que você precisa ser extremamente cuidadoso ao definir [&] ou [=], porque todo o contexto será armazenado dentro do fechamento e isso pode ser bastante subótimo da memória. Além disso, perdemos o operador de conversão para um ponteiro de função, porque agora é necessário o seu contexto de chamada normal. E agora o código acima não será compilado:

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

No entanto, se você ainda precisar passar o fechamento em algum lugar, ninguém cancelou o uso da função std ::.

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

Agora que descobrimos aproximadamente o que são lambdas e fechamentos em C ++, passemos a escrever diretamente o decorador. Mas primeiro, você precisa decidir sobre nossos requisitos.

Portanto, o decorador deve usar nossa função ou método como uma entrada, adicionar a funcionalidade necessária (por exemplo, isso será omitido) e retornar uma nova função quando for chamada, nosso código e o código da função / método forem executados. Neste ponto, qualquer pythonist que se preze diz: “Mas como assim! O decorador deve substituir o objeto original e qualquer chamada pelo nome deve chamar uma nova função! ” Apenas esta é a principal limitação do C ++, não podemos impedir que o usuário invoque a função antiga. Obviamente, existe uma opção para obter seu endereço na memória e triturá-lo (nesse caso, acessá-lo levará a um encerramento anormal do programa) ou substituir seu corpo por um aviso de que ele não deve ser usado no console, mas que está repleto de sérias conseqüências. Se a primeira opção parecer bastante difícil,o segundo, ao usar várias otimizações do compilador, também pode levar a uma falha e, portanto, não as usaremos. Além disso, o uso de qualquer macro mágica aqui considero redundante.

Então, vamos escrever o nosso decorador. A primeira opção que me veio à mente foi 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)...);
        };
    }
};

Seja uma estrutura com um método estático que recebe std :: function e retorne um fechamento que terá os mesmos parâmetros que nossa função e, quando chamado, chamará nossa função e retornará seu resultado.

Vamos criar uma função simples que queremos decorar.

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

E nosso principal ficará assim:

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


Tudo funciona, está tudo bem e, em geral, Hurrah.

Na verdade, esta solução tem vários problemas. Vamos começar em ordem:

  1. Esse código só pode ser compilado com a versão C ++ 14 e superior, pois não é possível conhecer o tipo retornado antecipadamente. Infelizmente, tenho que conviver com isso e não encontrei outras opções.
  2. make exige que std :: function seja passado para ele, e a passagem de uma função pelo nome leva a erros de compilação. E isso não é tão conveniente quanto gostaríamos! Não podemos escrever código como este:

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

  3. Além disso, não é possível decorar um método de classe.

Portanto, após uma breve conversa com colegas, a seguinte opção foi inventada para o C ++ 17 e 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)...
            );
        };
    }
};

As vantagens dessa opção específica são que agora podemos decorar absolutamente qualquer objeto que tenha um operador () . Por exemplo, podemos passar o nome de uma função livre, um ponteiro, um lambda, qualquer functor, std :: function e, claro, um método de classe. No caso deste último, também será necessário passar um contexto para ele ao chamar a função decodificada.

Opções de aplicação
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));
}


Além disso, esse código pode ser compilado com o C ++ 14 se houver uma extensão para o uso de std :: invoke, que precisa ser substituída por std :: __ invoke. Se não houver extensão, você terá que desistir da capacidade de decorar métodos de classe, e essa funcionalidade ficará indisponível.

Para não escrever o pesado "std :: forward <decltype (args)> (args) ...", você pode usar a funcionalidade disponível no C ++ 20 e fazer nosso lambda clichê!

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)...
            );
        };
    }
};

Tudo é perfeitamente seguro e até funciona da maneira que queremos (ou pelo menos fingimos). Esse código é compilado para as versões 10-x gcc e clang e você pode encontrá-lo aqui . Também haverá implementações para vários padrões.

Nos próximos artigos, passaremos à implementação canônica de decoradores usando o exemplo Python e sua estrutura interna.

All Articles