Comment compiler un décorateur - C ++, Python et sa propre implémentation. Partie 1

Cette série d'articles sera consacrée à la possibilité de créer un décorateur en C ++, les caractéristiques de leur travail en Python, et l'une des options pour implémenter cette fonctionnalité dans son propre langage compilé sera considérée, en utilisant l'approche générale pour créer des fermetures - conversion de fermeture et modernisation de l'arbre de syntaxe.



Avertissement
, Python — . Python , (). - ( ), - ( , ..), Python «» .

Décorateur en C ++


Tout a commencé avec le fait que mon ami VoidDruid a décidé d'écrire un petit compilateur en guise de diplôme, dont la caractéristique principale est les décorateurs. Même pendant la pré-défense, lorsqu'il a souligné tous les avantages de son approche, qui comprenait la modification de l'AST, je me suis demandé: est-il vraiment impossible de mettre en œuvre ces mêmes décorateurs dans le grand et puissant C ++ et de se passer de termes et d'approches compliqués? Sur ce sujet, je n'ai trouvé aucune approche simple et générale pour résoudre ce problème (à propos, je n'ai trouvé que des articles sur la mise en œuvre du modèle de conception), puis je me suis assis pour écrire mon propre décorateur.


Cependant, avant de passer à une description directe de mon implémentation, je voudrais parler un peu de la façon dont les lambdas et les fermetures en C ++ sont arrangés et quelle est la différence entre eux. Faites immédiatement une réserve que s'il n'y a aucune mention d'une norme spécifique, alors par défaut, je veux dire C ++ 20. En bref, les lambdas sont des fonctions anonymes et les fermetures sont des fonctions qui utilisent des objets de leur environnement. Ainsi, par exemple, à partir de C ++ 11, un lambda peut être déclaré et appelé comme ceci:

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

Ou attribuez sa valeur à une variable et appelez-la plus tard.

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

Mais que se passe-t-il pendant la compilation et qu'est-ce que lambda? Afin de vous immerger dans la structure interne du lambda, allez simplement sur le site Web cppinsights.io et exécutez notre premier exemple. Ensuite, j'ai joint une conclusion possible:

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


