Files
Info/2 курс/Языки программирования/Формальные грамматики и теория автоматов.md
T

26 KiB
Raw Blame History

Содержание

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

Формальная грамматика - точный, механический способ задать, какие строки принадлежат языку.

Формальные грамматики состоят из четырех компонентов: терминалы, нетерминалы, правила вывода и стартовый символ.

Терминалы - конечные символы, которые реально появляются в тексте (к примеру, числа, арифметические символы, скобки).

Нетерминалы - промежуточные категории, которые раскрываются по правилам (к примеру, "выражение", "слагаемое").

Вывод строки - это последовательное применение правил от стартового символа до терминалов.

Основы

Формальные грамматики состоят из нескольких уровней: синтаксиса, семантики и прагматики.

Синтаксис отвечает за правильность написания самого текста программы. Примером синтаксиса может являться правильность расставленных скобок, постановка точки с запятой в конце инструкции и так далее.

Семантика отвечает за значение записанной информации. Примером семантики может быть значение записи "a+b" (что именно должна вычислить данная запись) и тому подобное.

Прагматика отвечает за то, как данные инструкции могут быть использованы на практике. Примерами прагматики являются стиль, идиомы, соглашения сообщества и так далее.

Тем не менее, очень важно не путать уровни между собой (хотя это и не просто). К примеру, выражение \frac{1}{0} синтаксически безупречно (форм записи оно не нарушает), но семантически возникает вопрос: а что данное выражение должно вычислить? В разных языках ответ на этот вопрос представлен по-разному: исключение, бесконечность, неопределенное поведение.

Форма Бэкуса-Наура

Форма Бэкуса-Наура (БНФ) - стандартная нотация записи формальных грамматик.

Данная форма была названа в честь Джона Бэкуса и Питера Наура. Впервые данная форма была использована ими для описания Algol 60.

В основе синтаксиса грамматики лежат пара простых символов: ::= (раскрывается в) и | (альтернатива, "или"). Нетерминалы в этой грамматике чаще всего берутся в угловые скобки или пишутся курсивом. Терминалы же берутся в кавычки.

Вывод строки в этой грамматике происходит пошагово. На каждом шаге раскрывается по одному нетерминалу. Порядок раскрытия может быть любым - это не влияет на итоговый результат.

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

Пример вывода строки

Рассмотрим грамматику, заданную следующими условиями.

<expr>   ::= <expr> "+" <term>
           | <term>

<term>   ::= <term> "*" <factor>
           | <factor>

<factor> ::= "(" <expr> ")"
           | <number>

<number> ::= "0" | "1" | "2" | ... | "9"

С такой грамматикой выведем строку "2+3" и таким образом докажем, что она принадлежит языку.

<expr>
  -> <expr> + <term>
  -> <term> + <term>
  -> <factor> + <term>
  -> 2 + <term>
  -> 2 + <factor>
  -> 2 + 3

Спецификация языка

По своей сути, спецификация языка - это та же формальная грамматика.

Рассмотрим фрагмент реальной спецификации языка (упрощенно, в духе C).

<if-statement> ::=
	"if" "(" <expr> ")" <statement>
	| "if" "(" <expr ")" <statement>
	  "else" <statement>

<statement> ::=
	<if-statement>
	| <block>
	| <expr> ";"

<block> ::= "{" <statement>* "}"

Про звезду * в последней строке после <statement> поговорим чуть позже.

В примере видно, что <statement> ссылается сам на себя, используя для этого <if-statement> и <block>. Такая рекурсивная вложенность в грамматике и делает синтаксис язык контекстно-свободным, а не регулярным.

Теория автоматов

История

Изначально, теория автоматов родилась не из программирования, а из вопроса "что вообще значит вычислять?". Программирование появилось позже, как массовая практика, и унаследовало уже готовый аппарат.

В самом начале, в 1943 году, Уоррен Маккаллок и Уолтер Питтс создали формальную модель нейрона, которая по своей сути является переключательным автоматом. В 1950-ых годах Стивен Клини вывел связь регулярных выражений и конечных автоматов. В 1956 году Ноам Хомский предложил иерархию формальных грамматик. В 1959 году Михаэль Рабин и Дана Скотт дали строгое определение НКА, а также доказали его эквивалентность с ДКА.

Иерархия Хомского

В своей теории Хомский делит грамматику языка на четыре типа - от 3 до 0. Чем ниже номер типа, тем мощнее грамматика и тем сложнее устройство, нужное для распознавания.

Тип 3 - регулярные грамматики. Примерами языка с такой грамматикой будут являться различные идентификаторы или числа. Для распознавания такой грамматики в основном используется конечный автомат.

