«Может, в следующий раз вы посмотрите дважды — и не станете судить о книге по обложке» — Зекора

Спор «скобочки или отступы» стар как мир. У него нет арбитра и нет призового фонда, но есть свои ветераны и есть души, однажды написавшие end в Питоне и с тех пор не находящие покоя. Победить в этом споре невозможно: это битва вкусов, а не фактов. Поэтому я предлагаю закончить его не триумфом одной из сторон, а взаимным разоружением.

Я написал небольшую утилиту, которая переводит исходный код Python между тремя синтаксисами: классическими отступами, C-подобными фигурными скобками и S-выражениями в духе Лиспа. Один и тот же алгоритм быстрой сортировки предстаёт в трёх обличьях:

def quicksort(items):
    if len(items) <= 1:
        return items
    pivot, *rest = items
    lo = [x for x in rest if x < pivot]
    hi = [x for x in rest if x >= pivot]
    return quicksort(lo) + [pivot] + quicksort(hi)
def quicksort(items) {
    if len(items) <= 1 {
        return items;
    }
    pivot, *rest = items;
    lo = [x for x in rest if x < pivot];
    hi = [x for x in rest if x >= pivot];
    return quicksort(lo) + [pivot] + quicksort(hi);
}
(defn quicksort (items)
  (if (<= (len items) 1) (return items))
  (set! '(pivot (* rest)) items)
  (set! lo (list-comp x (for [x in rest]) (when (< x pivot))))
  (set! hi (list-comp x (for [x in rest]) (when (>= x pivot))))
  (return (+ (+ (quicksort lo) [pivot]) (quicksort hi))))

Код один и тот же, смысл один и тот же, а «шкуры» — разные. И это не просто внешнее сходство: замкнутый цикл py → шкура → py даёт абстрактное синтаксическое дерево, идентичное оригиналу, что проверяется сравнением на уровне ast.dump. Как именно это проверяется на 38 модулях стандартной библиотеки, я расскажу ниже.

Цитаты из MLP:FIM — дань уважения человеку, открывшему мне дверь на Хабр. Привет, друг! \V/

Зачем это нужно (спойлер: не нужно, и в этом весь смысл)

«Вечный хаос — это же шоколадный дождь, ребят! Шо-ко-лад-ный дождь!» — Пинки Пай

На вопрос «зачем» я отвечу сразу: чтобы перестать спорить. Сторонник скобочек сможет читать любой проект в своём привычном стиле, адепт отступов — в своём, а ценители Лиспа наконец получат наглядный материал, чтобы объяснить всем суть S-выражений.

Есть и более утилитарные, пусть и скорее теоретические, применения:

  • код-ревью в том формате, который удобен проверяющему;

  • обучение: синтаксис — это лишь одежда, семантика — тело;

  • тренировка мышления при переходе между языковыми парадигмами;

  • и главное — холивар-вечеринка в комментариях. Пристегнитесь.

Как этим пользоваться

«Она была под буквой „Э“!» — Пинки Пай, нашедшая справочник

as lisp foo.py            # Python -> S-выражения
as brace foo.py -o foo.cpy
as py foo.lpy             # обратно
cat foo.py | as brace - --from py

Направление конвертации определяется расширением файла: .py, .cpy, .lpy. Флаг --from позволяет его переопределить. Утилита устанавливается одной командой: pip install like-a.

Да, она называется as — по ключевому слову Python, знакомому всем по конструкции import json as j. Командная строка читается как простая английская фраза: as lisp — «как лисп». На PyPI имя as зарезервировано, поэтому пакет опубликован под псевдонимом like-a — like a boss lisp.

(Пользователям GNU binutils: я знаю про ассемблер. Двухбуквенное пространство имён было слишком соблазнительным. Если ~/.local/bin в вашем PATH стоит раньше /usr/bin — создайте alias для сборки C. Или посоветуйте ассемблеру сменить имя на трёхбуквенное).

Два слоя: где нужны токены, а где — дерево

«Голова у него — пони, а тело — из всякого прочего» — Чирили, про драконеквуса

Если после примеров вы подумали: «Что там конвертировать, заменить отступы на скобки?» — я думал так же. И ошибался. За вывеской «сменить синтаксис» скрываются две задачи разной сложности.

Отступы ⟷ Скобки — это перекладка строк, а не структуры. Границы блоков уже известны лексеру: стандартный tokenize выдаёт пары INDENT/DEDENT, остаётся лишь добавить { и }. Конвертация идёт на уровне токенов, поэтому комментарии, пустые строки и исходное форматирование сохраняются.

Отступы ⟷ S-выражения — это перестройка дерева. Выражение a + b * c должно превратиться в (+ a (* b c)), а для этого нужно знать приоритеты операций. Писать свой Pratt-парсер — затея рискованная. Гораздо надёжнее положиться на модуль ast, который уже прошёл через все граничные случаи, накопленные экосистемой Python за десятилетия. Конвертер просто обходит готовое дерево и печатает его в новой нотации. Обратный путь обеспечивает небольшой ридер S-выражений, строящий AST напрямую.

Отсюда следует изящный вывод, который меня особенно радует: S-выражения — это не «другой синтаксис», а то же самое дерево, но в текстовой форме. Что, впрочем, не секрет для любого лиспера.

Боевые истории: что ломалось на стандартной библиотеке

«Хаос — это чудесно, чудесно!» — Дискорд

Пример с quicksort — мой собственный, написанный для наглядности. С чужим кодом всё оказалось куда интереснее: его писали 35 лет, не оглядываясь на мой конвертер. Ниже — реальные баги, с которыми я столкнулся, добиваясь работоспособности замкнутого цикла на 38 модулях CPython. Стандартная библиотека — лучший в мире фаззер синтаксиса.

Set-литерал в заголовке. В enum.py (строка 1057) встречается конструкция:

if target not in {
        None,
        None.__new__,
        object.__new__,
        Enum.__new__,
    }:
    __new__ = target

Многострочный set-литерал в {} вступает в конфликт с блочными скобками — заголовок if при конвертации в brace-шкуру получает два набора фигурных скобок. Решение: заголовки, уже содержащие {}, при переводе сворачиваются в одну строку.

Обратный слэш-продолжение внутри скобок. inspect.py, строка 3248:

raise TypeError('missing a required argument: {arg!r}'. \
                format(arg=param_name)) from None

У строки-продолжения есть свой вклад в баланс скобок (здесь — открытая ( от TypeError(). Наивная буферизация теряла этот вклад, и баланс «уезжал» на четыреста строк. Вывод: у каждой строки есть свой delta, и он должен учитываться всегда, даже если строка уходит в буфер.

Пользовательская функция quote. В shlex.py (строка 323) есть функция def quote(s). В lisp-шкуре кортежи печатаются как '(элементы...) — это спец-форма quote. Вызов (quote s) стал неотличим от одноэлементного кортежа. Почти. Настоящий кортеж-литерал всегда содержит список. Пришлось научить ридер различать кортеж (quote (...)) и вызов функции (quote x). Которая, между прочим, живёт себе в shlex и ни о чём таком даже не думает.

Строка "quote" как ключ словаря. В csv.py (строка 275) словарь {'delim': ..., 'quote': ...} при печати превращался в {("delim" x) ("quote" y)}, и ридер принимал строковый ключ за голову спец-формы. Лечение: проверять тип «головы» — спец-форма начинается с символа quote, а не со строки "quote". Для этого и был создан подкласс str по имени Sym.

Замкнутый цикл как контракт

«Крест на сердце, взлететь готова — воткну кексик себе в глаз!» — Пинки Пай, клятва

«Без потерь» — любимая фраза из многих README. Вот моё определение, которое проверяет машина:

ast.dump(ast.parse(src)) == ast.dump(ast.parse(
    convert(convert(src, 'py', skin), skin, 'py')))

для skin из {brace, lisp} и для всех файлов тестового корпуса.

Метрики проекта: 92 теста = 13 юнит-тестов + 76 прогонов на корпусе (38 файлов × 2 направления) + 2 теста самоприменения + 1 тест на исполняемость сконвертированного конвертера.

И вишенка на торте — самоприменение. Каждый из семи модулей пакета aslang прогоняется через оба конвертера. Результат обязан быть AST-эквивалентным и, что приятнее, исполняемым. Где-то в тестах есть экземпляр ридера S-выражений, который сам был напечатан принтером из AST, распарсен обратно и теперь выполняет свою работу. Утилита пережила собственную конвертацию. Это тот род самореференции, ради которого и стоило писать этот инструмент.

Чем это отличается от аналогов

«Надо, чтобы это было процентов на двадцать круче» — Рэйнбоу Дэш

bython

Hy

Nim

as

направление

-> py

-> py AST

свой язык

любое -> любое

скобочная шкура

+

-

+

+

S-выражения

-

+ (свои)

-

+

гарантия круга

-

-

-

+ (AST-equal)

семантика Python

+

почти

нет

+ точно

bython — это препроцессор: он пишет скобки, но исполняет отступы, и работает только в одну сторону. Hy — настоящий Лисп со своей семантикой и макросами; он не переводит Python, он его заменяет. Nim — это вообще другой язык. as занимает оставшуюся нишу: это чистый синтаксический слой над неизменной семантикой Python, работающий во все стороны с доказуемой обратимостью.

Ограничения

«Первое правило нашей игры: никаких крыльев и никакой магии» — Дискорд

  • Комментарии в lisp-шкуре — best-effort: ast их не хранит, конвертер пытается вернуть их по номерам строк. В brace-шкуре они живут как родные.

  • typing.TypeAlias и другие новинки последних версий пока не поддерживаются lisp-ридером.

  • Threading-макросы ->/->> сознательно не реализованы: они делают round-trip неоднозначным, что ломает главный тезис проекта.

Если зайдёт

«Верну их вам в своё время» — Дискорд, про отобранные крылья и магию

Есть направление, которое я сознательно оставил на будущее: C без скобок, с отступами. Почему не сейчас? В Python скобки избыточны — лексер и так знает границы блоков. В C же скобки несут смысл: enum {A, B}, инициализаторы, GNU-выражения ({ ... }). Чтобы убрать только блочные и не тронуть литеральные, нужен полноценный парсер вроде libclang или tree-sitter, а не простая перекладка токенов. Замкнутый цикл в таком случае возможен только на уровне AST — не побайтовый.

Частичный ответ уже есть — это Nim: пишешь с отступами, на выходе получаешь C. Но Nim — это отдельный язык. Моя же «шкура» — про другое: читать существующий код в удобном виде, не переписывая его. И спрос здесь двусторонний: многим нравится всё в Nim, кроме питон-стиля, а скобочной формы у него нет — в его грамматике скобки заняты сетами и прагмами.

Nim-программистам привет: скобки для данных, а не для блоков — это красиво. И моя шкура ничего бы у Nim не отбирала. Так что работы здесь в разы больше, но интерес к Nim доказывает, что идея может быть востребована. Если зайдёт — займусь.

Попробуйте на чём-то своём

«Ура.» [тихо] — Флаттершай

as lisp ваш_файл.py

Прогоните свой самый нелюбимый модуль через обе шкуры. Самые красивые и самые страшные результаты несите в комментарии — лучшее я размещу в README. А если после этого вам всё ещё хочется поспорить о скобочках и отступах — ну, вы поняли: спорить больше не о чем.


Ссылки: GitHub | GitFlic | PyPI | таблица аналогов и form reference — в README.