
- •Теоретические основы формальных языков и их трансляции (магистратура)
- •1. Грамматики и языки.
- •1.1. Обсуждение грамматик.
- •1.2. Символы и цепочки.
- •1.3. Формальное определение грамматики и языка.
- •1.4. Синтаксические деревья и неоднозначность.
- •1.5. Задача разбора.
- •1.6. Некоторые отношения применительно к грамматикам.
- •1.7. Практические ограничения, налагаемые на грамматики.
- •1.9. Некоторые проблемы теории формальных языков.
- •2. Регулярные выражения и конечные автоматы.
- •2.1. Диаграммы состояний.
- •2.2. Детерминированный конечный автомат.
- •2.4. Построение ка из нка.
- •3. Нисходящие распознаватели.
- •3.1. Нисходящий разбор с возвратами.
- •3.2. Проблемы нисходящего разбора и их решение.
- •5.3. Лексический анализ.
- •5.4. Синтаксический анализ.
- •5.5. Генерация кода.
- •5.6. Оптимизация кода.
- •5.7. Анализ и исправление ошибок.
- •5.8. Принципиальная модель компилятора.
- •1.1. Обсуждение грамматик
3.2. Проблемы нисходящего разбора и их решение.
Прямая левосторонняя рекурсия.
Алгоритмы, в которых цель определена с использованием левосторонней рекурсии, имеют серьезный недостаток. Если X - наша цель, а первое же правило для X имеет вид X::=X..., то мы незамедлительно усыновляем того, кто будет искать X. Он в свою очередь немедленно "заведет" себе сына, чтобы тот искал X. Таким образом, каждый будет сваливать на своего сына ответственность, и для решения этой задачи не хватит всего населения Китая.
По этой причине правила грамматики пишут с применением правосторонней рекурсии вместо более привычной левосторонней. Лучший способ избавиться от прямой левосторонней рекурсии - записывать правила, используя итеративные и факультативные обозначения. Запишем правила
(3.1) E::=E+T|T как E::=T{T} и
T::=T*F|T/F|F как T::=F{*F|/F}
Сейчас будут сформулированы два принципа, на основании которых правила языка, включающие прямую левостороннюю рекурсию, преобразуются в эквивалентные правила, использующие итерацию.
Факторизация. Если существуют правила вида U::=xy|xw|...|xz, то их надо заменить правилами U::= x(y|w|...|z), где скобки являются метасимволами.
Допустима факторизация и в более общей форме, такая, как в арифметических выражениях. Например, если y=y1y2 и w=y1w2, мы могли бы заменить U::=x(y|w|...|z) на U::=x(y1(y2|w2)|...|z).
Заметьте, что исходные правила U::=x|xy мы преобразуем к виду U::=x(y|), где - пустая цепочка. Когда бы ни использовалось подобное преобразование, всегда помещается как последняя альтернатива, так как мы принимаем условие, что если цель - , то эта цель всегда сопоставляется.
Помимо того что факторизация позволяет нам исключить прямую рекурсию, использование этого приема сокращает размеры грамматики и позволяет проводить разбор более эффективно.
После факторизации в грамматике останется не более одной правой части с прямой левосторонней рекурсией для каждого из нетерминалов. Если такая правая часть есть, мы делаем следующее.
Пусть U::=x|y|...|z|Uv - правила, у которых осталась леворекурсивная правая часть. Эти правила означают, что членом синтаксического класса U является x, y или z, за которыми либо ничего не следует, либо следует сколько-то v. Тогда преобразуем эти правила к виду U::=(x|y|...|z) {v}.
Данное преобразование позволяет избавиться от ненужных скобок, заключающих T (см. выраж. 3.1). В качестве другого примера преобразуем A::=BC|BCD| Axz|Axy.
Рис.3.1. Деревья, использующие рекурсию и итерацию.
Применив правило факторизации, получим A::= BC(D|) |Ax(z|y); применив последующее преобразование, получим A::= (BC(D|){x(z|y)}.
Использование итерации вместо рекурсии отчасти меняет и структуру деревьев. Таким образом, рис.3.1, a должен был бы походить на рис.3.1,b. Мы утверждаем, что эти два дерева следует рассматривать как эквивалентные; операторы "плюс" должны выполняться слева направо.
Общая левосторонняя рекурсия
Мы не решили всей проблемы левосторонней рекурсии: с прямой левосторонней рекурсией покончено, но общая левосторонняя рекурсия еще осталась. Таким образом, правила U::=Vx и V::=Uy|v Дают вывод U=>+ Uyx. Избавиться от этого не так просто, но обнаружить такую ситуацию можно. Исключим из исходной грамматики все правила с прямой левосторонней рекурсией. Символ U, получившейся в результате этих преобразований грамматики, может быть леворекурсивным тогда и только тогда, когда U FIRST+ U.
5. ОБЗОР ПРОЦЕССА КОМПИЛЯЦИИ.
5.1. АССЕМБЛЕР.
За исключением программы на машинном языке, состоящей из комбинации нулей и единиц, которые непосредственно дешифрирует компьютер, программа на языке ассемблера является простейшей. В языке ассемблера имеются операторы двух видов: команды, которые ассемблер транслирует в машинные команды, и директивы, которые служат указаниями ассемблеру во время процесса ассемблирования, но не транслируется в машинные команды. Для реализации одного оператора языка высокого уровня при трансляции требуются более одного оператора ассемблера.
Хотя каждая ассемблерная команда порождает только одну машинную команду, ее проще писать благодаря тому, что аббревиатуры, называемые мнемониками, показывают тип команды, а символьные цепочки, называемые идентификаторами, представляют собой адреса и, возможно, числа. Типичная ассемблерная команда
ADD AX,COST
прибавляет содержимое ячейки памяти, ассоциируемой с идентификатором COST, к регистру АХ. Аббревиатура ADD является мнемоникой команды.
Директива
COST DW ?
Заставляет ассемблер зарезервировать слово и ассоциировать с ним идентификатор COST, но не порождает машинной команды.
Поскольку ассемблер оказывается просто транслирующей программой, формат и синтаксис команд и директив определяются не компьютером, а тем, как написан ассемблер.
Каждая команда языка ассемблера в исходной программе может иметь до четырех полей следующего вида:
[Метка:] Мнемокод [Операнд] [;Комментарии]
Пробелы вводятся произвольно, но минимум один пробел должен быть в тех местах, где его отсутствие ведет к неоднозначности (например, между мнемоникой и первым операндом) Кроме того, пробелы не допускаются в мнемониках и идентификаторах, а в цепочках-константах и в комментариях они должны вводиться специальными символами. Метка - это идентификатор, присваиваемый первому байту команды, у которой она появляется. Наличие метки в команде не обязательно, но если она есть, метка становится символическим именем, которое применяется в командах переходов для передачи управления отмеченной команде. При отсутствии метки двоеточия быть не должно. Во всех командах необходимо наличие мнемоники. Наличие операндов зависит от команды - некоторые команды не имеют операндов, в других командах требуется один операнд, а в некоторых - два операнда. В случае двух операндов они разделяются запятой. Поле комментария предназначено для пояснения программы и может содержать любую комбинацию символов. Оно не обязательно, и при отсутствии комментария точка с запятой не нужна. Комментарием может быть целая строка и в этом случае первым символом в строке должна быть точка с запятой. Ассемблерная команда должна иметь операнд для каждого операнда машинной команды и обозначение каждого операнда должно идентифицировать режим его адресации. При двух операндах первым указывается операнд-получатель, а вторым - операнд-источник. (Квадратные скобки вокруг полей метки, операнда и комментария показывают, что эти поля не обязательны; ни в коем случае не набирайте эти скобки при вводе программ.)
Чтобы разобраться каким образом происходит преобразование исходной программы на языке высокого уровня в объектную программу, рассмотрим мнемокоды (таблица 5.1).
Таблица 5.1
Мнемокоды ассемблера IBM PC
Мнемокод |
Назначение |
ААА |
Скорректировать сложение для представления в кодах АSCII |
AAD |
Скорректировать деление для представления в кодах ASCII |
ААМ |
Скорректировать умножение для представления в кодах ASCII |
AAS |
Скорректировать вычитание для представления в кодах ASCII |
ADC |
Сложить с переносом |
ADD |
Сложить |
AND |
Выполнить операцию И |
CALL |
Вызвать процедуру |
CBW |
Преобразовать байт в слово |
CMP |
Сравнить значения |
CMPS, CMPSB, CMPSW |
Сравнить строки |
CWD |
Преобразовать слово в двойное слово |
DIV |
Поделить |
ESC |
Передать команду сопроцессору |
HLT |
Остановиться |
IDIV |
Разделить целые числа |
IMUL |
Умножить целые числа |
IN |
Считать значение из порта |
INT |
Прервать |
LEA |
Загрузить исполнительный адрес |
LODS, LODSB, LODSW |
Загрузить строку |
LOOP |
Повторять цикл до конца счетчика |
MOV |
Переслать значение |
MOVS, MOVSB, MOVSW |
Переслать строку |
MUL |
Умножить |
NEG |
Обратить знак |
NOT |
Обратить биты |
OR |
Выполнить операцию ИЛИ |
OUT |
Вывести значение в порт |
POP |
Поместить значение в стек |
PUSH |
Извлечь значение из стека |
RET |
Возвратиться в вызывающую процедуру |
STOS,STOSB, STOSW |
Сохранить строку |
SUB |
Вычесть |
TEST |
Проверить |
XOR |
Выполнить операцию ИСКЛЮЧАЮЩЕЕ ИЛИ |
MOV – основная команда общего назначения, позволяющая пересылать байт или слово между регистром и ячейкой памяти или между двумя регистрами. Она может также пересылать непосредственно адресуемое значение в регистр или в ячейку памяти.
ADD – складывает содержимое операнда – источника и операнда – приемника и помещает результат в операнд – приемник.
SUB – вычитает операнд – источник из операнда – приемника и помещает результат в операнд – приемник.
MUL – умножает числа без знака и заносит результат по умолчанию в регистр – аккумулятор, используемый для выполнения арифметических операций над данными.
DIV – выполняет деление чисел без знака, где источник – делитель, находящийся в регистре общего назначения или в ячейке памяти, а делимое по умолчанию извлекается из регистра – аккумулятора.
Слова AH, AL, AX, BH, BL, BX, BP, CH, CL, CS, DH, DL, DX, DI, DS, ES, SI, SP, ST являются зарегистрированными именами регистров (аккумуляторы).
Представленные в таблице 5 мнемокоды, будем использовать далее в конкретных примерах.
Например, фрагмент программы, реализующий расчет выражения W=X+Y+24-Z может выглядеть следующим образом:
Директивы определения данных, команды ввода и др. X,Y,W,Z определены как переменные-слова.
MOV AX, X ;передать (X) в AX
ADD AX, Y ;прибавить (Y) к AX
ADD AX, 24 ;прибавить 24 к сумме
SUB AX,Z ;вычесть (Z) из (X)+(Y)+24
MOV W, AX ;запомнить результат в W
5.2. ОСНОВНЫЕ ЧАСТИ КОМПИЛЯТОРА.
Перевод (трансляция) — это некоторое отношение между цепочками, или, другими словами, это некоторое множество пар цепочек. Компилятор определяет перевод, образуемый парами вида (исходная программа, объектная программа).
Существуют два фундаментальных метода организации перевода. Первый из них предполагает перевод программы написанной на языке высокого уровня в программу на машинном языке или на языке ассемблера (объектную программу), что предполагает разработку компилятора. Второй пооператорно считывает исходную программу, переводит их в машинные коды и сразу выполняет, минуя объектную программу, что предполагает разработку интерпретатора.
Желательные качества транслятора таковы:
1) эффективность трансляции - время необходимое для обработки входной цепочки (программы) линейно зависит от ее длины;
2) небольшой объем;
3) корректность – желательно иметь небольшой конечный тест, такой, что если транслятор прошел через него, то правильность работы транслятора гарантированна для всех входных программ.
Во многих компиляторах для многих языков программирования есть общие процессы. Попытаемся выделить сущность некоторых из этих процессов. При этом мы постараемся устранить из них по возможности все, что связано с конкретной реализацией и зависит от машины и операционной системы. Хотя соображения, относящиеся к реализации, важны (плохая реализация может испортить хороший алгоритм), нам кажется, что понимание фундаментальной природы проблемы существенно само по себе и позволяет применить технику, созданную для решения этой проблемы, к другим проблемам, по существу сходным с нею.
Исходная программа, написанная на некотором языке программирования, есть не что иное, как цепочка знаков. Компилятор в конечном итоге превращает эту цепочку знаков в цепочку битов — объектный код. В этом процессе часто можно выделить подпроцессы со следующими названиями:
(1) Лексический анализ.
(2) Работа с таблицами.
(3) Синтаксический анализ, или разбор.
(4) Генерация кода, или трансляция в промежуточный код (например, язык ассемблера).
(5) Оптимизация кода.
(6) Генерация объектного кода (например, ассемблирование).
В конкретных компиляторах порядок этих процессов может несколько отличаться от указанного, а некоторые из них могут объединяться в одну фазу. Кроме того, никакая входная цепочка не должна нарушать работу компилятора, т.е. он должен обладать способностью, реагировать на любую из них. Для входных цепочек, не являющихся синтаксически правильными программами, компилятор должен выдать соответствующие сообщения об ошибках.
Мы кратко опишем первые пять фаз компиляции. В реальном компиляторе они не обязательно разделены. Однако методически часто оказывается удобным расчленить компилятор на эти фазы, чтобы изолировать проблемы, присущие именно этим частям процесса компиляции.