Тип 2 - контекстно-свободные грамматики. Примером языка с такой грамматикой может быть синтаксис любого языка программирования. Для распознавания такой грамматики используется автомат с магазинной памятью.

Тип 1 - контекстно-зависимые грамматики. Примером языка с такой грамматикой может быть какой-либо согласование типов или размерностей. Для распознавания такой грамматики используется линейно ограниченный автомат.

Тип 0 - рекурсивно перечислимые грамматики. Примером языка с такой грамматикой может быть любой алгоритмически заданный язык. Для распознавания такой грамматики используется машина Тьюринга.

Конечный автомат

Конечный автомат - пятерка (Q, \Sigma, \delta, q_0, F), где Q - конечное множество состояний, \Sigma - алфавит (то есть множество допустимых входных символов), \delta - функция переходов из состояния и символа в новое состояние, q_0 - начальное состояние, F - множество принимающих (или финальных) состояний.

Строка принимается автоматом, если, начав в q_0 и применяя \delta символ за символом, автомат оказывается в одном из состояний F.

Такие автоматы можно встретить в лексическом анализе (разбивает текст программы на токены), сетевых протоколах (разбирает TCP-состояние, парсит HTTP-заголовки), играх (поведение NPC), регулярных выражениях и схемотехнике (сам по себе конечный автомат является стандартной моделью цифровых схем управления).

ДКА и НКА

Детерминированный конечный автомат (ДКА) - конечный автомат, в котором существует ровно один возможный переход из определенного состояния по данному символу.

Недетерминированный конечный автомат (НКА) - конечный автомат, в котором существует ноль, один или несколько возможных переходов из определенного состояния по данному символу, а также возможный $\varepsilon$-переходы (то есть переходы без чтения символа).

Выразительная сила как у ДКА, так и у НКА одинакова. То есть, любой НКА можно преобразовать в эквивалентный ДКА.

Тем не менее, НКА иногда естественнее описывает поставленную задачу, но ДКА проще моделировать словами. Чаще всего при переходе из НКА в ДКА теряется удобство описания.

Регулярные выражения и автоматы

Теорема Клини - регулярные выражения и конечные автоматы имеют одинаковую выразительную силу.

Иными словами, любому регулярному выражению можно сопоставить эквивалентный конечный автомат, и наоборот - любому конечному автомату можно сопоставить регулярное выражение. Регулярное выражение - это просто более компактная и понятная для человека форма записи того же языка.

Построение Томпсона

Пусть у нас есть регулярное выражение. По теореме Клини, конечный автомат, эквивалентный данному выражению, существует. Но как его получить?

Построение Томпсона - алгоритм для получения конечного автомата из регулярного выражения, согласно которому необходимо сначала разобрать регулярное выражение на элементарные части, а после собрать снизу вверх.

Всего таких элементарных частей три.

  • Конкатенация (ab) - соединить автомат для a с автоматом для b по $\varepsilon$-переходу
  • Альтернатива (a|b) - новое начальное состояние с двумя $\varepsilon$-переходами к автоматам a и b
  • Звезда Клини (a*) - $\varepsilon$-переход, позволяющий пройти через автомат для a ноль, один или более раз.

Именно по такому алгоритму работает большинство движков для регулярных выражений в стандартных библиотеках языков программирования.

Минимизация

Построение Томпсона очень хорошо работает для построения автомата, но обычно дает автомат с избыточным числом состояний. Именно поэтому существуют методы минимизации полученного конечного автомата.

Теорема Майхилла-Нероуда - для каждого регулярного языка существует единственный минимальный ДКА.

Данную теорему придумали и доказали Джон Майхилл и Энил Нероуд в 1958 году.

По сути это означает, что для минимизации конечного автомата можно объединить два состояния, если они ведут себя одинаково для любого продолжения строки. На практике это означает, что будет меньше состояний, а значит на регистр или переменную будет уходить меньше памяти, а значит переход будет быстрее.

Современные компиляторные генераторы лексеров (flex/lex) минимизируют автомат автоматически, прежде чем сгенерировать код.

Границы конечных автоматов

Конечно, не все языки можно выразить через конечные автоматы.

Классическим примером этого является язык сбалансированных скобок. Для того, чтобы конечный автомат смог его разобрать, ему необходима неограниченная память (ведь он должен помнить количество скобок, которые еще не закрылись).

Формально такую ограниченность конечных автоматов можно доказать через лемму о накачке.

