Перейти к основному содержимому

Формальные грамматики и разбор

Архитектору Инженеру

Грамматика — это правила, по которым из "абстрактных" символов порождают строки языка. Парсер в компиляторе делает обратное: по строке (исходному коду) восстанавливает, какие правила сработали. Без грамматики нет однозначного синтаксиса; без разбора нет AST.

Обзор иерархии: Формальные языки и автоматы. Автоматы: конечные, магазинные. Этапы компиляции: Программа — компиляция.


Для читателя без математического фона

Грамматика — это правила "как собирать правильные фразы" из кусочков. В русском: "предложение = подлежащее + сказуемое + …". В коде: "выражение = выражение + терм | терм". Парсер читает готовую строку (исходник) и проверяет: можно ли её разобрать по этим правилам, и какое дерево получилось.

Бытовой примерАналог в грамматике
Рецепт блюдаправила P
Готовое блюдопредложение (только терминалы)
Промежуточный черновик с пометками "ещё добавить соус"сентенциальная форма (терминалы + нетерминалы)
Схема "из чего состоит"дерево вывода
Упрощённая картинка для повараAST в компиляторе

Терминал — "лист", конкретный символ в тексте (+, if, имя переменной). Нетерминал — "заготовка" (Expr, Stmt), которую ещё нужно развернуть по правилам.


Направления изучения грамматик и языков

НаправлениеВопросПример
Порождениекак сгенерировать все правильные строки?правила E → E + T | T
Распознаваниепринадлежит ли слово языку?парсер на входе a+b*c
Анализкакая структура у слова?дерево вывода → AST
Преобразованиекак упростить грамматику для алгоритма?убрать левую рекурсию
Сравнение классовчто мощнее?регуляр ⊂ КС ⊂ …

В разработке вы постоянно в распознавании и анализе — JSON, SQL, свой DSL, конфиги. Порождение видно в генераторах кода и тестовых данных.


Основные понятия порождающих грамматик

Формальная грамматика (в смысле Хомского):

G = (N, Σ, P, S)

