Конвертер синтаксиса, который заканчивает главный холивар
«Может, в следующий раз вы посмотрите дважды - и не станете судить о книге по обложке» - Зекора
Спор «скобочки или отступы» старше, чем некоторые из его участников. У
этого спора нет судьи и нет призового фонда, но есть ветераны и есть потерянные
души, которые однажды написали end в Питоне и с тех пор не могут
остановиться. Выиграть этот спор невозможно: стороны спорят о вкусах, а не о
свойствах. Поэтому предлагаю - давайте закончим этот спор не победой, а разоружением.
Я написал маленькую утилиту, которая переводит исходный код Python между тремя синтаксисами: классические отступы, C-подобные фигурные скобки, S-выражения в духе Лиспа. Один и тот же quicksort, три вида:
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, структурно
идентичный оригиналу - сравнение на уровне ast.dump. Как это
проверяется на 38 модулях стандартной библиотеки и на самом конвертере,
расскажу ниже.
Зачем это нужно (спойлер: не нужно, и в этом смысл)
«Вечный хаос - это же шоколадный дождь, ребят! Шо-ко-лад-ный дождь!» - Пинки Пай
На вопрос «зачем» отвечу сразу: чтобы перестать спорить. Сторонник скобочек может читать любой проект в своих скобочках, сторонник отступов - в своих отступах, а любители Лиспа наконец получат повод объяснить всем про 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 запрещено
(ключевые слова Python регистрировать не разрешают), поэтому пакет живёт под
именем like-a - like a boss lisp.
(Пользователям GNU binutils: я знаю про ассемблер. Двухбуквенное
пространство имён было слишком лакомым. Если ~/.local/bin в вашем
PATH стоит раньше /usr/bin - alias для сборки C. Или предложите
ассемблеру переехать: трёхбуквенное сокращение ему подходит больше, к слову.)
Два слоя: где нужны токены, а где - дерево
«Голова у него - пони, а тело - из всякого прочего» - Чирили, про драконеквуса
Если после примеров выше вы подумали «чего там конвертировать, отступы на скобки заменить» - я думал так же. Ошибался: под вывеской «сменить синтаксис» прячутся две задачи с разной сложностью и разными гарантиями.
Отступы <-> скобки - перекладка строк, а не структуры. Блочная
граница уже известна лексеру: tokenize стандартной библиотеки выдаёт
пары INDENT/DEDENT, остаётся дописать { на INDENT и } на DEDENT.
Конвертация идёт на уровне токенов, без построения дерева. Поэтому в
этой паре выживают комментарии, пустые строки и форматирование: мы
переписываем границы блоков, не трогая остальной текст.
Отступы <-> S-выражения - перестройка дерева. a + b * c
превращается в (+ a (* b c)), и чтобы это сделать, нужно знать
приоритеты операций. Можно написать свой Pratt-парсер… а можно не
писать: модуль ast уже давно распарсил всё правильно, пройдя через все граничные
случаи, накопленные экосистемой Python за долгие годы. Конвертер просто обходит
готовое дерево и печатает его в новой нотации. Обратный путь делает
маленький ридер S-выражений, который строит AST напрямую.
Отсюда бесплатный вывод, который лично меня радует: S-выражения - это не «другой синтаксис», это то же дерево, но в текстовой форме. Что, как известно, никого не удивляет в Лиспе.
Боевые истории: что ломалось на stdlib
«Хаос - это чудесно, чудесно!» - Дискорд
Quicksort из вступления - мой код, написанный для иллюстрации (для красоты). С чужим кодом всё интереснее: его писали 35 лет, не оглядываясь на мой конвертер. Все примеры ниже - реальные баги, найденные, пока я добивался работоспособности замкнутого цикла на 38 модулях стандартной библиотеки. CPython - лучший фаззер синтаксиса. Комьюнити находило такие темные углы, куда нормальный человек не заглядывает.
Set-литерал в заголовке. В enum.py (строка 1057) встречается:
if target not in {
None,
None.__new__,
object.__new__,
Enum.__new__,
}:
__new__ = target
Многострочный set-литерал в {} коллидирует с блочными скобками -
заголовок if при переводе в brace-кожу получает два конкурирующих
{. Решение: заголовки, содержащие фигурные скобки, сворачиваются в
одну строку.
Backslash-продолжения внутри скобок. 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). В lpy-шкуре кортежи печатаются как
'(элементы...) - спец-форма quote. Вызов (quote s) оказался на
глаз неотличим от кортежа из одного элемента… нет, отличим: настоящий
кортеж-литерал всегда содержит список. Ридер теперь различает кортеж
(quote (...)) и вызов пользовательской функции (quote x). Которая, между прочим, живёт себе в shlex и ни о чём таком даже не думает.
Строка "quote" как ключ словаря. В csv.py (строка 275) словарь
{'delim': ..., 'quote': ...} при печати давал форму
{("delim" x) ("quote" y)}, и ридер принимал строковый ключ "quote"
за голову спец-формы. Лечение: проверять тип головы - спец-форма
начинается с символа 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 - отдельный язык со своей стандартной библиотекой, на нём пишут с нуля. Шкура для C - про другое: читать существующий код в удобном виде, не переписывая. И спрос двусторонний: многим нравится всё в Nim, кроме питон-стиля - а скобочной формы у Nim нет, в его грамматике скобки заняты сетами и прагмами.
Nim-программистам привет: скобки под данные, а не под блоки - это красота дизайна, а не упущение. И шкура ничего бы у Nim не отбирала: канон остаётся за отступами, просто народ с C-привычками принёс бы код, который всегда можно было бы привести к каноническому виду. Так что работы в разы больше, но интерес к Nim доказывает, что это может быть востребовано - если зайдёт, займусь.
Попробуйте на чем-то своём
«Ура.» [тихо] - Флаттершай
as lisp ваш_файл.py
Прогоните свой самый нелюбимый модуль через обе шкуры. Самые красивые и самые страшные результаты несите в комментарии, лучшее буду размещать в README. А если после этого вам всё ещё хочется поспорить о скобочках и отступах - ну, вы поняли: спорить больше не о чем.
Ссылки: GitHub | GitFlic | PyPI | таблица аналогов и form reference - в README.











