Декораторы — одна из самых необычных особенностей Python. Это инструмент, который полноценно может существовать только в динамически типизированном, интерпретируемом языке. В первой части статьи мой товарищ Witcher136 показал, как в С++ реализовать наиболее приближенную к эталонной (питоновской) версию декораторов. Я же расскажу про то, как решил попытаться реализовать декораторы в компилируемом языке программирования, для чего в итоге написал написал собственный небольшой компилятор на Haskell на основе LLVM.  Перед погружением в алгоритм компиляции декораторов, поговорим про реализацию декораторов в питоне, и почему она не может быть в таком же виде воспроизведена в компилируемом языке. Сразу отмечу — в данной статье под питоном понимается CPython. Все подкапотные детали относятся только к нему. Декораторы могут изменять сигнатуры функций, к которым применяются, менять собственный результат в зависимости от параметров, известных только во время исполнения, и так далее — все это слабо соотносится с представлениями о поведении функций в компилируемых, статически типизированных языках. Как работают декораторы в Python, в общем-то интуитивно понятно любому человеку, знакомому с этим языком: Это вполне логичное и простое объяснение — и, в общем-то, верное — оно правильно описывает общую концепцию, но есть несколько важных деталей. Интерпретатор питона является виртуальной машиной для Python-байткода. Вирутальная же машина, в свою очередь является стэк-машиной — несколько упрощая, можно сказать, что она имеет набор встроенных инструкций и стек, на котором вперемешку лежат данные и коды инструкций. Когда интерпретатор встречает на стеке команду, он пытается ее выполнить, использая как аргументы то, что лежит на стеке после нее — либо какие-то константы или явно переданные данные из "обычной" памяти. Благодаря этому, интерпретатору не надо знать о типах объектов, соответстующих символам в коде, вплоть до момент выполнения операций над ними — когда очередь дойдет до выполнения какой-либо "конкретной" инструкции тогда он и проверит тип. Сильно упрощая можно объяснить это так: BINARY_SUBSTRACT (вычитание) упадет с TypeError, если дальше на стэке лежат число 1 и строка 'a'. В тоже время, выполнение STORE_FAST для одного и того же имени (запись в одну и ту же переменную), когда один раз на стэке лежит число, а в другой раз строка, не приведет к TypeError, т.к. в инструкцию по выполнению команды STORE_FAST не входит проверка типа — только связывание имени с новым объектом. Создание замыканий, таких как new в примере выше — это также одна из инструкций виртуальной машины. А если разобрать байт-код, соответствующий применению декоратора, то можно увидеть, что это действительно просто вызов функции decorator с соответствующими аргументами и сохранение результата под именем old. Декораторы применяются в рантайме. В примере выше значение decorator может меняться вплоть до самого его использования, например вот так: С точки зрения нашего умозрительного компилятора, значение функции old может поменяться на что угодно в процессе выполнения программы. В некоторых языках (например, C++) замыкания реализованы так, что даже при одинаковой сигнатуре они будут разного типа (из-за разницы в захваченном ими окружении), что не позволит провернуть такой трюк. В Python же каждое замыкание носит всё свое окружение с собой — в питоне всё, включая функции, это объекты, так что замыкания "могут себе это позволить", тем более потребление памяти и быстродействие не являются приоритетом для этого языка. Теоретически, это можно обойти, сделав old указателем на void-функцию, значение которого равно результату применения декоратора, и тогда нам не будет важен его реальный тип — однако это низкоуровневый хак, который убивает любые гарантии, даваемые системой типов, и заставляющий программиста самого следить за типами. С похожей проблемой связана и следующая особенность декораторов, очевидная в контексте Python, но создающая огромные проблемы для статически типизированного языка: сигнатура получившейся после применения декоратора функции может не совпадать с сигнатурой декорируемой функции. Допустим, наш компилятор научился применять декораторы. Тогда он может просто считать функцию one_arg функцией с таким же возвращаемым значением и аргументами, как и у замыкания внутри декоратора (на которое она заменяется) — однако любому, кто будет читать такой код, будет непонятно, почему эта функция имеет такой тип (например, если декоратор "библиотечный" и его исходников нет в коде самого приложения). Кроме того, что если декораторов применено несколько? Тогда понять сигнатуру функции "на глаз" будет очень сложно. Кроме того, для этого декораторы должны применятся на этапе компиляции и от варианта с их применением во время исполнения, где decorator может быть изменяемым указателем на функцию-декоратор, придется отказаться. Альтернативным решением является проверять на этапе компиляции, что тип замыкания, возвращаемого декоратором, наоборот — полностью совпадает с типом декорируемой функции. Но для этого такой язык должен обладать довольно продвинутой системой типов, различающей функции по типу аргументов и возвращаемых значений. Это также подводит нас к обратной проблеме — какой тип должен быть у аргумента декоратора — в наших примерах это аргумент с названием func? Чаще всего этот аргумент, представляющий декорируемую функцию, вызвается внутри замыкания — значит нам хотелось бы знать хотя бы тип возвращаемого значения, не говоря уже об аргументах. Если мы его строго зафиксируем с помощью объявления func как функции типа A, мы ограничили область применения декоратора функциями типа A. Если же мы и это объявляем как void* func, и предлагаем программисту самому везде приводить нужные типы, то проще писать на питоне. Один из вариантов — выводить тип func автоматически, например с помощью шаблонов — так и сделал Witcher136 в предыдущей статье. Однако это не решает проблему с разными сигнатурыми возвращаемых замыканий, а так же делает определения декораторов совершенно нечитаемыми для простых смертных (см. примеры на C++ в предыдущей статье). Подведем итоги. Реализация декораторов в компилируемом языке создает следующие сложности: Пришло время признаться — в этом году я защищал дипломную работу по теме "Использование и реализация инструментов метапрограммирования в компилируемых языках", в которой и рассматривал декораторы, как пример инструмента удобного, но в оригинальном виде невозможного в компилируемом языке. В этой работе я предложил собственный вариант их реализации, написав небольшой исследовательский компилятор для демонстрации способа компиляции простых декораторов без использования замыканий. Про этот компилятор я и расскажу далее. Для создания компилятора я выбрал Haskell, как язык для написания фронтенда, и LLVM в качестве компиляторного бэкенда. Для Haskell есть замечательная библиотека llvm-hs, предоставляющая все необходимые биндинги к LLVM. В поставку самого языка также входит библиотека Parsec, предназначенная для создания парсеров, путем комбинации различных парсер-функций этой библиотеки (я думаю, что на этом моменте читатель догадался, что Parsec — сокращение от parser combinators). Я опущу описание синтаксиса получившегося языка, который я окрестил Grit, и всех его тривиальных деталей (язык достаточн похож на Си, чтоб не требовать лишних пояснений, по крайней мере по части синтаксиса) — остановимся подробно только на интересных его особенностях. Исходный код компилятора можно найти здесь. Любое выражение в Grit, будь то сложение, блок кода или if-else, возвращает результат, который можно использовать внутри любого другого выражения — например, присвоить переменной или передать функции как аргумент. В данном примере, i будет равен 1, если функция someFunction вернет положительное значение, и нулю, если вернется 0 или меньше. Функции в данном языке возвращают либо результат последнего выражения в своем теле, либо значение (на момент выхода из функции) специально указанной переменной. Это обсуловлено тем, что язык фразированный — тело функции это блок кода, который является валидным выражением Grit, а значит он уже должен возвращать значение — я решил, что разумнее всего сделать этим значением результат последнего выражения в блоке. А ключевое слово returns, описанное в примерах ниже — просто синтаксический сахар, эквивалентный выражению переменная; в конце блока. Обратите внимание, что поскольку блок кода является выражением, то тело функции как бы "присваивается" ей же — после объявления сигнатуры функции идет знак "равно", затем блок кода и в конце точка с запятой — такой же синтаксис, как у обычной операции присваивания. В языке Grit есть ключевое слово auto, означающее, что тип переменной (или функции) должен быть выведен автоматически. Механизм вывода самый банальный — для переменных это тип значения выражения, которе им присваивается. Если присваивается результат арифметической операции — компилятор попытается вывести тип в зависимости от этой операции — для деления это будет число с плавающей точкой, для сложения — тип будет зависить от операндов и т.д.
Тип функции будет выведен такой же, как у последнего выражения в ее теле или переменной, указанной в returns. Если вам интересно внутреннее устройство языка, то сообщите об этом в комментариях, и я постараюсь написать отдельную статью про это. А для нас теперь пришло время наконец ответить на главный вопрос этой серии статей — как скомпилировать декоратор? Как было указано ранее, при разработке декораторов для компилируемого языка программирования, нужно определиться с двумя вещами — будут они применяться в runtime или compile-time, и как определять типы аргументов и результата итоговой функции. В своем компиляторе, я разработал механизм применения декораторов, решающий эти проблемы, хоть и создающий некоторые другие ограничения — результат получился достаточно интересным, чтобы поделиться им с аудиторией Хабра. Во-первых, декораторы в Grit применяются на этапе компиляции — это позволяет, после перестройки синтаксического дерева (оно же AST, abstract syntax tree), вывести тип возвращаемого значения и скопировать объявления аргументов. Во-вторых, декораторы не являются функциями, а лишь похожими на них конструкциями. Посмотрим на конкретный пример и разберемся, как данный способ объявления декораторов решает указнные в первой части статьи проблемы: Это декоратор, который вызовет исходную функцию, и вернет 0, если ее результат меньше 0, иначе он вернет результат без изменений. @auto flatten — объявление декоратора с именем flatten и типом @auto — то есть тип будет выведен в зависимости от функции, к которой он применен (@ — указатель но то, что это объявление декоратора, а не функции). Аргументов у декоратора нет. Это не функция, и толку в аргументах в объявлении декоратора было бы немного — у них могут быть разные имена и типы, в зависимости от функций, к которым он применяется, так что нет никаких очевидных способов использовать их. Вы наверняка уже обратили внимание на необычную метку в теле декоратора — @target. Это указание компилятору на то, что в это место надо вставить целиком тело декорируемой функции. Аргументы функции будут скопированы как аргументы для данного инстанса декоратора (что на уровне синтаксического дерева превращает его в новую функцию), а блок кода, являющийся телом исходной функции, вернет свой результат в место вызова, поскольку язык фразированный (этот механизм был описан ранее).
Таким образом, подобное изменение AST эквивалентно вызову исходной функции на месте @target, с правильными аргументами. После этого, исходную функцию можно просто заменить получившейся новой функцией, и все — декоратор применен. Если же декораторов у функции несколько, то этот процесс можно повторять несколько раз. Декораторы в виде, реализованном в Grit, позволяют делать множество различных вещей — большинство из них те же, что и в Python. Например, можно ожидать захвата ресурса: Или вызывать функцию, только если установлен какой-либо флаг: И так далее Рассмотрим этот механизм на примере сгенерированного компилятором Grit синтаксического дерева для простой программы с декораторами: Здесь уже рассмотренный нами декоратор flatten применяется к функции, увеличивающей свой аргумент на единицу. Так выглядит "сырое" внутреннее представление, до какой-либо обработки вообще: Не вдаваясь в конкретные обозначения, сразу видно две вещи — определение декоратора это самостоятельная конструкция с типом Decorator, и функция incr на данном этапе существует в своем исходном виде, и хранит информацию о том что к ней применен модификатор Decorator "flatten". В теле декоратора же мы видим метку DecoratorTarget — сюда будет вставленно тело функции incr. Структура программы проходит внутри компилятора несколько этапов обработки, перед тем как происходит кодогенерация — первый из них это применение декораторов. Таким образом, после того как синтаксическое дерево функции будет модифицированно с учетом декораторов, реализация большей части их функционала произойдет автоматически, с помощью "встроенных" средств языка — вывода типов, получения результата выполнения блока кода как обычного выражения и так далее. Посмотрим на аннотированное, полностью обработанное и готовое к кодогенерации представление той же программы: Здесь мы можем заметить несколько важных вещей: Таким образом, в процессе разработки этого компилятора, удалось вывести общий алгоритм применения декораторов для компилируемого, статически типизированного языка. Он не обладает всеми возможностями декораторов в Python, но приближается к ним настолько, насколько это возможно в таком языке, как Grit. Разумеется, это не окончательная и не идеальная версия — например, таким декораторам можно было бы добавить собственные, декораторные аргументы: Это вполне сработало бы при текущем подходе — самый простой вариант это при применении декоратора убрать аргумент mutex M, и в теле конкретного инстанса декоратора заменить обращения к этому аргументу обращениями к "мьютексКоторыйФункцияДолжнаЗахватить", который должен существовать в области видимости декорируемой функции исходя из объявления (кстати, такой способ создавать декораторы с аргументами выглядит гораздо привлекательнее того, как они реализованы в Python — замыкание внутри замыкания внутри функции). Кроме того, я экспереминтировал с меткой @args, дающей внутри декоратора доступ к аргументам целевой функции, и так же разварачивающейся в "обычный код" на этапе обработки синтаксического дерева. Например, @args.length — количество аргументов, @args.1 — ссылка на первый аргумент и так далее. Что-то из этого работает, что-то пока не совсем — но принципиально сделать это возможно. Основным итогом этой работы мне хочется считать то, что я наконец изучил Haskell исследование подохода к компиляции сложных структур, путем предоставления разработчику синтаксических меток, которые, смешиваясь с валидным кодом на данном языке программирования, создают интересные и мощные инструменты. От макросов это отличается большей строгостью, стандартизированностью (что упрощает не только написание кода, но и понимание и отладку), и гибкостью из-за поддержки специальной логики со стороны компилятора. Код получившегося компилятора можно найти здесь, собирается он с помощью stack P.S. Это был очень интересный и необычный для меня опыт — надеюсь, что и вы смогли вынести для себя что-нибудь полезное из этого рассказа. Если нужна отдельная статья про написание компилятора на Haskell на основе LLVM — пишите в комментарии.
На любые вопросы постараюсь ответить в комментариях, или в телеграмме — @nu11_pointer_exception Разработчик