company_banner

Макросы для питониста. Доклад Яндекса

    Как можно расширить синтаксис Python и добавить в него необходимые возможности? Прошлым летом на PyCon я постарался разобрать эту тему. Из доклада можно узнать, как устроены библиотеки pytest, macropy, patterns и как они добиваются таких интересных результатов. В конце есть пример кодогенерации с помощью макросов в HyLang — Lisp-образного языка, бегущего поверх Python.


    — Привет, ребята. Хочу в первую очередь поблагодарить организаторов PyCon. Я разработчик в Яндексе. Доклад будет совсем не про работу, а про экспериментальные вещи. Возможно, кого-то из вас они наведут на мысль, что в Python можно делать клевые штуки, о которых вы раньше даже не догадывались, не мыслили в эту сторону.

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

    pytest


    Скорее всего, вы сталкивались с тестовым фреймворком pytest, многие тут почти наверняка его используют. Я не знаю, замечали ли вы когда-нибудь, но под капотом он тоже делает некоторую магию.



    Например, у вас есть такой простенький тест. Если вы запустите его без pytest, то он выдаст просто AssertionError.



    К сожалению, мой пример немножко вырожденный, и тут сразу видно, что len берется от списка из трех элементов. Но если бы вызывалась какая-то функция, то вы бы из такого AssertionError никогда бы не узнали, что функция вернула. Она вернула просто что-то, что не равно сотне.



    Однако если это запустить под pytest, то он выведет дополнительную отладочную информацию. Как он делает это внутри?



    Эта магия работает очень просто. Pytest создает свой собственный специальный hook, который срабатывает в момент загрузки модуля с тестом. После этого pytest самостоятельно парсит этот питоновский файл, и в результате парсинга получается его промежуточное представление, которое называется AST-деревом. AST-дерево — основная концепция, которая позволяет менять код Python на лету.

    После получения такого дерева pytest накладывает на него преобразование, которое ищет все выражения под названием assert. Он их меняет определенным образом, он компилирует получившееся новое AST-дерево, и получается модуль с тестами, который потом запускается на обычной Python Virtual Machine.



    Примерно так выглядит исходное не преобразованное в pytest AST-дерево. Выделенная красная область — это наш Assert. Если присмотреться, вы увидите его левую и правую часть, сам список.

    Когда pytest преобразует это и генерит новый год, дерево начинает выглядеть вот так.



    Здесь около сотни строк кода, которые pytest сгенерил за вас.



    Если преобразовать это AST-дерево обратно в Python, оно будет выглядеть примерно так. Красным здесь подсвечены области, где pytest вычисляет левые и правые части выражения, формирует сообщение об ошибке и выполняет raise AssertionError, если что-то пошло не так с этим сообщением об ошибке.

    Паттерн-матчинг


    Что еще можно делать с помощью такой штуки? Можно преобразовывать любой питоновский код. И есть одна замечательная библиотека, которую я нашел совершенно случайно на PyPI, там интересно бывает порыться. Она делает паттерн-матчинг.



    Возможно, кому-то этот код знаком. Он считает факториал рекурсивно. Давайте посмотрим, как его можно записать с помощью паттерн-матчинга.



    Для этого достаточно навесить на функцию декоратор. Обратите внимание: внутри тела функция уже работает по-другому. Каждый из этих if — правила для паттерн-матчинга, который разбирает выражение, поступающее на вход функции, и каким-то образом его преобразует. Причем здесь даже нет явных возвратов результата. Потому что библиотека patterns, когда она преобразует тело функции, во-первых, проверяет, что там содержатся только if, а во-вторых — добавляет неявные возвраты результата, таким образом изменяя семантику языка. То есть она делает новый DSL, который работает уже несколько по-другому. И благодаря этому можно записывать какие-то вещи декларативно.


    Предыдущая функция как бы записана в три строчки.





    А остальные строчки добавляют дополнительный функционал, который позволяет, например, считать факториал от списка значений или пропускать его через произвольную функцию.

    Как писать преобразования самому? macropy!


    Теперь вы наверняка задаетесь вопросом, а как можно это самостоятельно применить? Потому что муторно делать, как pytest: вручную парсить файлы, искать код, который нужно преобразовать. В pytest этим занимается отдельный модуль на тысячу или больше строк.

    Чтобы не делать это самому, одни умные ребята уже придумали для нас модуль, который называется macropy.

    Эта версия модуля есть и для второго Python, и для третьего. Писали его еще во времена второго Python. Тогда ребятам было по приколу разобраться, что можно сделать с Python, и в состав библиотечки входят разные примеры. Давайте на них посмотрим, они дадут вам представление, что можно с помощью этой техники делать. Первая прикольная вещь, описанная у них в tutorial, — это макрос, который реализует для второго Python форматные строки, как в третьем.



    Выделенное красным выражение как раз и является синтаксисом вызова макроса. Буква S — это название макроса, а дальше в квадратных скобках идет выражение, которое он преобразует. В результате сюда подставляются переменные. Это работает во втором Python, но в третьем в таком макросе уже нет нужды. Таким образом, вы, например, можете сделать свой макрос, который реализует более сложную семантику и делает более прикольные штуки, чем стандартные форматные строки.



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



    Второй пример тоже интересен. Это сокращенная нотация для записи лямбд. Макрос f принимает ряд аргументов и возвращает вместо себя функцию. Каждое выражение начинающееся с имени макроса “f”, скобочки и дальше абсолютно любого выражения, преобразуется в лямбду.



    По-моему, это тоже классно, особенно для тех, кто любит разрабатывать и писать код в функциональном стиле и использовать MapReduce.


    Вот еще один знакомый всем пример. Эта функция считает факториал, код подсвечен красным. Что будет, когда она вызовется?



    Она на Python выдаст ошибку, потому что упрется в лимит стека и будет вот такой некрасивый RecursionError.



    Как это можно починить? C помощью macropy исправить проблему очень просто.



    Вы навешиваете декоратор, он берет тело функции и некоторым магическим образом его преобразует. В самой функции вам ничего менять не надо, macropy всё сделает за вас.



    И функция вернет вполне себе нормальный результат, уходящий далеко в подпол.


    Как macropy это делает?



    Он заменяет все вызовы самой же функции на специальный объект TailCall, который потом декоратором TCO вызывается в цикле.



    Схема выглядит примерно так. Декоратор в цикле вызывает функцию до тех пор, пока она не вернет какой-нибудь нормальный результат вместо TailCall. А если вернула, то возвращает его. И все. Вот такие клевые штуки можно делать с помощью макросов!

    В состав macropy входят и другие примеры. Я надеюсь, те, кто из вас любопытный, пойдут и посмотрят их самостоятельно. Скажем, есть штуки, полезные для отладки.



    Расскажу про еще одну прикольную вещь. Один из примеров — вот такой макрос query. Что он делает? Внутри него вы пишете обычный питоновский код, который потом можно использовать как обычный результат выполнения этого выражения. Но внутри macropy трансформирует этот код и делает из него код на языке запросов SQL Alchemy.



    Он переписывает его за вас, делает вот это страшное выражение. Его можно переписать руками, тогда оно будет покороче. Я это сделал.



    Вот исходное выражение. После разворачивания макроса оно принимает примерно вот такой вид.



    Возможно, кому-то интересно писать код, большее похожий на Python, и не заставлять своих разработчиков писать запросы на DSL SQL Alchemy.

    Точно так же можно из Python генерировать все что угодно — чистый SQL, JavaScript — и сохранять его куда-нибудь рядышком в файл, а потом использовать на фронтенде.



    Теперь посмотрим, как сделать свой макрос самому. С macropy это очень просто.

    Макрос — функция, которая на вход принимает AST-дерево и, каким-то образом его преобразуя, возвращает новое. Вот пример макроса, который добавляет в вызов assert описание, содержащее исходное выражение, чтобы нам было понятно, из-за чего произошла ошибка AssertionError.

    Здесь внутренняя функция replace_assert — вспомогательная. Она за вас делает рекурсивный спуск по дереву. Внутрь replace_assert передается элемент поддерева.



    За счет этого вы можете внутри проверить его тип и? если это вызов Assert, сделать с ним что-то. Здесь я приведу такой простой синтетический пример, который берет левую часть, правую, делает из них сообщение об ошибке, и записывает все в атрибут msg. Это и есть сообщение, которое нужно будет вернуть.







    При использовании вы навешиваете такой макрос на блок кода с помощью контекстного менеджера with, и весь код, который внутрь попадает контекстного менеджера, проходит эту трансформацию. Внизу видно, что к AssertionError добавилось наше сообщение об ошибке, которое мы сформировали из выражения len([1, 2, 3]).



    Однако у этого способа есть одно ограничение, которое лично меня заставляет грустить. Я пытался в качестве эксперимента сделать новые конструкции, которые будут работать в языке. Например, некоторым нравится switch или условные конструкции типа unless. Но к сожалению, это невозможно: macropy и любые другие инструменты, которые работают с AST-деревом, применяются в момент, когда исходный код уже прочитан и разбит на токены. Код читает парсер Python, грамматика которого зафиксирована в интерпретаторе. Чтобы ее поменять, нужно перекомпилировать Python. Сделать так, конечно, можно, но это уже будет форк Python, а не библиотечка, которую можно выложить на PyPI. Поэтому сделать такие конструкции с помощью macropy нельзя.

    HyLang


    К счастью, я за свою долгую жизнь писал не только на Python и интересовался разными другими альтернативными языками. Есть синтаксис, который многими не любим, зато более прост и гибок. Это s-expressions.

    На наше счастье, существует надстройка над Python, которая называется HyLang. Эта штука в чем-то напоминает Clojure, только Clojure бежит поверх JVM, а HyLang работает поверх Python Virtual Machine. То есть он предоставляет вам новый синтаксис для написания кода. Но при этом весь код, который вы напишете, будет полностью совместим с существующими питоновскими библиотеками, и его можно будет использовать из питоновских библиотек.



    Выглядит это примерно так.



    Слева часть, написанная на Python, справа — на HyLang. А снизу для них обоих приведен байт-код, который получается в итоге. Вы, наверное, заметили, что он абсолютно одинаков, меняется только синтаксис. HyLang s-expressions, который многие не любят. Противники «скобочек» не понимают, что такой синтаксис дает языку огромную мощь, потому что придает конструкциям языка единообразие. А единообразие позволяет применять макросы для реализации любых конструкций.

    Это достигается за счет того, что внутри каждого выражения первый элемент — всегда какое-то действие. А дальше идут его аргументы.

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



    Посмотрим, как работает простой макрос на HyLang. Чтобы делать то же самое, что мы делали с Assert при помощи macropy, нужен только этот код.

    Наш HyLang-макрос получает на вход данные, которые представляют собой код. Дальше макрос может легко использовать любую часть этого кода, чтобы создать новый код. Главное отличие макросов от функций: на вход поступают выражения, а не значения. Если мы вызовем наш макрос как (is (= 1 2)) то на вход он получит выражение (= 1 2), а не значение False.



    Так мы можем сформировать сообщение об ошибке, что что-то пошло не так.



    А дальше просто вернуть новый код. Такой синтаксис с обратной кавычкой и тильдами означает примерно следующее. Обратная кавычка говорит: возьми это выражение как есть и верни его как есть. А тильда говорит: подставь вот сюда значение переменной.



    Поэтому, когда мы так напишем, макрос при раскрытии вернет нам новое выражение, которое будет тем самым assert с дополнительным сообщением об ошибке.

    HyLang — прикольная штука. Правда, пока мы не используем его. Может быть, никогда и не будем. Все перечисленные штуки носят экспериментальный характер. Мне хочется, чтобы вы ушли отсюда с ощущением, что в Python можно сделать какие-то вещи, о которых вы, возможно, раньше даже не задумывались. И может быть, какие-то из них найдут практическое применение в вашей постоянной работе.

    На этом у меня все. Вы можете посмотреть ссылочки:

    • Patterns,
    • MacroPy,
    • HyLang,
    • Книга OnLisp — для продвинутого изучения возможностей макросов. Это для особо интересующихся. Правда, книга целиком основана не на Python, а на языке Common Lisp. Но для более глубокого изучения это будет даже интересно.
    Яндекс
    Как мы делаем Яндекс

    Comments 8

      +2
      А зачем все это?
      Сделаем из питона еще одного монстра типа C++?
      Тот же pytest простые вещи делает сложным и неочевидным путем.
        +2
        С помощью макросов можно сложные вещи делать более простыми в использовании.

        Так же можно оптимизировать код под конкретную зада, собирая функции из AST по кусочкам.

        Это такой же инструмент, как например ООП. Просто другой.
        Он открывает новые возможности для людей, обладающих фантазией.
          +2
          Он открывает новые возможности для людей, обладающих фантазией.

          В реальной жизни я часто встречаю фичи, которые используют просто ради прикола, а потом очень сложно такой код поддерживать. Нужно, например подправить тест, и вместо того, чтобы просто подправить данные, нужно пролезть через кучу декораторов и найте, где там что.
          А когда найдешь, оказывается, что все, что декораторы делают, это проходять по массиву данных. Но чтобы это понять нужно открыть три файла и прочитать пару страниц документации.
            0
            Да, так бывает. Нужно соблюдать баланс.
        0
        Когда макрос раскрывается, а это происходит в момент загрузки модуля, то он просто преобразуется к такому коду.

        А есть какие-нить способы посмотреть преобразованный код? Без загрузки модуля
        Например для отладки макроса
          +1
          Для этого надо запустить процесс кодогенерации и напечатать получившийся результат. Например в REPL.

          Есть такой Python пакет – «meta». В нём есть утилиты для работы с AST, в том числе и для печати AST дерева. Можно использовать его.

          В этом туториале можно почитать подробнее: macropy3.readthedocs.io/en/latest/ast.html

          А в Common Lisp, к примеру, раскрытие макросов встроено в язык и в IDE. Его можно вот так вызвать из REPL:

          ;; Сначала определим макрос:
          CL-USER> (defmacro trace-forms ((&optional (stream t))
                                  &body body)
                     `(progn ,@(loop for form in body
                                     collect `(format ,stream "~S -> ~S~%"
                                                      ',form
                                                      ,form))))
          TRACE-FORMS
          
          ;; Вот так он работает:
          CL-USER> (trace-forms ()
                     1
                     :foo
                     "bar"
                     (+ 1 3))
          1 -> 1
          :FOO -> :FOO
          "bar" -> "bar"
          (+ 1 3) -> 4
          NIL
          
          ;; А так можно посмотреть,  в какой код он раскрывается:
          CL-USER> (macroexpand-1
                    '(trace-forms ()
                      1
                      :foo
                      "bar"
                      (+ 1 3)))
          (PROGN
           (FORMAT T "~S -> ~S~%" '1 1)
           (FORMAT T "~S -> ~S~%" ':FOO :FOO)
           (FORMAT T "~S -> ~S~%" '"bar" "bar")
           (FORMAT T "~S -> ~S~%" '(+ 1 3) (+ 1 3)))
          T
          
          +2

          Есть зло, есть адское зло, и есть макросы

            +1
            Метапрограммирование выглядят очень круто, но на деле сложно в сопровождении. Собственно, почему метаклассы и не рекомендуется писать налево и направо. Возможно, где-то и есть мифический баланс между крутостью и читаемостью, но я его пока не встречал. А отладка такого кода потребует пары часов *непрерывной* концентрации, что не всегда возможно.

            Only users with full accounts can post comments. Log in, please.