Интуиция за Pratt parsing: как работают приоритеты операторов
Pratt parsing разбирает выражения с приоритетами за один проход и ~40 строк кода. Разбираем алгоритм по шагам — от токенов до AST, с примерами на Python.
, отредактировано
Перевод статьи Intuiting Pratt parsing. Автор: Louis.
Вы знаете, что a + b * c + d вычисляется как a + (b * c) + d. Но как объяснить это машине? Как закодировать правила приоритета операторов так точно, чтобы парсер сам правильно строил дерево разбора?
Pratt parsing — элегантный алгоритм, придуманный Воном Праттом в 1973 году. Он лежит в основе парсеров многих современных языков, но его описания часто сводятся к магическим таблицам приоритетов без объяснения, почему это работает. Эта статья строит интуицию с нуля.
Ключевые выводы
— AST — основная структура данных: оператор над операндами, вычисление снизу вверх.
— Приоритет операторов определяет наклон дерева: выше приоритет — глубже в дерево.
— При падении приоритета алгоритм поднимается по «хребту» дерева — это и есть суть Pratt parsing.
— Левая и правая ассоциативность задаются через LBP и RBP (left/right binding power).
— Полный парсер с поддержкой всех операторов — около 10 строк на Python.
Абстрактное синтаксическое дерево
Наиболее распространённое решение для разбора выражений — абстрактное синтаксическое дерево (AST, Abstract Syntax Tree). В AST каждый оператор стоит над своими операндами. Вычисление идёт снизу вверх: сначала обрабатываются дочерние узлы, затем операция над ними.
Для выражения a + b * c + d правильное AST выглядит так:
Узел * находится глубже, чем +, — это и отражает более высокий приоритет умножения. При вычислении сначала перемножаются b * c, результат складывается с a, затем прибавляется d.
Как приоритет влияет на форму дерева
Здесь кроется главная интуиция Pratt parsing. Приоритет операторов напрямую определяет, в какую сторону «наклоняется» дерево:
- Убывающий приоритет (например,
* + ==) — дерево наклонено влево: операторы с большим приоритетом оказываются глубже. - Возрастающий приоритет (например,
== + *) — дерево наклонено вправо. - Равный приоритет — по соглашению левая ассоциативность, дерево наклоняется влево.
Рассмотрим промежуточное состояние разбора. Пусть мы уже построили дерево I для выражения (a > b + c * d) — это правонаклонное дерево: > наверху, * в самом низу.
Теперь встречаем следующий оператор. Куда вставить новый узел? Зависит от приоритета:
[I] * e— приоритет*не меньше приоритета*в дереве, вставляем в самый глубокий узел.[I] + e— приоритет+ниже*, но выше>, поднимаемся по хребту выше узла*.[I] == e— приоритет==ниже всего дерева, всё деревоIстановится левым потомком.
Ключевое наблюдение: когда встречается оператор с меньшим приоритетом, мы поднимаемся по правому хребту дерева, собирая узлы с более высоким приоритетом. Это и есть Pratt parsing — алгоритм, который реализует именно этот подъём по хребту.
От интуиции к коду
Начнём с простого случая — правонаклонного дерева. Каждый оператор правоассоциативен, приоритет не учитывается:
Здесь leaf() читает следующий терминал (число или переменную), peek() смотрит на текущий токен без продвижения, advance() читает и сдвигает позицию.
Добавляем приоритет. Рекурсивный вызов передаёт приоритет текущего оператора как порог — это ограничивает, какие операторы следующий уровень может «захватить»:
Это уже работает для правоассоциативных операторов. Но у нас if — разобрали один оператор и вышли. Для левоассоциативных нужен цикл:
Полный Pratt parser
Замена if на while — это и есть процедура подъёма по хребту дерева. Цикл продолжается, пока следующий оператор имеет достаточно высокий приоритет, чтобы «захватить» левую часть. Как только приоритет падает — выходим, и текущее поддерево передаётся вверх по стеку вызовов.
Правая ассоциативность через binding power
До сих пор у нас одно число приоритета на оператор. Но для управления ассоциативностью нужно различать, насколько «сильно» оператор притягивает операнды слева и справа. Вводятся два понятия:
- LBP (left binding power) — сила притяжения левого операнда.
- RBP (right binding power) — сила притяжения правого операнда.
Правила ассоциативности через binding power:
- Левоассоциативные операторы (
+,*): LBP == RBP. Правый рекурсивный вызов получает тот же приоритет — следующий оператор с таким же приоритетом не захватывается, строится левое дерево. - Правоассоциативные операторы (
**,=): RBP = LBP − 1. Правый рекурсивный вызов получает чуть меньший приоритет — следующий оператор с таким же приоритетом захватывается, строится правое дерево.
Это финальная версия Pratt parser. Всего 8 строк кода — и корректный разбор всех операторов с любым приоритетом и ассоциативностью.
Частые вопросы
Что такое Pratt parsing?
Pratt parsing (или top-down operator precedence parsing) — алгоритм разбора выражений, предложенный Воном Праттом в 1973 году. Он эффективно обрабатывает операторы с различным приоритетом и ассоциативностью через понятие binding power. Алгоритм используется в парсерах JavaScript (V8), Rust, Go и других языков.
Чем Pratt parsing отличается от рекурсивного спуска?
Классический рекурсивный спуск кодирует каждый уровень приоритета отдельной функцией: parse_additive(), parse_multiplicative() и т. д. При добавлении нового оператора нужно менять структуру функций. Pratt parsing обрабатывает все операторы одной функцией с таблицей приоритетов — добавить новый оператор значит просто добавить строку в таблицу.
Где используется Pratt parsing на практике?
Алгоритм применяется в парсерах реальных языков программирования: в компиляторе Rust, интерпретаторе JavaScript V8, парсере Clang (C/C++), а также в учебных реализациях — например, в книге «Crafting Interpreters» Роберта Нистрома. Это стандартный инструмент при написании собственного языка.
Что такое binding power и зачем нужны LBP и RBP?
Binding power («сила притяжения») описывает, насколько крепко оператор держит свои операнды. LBP — сила для левого операнда, RBP — для правого. Для левоассоциативных операторов LBP == RBP. Для правоассоциативных RBP на единицу меньше LBP — это позволяет следующему оператору с таким же приоритетом «захватить» правый операнд, строя правое дерево вместо левого.
Итог
Pratt parsing — не хитрый трюк, а следствие простой геометрической интуиции. Деревья разбора наклоняются влево или вправо в зависимости от приоритета операторов. Когда встречается оператор с меньшим приоритетом, алгоритм поднимается по правому хребту текущего дерева — ровно настолько, насколько нужно.
Ключевые детали реализации:
whileвместоif— реализует подъём по хребту для левоассоциативных операторов.- Передача приоритета в рекурсию — ограничивает, какие операторы «захватываются» на следующем уровне.
- LBP / RBP — контролируют ассоциативность через разницу в единицу.
Понимание этой геометрии делает Pratt parsing прозрачным: каждая строка кода отражает конкретное геометрическое действие с деревом, а не магию таблиц приоритетов.