Почти в любом языке программирования синтаксис представляет собой контекстно-свободный, а не регулярный язык. Поэтому лексер (конечный автомат) сам по себе не может разобрать структуру программы целиком, для этого ему нужен парсер с дополнительной памятью (стеком).

Формы представления конечного автомата

Конечный автомат можно описать в виде пять разных форм.

  • Словесное описание
  • Регулярные выражения
  • Диаграмма конечного автомата
  • Формальная функция переходов \delta
  • Код

Примеры конечных автоматов

Автомат для идентификаторов

В данном случае, идентификатор - буква, а затем произвольное количество букв или цифр. Необходимо проверить, является ли данный идентификатор частью языка. Тогда у нас есть два состояния: q0 и q1.

q0 - это начальное состояние, когда ни одного символа еще не прочитано. Если следующим символом будет прочитана буква, то состояние переходит в q1, иначе строка не идентификатор.

q1 - это принимающее состояние, когда прочитана хотя бы одна буква, и дальше можно продолжать как буквами, так и цифрами. Если следующим будет прочитана либо буква, либо цифра, то состояние переходит снова в q1, иначе строка не идентификатор.

Автомат для комментариев

Пусть у лексера есть задача распознать комментарий вида /* текст */. В идеале, этот автомат мог бы состоять из двух состояний, но тут есть ловушка: внутри может быть символ "*", который не заканчивает комментарий. Поэтому в данном случае необходимо четыре состояния.

Эти состояния можно задать следующим образом.

q0: обычный текст
	'/' -> q1
	иначе -> q0

q1: увидели '/', ждем '*'
	'*' -> q2
	иначе -> q0

q2: внутри комментария
	'*' -> q3
	иначе -> q2

q3: увидели '*' внутри, ждем '/'
	'/' -> q0 (комментарий закрыт)
	'*' -> q3 (еще одна звездочка)
	иначе -> q2 (это была не концовка)
Автомат для регулярного выражения

Пусть нам необходимо прочитать только те строки, которые начинаются с TODO. Для этого используется регулярное выражение ^TODO.

Тогда для реализации автомата нам понадобится четыре состояния, по одному на каждую букву слова TODO. При любом отклонении от шаблона строка сразу выбрасывается.

Тогда можем представить такой автомат.

$ grep '^TODO' notes.txt

; автомат для ^TODO (упрощенно)
; q0 --T--> q1 --O--> q2 --D--> q3 --O--> q4 (accept)
; любой другой символ из q0...q3 -> reject

q0: читаем 'T' -> q1, иначе reject
q1: читаем 'O' -> q2, иначе reject
q2: читаем 'D' -> q3, иначе reject
q3: читаем 'O' -> q4 (accept)
Автомат для четного числа символов "a"

В данном случае, язык - строки над алфавитом {a, b} (то есть строки, состоящие только из символов a и b), где количество символов a - четное.

Тогда для того, чтобы описать этот автомат, можем использовать два состояния: q_even (начальное и принимающее) и q_odd.

Символ a переключает четность, символ b четность не меняет. А значит, будет существовать всего четыре правила переходов.

δ(q_even, a) = q_odd
δ(q_even, b) = q_even
δ(q_odd, a) = q_even
δ(q_odd, b) = q_odd

Тогда можем закодировать этот автомат на языке C.

enum { Q_EVEN, Q_ODD } state = Q_EVEN;

for (const char *p = input; *p; p++) {
	if (*p == 'a') {
		state = (state == Q_EVEN) ? Q_ODD : Q_EVEN;
	}
	// символ 'b' состояние не меняет
}

int accepted = (state == Q_EVEN);

При таком кодировании автомата, состояние - это переменная или регистр. В главном цикле читается каждый очередной символ и определяется текущее состояние. Далее выполняется switch или if по состоянию и реализация правил переходов напрямую. В конце проверяется нахождение итогового значения во множестве принимающих.

Аналогичным образом можем закодировать этот автомат на ассемблере.

; rdi - указатель на строку, rsi - длина
; al = 0 (Q_EVEN) / 1 (Q_ODD)
count_parity:
	xor  al, al         ; state = Q_EVEN
	xor  rcx, rcx       ; i = 0
.loop:
	cmp  rcx, rsi
	jge  .done
	mov  dl, [rdi + rcx]
	cmp  dl, 'a'
	jne  .next          ; символ b - state не меняем
	xor  al, 1          ; переключить четность
.next:
	inc  rcx
	jmp  .loop
.done:
	ret                 ; al = итоговое состояние

В данном случае регистр играет роль переменной state. Условный переход jne/je реализует переход по состоянию. Цикл по строке - это метка + условный переход назад.