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

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

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

Грамматика — это правила, по которым из «абстрактных» символов порождают строки языка. Парсер в компиляторе делает обратное: по строке (исходному коду) восстанавливает, какие правила сработали. Без грамматики нет однозначного синтаксиса; без разбора нет 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.

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).