
- •Теоретические основы формальных языков и их трансляции (магистратура)
- •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. Обсуждение грамматик
5.4. Синтаксический анализ.
Как уже упоминалось, выходом лексического анализатора является цепочка лексем. Эта цепочка образует вход синтаксического анализатора, исследующего только первые компоненты лексем - их типы. Информация о каждой лексеме (вторая компонента) используется на более позднем этапе процесса компиляции для генерации машинного кода.
Синтаксический анализ, или разбор, как его еще называют, - это процесс, в котором исследуется цепочка лексем и устанавливается, удовлетворяет ли она структурным условиям, явно сформулированным в определении синтаксиса языка.
Какова синтаксическая структура данной цепочки, существенно знать также и при генерации кода. Например, синтаксическая структура выражения А+В*С должна отражать тот факт, что сначала перемножаются В и С, а потом результат складывается с А. При любом другом порядке операций нужное вычисление не получится.
Разбор - одна из наиболее понятных фаз компиляции. По совокупности синтаксических правил можно автоматически построить синтаксический анализатор, который будет проверять, имеет ли исходная программа синтаксическую структуру, определяемую этими правилами. Изложим несколько различных методов разбора и алгоритмов построения синтаксических анализаторов по заданной грамматике.
Выходом анализатора служит дерево, которое представляет синтаксическую структуру, присущую исходной программе. В некотором отношении синтаксический анализ программы напоминает разбор предложений, который все мы проводили в школе.
П р и м е р 5.2. Допустим, что выход лексического анализатора — цепочка лексем:
<ид>1 := (ид>2 + <ид>3)*<ид>4
Эта цепочка передает информацию о том, что необходимо выполнить в точности следующее:
(1) <ид>3 прибавить к <ид>2,
(2) результат (1) умножить на <ид>4,
(3) результат (2) поместить в ячейку, зарезервированную для <ид>1
Эту последовательность шагов можно представить наглядно с помощью помеченного дерева, показанного на рис.5.1
Внутренние вершины дерева представляют те действия, которые надо выполнить. Прямые потомки каждой вершины либо представляют аргументы, к которым нужно применить действие (если соответствующая вершина помечена идентификатором или является внутренней), либо помогают определить, каким должно быть это действие (в частности, это делают знаки + , *, :=)• Заметим, что скобки в цепочке в дереве явно не указаны, хотя мы могли бы изобразить их в качестве прямых потомков вершины n1. Роль скобок только в том, что они влияют на порядок операций. Если бы в рассматриваемой цепочке их не было, следовало бы поступить согласно обычному соглашению о том, что умножение “предшествует” сложению, и на первом шаге перемножить <ид>3 и <ид>4.
Рис. 5.1 Древовидная структура.
5.5. Генерация кода.
Дерево, построенное синтаксическим анализатором, используется для того, чтобы получить перевод входной программы. Этот перевод может быть программой в машинном языке, но чаще он бывает программой в промежуточном языке, таком, как язык ассемблера или „трехадресный код” (последний образуется из простых операторов, каждый из которых включает не более трех идентификаторов, например, А = В, А = В + С).
Если требуется, чтобы компилятор произвел существенную оптимизацию кода, то предпочтительнее код трехадресного типа. Так как трехадресный код не привязывает вычисления к конкретным регистрам вычислительной машины, регистры легче использовать для более эффективной оптимизации. Если предполагается малая оптимизация или никакая, то в качестве промежуточного языка лучше взять язык ассемблера или даже машинный код. Для того чтобы проиллюстрировать узловые моменты процесса трансляции, рассмотрим пример трансляции на язык ассемблерного типа.
Пусть в этом примере наша машина имеет один рабочий регистр (сумматор, или регистр результата) и команды языка ассемблера, вид которых определен в таблице 5.3. Запись „с (m)—> сумматор” означает, что содержимое ячейки памяти m надо поместить в сумматор. Через = m обозначено численное значение m. Из этих замечаний ясен смысл преобразований кодирования.
Таблица 5.3
-
Команда
Действие
MOV m
c(m) - сумматор
ADD m
с(сумматор)+ с(m) - сумматор
MUL m
с(сумматор)* с(m) - сумматор
MOV m
сумматор=m
Выходом синтаксического анализатора служит дерево (или некоторое представление дерева), представляющее синтаксическую структуру цепочки лексем, полученной на выходе лексического анализатора. С помощью этого дерева и информации, хранящейся в таблице имен, можно построить объектный код. На практике построение дерева и генерация кода часто осуществляются одновременно, но методически удобнее считать, что они происходят последовательно.
Существует несколько методов построения промежуточного кода по синтаксическому дереву. Один из них, называемый синтаксически управляемым переводом (трансляцией), особенно изящен и эффективен. В нем с каждой вершиной n связывается цепочка С (n) промежуточного кода. Код для вершины n строится сцеплением в фиксированном порядке кодовых цепочек, связанных с прямыми потомками вершины n, и некоторых других фиксированных цепочек. Процесс перевода идет, таким образом, снизу вверх. Фиксированные цепочки и фиксированный порядок задаются используемым для перевода алгоритмом.
Здесь возникает важная проблема: для каждой вершины n выбрать код С (n) так, чтобы код, приписываемый корню, оказался искомым кодом всего оператора. Вообще говоря, нужна какая-то интерпретация кода С (n), которой можно было бы единообразно пользоваться во всех ситуациях, где может встретиться вершина n.
Для арифметических операторов присваивания нужная интерпретация получается весьма естественно; мы опишем ее в следующих абзацах. В общем случае при применении синтаксически управляемой трансляции интерпретация должна задаваться создателем компилятора. Эта задача может оказаться легкой или трудной, и в трудных случаях, возможно, придется учитывать структуру всего дерева.
В качестве характерного примера опишем синтаксически управляемую трансляцию арифметических выражений. Заметим, что на рис. ** есть три типа внутренних вершин, зависящих от того, каким из знаков :=, +, * помечен средний потомок. Эти три типа вершин показаны на рис. 5.2, где треугольниками изображены произвольные поддеревья (возможно, состоящие из единственной вершины). Для любого арифметического оператора присваивания, включающего только арифметические операции + и * можно построить дерево с. одной вершиной (корнем) типа а и остальными внутренними вершинами только типов б и в.
Рис. 5.2. Типы внутренних вершин.
Код, соответствующий вершине n, будет иметь следующую интерпретацию:
(1) Если n — вершина типа а, то С (n) будет кодом, который вычисляет значение выражения, соответствующего правому поддереву, и помещает его в ячейку, зарезервированную для идентификатора, которым помечен левый потомок.
(2) Если n — вершина типа б или в, то цепочка MOV С (n) будет кодом, засылающим в сумматор значение выражения, соответствующего поддереву, над которым доминирует вершина n.
Так, для дерева, изображенного на рис. 5.1, код MOV С (n1) засылает в сумматор значение выражения <ид>2+<ид>3, код MOV С (n2) засылает в сумматор значение выражения (<ид>2 +<ид>3)* <ид>4, а код С(n3) засылает в сумматор значение последнего выражения и помещает его в ячейку, предназначенную для <ид>1.
Теперь надо показать, как код С (n) строится из кодов потомков вершины n. В дальнейшем мы будем предполагать, что операторы языка ассемблера записываются в виде одной цепочки и отделяются друг от друга точкой с запятой или началом новой строки. Кроме того, мы будем предполагать, что каждой вершине n дерева приписано число l(n), называемое ее уровнем, которое означает максимальную длину пути от этой вершины до листа. Таким образом, l(n) = 0, если n — лист, а если n имеет потомков n1 ,..., nk, то l(n) = max l (ni.)+1,при 1<i<k. Уровни l(n) можно вычислять снизу вверх одновременно с вычислением кодов С (n). Уровни записываются для того, чтобы контролировать использование временных ячеек памяти. Две нужные нам величины нельзя засылать в одну и ту же ячейку памяти. На рис.5.3 показаны уровни вершин дерева, изображенного на рис. 5.1.
Теперь определим синтаксически управляемый алгоритм генерации кода, предназначенный для вычисления кодов С (n) всех вершин дерева, состоящего из листьев, корня типа а и внутренних вершин типов б и в.
Рис. 5.3 Дерево с уровнями.
Алгоритм. Синтаксически управляемая трансляция простых операторов присваивания.
Вход. Помеченное упорядоченное дерево, представляющее оператор присваивания, включающий только арифметические операции + и *. Предполагается, что уровни всех вершин уже вычислены.
Выход. Код в языке ассемблера, вычисляющий этот оператор присваивания.
Метод. Делать шаги (1) и (2) для всех вершин уровня 0, затем для вершин уровня 1 и т. д., пока не будут обработаны все вершины дерева.
(1) Пусть n—лист с меткой <ид>j.
(1.1) Допустим, что элемент j таблицы идентификаторов является переменной. Тогда С (n) — имя этой переменной.
(1.2) Допустим, что элемент j таблицы идентификаторов является константой k. Тогда С (n) — k.
(2) Если n— лист с меткой :=, * или +, то С (n) — пустая цепочка. (В этом алгоритме нам не нужно или мы не хотим выдавать выход для листьев, помеченных :=, * или +.)
(3) Если n — вершина типа а и ее прямые потомки — это вершины n1, n2 и n3, то С(n) — цепочка MOV С(n1), AX.
(4) Если n — вершина типа б и ее прямые потомки — вершины n1,n2 и n3, то С(n) — цепочка MOV AX, C(n1); ADD AX, C(n3)
(5) Если n —вершина типа в, а все остальное, как в (4), то С (n) — цепочка MOV AX, C(n1); MUL AX, C(n3)
Рассмотрим использование данного алгоритма на примере.
П р и м е р 5.3. Применим данный алгоритм к дереву, изображенному на рис. 5.3. То же дерево, на котором явно выписаны коды, сопоставляемые с каждой его вершиной, показано на рис. 5.4. С вершинами, помеченными <ид>1, ..., <ид>4, связаны соответственно коды COST, PRICE, TAX и 30. Теперь мы можем вычислить C(n1). Формула из правила (4) дает
C(n1) = MOV AX, PRICE; ADD AX, TAX
Рис.5.4. Дерево с генерированными кодами.
Таким образом, вычисляется в сумматоре сумма значений переменных PRICE и TAX.
Далее можно вычислить С (n2) по правилу (5) и получить
С(n2) = MUL AX, 30
Затем вычисляем С (n3) по правилу (3) и получаем
С (n3) = MOV COST, AX
Список команд языка ассемблера (вместо точки с запятой разделителем в нем служит новая строка), который является переводом нашего первоначального оператора COST := (PRICE + TAX)*30, таков:
MOV AX, PRICE
ADD AX, TAX
MUL AX, 30
MOV COST, AX
Предполагается, что дрективы определения данных, команды ввода и др. выполнены ранее.