Ainsi, lors de la compilation, le lambda se transforme en classe, ou plutôt en foncteur (un objet pour lequel l' opérateur () est défini ) avec un nom unique généré automatiquement, qui a un opérateur () , qui accepte les paramètres que nous avons passés à notre lambda et que son corps contient le code que notre lambda doit exécuter. Avec cela, tout est clair, mais qu'en est-il des deux autres méthodes, pourquoi sont-elles? Le premier est l'opérateur de transtypage en un pointeur de fonction, dont le prototype coïncide avec notre lambda, et le second est le code qui devrait être exécuté lorsque notre lambda est appelé sur une affectation préliminaire à son pointeur, par exemple comme ceci:

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

Eh bien, il y a moins d'une énigme, mais qu'en est-il des fermetures? Écrivons l'exemple le plus simple d'une fermeture qui capture la variable «a» par référence et l'augmente d'une unité.

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

Comme vous pouvez le voir, le mécanisme de création de fermetures et de lambdas en C ++ est presque le même, donc ces concepts sont souvent confondus et les lambdas et les fermetures sont simplement appelées lambdas.

Mais revenons à la représentation interne de la fermeture en C ++.

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

Comme vous pouvez le voir, nous avons ajouté un nouveau constructeur non par défaut qui prend notre paramètre par référence et l'enregistre en tant que membre de la classe. En fait, c'est pourquoi vous devez être extrêmement prudent lorsque vous définissez [&] ou [=], car tout le contexte sera stocké dans la fermeture, et cela peut être assez sous-optimal de la mémoire. De plus, nous avons perdu l'opérateur de transtypage en un pointeur de fonction, car maintenant, pour son contexte d'appel normal, il est nécessaire. Et maintenant, le code ci-dessus ne se compilera pas:

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

Cependant, si vous devez toujours passer la fermeture quelque part, personne n'a annulé l'utilisation de la fonction std ::.

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

Maintenant que nous avons approximativement compris ce que sont les lambdas et les fermetures en C ++, passons directement à l'écriture du décorateur. Mais d'abord, vous devez décider de nos besoins.

Ainsi, le décorateur doit prendre notre fonction ou méthode en entrée, y ajouter les fonctionnalités dont nous avons besoin (par exemple, cela sera omis) et retourner une nouvelle fonction lors de son appel, qui exécute notre code et le code de fonction / méthode. À ce stade, tout pythoniste qui se respecte dira: «Mais comment ça! Le décorateur doit remplacer l'objet d'origine et tout appel à celui-ci par son nom doit appeler une nouvelle fonction! » C'est juste la principale limitation de C ++, nous ne pouvons pas empêcher l'utilisateur d'invoquer l'ancienne fonction. Bien sûr, il existe une option pour obtenir son adresse en mémoire et la broyer (dans ce cas, y accéder entraînera une fin anormale du programme) ou remplacer son corps par un avertissement qu'il ne doit pas être utilisé dans la console, mais cela est lourd de conséquences graves. Si la première option semble assez difficile,alors le second, lors de l'utilisation de diverses optimisations du compilateur, peut également entraîner un plantage, et nous ne les utiliserons donc pas. De plus, l'utilisation de n'importe quelle magie macro ici est redondante.

Passons donc à l'écriture de notre décorateur. La première option qui m'est venue à l'esprit était la suivante:

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

Soit une structure avec une méthode statique qui prend std :: function et retourne une fermeture qui prendra les mêmes paramètres que notre fonction et lorsqu'elle sera appelée, elle appellera simplement notre fonction et retournera son résultat.

Créons une fonction simple que nous voulons décorer.

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

Et notre principal ressemblera à ceci:

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


Tout fonctionne, tout va bien et en général Hourra.

En fait, cette solution pose plusieurs problèmes. Commençons dans l'ordre:

  1. Ce code ne peut être compilé qu'avec les versions C ++ 14 et supérieures, car il n'est pas possible de connaître le type retourné à l'avance. Malheureusement, je dois vivre avec cela et je n'ai pas trouvé d'autres options.
  2. make requiert que std :: function lui soit passé, et passer une fonction par son nom conduit à des erreurs de compilation. Et ce n'est pas du tout aussi pratique que nous le souhaiterions! Nous ne pouvons pas écrire de code comme ceci:

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

  3. De plus, il n'est pas possible de décorer une méthode de classe.

Par conséquent, après une courte conversation avec des collègues, l'option suivante a été inventée pour C ++ 17 et supérieur:

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

Les avantages de cette option particulière sont que maintenant nous pouvons décorer absolument n'importe quel objet qui a un opérateur () . Ainsi, par exemple, nous pouvons passer le nom d'une fonction libre, un pointeur, un lambda, n'importe quel foncteur, std :: function, et bien sûr une méthode de classe. Dans ce dernier cas, il faudra également lui passer un contexte lors de l'appel de la fonction décodée.

Options d'application
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));
}


De plus, ce code peut être compilé avec C ++ 14 s'il existe une extension pour utiliser std :: invoke, qui doit être remplacée par std :: __ invoke. S'il n'y a pas d'extension, vous devrez renoncer à la possibilité de décorer les méthodes de classe et cette fonctionnalité deviendra indisponible.

Afin de ne pas écrire le lourd "std :: forward <decltype (args)> (args) ...", vous pouvez utiliser la fonctionnalité disponible avec C ++ 20 et faire de notre lambda passe-partout!

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

Tout est parfaitement sûr et fonctionne même comme nous le voulons (ou du moins fait semblant). Ce code est compilé pour les versions gcc et clang 10-x et vous pouvez le trouver ici . Il y aura également des implémentations pour diverses normes.

Dans les prochains articles, nous allons passer à l'implémentation canonique des décorateurs en utilisant l'exemple Python et leur structure interne.

All Articles