СимволИмяПример
Nнетерминалы (переменные)E, T, Stmt
Σтерминалы (алфавит "букв")+, if, id, (
Pправила (продукции)E → E + T
S ∈ Nаксиома (стартовый символ)E

Слово (предложение) — конечная строка только из терминалов.


Вывод

Шаг вывода: в текущей строке, где среди символов есть нетерминал A, выбирают правило A → α и заменяют одно вхождение A на α.

Вывод — цепочка таких шагов от S до слова из терминалов.

Пример (арифметика, упрощённо):

S = E
E → E + T | T
T → T * F | F
F → ( E ) | id

Слово id + id:

E ⇒ E + T ⇒ T + T ⇒ F + T ⇒ id + T ⇒ id + F ⇒ id + id

Как читать цепочку : на каждом шаге выбирают один нетерминал в строке и заменяют его на правую часть правила (здесь — левыйmost, самый левый нетерминал).

ШагТекущая строкаПравилоКомментарий
0EE → E + T"выражение — выражение плюс терм"
1E + TE → Tлевый E свернули в T
2T + TT → F
3F + TF → idпервый операнд
4id + TT → F
5id + FF → idвторой операнд
6id + idтолько терминалы — предложение

Символ id здесь — один терминал "идентификатор" (лексер в реальном компиляторе выдаст конкретное имя, например foo).


Сентенциальные формы

  • Сентенциальная форма — любая строка (терминалы + нетерминалы), полученная из S за конечное число шагов.
  • Предложение — сентенциальная форма без нетерминалов.

Язык L(G) — множество всех предложений, выводимых из S.


Левый и правый вывод

При каждом шаге можно заменять левыйmost или правыйmost нетерминал — получаются левосторонний и правосторонний выводы.

ПарсерКакой вывод строит
LL(k)левый — "сверху вниз" совпадает с порядком развёртки
LRправый — "снизу вверх" сворачивает к корню

Для однозначной грамматики левый и правый вывод одного слова совпадают как дерево; для неоднозначной — могут дать разные деревья при одном слове.


Дерево вывода

Дерево вывода фиксирует структуру — корень — S, дети — символы правой части применённого правила, листья — терминалы слева направо дают слово.

E
/|\
E + T
| |
F F
| |
id id

Компилятор строит AST — сжатую версию дерева без "синтаксического шума" (скобки, лишние уровни). См. семантический анализ.

Дерево выводаAST
Назначениепоказать какие правила сработалиудобно вычислять и оптимизировать
Скобки, запятыечасто остаются как узлычасто убирают
Несколько уровней E, T, Fполная цепочка грамматикиобычно узел + с двумя детьми

Пример: для id + id * id AST — корень +, слева id, справа поддерево * с двумя id — без промежуточных нетерминалов E, T, F.


Нотации BNF и EBNF

BNF (Backus–Naur Form):

<expr> ::= <expr> "+" <term> | <term>
<term> ::= <term> "*" <factor> | <factor>
<factor> ::= "(" <expr> ")" | <id>

EBNF добавляет удобства: { ... } — повтор, [ ... ] — опционально, "+" — терминал.

Спецификации JSON Schema, OpenAPI, языки вроде W3C — наследники той же идеи.


Классификация грамматик Н. Хомского

Тип задаётся ограничением на вид правил α → β:

ТипОграничение на правилоАвтоматПример языка
0без ограничений (кроме длины)МТвсё алгоритмически порождаемое
1контекстно-зависимые: αAγ → αβγ, `β≥ 1`
2контекстно-свободные: A → βМП-автоматсбалансированные скобки
3регулярные (праволинейные): A → aB или A → aКАсуффикс ab, чётное число a

Вложение: тип 3 ⊂ тип 2 ⊂ тип 1 ⊂ тип 0.

Практический ориентир

Лексер — почти всегда регулярный (тип 3).

Синтаксис выражений и блоков — КС (тип 2).

Семантика типов и анализ потока — уже отдельные алгоритмы.


Грамматический разбор

Разбор (parsing) — по слову w построить дерево вывода (или доказать, что w ∉ L(G)).

СтратегияИдеяПримеры алгоритмов
Сверху внизот S к слову, "разворачиваем" нетерминалырекурсивный спуск, LL(k)
Снизу вверхот слова к S, "сворачиваем" фрагментыLR, LALR, SLR
Универсальные для КСCYK (динамическое программирование)учебные, XML-подобные

В компиляторе упоминаются рекурсивный спуск, LR/LALR — это инженерные реализации для детерминированных КС-грамматик.


LL и LR — интуиция

  • LL — читает вход слева направо, строит левый вывод; "предсказывает" по lookahead, какое правило применить.
  • LR — тоже слева направо, но правый вывод; сильнее по классу грамматик, стандарт для yacc/Bison.

Конфликты shift/reduce в LR — грамматика неоднозначна или нужна другая таблица.


Неоднозначность

Грамматика неоднозначна, если существует строка w ∈ L(G), для которой есть два различных дерева вывода (эквивалентно: два разных левых или два разных правых вывода — в зависимости от постановки).

Не путать с конфликтом shift/reduce в LR: таблица может быть построена с приоритетами, хотя грамматика формально неоднозначна; генераторы парсеров часто ограничивают класс грамматик, чтобы таблица была детерминированной.

Классика — "висячий else":

stmt ::= if expr then stmt
| if expr then stmt else stmt
| other

Для вложенных if без else неясно, к какому if относится else. Решения — переписать грамматику, приоритеты в yacc, явные endif в языке (Ada, Lua).

Неоднозначные КС-грамматики не всегда приводят к неоднозначным LR-таблицам (иногда таблица + приоритеты спасают), но для генераторов парсеров лучше однозначная грамматика.


Неоднозначность выражений a + b * c

Одна строка — два дерева (приоритет * vs +):

E E
/|\ / \
E + T T + T
| | | |
T T F F
| | | |
F F a *
| | / \
a * F F
/ \ | |
F F b c
| |
b c

Языки фиксируют приоритет грамматикой (T под E, F под T) или приоритетами в yacc. Без этого семантика "по умолчанию" не определена.


Преобразования КС-грамматик

Перед построением парсера грамматику нормализуют:

ПреобразованиеЗачем
Удаление ε-правил A → εупростить таблицы (кроме S при необходимости)
Удаление бесплодных нетерминаловне порождают терминальных строк
Удаление недостижимых из S"мусор" в грамматике
Устранение левой рекурсии A → Aα | …LL(1) не работает с левой рекурсией
Приведение к CNFA → BC или A → a — теоремы и CYK

Левая рекурсия (естественна для левоассоциативных операторов):

E → E + T | T

Для LL заменяют на правую итерацию через вспомогательный нетерминал:

E → T E'
E' → + T E' | ε

В LR левая рекурсия часто нормальна — отсюда различие инструментов.

Пример ε-удаления: если было List → Item List | ε, после удаления ε нужно явно разрешить пустой список отдельным правилом или стартовым символом.


LL(1) — FIRST и FOLLOW (практика генераторов)

Для детерминированного разбора сверху вниз на каждом нетерминале A смотрят:

  • FIRST(α) — с каких терминалов могут начинаться строки, выводимые из α.
  • FOLLOW(A) — какие терминалы могут идти сразу после A в каком-то выводе.

Таблица LL(1): строка — нетерминал, столбец — текущий входной терминал; ячейка — какое правило A → β применить. Конфликт в ячейке → грамматика не LL(1) (нужен k > 1 или LR).

Инструменты вроде ANTLR, расчёт FIRST/FOLLOW в учебниках — прямое продолжение этой таблицы.


CYK (идея алгоритма)

Для грамматики в CNF (A → BC или A → a) проверка "слово w длины n в языке?" — динамическое программирование по подстрокам — O(n³) времени, универсально для КС, но на больших n тяжело.

Используют в учебных целях и для узких грамматик; промышленные парсеры редко на чистом CYK.


Лемма о накачке для КС (интуиция)

Если язык КС, то для достаточно длинного слова z ∈ L можно разбить z = uvxyz так, что "накачанные" u vⁿ x yⁿ z всё ещё в L (с условиями на длину v,y).

Следствие: { aⁿbⁿ | n ≥ 0 }не регулярный (уже регулярная лемма); { aⁿbⁿcⁿ }не КС (доказательство накачкой для КС).


Связь грамматик и автоматов

ГрамматикаАвтомат
Регулярная (тип 3)ДКА / НКА
КС (тип 2)МП-автомат
Тип 0Машина Тьюринга

Теорема: язык генерируется КС-грамматикой тогда и только тогда, когда распознаётся МП-автоматом.

Регулярные грамматики (праволинейные A → aB | a) — то же, что регулярные языки и regex.


Практика — от грамматики к коду

  1. Записать грамматику в BNF/EBNF (спецификация языка).
  2. Устранить левую рекурсию, если нужен LL.
  3. Сгенерировать парсер (ANTLR, yacc, tree-sitter) или написать рекурсивный спуск вручную.
  4. В действиях парсера строить AST и диагностировать ошибки с позицией в исходнике.

PEG (parsing expression grammars) — альтернатива: упорядоченный выбор e1 / e2 ("первый успешный"), не всегда совпадает с классическими КС.


Рекурсивный спуск (минимальный парсер выражений)

Грамматика E → E + T | T, T → T * F | F, F → ( E ) | id — классическая взаимная рекурсия (статья 41):

class ParseError(Exception):
pass

class Parser:
def __init__(self, tokens: list[str]):
self.tokens = tokens
self.i = 0

def peek(self) -> str | None:
return self.tokens[self.i] if self.i < len(self.tokens) else None

def eat(self, expected: str | None = None) -> str:
tok = self.peek()
if tok is None or (expected and tok != expected):
raise ParseError(f"ожидался {expected!r}, получен {tok!r}")
self.i += 1
return tok

def parse_E(self):
node = ("E", self.parse_T())
while self.peek() == "+":
self.eat("+")
node = ("+", node, self.parse_T())
return node

def parse_T(self):
node = ("T", self.parse_F())
while self.peek() == "*":
self.eat("*")
node = ("*", node, self.parse_F())
return node

def parse_F(self):
if self.peek() == "(":
self.eat("(")
node = self.parse_E()
self.eat(")")
return node
if self.peek() == "id":
return ("id", self.eat("id"))
raise ParseError("ожидался id или (")

print(Parser(["id", "+", "id", "*", "id"]).parse_E())

Для LL(1) ту же грамматику сначала устраняют левую рекурсию (E'); LR-парсеры часто оставляют левую рекурсию — см. таблицу выше.


Ошибки разбора в UX

Хороший парсер возвращает не "syntax error", а:

  • позицию (строка, столбец);
  • ожидаемые терминалы (ожидался ';', ожидался ')');
  • иногда восстановление (panic-mode, sync на ; или }).

Это инженерная надстройка над формальным выводом; теория указывает, на каком нетерминале и при каком входном токене дерево перестало строиться.


Упражнение

  1. Запишите BNF для списка 1, 2, 3 (элементы через запятую) с опциональным хвостом.
  2. Устраните левую рекурсию в E → E + T | T вручную (E').
  3. Постройте дерево вывода для if a then if b then s else s2 — сколько "висячих else" без правил приоритета?

Вопросы для самопроверки

  1. Что входит в кортеж G = (N, Σ, P, S)?
  2. Чем дерево вывода отличается от AST?
  3. Почему язык сбалансированных скобок не регулярный?
  4. Зачем устраняют левую рекурсию перед LL-парсером?
  5. Приведите пример неоднозначности в грамматике операторов или if.

Дальше: Конечные автоматы и регулярные языки.