Как скомпилировать декоратор — C++, Python и собственная реализация. Часть 1

    Данная серия статей (как выяснилось, целых две) будет посвящена возможности создания декоратора в языке С++, особенностям их работы в Python, а также будет рассмотрен один из вариантов реализации данного функционала в собственном компилируемом языке, посредством применения общего подхода для создания замыканий — closure conversion и модернизации синтаксического дерева. Вторая часть уже доступна: здесь.



    Дисклеймер
    В данной статье под декоратором понимается не паттерн проектирования, а декоратор в Python — способ изменить поведение функции. Декоратор в Python это функция, применяемая к другой (декорируемой). Функция-декоратор создает замыкание (новую функцию), вызывающее декорируемую функцию внутри себя и делающее что-то еще нужное программисту (логгирование вызовов, захват ресурсов и т.д.), а интерпретатор Python затем «привязывает» к названию целевой функции получившееся замыкание.

    Декоратор в С++


    Все началось с того, что мой товарищ VoidDruid решил в качестве диплома написать небольшой компилятор, ключевой фичей которого являются декораторы. Еще во время предзащиты, когда он расписывал все преимущества его подхода, заключавшегося в изменении AST, мне стало интересно: а неужели в великом и могучем С++ невозможно реализовать эти самые декораторы и обойтись без всяких сложных терминов и подходов? Прогуглив эту тему, я не нашел никаких простых и общих подходов к решению данной проблемы (к слову сказать, попадались лишь только статьи про реализацию паттерна проектирования) и тогда засел за написание своего собственного декоратора.


    Однако перед тем, как перейти к непосредственному описанию моей реализации, я бы хотел рассказать немного про то, как устроены лямбды и замыкания в С++ и какая между ними разница. Сразу оговорюсь, что если нет никаких упоминаний конкретного стандарта, то по умолчанию имеется в виду С++20. Если говорить коротко, то лямбды – это анонимные функции, а замыкания, функции, которые используют объекты из своего окружения. Так например, начиная с С++11, лямбду можно объявить и вызвать так:

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

    Или присвоить ее значение переменной и вызвать потом.

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

    Но что же происходит во время компиляции и что из себя представляет лямбда? Для того, чтобы погрузиться во внутреннее устройство лямбды достаточно перейти на сайт cppinsights.io и запустить наш первый пример. Далее я приложил возможный вывод:

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


    Итак, лямбда при компиляции превращается в класс, а точнее функтор (объект, для которого определен оператор() )с автоматически сгенерированным уникальным именем, у которого есть оператор (), который и принимает те параметры, которые мы передали нашей лямбде и его тело содержит тот код, который наша лямбда должна выполнять. С этим вроде, все понятно, а что же другие два метода, зачем они? Первый это оператор приведения к указателю на функцию, прототип которой совпадает с нашей лямбдой, а второй – код, который должен выполниться при вызове нашей лямбды при предварительном присвоении ее указателю, например так:

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

    Что ж, на одну загадку стало меньше, а как же дело обстоит с замыканиями? Напишем простейший пример замыкания, которое захватывает по ссылке переменную «а» и увеличивает ее на единицу.

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

    Как вы могли заметить, механизм создания замыканий и лямбд в С++ почти один и тот же, поэтому эти понятия довольно часто путаются между собой и лямбды и замыкания называют просто лямбдами.

    Но вернемся к внутреннему представлению замыкания в С++.

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

    Как вы можете заметить, у нас добавился новый, не дефолтный конструктор, принимающий наш параметр по ссылке и сохраняющий его как член класса. Собственно, именно поэтому нужно быть предельно внимательным при выставлении [&] или [=], ведь весь контекст замыкание будет хранить внутри себя, а это может быть довольно неоптимально по памяти. Кроме того, у нас пропал оператор приведения к указателю на функцию, ведь теперь для ее нормального вызова необходим контекст. И теперь вышеописанный код не скомпилируется:

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

    Однако, если вам все же нужно куда-то передать замыкание, никто не отменял использование std::function.

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

    Теперь, когда мы примерно разобрались, что представляют из себя лямбды и замыкания в С++, давайте перейдем к непосредственному написанию декоратора. Но для начала, необходимо определиться с нашими требованиями.

    Итак, декоратор должен принимать на вход нашу функцию или метод, добавлять к ней необходимый нам функционал (для примера это будет опущено) и возвращать новую функцию при вызове которой происходит выполнение нашего кода и кода функции/метода. На этом моменте любой уважающий себя питонист скажет: «Но как же! Декоратор должен заменять исходный объект и любое обращение к нему по имени должно вызвать уже новую функцию!». Как раз в этом и есть основное ограничение С++, мы не можем никак помешать пользователю вызвать старую функцию. Конечно, есть вариант получить ее адрес в памяти и перетереть (в таком случае обращение к ней приведет к аварийному завершению программы) или заменить ее тело на вывод в консоль предупреждения о том, что ее не нужно использовать, но это чревато серьезными последствиями. Если первый вариант вообще кажется довольно жестким, то второй, при использовании различных оптимизаций компилятора, тоже может привести к падению, а поэтому их мы использовать не будем. Также, использование любой макросной магии здесь я считаю излишним.

    Итак, перейдем к написанию нашего декоратора. Первый вариант, который пришел мне на ум был следующим:

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

    Пусть это будет структура со статическим методом, который принимает std::function и возвращает замыкание, которое будет принимать те же параметры, что и наша функция и при вызове будет просто вызывать нашу функцию и возвращать ее результат.

    Давайте создадим простую функцию, которую мы хотим задекорировать.

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

    И наш main будет выглядеть следующим образом:

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


    Все работает, все прекрасно и вообще Ура.

    Собственно, у данного решения есть несколько проблем. Начнем по порядку:

    1. Этот код может собраться только при версии С++14 и выше, так как невозможно узнать заранее возвращаемый тип. К сожалению, с этим придется жить и я не нашел других вариантов.
    2. make требует, чтобы ему передавали именно std::function, а передача функции по имени приводит к ошибкам компиляции. И это совсем не так удобно, как хотелось бы! Мы не можем писать код наподобие этого:

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

    3. Также, невозможно задекорировать метод класса.

    Поэтому, после небольшого разговора с коллегами, был придуман следующий вариант для С++17 и выше:

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

    Преимущества именно этого варианта состоят в том, что теперь мы можем декорировать абсолютно любой объект у которого есть оператор(). Так например, мы можем передать имя свободной функции, указатель, лямбду, любой функтор, std::function и конечно же метод класса. В случае с последним необходимо будет также при вызове задекорированной функции передать ей контекст.

    Варианты применения
    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));
    }


    Кроме того, этот код можно собрать с С++14 если есть расширение для использования std::invoke, который нужно заменить на std::__invoke. Если же расширения нет – то придется отказаться от возможности декорировать методы класса, а данный функционал станет недоступным.

    Чтобы не писать громоздкое «std::forward<decltype(args)>(args)...» можно воспользоваться функционалом, доступным с С++20 и сделать нашу лямбду шаблонной!

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

    Все прекрасно-безопасно и даже работает так, как мы хотим (или, по крайней мере, делает вид). Данный код собирается и под gcc и под clang 10-x версий и найти его можно вот здесь. Там же будут лежать реализации для различных стандартов.

    В следующей статьях мы перейдем к каноничной реализации декораторов на примере Python и их внутреннему устройству.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 8

      0
      Скажите, почему вы объявляете Decorator как struct, в то время как фактически это namespace? Мне такие struct-ы попадаются в старом коде, вероятно, написанном до появления namespace (сейчас попробовал найти, когда это произошло, и не нашёл — как будто фича namespace в C++ была всегда). Или вы планируете добавить не-static содержимое, или защищаете Decorator от сторонних добавлений?
        0
        Изначально, в первой версии декоратора, у меня шаблонной была именно структура. А только потом я догадался, как можно лучше это сделать. Так что в принципе, вы действительно можете использоваться namespace или вообще сделать функцию свободной)
        0
        не стоит в лямбду захватывать ссылку
          0
          А как бы вы это сделали? [f = std::move(function)]?
            0
            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)...);
              };
            }

            можно не переименовать, т.к. func слева от = в захвате относится к неймспейсу лямбды
              0
              Круто, спасибо за замечание и предложенное решение) У меня почему-то были ошибки компиляции, когда я пытался сделать decltype) Наверное, не там ставил троеточие)
          +1
          Картинка ввела в заблуждение — ожидал применения тяжёлой артиллерии в виде Clang libtooling.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.