Робин Хантер

advertisement
Робин Хантер
Основные концепции компиляторов
Дополнительная литература
Упражнения
Содержание
Предисловие
Благодарности
Глава 1. Процесс компиляции
1.1. Вступление
1.2. Основные понятия
1.3. Процесс компиляции
1.4. Этапы, фазы и проходы
1.5. Интегрированные среды разработки
1.6. Проектирование компилятора
1.7. Использование инструментальных средств
1.8. Резюме
Дополнительная литература
Упражнения
Глава 2. Определение языка
2.1. Вступление
2.2. Определяя синтаксис
2.3. Грамматики
2.4. Отличия регулярных и контекстно-свободных языков
2.5. Порождения
2.6. Неоднозначные грамматики
2.7. Ограничения контекстно-свободных грамматик
2.8. Задача синтаксического анализа
2.9. Определение семантики
2.10. Резюме
Дополнительная литература
Упражнения
Глава 3. Лексический анализ
3.1.
3.2.
3.3.
3.4.
3.5.
3.6.
3.7.
3.8.
Вступление
Основные понятия
Распознавание символов
Lex
Другие применения Lex
Взаимодействие с YACC
Лексические затруднения
Резюме
9
10
11
11
11
12
15
22
22
23
25
25
26
27 •
27
27
31
39
41
42
47
51
52
53
53
54
57
57
57
58
62
71
75
76
78
79
79
Глава 4, Нисходящий синтаксический анализ
81
4.1. Вступление
4.2. Критерии принятия решений
4.3. 1Х(1)-грамматики
4.4. Рекурсивный спуск
4.5. Преобразования грамматик
4.5.1. Удаление левой рекурсии
4.5.2. Факторизация
4.6. Достоинства и недостатки ЬЦ1)-анализа
4.7. Введение действий в грамматику
4.8. Резюме
Дополнительная литература
Упражнения
81
81
84
89
101
102
105
106
106
Глава 5. Восходящий синтаксический анализ
109
5.1. Вступление
5.2. Основные понятия
5.3. Использование таблицы синтаксического анализа
5.4. Создание таблицы синтаксического анализа
5.5. Особенности LR-анализа
5.6. Введение в YACC
5.7. Вычисление метрик
5.8. Использование YACC
5.9. Резюме
Дополнительная литература
Упражнения
Глава 6. Семантический анализ
6.1. Вступление
6.2. Не-контекстно-свободные характеристики языка
6.3. Таблицы компилятора
6.3.1. Таблицы символов
6.3.2. Таблицы типов
6.3.3. Другие таблицы
6.4. Реализация наследования в C++
6.5. Резюме
Дополнительная литература
Упражнения
Глава 7. Распределение памяти
7.1. Вступление
7.2. Память
7.3. Статическая и динамическая память
Содержание
96
96
98
.
109
109
113
120
132
135
140
143
153
154
154
157
157
157
162
162
167
168
169
170
171
171
173
173
173
176
7.4. Адреса времени компиляции
7.5. Куча
7.6. Резюме
Дополнительная литература
Упражнения
.
Глава 8. Генерация кода
8.1. Вступление
8.2. Создание промежуточного кода
8.2.1. Трехадресный код
8.2.2. Р-код
•
8.2.3. Байт-код
8.3. Создание машинного кода
8.3.1. Выбор инструкций
8.3.2. Распределение регистров
8.3.3. Распределение регистров путем раскрашивания графа
8.4. Оптимизация кода
8.5. Генераторы генераторов кода
8.6. Резюме
Дополнительная литература
Упражнения
Приложение А. Решения упражнений
Глава 1
Глава 2
Глава 3
Глава 4
Глава 5
Глава 6
Глава 7
Глава 8
Глоссарий
Литература
Предметный указатель
185
189
194
194
195
Предисловие
197
Изучение компиляторов является центральным и одним из наиболее востребованных аспектов компьютерных наук. Написание компилятора требует знания исходного языка и целевой машины и обеспечения их взаимосвязи. Наличие современного инструментального обеспечения освобождает программиста от многих утомительных, подверженных ошибкам
моментов при создании компилятора.
Мое первое знакомство с компиляторами произошло в 1967 году на
лекциях создателя ранних моделей компиляторов для языка ALGOL 60
Питера Наура (Peter Naur) в Университете Ньюкасла. Позднее, в 1974 году, мне посчастливилось прослушать расширенный курс по конструированию компиляторов в Техническом университете Мюнхена (отдел Ф. Л.
Бауэра (F. L. Bauer)). С тех пор я, с некоторыми перерывами, работаю с
компиляторами и инструментами для их разработки.
Большинство теоретических разработок и методов для конструирования
компиляторов возникло в 1970-х, а некоторые из них даже раньше. В то же
время развитие языков программирования постоянно ставило перед создателями компиляторов новые задачи: от определения языка ALGOL 60 (ранние
работы Наура в Дании, а также Ренделла (Randell) и Рассела (Russell) в Великобритании) до удовлетворения потребностей возникших позже языка Ada и
языков объектно-ориентированного программирования. Современные требования Java к сетевой безопасности, эффективности и переносимости вновь
ставят перед создателями компиляторов новые проблемы. В то же время появление Java Virtual Machine дает великолепный пример промежуточного
языка, позволяющего проиллюстрировать различные аспекты генерации кода
и распределения памяти.
Процесс создания компилятора соединяет в себе как творческую, так и
рутинную работу. Он требует хорошей инструментальной поддержки, что отчетливо видно при изучении истории развития компиляторов. В наше время
доступно множество инструментальных средств для создания компиляторов.
Некоторые из них можно получить бесплатно через Internet, но на данный
момент наиболее используемыми и распространенными инструментальными
средствами являются Unix Lex и YACC (сейчас они могут применяться и на
других платформах). В Internet доступны также бесплатные версии Lex и
YACC, известные как Flex и Bisson. Также можно получить существующие
версии turbo Pascal (изначально основанные на С).
Во многих книгах по компиляторам описываются средства Lex и
YACC с примерами их использования, но лишь в немногих из них содержится достаточно информации для того, чтобы читатель сам смог
воспользоваться этими инструментами для решения своих задач. Именно
поэтому одной из задач, поставленных при написании данной книги, яв-
197
197
198
201
203
206
208
208
213
215
219
220
220
220
223
223
224
226
230
232
233
234
234
23?
247
249
Содержание
ляется всестороннее (хотя и не полное, все-таки книга небольшая) рассмотрение Lex и YACC. При этом их использование иллюстрируется не
только на примерах, связанных с созданием компиляторов, но и на примерах, затрагивающих другие синтаксически управляемые средства, например, инструменты по вычислению метрик исходного кода.
Основным языком при написании различных алгоритмов на базе Lex
и YACC является С. Данная книга прежде всего посвящена компиляции
императивных языков, поэтому язык С также будет применяться в качестве исходного языка во многих примерах, описывающих различные аспекты компиляции. В то же время многие свойства языка, компиляцию
которого мы желаем рассмотреть, не связаны с С, поэтому в таких случаях
будут использоваться другие, более подходящие языки — FORTRAN,
Pascal, Ada, ALGOL 68, C++, Java.
Первая часть книги посвящена этапу анализа процесса компиляции.
После введения в процесс компиляции (глаю 1) следуют главы по определению языка, лексическому анализу, нисходящему и восходящему синтаксическому анализу и семантическому анализу (главы 2—6). Более короткая вторая часть книги посвящена этапу синтеза процесса компиляции. Она содержит две главы, в которых рассмотрено распределение
памяти и генерация кода (главы 7—8). Каждая глава завершается серией
упражнений, ответы на которые приведены в конце книги. Кроме того, в
конце каждой главы помещен список литературы, рекомендуемой для
дальнейшего изучения. Значение большинства технических терминов,
используемых в книге, можно найти в глоссарии.
Данная книга рассчитана на односеместровый курс по компиляторам и их
инструментальным средствам, подобный читаемому в Университете Стратклайда (University of Stratchclyde). Названный университетский курс также
включает практические занятия, на которых студенты закрепляют навыки по
использованию Lex и YACC. Как и другие книги серии "Основы вычислительных систем", эта книга стремится подать все необходимые аспекты процесса компиляции, не пытаясь охватить всю область.
Благодарности
С особым удовольствием я выражаю свою признательность издательству
Prentice Hall за помощь и поддержку в создании настоящей книги. Необходимо также поблагодарить Бориса Когана (Boris Cogan) и Тамару Матвееву (Tamara Mafveeva) за их полезные комментарии к тексту. Благодарю свою жену Кейт (Kate) за ее поддержку и терпение, а также за проверку всей рукописи, которую она охотно выполнила. Благодарю
рецензентов издательства и редактора серии Рея Велланда (Rey Welland)
за их конструктивные замечания на разных этапах создания книги.
Робин Хан тер (Robin Hunter)
Глазго
Май 1998
W
т.»
Предисловие
Глава 1
Процесс КОМПИЛЯЦИИ
1.1. Вступление
В этой главе будут определены основные понятия компилятора языка,
его структура, функции и реализация. В частности, будут рассмотрены
следующие вопросы.
« Взаимосвязь между различными языками, задействованными в типичном компиляторе.
• Типичная структура компиляторов.
• Функции основных фаз компиляции.
• Цели, которые ставятся при разработке компиляторов.
• Роль инструментальных средств при разработке компилятора.
• Возможная степень автоматизации разработки компилятора.
1.2. Основные понития
Программное обеспечение можно создавать с помощью множества языков программирования. Ими могут быть традиционные императивные
языки (COBOL, FORTRAN, Pascal, С), объектно-ориентированные языки (C++, Smalltalk, Java), функциональные или логические языки (LISP,
Prolog), языки четвертого поколения или визуальные языки (Visual C++,
Visual Basic, Delphi). Задача компилятора состоит в преобразовании ориентированного на пользователя представления программного обеспечения в машинно-ориентированное (в котором происходит непосредственное выполнение программы компьютером). Компиляторы — это изначально изощренные системы обработки текста, которые имеют много
общего с другими инструментальными средствами обработки текста, написанными на языке программирования либо на естественном языке.
Обрабатываемый ими текст может быть написан как вручную на обычном языке, так и полуавтоматическим способом при использовании визуальных языков или языков четвертого поколения.
Задача компилятора обычно рассматривается на двух этапах.
1, Этап анализа, на котором анализируется исходный текст.
2. Этап синтеза, на котором генерируется машинно-ориентированное
представление,
В дальнейшем вход этапа анализа будем называть исходным текстом
или исходным кодом, а выход этапа синтеза — целевым текстом или целевым кодом. Преобразование исходного кода в целевой обычно называется
процессом компиляции. Процесс компиляции осуществляет компилятор.
Компилятор языка можно также назвать реализацией языка. Произведенный компилятором целевой код может иметь вид машинного кода для
некоторой машины (компьютера) или ассемблерного кода, или некото рого промежуточного кода, который в дальнейшем будет преобразован
(уже с помощью других инструментальных средств) или в ассемблерный
код, или л машинный код для некоторой машины. Возможен также вариант, когда промежуточный код непосредственно используется посред ством интерпретатора,
Несмотря на то, что предлагаемая книга посвящена процессу компиляции, при случае будут рассматриваться и другие возможности применения текстуального анализа. Следует отметить, что при рассмотрении
компиляции большее внимание будет уделяться вопросам анализа, чем
синтеза. Это связано с тем, что понятия стадии анализа обладают большей степенью общности и применимости по сравнению с частными, зависимыми от машины понятиями стадии синтеза.
Технологии компиляторов значительно развились со времени появления компьютеров, и теперь стала возможной автоматизация многих аспектов процесса создания компилятора. Существующие инструментальные средства позволяют автоматизировать создание анализаторов, хотя
успехи по автоматизации создания генераторов кода намного скромнее.
Вопросы возможной степени автоматизации будут рассматриваться в последующих главах, где с этой целью будут широко использованы средства
генерации анализаторов Lex и YACC.
В основном языком создания компиляторов с помощью Lex и YACC
является С. Поэтому в дальнейшем в качестве языка реализации (языка
написания компилятора) будет подразумеваться именно С, и требуемые
алгоритмы будут описываться на нем. Чтобы избежать частого перехода
от одного языка к другому, реализуемым языком также будет считаться
С. Впрочем, для описания отдельных моментов будут использоваться и
другие языки, более удобные в этих конкретных случаях.
1.3. Процесс компиляции
Процесс компиляции представляет собой преобразование одного языка в
другой, переход от исходного кода, на котором работает программист или
который автоматически генерируется из некоторого высокоуровневого
представления, к целевому коду, который выполняется на машине, воз-
12
Глава 1. Процесс компиляции
можно, после некоторых преобразований. Схематически этот процесс
изображен на рис. 1.1
Исходный
Целевой
Процесс компиляции
код
код
Рис. 1.1.
Как уже упоминалось, процесс компиляции также включает третий
язык — язык реализации. Им может быть тот же язык, что и исходный
или целевой код, но это необязательно. По возможности, в качестве язы ка написания компилятора выбирается наиболее удобный — либо с точки зрения внутренних характеристик (например, подверженность ошиб кам), либо с точки зрения доступности и совместимости с инструментальными средствами.
Три языка, задействованных в реализации, удобно представить с помощью Т-диаграммы, содержащей каждый язык в отдельной ветви. На рис. 1.2
изображен компилятор, который написан на С и преобразует Java в байт-код
(язык, интерпретируемый посредством Java Virtual Machine).
Байт-код
Java
С
Рис. 1.2.
На рис. 1.3 изображен компилятор, который написан на машинном
коде (М-код) и преобразует Pascal в машинный код. Данный пример является иллюстрацией того факта, что операционный компилятор обычно
пишется (и производит код) на том же языке, на котором работает машина, где планируется выполнять данный код. Однако, если часть программного обеспечения (возможно, для встроенных систем) компилируется на машине, отличной от той, на которой это обеспечение будет использоваться, то потребуется задействовать два машинных кода. Один из
них предназначен для машины, на которой запушен компилятор (язык,
на котором компилятор написан), а второй — для машины, на которой
будет использоваться данное, программное обеспечение (язык, который
генерирует компилятор). Для примера на рис. 1.4 изображен компилятор
для компилирования программного обеспечения встроенных систем.
Чтобы компилятор мог функционировать, он должен быть написан на
коде той машины, на которой будет установлен. Однако, этот код может
оказаться неудобным для написания программы компилятора по причине его низкого уровня.
1.3. Процесс КОМПИЛЯЦИИ
13
них диаграмм должны быть одинаковыми, а два соседствующих появления языка C++ и машинного кода должны относиться к одному языку.
Изображенный внизу М-код мог, по сути, быть любым языком, определяемым машиной, на которой компилируется компилятор.
С помощью Т-диаграммы легко показать, как перенести компилятор,
написанный для одной машины, на другую. Пусть имеется компилятор,
созданный для машины А, тогда при его переносе на машину В нужно,
по меньшей мере, изменить язык реализации. Чем пытаться преобразовать код одной машины в код другой, намного проще вернуться к первоначальной версии компилятора, написанной на языке высокого уровня,
и откомпилировать ее в код машины В.
Во многих случаях необходимо преобразовать выход кода с помощью
компилятора (например, в код машины В). Это уже совсем другая задача,
и ее сложность будет зависеть от иных факторов, например, от „того, на
сколько автономно производство кода.
*
М-код
Pascal
М-код
Рис, 1.3.
Т-код
C++
Н-код
1,4. Этапы5 фазы и прожоды
Рис. 1.4.
Часто эту проблему решают следующим образом: вместо того, чтобы писать программу на языке низкого уровня, ее получают путем компиляции
программы, написанной на языке высокого уровня. Поэтому сами по себе
компиляторы изначально пишутся на языке высокого уровня, а затем с
помощью уже существующих компиляторов преобразуются к виду, который воспринимает машина. Данную ситуацию можно изобразить с помощью трех расположенных рядом Т-диаграмм (рис. 1.5).
М-код
C++
C++
C++
с++
М-код
Vi-етд М-код
М-код
Рис. 15.
и
i
i
В левом верхнем углу рисунка расположен компилятор, изначально
написанный на C++, а в правом верхнем углу — исполняемый компилятор, полученный после компиляции компилятором, помещенным внизу
рисунка. Эти три Т-диаграммы можно объединить, что позволит обнаружить некоторые закономерности получения одного компилятора из другого. Например, два языка в соответствующих двух верхних частях верх14
.Глава 1. Процесс КОМПИЛЯЦИИ
Хорошо написанный компилятор имеет модульную структуру и должен
представлять пример хорошо сконструированной программы. Логически
процесс компиляции разделяется на этапы, которые, в свою очередь,
разделяются на фазы. Физически компилятор разделяется на проходы.
Остановимся на этих вопросах более детально.
Как уже говорилось, основными (и часто единственными) этапами
компиляции являются анализ (определение структуры и значения исходного кода) и синтез (построение целевого кода). Кроме того, может быть
этап предварительной обработки, в которой происходит присоединение
исходных файлов, развертывание макросов и т.п. Этот этап достаточно
прост и в основном связан с языками С и C++. Подробно данный этап
рассматриваться не будет.
Типичные фазы процесса компиляции показаны на рис 1.6.
Этап анализа принято разделять на три отдельных фазы.
1. Лексический анализ.
2. Синтаксический анализ.
3. Семантический анализ.
Этап синтеза состоит из следующих (всех или нескольких) фаз.
• Генерация машинно-независимого кода.
• Оптимизация машинно-независимого кода.
• Распределение памяти.
• Генерация машинного кода.
• Оптимизация машинного кода.
1.4. Этапы, фазы и проходы
IS
Синтаксический
анализ
Лексический
анализ
Семантический
анализ
Анализ
Генерация
> Оптимизация > Распремашинномашинно. деление
памяти
независимого
независимого
кода
кода
■
►
Генерация > Оптимизация
машинного
машинного
кода
кода
Синтез
Рис. 1.6.
Лексический анализ — это относительно простая фаза, в которой
формируются символы (или токены) языка. Слова языка, например,
for
do
while
или пользовательские идентификаторы, например,
name
salary
или последовательности знаков, например,
++
number Int return do == ++
то лексический анализатор просто бы передал этот текст в символьном
виде синтаксическому анализатору. Другими словами, лексический ана16
стоит задуматься об эффективности остальных фаз процесса.
В процессе синтаксического анализа определяется общая структура
программы, что включает понимание порядка следования символов в
программе. Это означает, что синтаксический анализатор должен обладать информацией о контексте, в котором он работает, т.е. учитывать уже
прочитанные символы. Результатом работы синтаксического анализатора
является представление программы в древовидной форме, которую называют синтаксическим деревом. К примеру, выражение
(а + Ь) * (с + d) может быть представлено в виде,
показанном на рис. 1.7.
==
удобно воспринимать как один символ, как это делается на этапе анализа. Задачей фазы лексического анализа или лексического анализатора является переход от последовательности знаков к символам языка, с которыми в дальнейшем будут работать синтаксическая и семантическая фазы. Такой подход копирует поведение человека при чтении программы. В
конечном счете, мы ведь воспринимаем текст программы не как простой
набор знаков, а как набор символов, состоящих из этих знаков.
Наряду с преобразованием последовательности знаков в символы,
лексический анализатор также обрабатывает пробелы и удаляет комментарии и любые другие символы, не имеющие смысловой нагрузки для
последующих этапов анализа. Важно отметить, что лексический анализатор всего лишь формирует символы — их последовательность не имеет
для него никакого значения, т.е., если бы программа, допустим написанная на С, начиналась с
;
лизатор обычно не работает с контекстом. Если он обрабатывает один
символ, для него совершенно не важно, что предшествов ало этому символу или что последует за ним.
Сравнительная простота и четкая формулировка лексического анализа
позволяют легко автоматизировать создание лексических анализаторов, и
в настоящее время существует множество инструментальных средств, позволяющих генерировать лексические анализаторы для языка, исходя
только из его лексической (т.е. локальной) структуры. О том, как использовать эти инструменты, будет рассказано в главе 3.
Несмотря на сравнительную простоту лексического анализатора, его
выполнение может занимать значительное время в процессе компиля ции. Это не должно удивлять, если учесть, что лексический анализ —
единственная фаза компиляции, которая имеет дело со знаками, которых
значительно больше языковых символов. Если же работа лексического
анализатора не занимает значительную часть времени компиляции, то
Глава 1. Процесс КОМПИЛЯЦИИ
Это представление называется абстрактным синтаксическим деревом.
Следует отметить, что в данном представлении нет необходимости показывать скобки, поскольку структура, задаваемая ими в первоначальном выражении, представляется структурой дерева. Таким же образом вся программа
может быть представдена с помощью абстрактных сишаксических деревьев.
1.4. Этапы, фазы и проходы
17
Синтаксический анализатор считывает символы в программе слева
направо (привычным способом). В процессе считывания он должен
уметь определять, является ли последовательность уже считанных символов началом программы. Например, первые десять входных символов
могут быть началом некоторой программы, а первые одиннадцать — нет.
В этом случае последующие действия компилятора будут зависеть от
принятого способа восстановления после ошибок. По крайней мере, компилятор должен указать на одиннадцатый символ и сообщить, что в этой
точке вход стал некорректным. Многие компиляторы имеют более широкие возможности, например, могут выводить (предположительно полезные) сообщения следующего характера:
semi-colon missing?
Стоит отметить, что иногда подобные сообщения не помогают, а наоборот, еще больше запутывают.
Возникновение ошибки при считывании одиннадцатого символа может быть связано как с некорректностью настоящего символа, так и с
ошибкой в предыдущих символах программы, и это — одна из причин
того, что сообщения об ошибках могут причинять неудобства. Это не означает, что компилятор может выдать сообщение об отсутствующей
ошибке, он просто может указать неверное место ее нахождения и из-за
этого неправильно ее охарактеризовать. Вопросы правильной диагностики
ошибок и стратегии их устранения чрезьычайно важны, но в данной
книге они не рассматриваются, дабы не отклоняться от главной темы и
общих принципов книг серии "Основы вычислительных систем".
Синтаксические анализаторы для проверки синтаксиса могут быть построены автоматически (с использованием соответствующих инструментальных средств) из определения языка, хотя при этом требуется писать
код для формирования абстрактного синтаксического дерева. Об использовании таких инструментов рассказывается в главах 4 и 5.
Фаза синтаксического анализа является ключевой на этапе анализа.
Она непосредственно взаимодействует с лексической фазой, а результаты
ее работы в дальнейшем будут использоваться семантической фазой.
Кроме того, фаза синтаксического анализа создает основу для работы
компилятора в целом, коды этапа синтеза образуются именно благодаря
взаимодействию со структурой, которую образует фаза синтаксического
анализа. К тому же, синтаксический анализ создает основу для работы
большого количества инструментальных средств анализа исходного кода,
например, различных инструментов измерений, перекрестных ссылок,
компоновки и т.д. Все упомянутые типы анализа являются частными
случаями статического анализа, т.е. анализа, который осуществляется
над исходным кодом без его выполнения. Анализ кода, связанный с выполнением команд исходного кода, называется динамическим.
Методы синтаксического анализа требуют интенсивного изучения.
Некоторые из них обладают большей общностью, чем другие (в смысле
применимости к более широкому классу языков), некоторые легче авто18
Глава 1. Процесс КОМПИЛЯЦИИ
матизировать, а некоторые более эффективны. Далее в главах 4 и 5 будут
подробно рассмотрены два ведущих метода — метод рекурсивного спуска,^
являющийся наглядным и простым при кодировании, и синтаксический
анализ SLR(1), который легко автоматизируется и пригоден для большого
диапазона языков.
Некоторые свойства языков программирования не могут быть проверены простым сканированием слева направо без создания таблиц произвольного размера для обеспечения доступа к информации на некотором
расстоянии (с точки зрения символов исходного кода). В эту категорию,
к примеру, попадает информация о типах переменных и области их видимости. Проверка этих типов свойств языков программирования
(называющихся статической семантикой) выполняется не в синтаксической, а в семантической фазе анализа. Это означает, что синтаксический
анализатор не заметит несовместимости используемых типов. Проблема
такого рода будет исследоваться в семантической фазе анализа.
Семантический анализ обычно инициируется синтаксическим анализатором для создания и получения доступа к соответствующим таблицам.
То, что семантический анализ может не производиться автоматически
синтаксическим анализатором, имеет свои положительные стороны;
ошибки статической семантики легко обнаруживаются, и после них легко производится восстановление,
Как уже говорилось, этап синтеза процесса компиляции состоит из
следующих основных фаз.
• Генерация машинно-независимого кода.
• Оптимизация машинно-независимого кода.
• Распределение памяти.
• Генерация машинного кода.
• Оптимизация машинного кода.
В частных случаях некоторые из этих фаз могут отсутствовать. Например,
если компилятор непосредственно компилирует в машинный код, пер вые две фазы могут пропускаться. Оптимизация кода может происходить
на уровне машинно-независимого кода, на уровне машинного кода, на
обоих уровнях или ни на одном. Распределение памяти является ключевой фазой, которая управляется одной из фаз генерации кода.
Существуют причины, по которым вначале необходимо создавать машинно-независимый код; это способствует переносимости компилятора
и служит для обособления зависимости от языка и зависимости от машины. Многие компиляторы также производят некоторые промежуточные коды, которые могут быть независимы от исходного языка, машинного языка или от обоих.
В свое время были приложены значительные усилия для создания так называемого Универсального промежуточного языка (Universal Intermediate
Language — UIL), удобного для использования в качестве промежуточного
языка для компиляции всех, или почти всех, языков на любую машину. К \
1.4. Этапы, фазы и проходы
19
сожалению, эта великолепная идея оказалась неосуществимой. Однако, на
данный момент существуют хорошо разработанные промежуточные языки
для компиляции исходных языков, например, Р-код для Pascal, Diana для
Ada, байт-код для Java. Также существуют промежуточные языки для компиляции на определенные машины, например, CTL для машины Manchester
MU5. Если бы существовал удачный язык UIL, то проблема использования
т языков на п машинах решалась бы созданием т препроцессоров (front end)
(каждый из них состоял бы из соответствующего анализатора одного из языков и генератора UIL) и л постпроцессоров (back end) (каждый из них состоял
бы из соответствующего транслятора с UIL в один из машинных кодов).
Сказанное выше иллюстрируется на рис. 1.8. С другой стороны, независимая
реализация каждого компилятора потребует создания т* п программных
блоков, отображающих каждый язык на каждую машину.
UIL
тязыков
п
м
а
шин
РИС . 1.8,
Одной из проблем при создании LJIL является проблема выбора уровня
языка: UIL может оказаться либо слишком высокоуровневым для некоторых
языков, либо слишком низкоуровневым для некоторых машин. Несмотря на
это, существует множество примеров компиляторов с одним исходным язы ком, который преобразуется в коды нескольких машин, и компиляторов, которые преобразуют различные исходные языки в один и тот же машинный.
Рассмотрим вопрос оптимизации кода. Потребность в ней может быть
различной. Если требуется очень эффективный код, то компилятор обязан
обеспечить значительную оптимизацию. В то же время во многих средах
скорость работы программного обеспечения не является критическим параметром, следовательно, необходима всего лишь незначительная оптимизация.
Некоторые типы оптимизации реализовать просто, и поэтому их часто включают в компиляторы, тогда как другие формы оптимизации, в особенности
глобальные (в отличие от локальных), трудоемки и требуют значительных затрат времени при компиляции, а посему употребляются редко. Многие компиляторы позволяют пользователю самому определить, требуется ли исчер- ■
пывающая (а следовательно, дорогая) оптимизация.
Глава I. Процесс КОМПИЛЯЦИИ
В фазе распределения памяти каждая константа и переменная, фигурирующие в программе, получают зарезервированное место в памяти для
хранения своего значения. Данная область памяти может иметь один из
следующих типов.
•
Статическая память, если время жизни переменной равно време
ни жизни программы. Не может быть освобождена до завершения
выполнения программы.
•
Динамическая память, если время жизни переменной равно времени
жизни определенного блока, функции или процедуры. Может быть
освобождена после выполнения данного фрагмента программы.
•
Глобальная память, если на момент компиляции время жизни неиз
вестно, а память должна выделяться и освобождаться в процессе вы
полнения. Эффективный контроль подобной памяти обычно подразу
мевает определенные служебные издержки времени выполнения.
Результатом работы фазы распределения памяти является создание
адреса, в котором содержится полная информация о локализации памяти.
В дальнейшем адрес передается генератору кода.
Этап синтеза процесса компиляции (в отличие от этапа анализа) не
очень поддается автоматизации, поэтому нельзя сказать, что средства его
инструментальной поддержки весьма широко распространены. Ранняя
идея создания компилятора компиляторов (compiler-compiler), программы, вход которой являлся бы спецификацией языка и машины, а вы ход — реализацией языка на машине, была в значительной степени осуществлена для этапа анализа и в меньшей степени — для этапа синтеза.
Если в логических терминах компилятор рассматривается как состоящий из этапов и фаз, физически он составлен из проходов (pass). Компилятор осуществляет проход каждый раз при считывании исходного кода
или его представления. Многие компиляторы являются однопроходными,
т.е. полный процесс компиляции полностью выполняется при однократном чтении кода. В этом случае различные описанные фазы будут выполняться параллельно (что, как правило, является наиболее удобным),
что устраняет необходимость сложной связи между различными проходами. Ранние компиляторы были многопроходными (обычно 7-8 проходов) по причине недостаточного объема памяти машин того времени.
Для современных компиляторов проблем с объемом памяти уже (как
правило) не существует, поэтому большинство из них являются однопроходными. В то же время некоторые языки, такие как ALGOL 68, невозможно откомпилировать за один проход. Это связано с тем, что информация, необходимая какой-то конкретной фазе, недоступна в той части
исходного кода, в которой она используется. Требуемые многопроходные
компиляторы можно легко описать как компиляторы с несколькими
предварительными проходами, в течение которых информация собирается
и записывается в таблицы с последующим использованием на этапах
анализа и синтеза.
1.4. Этапы, фазы и проходы
21
1.5. Интегрированные среды разработки
Современные компиляторы часто являются не отдельными, автономными инструментальными средствами, а представляют собой часть интегрированных сред разработки (Integrated Development Environment — IDE),
которые иногда называют средами программирования. Помимо предоставления средства компиляции, современная среда IDE предлагает средства языково-ориентированного редактирования, отладки, определения
рабочих профилей программы, управления конфигурацией и т.д. Хороший пример такой среды — Borland IDE для C/C++, которая в среде
Windows предлагает средства для выполнения множества различных операций, часть из которых выделена в перечисленные ниже группы.
• Редактирование со средствами вырезания, вставки, отмены опера
ции и т.п.
• Поиск со средствами поиска текста, замены текста и локализации
функций в процессе отладки.
• Обзор различных окон, содержащих средства диагностики и другую
информацию, связанную с текущим проектом, включая информа
цию по иерархии вызовов, расположению точек прерывания про
граммы, содержимого регистров, расположению переменных, ис
пользованию классов и т.д.
• Управление проектом, включая запуск новых проектов, компиля
цию и связывание различных компонентов проекта при раздель
ном контроле средств компиляции и сборочных файлов.
• Отладку для возможности запуска программы в обычном режиме
или режиме отладки со средствами пошагового выполнения, уста
новки точек прерывания, отслеживания значений выражений,
просмотра таблиц символов и т.д.
• Средства выполнения, связанные с IDE.
Borland Pascal IDE для Windows предлагает подобный интерфейс, что позволяет легко переходить^ одного языка на другой. Хотя подробный обзор сред IDE выходит за круг рассматриваемых в книге вопросов, в дальнейшем, время от времени, мы будем обращаться к инструментальным
средствам IDE.
Общая структура компилятора во многом зависит от его фазоюй структуры и
структуры синтаксического анализатора, создающего основу большинства
фаз, а структура синтаксического анализатора отражает свойства исходного
языка. Обычно при проектировании компилятора необходимо руководствоваться следующими нефункциональными требованиями.
!!
22
Глава 1. Процесс КОМПИЛЯЦИИ
•
•
•
•
•
•
•
Эффективная компиляция.
Минимальный размер компилятора.
Минимальная длина целевого кода.
Создание эффективного целевого кода.
Легкость переносимости.
Простота использования.
Практичность, что включает хорошие средства диагностики оши
бок и восстановления после ошибок.
Безусловно, одновременно удовлетворить всем вышеперечисленным
требованиям практически невозможно, поэтому некоторым из них приходится отдавать предпочтение. К примеру, легкость переносимости и
простота использования может не согласовываться с требованием минимального размера компилятора, а создание эффективного целевого кода—с требованием эффективной компиляции.
В обучающих средах, например, эффективность компиляции и хорошие средства диагностики могут быть важнее создания эффективного целевого кода,, тогда как для встроенных систем первоочередное значение
имеет размер и эффективность целевого кода. Многие компиляторы разрешают пользователю самому определять режим работы компилятора —
степень оптимизации, выполнение проверок времени выполнения и т.д.
1.7. Использование инструментальных средств
При создании компиляторов используются два основных типа инструментальных средств.
• Генераторы лексических анализаторов (lexical analyser generator).
• Генераторы синтаксических анализаторов (syntax analyser generator,
или parser generator).
На вход инструментального средства, которое создает лексический
анализатор, поступает информация о лексической структуре языка (как
из знаков составляются токены языка), а результатом его работы является лексический анализатор (например, программа на С) этого языка.
Графически данный процесс представлен на рис. 1.9.
На вход инструментального средства, создающего синтаксический
анализатор, поступает синтаксическое определение языка, а результатом
его работы является синтаксический анализатор (например, программа
на С) этого языка. Графически этот процесс представлен на рис. 1.10.
В настоящее время разработаны генераторы синтаксических анализаторов, поддерживающие наиболее распространенные методы грамматического разбора. Самым известным из генераторов синтаксических анализаторов разбора является YACC на основе Unix, который обычно используется совместно с генератором лексических анализаторов Unix,
1.7, Использование инструментальных средств
23
именуемым Lex. YACC (Yet Another Compiler-Compiler- "еще один
компилятор компиляторов") поддерживает достаточно общий метод восходящего синтаксического анализа и допускает введение программных
вставок на С, а также производство выхода на С.
Последовательность
знаков
Лексическая структура
Генератор лексического
анализатора
Лексически
й
анализатор
Рис. J. 9.
1оследовательность
символов
Генератор
_—__»—
_^»
синтаксического
анализатора
Синтакс
ический
анализатор
Дерево
синтаксического
анализа
г
Рис. 1,10,
Существует общедоступный аналог YACC, называемый Bison, а также
общедоступная версия Lex, именуемая Flex. Доступны также объектноориентированные версии Lex и YACC (Lex++ и YACC++, соответственно), написанные на C++. Качественные инструментальные средства нашли широкое применение при создании компиляторов, поскольку они
предлагают следующие преимущества.
24
•
•
•
•
Последовательность
символов
Синтаксическое
определение
•
•
Глава 1. Процесс компиляции
Легкость создания компилятора.
Большая уверенность в надежной работе компилятора, опираю
щаяся на уверенность в качестве инструментального средства.
Легкость в эксплуатации компиляторов, отчасти объясняемая по
нятностью принципов его работы.
Совместимость с большим классом компиляторов.
Создание основы для процесса компиляции в целом, включая ге
нерацию кода.
Возможность интеграции нескольких типов анализа в одном инст
рументальном средстве.
1.8. Резюме
В этой главе было сделано следующее.
• Введены основные понятия компиляции.
• Объяснено разделение компилятора на этапы, фазы и проходы.
• Описано предназначение основных компонентов компилятора.
• Обсуждены основные проектные цели при проектировании ком
пилятора и возникающие при этом противоречия.
• Представлены Lex и YACC как примеры средств разработки ком
пиляторов.
Дополнительная литература
Существует множество литературы для начального ознакомления с процессом компиляции и связанными с этим вопросами. Пожалуй, наиболее известной и одной из полезнейших книг (несмотря на ее "возраст") является
классическая работа [Aho, Sethi and Ullman, 1985]. В качестве более поздних
работ можно порекомендовать [Fisher and Leblank, 1988], [Bennett, 1990],
[Ullmann, 1994], [Wilhelm and Maurer, 1995], [London, 1997], [Appel, 1997] и
[Terry, 1997]. Кроме того, рекомендуется монография fWirth, 1996], где рассматриваются более общие вопросы в контексте компиляции языка Oberon,
и работа [Diller, 1988], в которой рассматривается компиляция функциональных языков. Работа [Watt, 1993] предоставляет всесторонний обзор области, а
в [Randell and Russel, 1964] и [Gries, 1971] представлены ранние тексты, посвященные компиляторам. Работа Наура по созданию ранних компиляторов
ALGOL 60 описана в книге [Naur, 1964]. Работа [Levine, Mason and Brown,
1992] — это, пожалуй, единственная книга, полностью посвященная Lex и
YACC. Общедоступные версии Lex и YACC (Hex и Bison, соответственно)
можно получить через Internet:
ht tp : // www. g n u . ai. mi t .e d u/ h o me . h t ml
1,8. Резюме
25
или (для высших учебных заведений Великобритании)
http://micros,hensa,ас.uk/
Определение языка
Упражнения
Li.
1.2.
1.3.
1.4.
1.5.
1J.
1.7.
1.8.
1.9.
1.11,
Глава 2
Перечислите правила совместимости для объединения Т-диаграмм.
Величина х используется для представления двух различных переменных в программе на С. Стоит ли ожидать, что лексический
анализатор сможет различить эти две переменные? Обоснуйте свой
ответ.
Нарисуйте абстрактное синтаксическое дерево для оператора присваивания.
Предложите три способа определения размера программы, написанной на языке высокого уровня.
Ро время какой фазы процесса компиляции происходит распознавание типа литерала?
Во время какой фазы процесса компиляции может обнаружиться
несовместимость типов?
Какой тип памяти для хранения переменных не очень удобен при
использовании стеков?
Предложите цели проектировгчия компиляторов, отличающиеся
от рассмотренных в главе.
Предложите количественное определение надежности компиляторов.
Приведите аргументы за и против использования С в качестве
языка реализации.
2.1. Вступление
В этой главе будет рассказано о существующих методах определения как
языков в общем, так и языков программирования в частности. Будут определены строки, составляющие язык, а также значение этих строк, что
позволит нам подойти к рассмотрению фундаментальной задачи этапа
анализа процесса компиляции — задачи синтаксического анализа. Итак, в
этой главе будут рассмотрены следующие вопросы.
• Методы определения бесконечного набора строк.
• Понятие грамматики для определения языков программирования.
• Иерархия грамматик.
• Выведение предложений языка из грамматики.
• Неоднозначные грамматики.
• Задача синтаксического разбора — как найти порождение предло
жения, исходя из грамматики.
• Методы определения семантики языков программирования.
2.2. Определи! синтаксис
Компилятор должен создавать правильный целевой код при любом вхо де, принадлежащем исходному языку, для которого разработан компилятор, или же одно или несколько сообщений об ошибках, если это невозможно (вход является искаженным, ошибочным, неправильным). Проверка правильности входа требует определения языка, на котором
написан исходный код. Это определение должно быть:
• точным (или формальным);
• лаконичным (чтобы компилятор не был слишком большого размера);
• машинно-читаемым.
Учебники по языкам обычно не яшмются достаточно точными и лаконичными для использования в наших целях, поэтому более удобным
оказывается языковой Стандарт (если он есть). Использование Стандарта
26
Глава 1, Процесс компиляции
должно также обеспечить совместимость компиляторов, предназначен ных для одного языка.
Определение языка должно определять все строки символов, существующих в данном языке (синтаксис языка), а также их значения или
планируемый эффект (семантика языка). Если язык состоит из конечного числа строк, то его определение не представляет принципиальной
сложности и заключается в (возможно утомительном) перечислении всех
элементов. В то же время, поскольку все интересующие нас языки содержат бесконечное число строк, требуется найти средство представления
бесконечного числа строк конечным образом.
Рассмотрим несколько очень простых языков и продемонстрируем метод
представления бесконечного числа строк символов, содержащихся в языке.
Например, язык, состоящий из всех строк, которые содержат произвольное
целое число символов х, можно описать следующим образом.
{х" | л > 0}
Здесь §нак "|" можно прочесть как "где", п — целое, а умножение следует
понимать как конкатенацию.
Ниже проводится другой пример языка.
Этот язык состоит из всех строк, которые состоят из одного или боль шего числа СИМЕЭЛОВ х, после чего следует такое же количество символов
у. Например, этому языку могут принадлежать следующие строки.
ху
хххууу
ххххххххуууууууу
С другой стороны, определение
{хУ I т, п > 0)
предсташшет язык, состоящий из строк, в которых вначале располагается
хотя бы один символ х, за которым следует хотя бы один у, причем число
элементов х не обязательно должно совпадать с числом элементов у.
Язык можно определить и следующим образом
то тогда строки
ххх (нуль элементов у)
и
УУУУУУУ (нуль элементов х)
также представляют возможные строки языка. При таком определении
языка ему также принадлежит элемент
е
28
Глава 2. Определение языка
Этот элемент — пустая строка, не содержащая ни знаков х, ни знаков у.
Как будет показано далее, пустые строки играют важную роль в определении языков программирования.
Рассмотренные выше языки можно также определить с помощью регулярных выражений, подобных приведенному ниже.
обоЗдесь символ "•" (другое название —
значает, что предшествующий ему элемент, употребляется нуль или
большее число раз. Если в каждой строке языка должно находиться минимум по одному элементу х и у, то язык можно определить следующим
образом.
хх*у/ Возможна альтернативная
запись.
хУ
Здесь плюс означает "одно или большее число вхождений предшествующего
элемента". Кроме того, в регулярных выражениях можно использовать символ "|" (в данном контексте читается как "или"). Таким образом, выражение
х'\/
представляет запись языка, стоки которого состоят из нуля или большего
количества элементов х или из нуля или большего количества элементов
у. Выражение
(а | ЬУ
представляет язык, строки которого состоят из нуля или большего числа
элементов а или Ь (другими словами, строка состоит из нуля или большего числа знаков, каждый из которых может быть а или Ь). В последнем выражении скобки употребляются для того, чтобы показать более
высокий приоритет знака | над знаком *. Принято, что знак * имеет бо лее высокий приоритет, чем |, поэтому язык
a\b*
будет содержать только строки, состоящие из одного элемента а или же
нуля или большего числа элементов Ь. Приведем другой пример регулярного выражения
(aab | ab)" Этот язык будет включать в себя
следующие строки.
е
aababaab
ababab
aabaabaabab
Регулярное выражение (aaa
| ab)"
2.2, Определяя синтаксис
29
/и
'X
иллюстрирует три оператора, используемых в регулярных выражениях,
т.е. *, конкатенацию (представленную последовательными символами) и
I, перечисленные в порядке убывания их приоритета.
Чтобы более формально определить понятие регулярного выражения,
введем вначале понятие алфавита. Алфавит представляет собой конечный набор символов, подобный приведенным ниже.
{0,1} {а.ш} {0..9} Если А — алфавит, то к числу регулярных выражений
относятся:
• нулевая строка (обозначается е);
• любой элемента А.
Кроме того, если Р и Q являются регулярными выражениями, то регулярными являются также следующие выражения.
PQ
P\Q
Р
(О следует после Р)
(РилиО)
(нуль или более вхоходений Р)
Следует отметить, что введенный ранее оператор + (одно иди более вхождений элемента), строго говоря, не является оператором регулярного выражения, поскольку при описании любого регулярного выражения можно обойтись и без него. Это связано с тем, что любое выражение, содержащее +, можно заменить эквивалентным выражением, содержащим
оператор конкатенации и оператор *. Например, выражение
ST.
&
эквивалентно записывается следующим образом
(abc)(abcf '
Таким образом, поскольку включение оператора + никоим образом не
расширяет возможности формы записи, его часто используют так, как
будто он является оператором регулярного выражения.
Как будет показано далее, использование регулярных выражений не
является самым подходящим способом описания языков программирования в целом, но они часто употребляются для определения символов
языков программирования через составляющие их знаки. Например, во
многих языках программирования идентификатор можно представить
следующим регулярным выражением.
$■■
Здесь / обозначает букву, a d — цифру. Число с фиксированной запятой
можно представить следующим выражением.
Один из языков, ранее рассмотренных в данном разделе,
невозможно определить посредством регулярного выражения, поскольку в
регулярных выражениях не существует возможности указать, что количество
элементов х должно равняться количеству элементов у. Следовательно, в
этом случае нам необходим более мощный механизм, который позволит
описать такой очевидно простой язык. Один из методов заключается в использовании продукции (production), подобной приведенным ниже.
S-* ху
Здесь символ "■->" можно читать как "может иметь вид". Продукции
могут использоваться для генерации строк языка с использованием следующих правил.
1. Начать с символа S и заменить его строкой, расположенной справа от
знака, продукции.
2. Если подученная строка не содержит больше символов S, она являет
ся строкой языка. В противном случае следует снова заменить S стро- '
кой после знака продукции, а затем снова вернуться к п. 2.
Приведем пример последовательности строк.
S
xSy
xxSyy
хххууу
Обычно подобную последовательность записывают следующим образом. S
=> xSy => xxSyy ■=> хххууу
Здесь знак "=>" читается "порождает". Последовательность шагов, использованная для генерации строки с применением продукций грамматики, называется порождением (derivation) строки.
Очевидно, что описанным выше способом могут быть получены все
строки языка
{/У | п > 0}
Более того (что весьма важно), при этом не будет порождена ни одна
строка, не принадлежащая указанному языку.
Итак, теперь мы готовы к определению понятия грамматики, основываясь на введенном выше понятии продукции.
2.3. Грамматики
Грамматика определяется как следующая четверка чисел.
(VT, VN,P,S)
Здесь d также
обозначает любую
цифру.
Глава 2. Определение языка
2.3. Грамматики
31
Здесь
сь
VT — алфавит, символы которого называют терминальными символами,
или терминалами.
VN — алфавит с нетерминальными символами, или нетерминалами. VT
и VN не имеют общих символов (т.е. VTr\VN= 0) (V определяется как
V T u V N)
Р — множество продукций (или правил), каждый элемент которого состоит из пары (а, р), где а — левая часть продукции, Р — правая часть
продукции, а сама продукция записывается следующим образом.
Здесь а принадлежит V* (строки, состоящие из одного или более символов V), а р принадлежит V (строки, состоящие из нуля или более символов
V). S принадлежит VN, называется символом предложения, или аксиомой
грамматики, и с него начинается генерация любой строки языка.
Грамматика используется для генерации последовательностей символов,
составляющих строки языка, начиная с аксиомы и последовательно заменяя
ее (или нетерминалы, которые появятся позднее) с помощью одного из порождений грамматики. На каждом этапе к нетерминалу из левой части применяется продукция, заменяющая .этот нетерминал последовательностью
символов своей правой чести. Процесс прекращается после получения строки,
состоящей только из терминальных символов (т.е. не содержащей нетерминалов). Языку принадлежат те, и только те строки символов, которые
можно получить с помощью заданной грамматики. Например, грамматикой
для языка { xYjn>0} будет грамматика G,. Gi = ({x,y},{S},P,S)
Здесь
P^{S-> xSy, S -* ху)
Грамматикой для языка
{хт/\т, п>0)
является 62,
Gz = ({x,y),{S, В), Р, S) Здесь набор продукций
Р имеет следующий вид.
S->yB
В -> yB
Шва 2. Определение языка
32
Поскольку пустая строка также принадлежит языку, то в набор Р входит
следующая продукция.
S->e
Строка
ххууу генерируется следующим
образом.
S=> xS=$> xxS => ххуВ => ххууВ => ххууу
Каждая из строк, фигурирующих в порождении, называется сешпенциалъH£U_j6ogMOM(sententMform), а последняя строка (состоящая только из
терминалов) Ha3biBaeTCg~/g^<?o3rcH»gA«(senterice>_ языка. Использование
символа "=>" между двумя сентенциальными формами обозначает, что
строка справа от этого символа получена из строки слева от него посредством одного порождения. В то же время можно записать следующее.
*
S => хууу
Это означает, что хууу порождается из S за нуль или более шагов. Подобным образом выражение
+
S => ххууу
означает, что хууу порождается из S за один или более шагов.
Условимся, что порождения
'
В-» у В
В-> у можно записать в более сжатой
форме
В~*уВ\у Здесь, как и ранее, символ "1" читается
как "или".
Как и в приведенных выше примерах, будем, как правило, использовать строчные буквы (иногда слова, состоящие только из строчных букв)
для обозначения терминалов грамматики, а заглавные буквы (иногда
слова из заглавных букв) для обозначения нетерминалов. Символы предложений часто будут обозначаться буквой S, хотя это обозначение не является обязательным. Греческие буквы будут использоваться для обозначения строк терминалов и/или нетерминалов. Во всех случаях, когда будут использоваться другие обозначения, это будет оговариваться.
Необходимо отметить, что для генерации конкретного языка обычно
не существует единственной грамматики. На тривиальном уровне любой
нетерминал можно заменить еще неиспользованным символом. Можно
произвести более значительные изменения: изменить форму и количество продукций. Рассмотрим следующий язык.
{/У |т, л 2 0}
Данный язык можно сгенерировать, используя такой набор продукций. 5
-»XY
2.3. Грамматики
33
Y->yY
Этот набор отличается от приведенного ранее для того же языка,
же язык, называются этиштентнь
компилятора часто оказывается полезнымилйГдаже
б^етггасаздаТ
необходимым заменить данную грамматику эквивалентной.
Данное выше определение грамматики допускает грамматики более
общих типов чем те, что были приведены в качестве примеров. Например, левая часть продукции не обязательно должна состоять из одного
символа. Рассмотрим следующую грамматику.
Здесь множеству Р принадлежат такие элементы. S
-> QNQ
RN~*NNR
RQ-+NNQ
В этом случае согласно второй продукции N в порождении можно заме
нить Я только в том случае, если N следует после Q; а согласно четвертой
продукции Я можно заменить Л/Л/, только если после R следует Q. Продукции__двляются
^
определен н?2к
То^джё7ГТйгшчТЯшГ*г5орождениями при использовании данной грамматики будут иметь подобный вид.
S => QNQ => QRQ=» ONNQ => аа
+ S =>
GWG => QRQ => QNNQ => QRNQ => QNNRQ => QNNNNQ => аааа
Из этого примера видно, что число элементов а всегда будет степенью
двойки, точное значение зависит от того, на каком этапе последовательность символов N (дайна которой яшяется степенью двойки) будет заменена символами а. Таким образом, язык, который генерируется данной
грамматикой, имеет следующий вид.
{ат j m является положительной степенью двойки}
Теперь мы готовы ввести важное понятие ие^о^ии^Шщског^Омш^^
hierarchy) грамматик/языков. Хомский определил четыре класса грамматик, которые назвал их грамматиками 0-го ... 3-го типов. Грамматики 0го типа, или лек^стно перечислимые (rmjrs|>£ejy^enumerable) грамматики,
определяются как всеТрц^матшетГкбторые соответствуют данному выше
определению без офаничений на типы продукций. Данные граммати ки — это наиболее общий класс, и, как будет показано далее, граммати34
СС-4Р1
длина строки а (исчисляемая в количестве символов, которое она может
содержать) была не больше длины строки р.
М<|(3|
О*=({а}, {S, N, Q, Я}, Р, S)
-I"
ки других типов могут быть получены путем наложения офаничений на
возможные формы продукций грамматик O-rd типа.
Грамматики 0-го типа эквивалентны машинам Тьюринга в том смысле, что для любой данной грамматики 0-го типа существует машина
Тьюринга, которая допускает, и только допускает, все предложения, сгенерированные данной грамматикой. И наоборот, для данной машины
Тьюринга существует грамматика 0-го типа, которая генерирует точно
все предложения, допускаемые машиной Тьюринга.
Первое ограничение на форму продукции, которое может возникнуть
в грамматике, — задать, чтобы для всех продукций вида
Глава 2. Определение языка
Грамматики, все продукции которых подчиняются данному ограничению, называются грамматиками 1-го типа, или контекстно-зависимыми
(context sensitive). Если обратиться к теории автоматов, грамматики 1-го
типа эквивалентны лтейно(щшичешшм автоматам в том же смысле,
как грамматики' 0-го типа эквивалет1ш~1ЙЯ1й1а15>Я'>в»риига. Следует
отметить, что рассмотренная выше грамматика для языка
{am \ m является неотрицательной степенью двойки}
является грамматикой 0-го, а не 1 -го типа, поскольку в продукции
Q~¥E
I
левая часть длиннее правой.
Если (помимо уже названного ограничения) в левой части продукции
должен находиться только один нетерминал, грамматика называется,
грамматикой 2-го типа, или кштексгщд^свой^днойJcontext free) грамма-(
тикой. За исключением Git все рассмотренные в этоЙ0таЙе~грТ1Мматики
принадлежат ко 2-му типу. Поскольку теория компиляторов практически
полностью основывается на грамматиках 2-го и 3-го типов, грамматики
0-го и 1-го типов в дальнейшем почти не будут рассматриваться.
В контекстно-свободных грамматиках удобно разрешить продукцию
(хотя строго эта продукция не разрешена даже в контекстно -зависимых
грамматиках.) Это позволит включить в язык пустую строку. В некоторых
грамматиках также будут встречаться продукции, в которых пустые строки
.генерируют другие нетерминалы. То, что мы сделали, не увеличивает силу
контекстно-свободных грамматик, но иногда оказывается полезным. Грамма
тики 2-го типа эквивалентныjfa^tf««b»c,£ema*«aiagMiEPsh-down automata^
Последним классом грамматик в иерархии Хомского тлЖШхШ^граМ'
мапгики 3-го типа, или регулярные грамматики. Однако, вначале опреде-1
л им П£п(кжи£мищ1^?£ш^
грамматику, каждая продукция которой ТшёягГодну из двух форм.
■•.2.3. Грамматики
35
А-> а
или
Здесь использована принятая выше договоренность об обозначении тер миналов и нетерминалов. Например, грамматика с продукциями SS
S-4X
S-*y В>уВ
является праволинейной. Если мы хотим включить в язык, генерируемый
этой грамматикой, пустую строку, следует ввести в грамматику такую
продукцию.
Здесь S — символ предложения. Рассмотрим, впрочем, следующую эквивалентную грамматику.
S-+XY
х —> хХ
1 II
Данная грамматика не является праволинейной, поскольку продукции 1,
3 и 5 не имеют требуемой формы. В праволинейных грамматиках нетерминалы, отличные от символов предложения, могут не генерировать пустые строки.
Грамматика, являющаяся праволинейной, называется регулярной
(regular). Кроме того, леволинейная грамматика (left iinear_grammar), которая определяется по ашЗК
______ , __ ......__ .„„, .„,w,w по Определению
относится к регулярным. Например, грамматика со следующими продукциями является леволинейной, следовательно, регулярной:
S - > Вх
S
Как и ранее, можно добавить следующую
продукцию. S ->e
Хотя грамматики, все продукции которых являются праволинейными,
принадлежат к регулярным и то же можно сказать о грамматиках, имеюГлава 2. Определение языка
щих только леволинейные продукции, грамматики, часть продукций которых являются праволинейными, например, А->аВ
а часть продукций — леволинейными, например,
P-*Qr регулярными не
являются. __
" Отметим,~что рассмотренная выше леволинейная грамматика генерирует тот же язык
что и праволинейная грамматика. В общем случае любой язык, который (
можно сгенерировать с помощью праволинейной грамматики, можно )
также
образовать
посредством
леволинейной
грамматики.
<
Язык, который можно сгенерировать с помощью регулярной грамма- \чйУ)
тики, называется регулярным. Регулярный язык
'
также можно определить следующим регулярным выражением.
х*/
Сказанное справедливо для всех регулярных языков. И наоборот, любой С
язык, определенный любым регулярным выражением, можно сгенериро- {
вать регулярной грамматикой (отсюда и название регулярный). Регуляр- j
ные языки и регулярные выражения эквивалентны конечным автоматам, f
Таким образом, имеем трехстороннюю эквивалентность между регуляр- \
ными выражениями, регулярными языками и конечными автоматами,
как показано на рис. 2.1. Для тех, кто не знаком с понятием конечных
автоматов, оно будет рассмотрено в следующей главе.
Конечные
автоматы
Регулярные
языки
Регулярные
выражения
РИС. 2.1.
Иерархия Хомского является включающей, так что все грамматики 3го типа являются грамматиками 2-го, все грамматики 2-го типа являются
грамматиками 1-го, а все грамматики 1-го типа — грамматиками 0-го типа. Схематически это показано на рис. 2.2.
[ 2.3. Грамматики
37
2.4. Отличия регулярных
и контекстно-свободных языков
Рис. 2.2.
Определим язык 3-го типа как язык с грамматикой 3-го типа, язык 2го типа — как язык с грамматикой 2-го типа и т.д. Таким образом, существует включающая иерархия языков, которая соответствует иерархии
грамматик. В то же время не стоит поспешно судить о типе языка — из
того, что грамматика языка не относится к третьему типу, еще не следует
отсутствие эквивалентной грамматики 3-го типа. Приведем пример языка
3-го типа, который можно определить грамматикой 2-го типа (этот язык
уже рассматривался выше).
{УУ|Л7, Л>0}
Данный язык можно сгенерировать следующими продукциями, не относящимися к 3-му типу.
S-+XY
Y-yyY
У->е
.
'
В то же время язык можно определить следующими продукциями 3-го типа.
S->xS
S -» у В
S-»x
В -> уВ В --> у
■
S-»e
В дальнейшем о грамматиках и языках 0-го и 1-го типов будет сказано немного. В то же время грамматики и языки 2-го и 3-го типов играют
важную роль в написании компилятора, посему в оставшейся части главы нас будут интересовать именно их отличительные особенности и ограничения с точки зрения выразительной силы, языков и грамматик.
Глава 2. Определение языка
При написании компиляторов широко используются как контекстносвободные (2-го типа), так и регулярные (3-го типа) языки. Регулярные
грамматики и языки являются подмножествами контекстно-свободных языков и грамматик, имеющими преимущество над последними с точки зрения
простоты. Таким образом, где это возможно, на этапе анализа процесса компиляции имеет смысл использовать регулярные грамматики и языки. Следовательно, мы должны распознавать ситуации, когда регулярные грамматики
и языки подходят к имеющимся задачам. К счастью, существует простое
свойство грамматики (будет рассмотрено несколько позднее), которое позволяет определить, является ли генерируемый язык регулярным. Прежде, правда, требуется ввести понятие рекурсии в грамматике. Продукции
/• >АЬ
К- УС В
С -» dCf
со; (ржат прямую рекурсию, поскольку нетерминал из левой части продукции
входит и в правую часть. В первом случае имеем левую рекурсию, поскольку
нетерминал из левой части продукции находится в крайней левой позиции
правой части продукции. Во втором случае имеем правую рекурсию, а в третьем
— среднюю, поскольку нетерминал из левой части продукции входит в
правую часть, но не находится ни на левой крайней, ни на правой крайней
позиции. Практически все грамматики содержат определенную рекурсию,
поскольку в противном случае было бы невозможно создавать произвольные
большие предложения. Стоит отметить, что тип рекурсии может быть весьма
важным, и в данном разделе нас особо будет интересовать средняя рекурсия.
Отметим также, что помимо прямой рекурсии существует непрямая, показанная в следующих примерах.
1. А->Вс
С -> Ае
2. PQ-* wPy
В первом случае имеем непрямую левую рекурсию, а во втором — непрямую
среднюю рекурсию. В непрямую рекурсию может быть вовлечено произвольно
большое число продукций. Создается впечатление, что непрямая рекурсия
сложнее прямой. На самом деде отличие между ними не такое значительное,
как кажется на первый взгляд. Дело в том, что существует простой алгоритм
(который в данной книге не рассматривается) преобразования непрямой рекурсии в прямую. Таким образом, в последующем рассмотрении будут фигурировать только грамматики с прямой рекурсией.
2.4. Отличия регулярных и контекстно-свободных языков
39
Чтобы определить, генерирует ли данная грамматика регулярный
язык, в первую очередь необходимо посмотреть, содержит ли она рекурсию. В тех немногих случаях, когда грамматика не содержит рекурсии,
язык является конечным, следовательно, регулярным, поскольку можно
просто перечислить все предложения языка. Л^бой^коне^шый_н§йор
строк МОЖНО Гфрдставиза>-^егуж;р«ой^^аммйтикой. Чрезвычайно полезным является утверждение (которое мы примем без доказательства):
"Если грамматика не содержит средней рекурсии (такую грамматику
также называют самовложеннпй (ьеЦ-&гфе.й$$}}, то генерируемый ею язык
являе тся р ег уляр ным " . И Чй4 >£ OV^^r - / л —^
Таким образом, язык с продукциями*
* '
S->XY
Х->хХ
Х->х
Y-*yY
f ^ j j w
р,
рр
анализа, основанные только на контекстно-свободных грамматиках, не
могут полностью охватить все аспекты синтаксического анализа. В дальнейшем будет показано, какое расширение требуется, чтобы данные
программы получили те сравнительно немногие функции, требуемые для
полного представления синтаксиса.
2.5. Порождения
S-* х
S->y
S->£
является регулярным, поскольку продукции не содержат средней рекур сии, а имеющаяся правая рекурсия не является проблемой.
Рассмотрим простой нерегулярный язык
{хлуп\п>0} с
продукциями
S*xy
В первой продукции этого языка имеется средняя рекурсия, следовательно, язык не является регулярным, итак, нерегулярными являются уже
достаточно простые языки!
Рассмотрим связь введенных понятий с созданием компиляторов.
Лексические аспекты большинства языков, такие как имена переменных,
числа, константы и многознаковые символы (например, ++), практически
всегда можно определить посредством регулярных выражений, следовательно, сгенерировать регулярными грамматиками. В то же время в
арифметических выражениях или составных операторах почти всегда
присутствует сопоставление с элементом, помещенным в скобки., которое
невозможно выразить посредством регулярных выражений. Например,
продукции для грамматики, генерирующей язык, строки которого состоят
из скобок сопоставления, будут иметь следующий вид
S~»e
Данная грамматика содержит самовложение, которое нельзя удалить.
лп
Подведем итоги: регулярные грамматики (3-го типа) почти всегда
можно использовать в качестве основы лексического аншщж. а контекстно-свободные грамматики (2-го типа), в основном, необходимы для
анализа. С ТРОГО говоря, программы синтаксического
б
Глава 2. Определение языка
Выше было показано, как порождения используются для генерации
предложений языка из грамматики языка. Например, язык
{УУ|п>0}
генерируется грамматикой с такими продукциями.
S-^xy
Порождение
S => xSy => xxSyy => xxxSyyy -> xxxxyyyy
генерирует следующее предложение.
xxxxyyyy
Данное порождение является единственным, генерирующим данное кон- ?
кретное предложение. Впрочем, в общем случае порождения не являются^
уникальными. Рассмотрим следующий язык.
Данный язык генерируется такими продукциями.
ТТГТ'
Y->y
Предложение
хххуу может быть сгенерировано
порождением
S => XY => xXY => xxXY => xxxY => xxxyY => хххуу
или порождением
S => XY => XyY => Хуу => хХуу => ххХуу => хххуу
Можно привести множество других порождений, также генерирующих данное предложение, которые будуг отличаться порядком использования про-
2,5. Порождения
41
дукций для Хи Y. Если на каждом шаге порождения заменяется крайний левый нетерминал сентенциальной формы (как в первом приведенном примере), такое порождение называется левым (leftmost). Подобным образом второе
из рассмотренных порождений называется правым (rightmost). Существуют
порождения, которые не являются ни левыми, ни правыми.
S => X У => хХ Y => xXyY => xxXyY => ххХуу => хххуу
Отметим, что в каждом из порождений предложения
хххуу
каждая продукция используется одинаковое число раз, но используются
они в разной последовательности.
Стоит обратить внимание, что в регулярных грамматиках (по крайней
мере,, выраженных в простейшей форме) для каждой строки существует
единственное порождение. Прежде всего это связано с тем, что в сентенциальной форме имеется не более одного нетерминала. Рассмотрим, например, грамматику со следующими продукциями.
Я • > хА Л -
из любого из них следуют два других. Иными словами, если одно из еле-)
дующих утверждений справедливо, то справедливы и остальные.
• Каждое сгенерированное грамматикой предложение имеет единст-|
венное левое порождение.
• Каждое сгенерированное грамматикой предложение имеет единст-|
венное правое порождение.
• Каждое сгенерированное грамматикой предложение имеет единст-'
венное синтаксическое дерево.
»хА
Л- ) у В->
уВ
Данная грамматика генерирует такой язык.
{/У | т, п > 0}
Предложение
хххуу можно получить единственным
образом.
S => хА => ххА => хххА => хххуВ => хххуу
Порождение можно описать и другим способом — с помощью синтаксического дерева (или дерева синтаксического разбора). Например, порождение
S => XY => xXY => xXyY => xxXyY => ххХуу => хххуу
(а также другие возможные порождения, дающие данное предложение)
соответствует синтаксическому дереву, изображенному на рис, 2.3. Различные порождения отличаются только порядком соединения отдельных
частей дерева.
Это утверждение представляет собой хорошо известный результат из теории грамматик, который здесь не доказывается. Грамматики, для которых справедливы вышеуказанные утверждения, называются однозначными
В противном случае грамматика является неоднозначной Если все
грамматики, генерирующие язык, являются неоднозначными, язык также
называют неоднозначным.
Исследуем понятие неоднозначных грамматик, для чего рассмотрим
грамматику с такими продукциями.
s~*s+s
Очевидно, что все предложения этой грамматики имеют следующий вид. х
х+х
Для многих грамматик любому предложению, которое можно сгенерировать,
соответствует единственное синтаксическое дерево, а также единственное
правое или левое порождение. Фактически, эти три условия эквивалентны:
Х + Х+ X
43
Глава 2, Определение языка
2.6. Неоднозначные грамматики
Г
Возьмем, к примеру, следующую строку.
х + х+х-Она имеет два левых
порождения.
S=>S+S=s>S+S+S=s>x+S+S=>x+x+S=>
Кроме того, данная строка имеет два правых порождения и два синтак сических дерева (рис. 2.4).
Рис. 2,4,
Эта грамматика явно неоднозначна, поэтому непригодна для некоторых желаемых методов синтаксического анализа. Если же синтаксическое
дерево все-таки необходимо как-то построить, то потребуются правила,
устраняющие неоднозначность, т.е. единственным образом определяющие синтаксическое дерево. По этой причине компиляторы часто создаются на основе однозначной грамматики, хотя (как будет показано далее)
это не является обязательным. Возникает два вопроса.
1. Известна неоднозначная грамматика языка. Существует ли однознач
ная грамматика для генерации того же языка?
2. Существуют ли алгоритмы, используя которые можно определить, яв
ляется ли данная грамматика или язык неоднозначными?
Ответ на первый вопрос — да, на второй (к сожалению) — нет. Проиллюстрируем положительный ответ на первый вопрос. Рассмотрим описанный выше язык с неоднозначной грамматикой. Этот язык можно определить посредством грамматики со следующими продукциями.
S-» S+x
S-4X
В'этом случае предложение
X+X+X
имеет единственное синтаксическое дерево, только одно правое порождение и только одно левое порождение, которое приводится ниже.
S=>S + x=>S + x+x=> S=>x + x+x
То же справедливо для любого предложения, генерируемого грамматикой,
следовательно, язык является однозначным, поскольку его можно задать однозначной грамматикой (выше бьш найден явный вид такой грамматики).
Поскольку (ответ на второй вопрос) не существует алгоритма, позволяющего в общем случае определить, является ли грамматика однозначной, не
существует также и алгоритма, посредством которого можно было бы в общем случае создать однозначную грамматику, эквивалентную неоднозначной. Это утверждение справедливо, поскольку в противном случае по выходу
алгоритма можно было бы определить, является ли язык однозначным.
Итак, проблема однозначности языка еще не разрешена, т.е. из теоретических рассуждений известно, что не существует общего решения данной задачи. Для читателя, знакомого с соответствующей теорией, определение, яв-
ляется язык однозначным или нет, эквивалентно определению, остановится
ли машина Тьюринга после прочтения определенного входа. В обоих случаях
можно найти решения для конечного набора входных условий, но в общем
случае задача не решается. Теория языка изобилует подобными нерешенными (или неразрешимыми) задачами, которые могут либо заинтересовать, либо
разочаровать (в зависимости от точки зрения). Все же, по меньшей мере, эти
задачи позволяют осознать, что даже у компьютера возможности ограничены!
44
Глава 2. Определение языка
Итак, существуют две родственные неразрешимые задачи, касающиеся неоднозначности в языках, а именно:
• задача определения, является ли грамматика неоднозначной;
• задача определения, является ли неоднозначным язык.
Хотя в общем случае задача не решена, существует один класс грамматик, для которых известно, что элементы являются неоднозначными,
т.е. существуют продукции, характеризующиеся одновременно правой и
левой рекурсией. Таким образом, рассмотренная выше грамматика с
продукцией
S->S+S
является неоднозначной. Следует отметить, что обратное утверждение
неверно, т.е. грамматики, не содержащие правой и левой рекурсии, не
обязательно являются однозначными (иначе у нас был бы простой критерий однозначности).
Наиболее известной неоднозначной грамматикой яшшется используемая во многих языках программирования для определения оператора if с
необязательной частью else. Эта грамматика часто определяется следующим образом.
statement -»if expr then statement else statement if
expr then statement ] other
2,6. Неоднозначные грамматики
Здесь вьщеленные слова являются терминалами грамматики, a other
("другое") генерирует операторы, отличные от операторов If. Кроме того,
в приведенном выше примере мы отклонились от ранее принятой договоренности обозначать строчными буквами терминалы, а прописными —
нетерминалы, поскольку statement ("оператор") — это нетерминал.
В данной грамматике отсутствуют продукции с правой и левой рекурсией, хотя одна из продукций является дважды рекурсивной! Тем не менее, эти продукции являются неоднозначными. Чтобы показать это, достаточно привести строку, для которой существует более одной правой
или левой продукции или более одного синтаксического дерева. Наиболее простая из подобных строк (вообще, их существует бесконечное число) имеет следующий вид.
other unmatched -»If expr
then statement |
If expr then matched else unmatched
statement
statement
other
If
if expr then If expr then other else other
В данной строке неоднозначность проявляется в том, что оператор else
может относиться к любому из двух операторов then. Некоторые языки
(например, COBOL) разрешают эту неоднозначность следующим образом:
при считывании кода елею направо каждый оператор else 'соотносится с
ближайшим предшествующим незанятым оператором then. Два синтаксических дерева для приведенного предложения изображены на рис. 2.5 и 2.6.
Рис. 2.6,
Данные продукции генерируют те же предложения, но уже однозначно. В то же время эти продукции стараются не использовать, поскольку
они менее наглядны. В дальнейшем, при рассмотрении генератора синтаксических анализаторов YACC, будет показано, что существуют более
естественные и элегантные способы разрешения неоднозначности.
Таким образом, при разработке компиляторов иногда возникают неоднозначные грамматики (наиболее частый случай рассмотрен выше), но
на практике их применение не вызывает особых проблем, так как обычно существуют простые методы разрешения неоднозначности. Неоднозначные языки (языки, для которых не существует однозначной грамматики) часто оказываются сложными для человеческого понимания и
обычно не используются в языках программирования.
statement
other
other
Рис. 2.5.
Для COBOL и многих других
языков с подобной структурой
правильным будет первое из
приведенных деревьев, а не
второе.
Впрочем, это следует не из
грамматики,
которой обычно задается язык, а
устанавливается
формально
(по крайней мере, так сделано в большинстве текстов и руководств по COBOL). Данное обстоятельство может навести на мысль, что с помощью одной
лишь грамматики невозможно разрешить неоднозначность. Это не так, поскольку существуют следующие альтернативные продукции.
2 Jc Ограничения контететно-свободныж
грамматик
Пришло время задаться вопросом: "Насколько контекстно-свободные грамматики подходят для генерации языков программирования?" Отметим для
начала, что существует ряд достаточно простых языков, которые нельзя получить с помощью контекстно-свободных грамматик. Например, в разделе 2.3
была рассмотрена грамматика 63, которая не является контекстно-свободной,
поскольку левые части некоторьк ее продукций не состоят из одного нетерминала. Данная грамматика генерирует следующий язык.
statement —> matched |
unmatched matched —> if expr then
matched else matched |
46
then
other
statement
If
expr
Глава 2. Определение языка
{ат | m — положительная степень двойки}
Разумеется, тот факт, что язык можно образовать с помощью не контекстно-свободной грамматики, еще не означает, что этот язык нельзя
L
2.7. Ограничения контекстно-свободных грамматик
47
образовать посредством контекстно-свободной грамматики. Впрочем, в
нашем конкретном случае можно показать, что данный язык нельзя образовать с помощью контекстно-свободной грамматики. Можно также
показать, что следующие простые на вид языки не являются контекстносвободными.
1. {iftfcf | п > 0}.
2. {а" | п — простое число}.
3. {ww\ wпринадлежит промежутку {0,1}*}-
Можно также привести примеры весьма похожих контекстно-свободных
языков.
1. {g4f\n>0}.
2. {ww" | w* — обратное к w, w принадлежит промежутку {0,1}*}.
Нас будет интересовать следующий практический вопрос: "Можно ли
с помощью контекстно-свободных грамматик выразить основные характеристики языка программирования?" Оказывается, это возможно в значительной степени.
Рассмотрим роль типов в языках программирования. Pascal и Ada
служат примером сильно типизированных языков, требующих значительной совместимости типов в различных контекстах. Например, фрагмент программы на языке Pascal.
var x : integer;
begin x := " ?'
является некорректным, поскольку х может принимать только целые
значения. Подобным образом, если переменная р объявлена как
procedure p {i, j: integer);
ее нельзя вызвать следующим образом
р(3,4,5)
(поскольку в вызове используется три параметра вместо двух). Подобным
образом, если массив А объявлен как
var A J 1 . . 1 0 ] of integer;
запись
А[2,3] := 0
'
будет ошибочной.
Во всех приведенных выше примерах показано неверное использование языка Pascal. Впрочем, невозможно написать контекстно-свободную
грамматику, которая бы сгенерировала все корректные программы языка
Pascal и при этом не создала ни одной программы с приведенными выше
ошибками. Следует отметить, что существует грамматика 0-го типа, которая генерирует точно все корректные программы языка Pascal. Эта
грамматика могла бы использоваться в качестве основы для создания
48
Глава 2. Определение языка
программы синтаксического анализа, но на практике ее не применяют
по двум причинам.
1. Отсутствие наглядности и неинтуитивная природа грамматик 0-го ти
па. В качестве иллюстрации можно рассмотреть грамматику G3, при
веденную в начале раздела.
2. Грамматика 0-го типа соответствует машинам Тьюринга, которые
могут считывать свой вход столько раз, сколько это необходимо.
Из второго пункта следует, что программа синтаксического анализа, основанная на грамматике 0-го типа, будет обладать теми же плюсами и
минусами, что и машина Тьюринга, в том числе необходимостью чтения
и записи на ленту произвольно большой длины произвольным образом.
Несмотря на ограничения, контекстно-свободные грамматики все же
используются в качестве основы программ синтаксического анализа. Контекстно-свободная грамматика, генерирующая расширенное множество
языков, может использовать в качестве основы программы синтаксического анализа при условии, что данная программа дополняется действиями по проверке типов, чтобы избежать ошибок, подобных приведенным
выше. На практике это означает создание таблиц символов и типов, в
которые помещается информация об объявлениях и определениях переменных и типов. Доступ к информации в таких таблицах может производиться на последующих этапах при использовании переменных и типов.
Для некоторых языков (например, ALGOL 68) это означает, что компиляция, в общем случае, не может быть осуществлена за один проход исходного кода. Это связано с тем, что часть информации, требуемой для
анализа выражений, может находиться далее по текст)' программы. Впрочем, большинство языков могут компилироваться за один проход.
Таким образом, большинство программ синтаксического анализа
включают следующие два элемента.
1. Часть, которая осуществляет проверку соответствия входа контекстносвободной грамматике, генерирующей расширенное множество языков.
Эта часть может создаваться "автоматически", исходя из грамматики.
2. Часть, состоящая из действий, вызываемых первой частью и направ
ленных на проверку дополнительных ограничений на вход.
Чтобы контекстно-свободная грамматика представляла оба названных
аспекта, ее можно улучшить путем введения дополнительных правил.
Один из способов, позволяющих это сделать, состоит в использовании
атрибутной грамматики (attribute grammar). Приведем пример.
Пусть мы хотим научиться определять значения выражений, которые
задаются грамматикой со следующими продукциями.
1. <ехрп> ::= <вхрг> "+" <term>
2. <ехрг> ::= <ternt>
3. <terrn> ::= <term> "*" <factor>
2.7. Ограничения контекстно-свободных грамматик
49
4. <term> ::= <factot>
5. <factor> ::= constant
6. <factor> ::= "C<
Здесь терминалы (такие как операторы) взяты в кавычки, чтобы показать, что они являются действительным представлением терминалов, и
отличить от имени терминала (такого как "constanf'), которое представляет набор (не обязательно конечный) действительных представлений.
С некоторыми терминалами и нетерминалами грамматики могут связываться атрибуты. Поскольку все атрибуты соответствуют значениям
("value") подвыражений или полных выражений, то естественно обозначить их VAL (там, где значения нельзя спутать). Чтобы не спутать значениЯ; различных символов, в продукции, будем (где это необходимо) использовать обозначения VAL1, VAL2 и т.д. Атрибуты называются синтезированными, поскольку они используются для передачи значений вверх по
синтаксическому дереву или (с другой стороны) для вычисления атрибутных значений, соответствующих левой части продукции, из значений,
соответствующих правой части. Атрибуты, которые переносят информацию в противоположном направлении, т.е. вниз по дереву или от левой к
правой части продукции, называются наследуемыми .
Ниже приводится пример контекстно-свободной грамматики, которая
дополнена атрибутными правилам для определения значений выражений
через значения их компонентов. Вертикальная стрелка перед именем атрибута обозначает, что он является синтезированным, а не наследуемым.
I,
2.8. Задача синтаксического анализа
Считалось, что если атрибут с одним и тем же именем используется в различных местах продукции, оба экземпляра имеют одинаковое значение.
Атрибутная грамматика для задания правил использования типов в языке,
подобном Pascal, является сложной, так как требует задания эквивалентности
наборов информации из таблиц символов, используемой на синтаксическом
дереве в качестве атрибутов. Впрочем, ограничения типов в языке Pascal, в
принципе, можно описать через атрибуты подобно тому, как задается значение выражения, определенного рассмотренной выше грамматикой.
Поскольку атрибутная грамматика задает правила соответствия, то ее
несложно преобразовать в действия по проверке выполнения правил со-
До настоящего момента рассматривалось, как можно использовать грамматику
для генерации предложений и для определения синтаксиса языка. В процессе
компиляции задача синтаксического анализа (или разбора) состоит из
нахождения порождения (если оно существует) конкретного предложения,
используя данную грамматику. Таким образом, требуется не столько найти
предложения, генерируемые грамматикой, сколько, используя грамматику, найти порождение данного предложения или указать, что его не существует.
В большинстве случаев искомыми являются левые или правые порождения,
которые обычно являются единственными. Если порождение не является
единственным (например, для неоднозначных грамматик), должны существовать правила (устранения неоднозначности), которые бы указывали, какое
именно из порождений следует использовать. Благодаря этому, результат
синтаксического анализа всегда вполне определен.
Конечно, пользователя не удовлетворит компилятор, который просто
сообщает, что строка не принадлежит к исходному языку, и прекращает
свою работу без дальнейшего анализа. На практике компилятор должен
также указывать на последний символ, на основании которого был сделан соответствующий вывод, после чего продолжать анализ, приняв некоторое допущение, наиболее подходящее в данном случае.
Простейший способ представить задачу синтаксического анализа —
вообразить большой лист бумаги, в верхней части которого располагается
символ предложения, а в его основании — предложения, которые требуется проанализировать. Задача синтаксического анализа далее состоит в
создании синтаксического дерева, которое бы соединяло символ предложения с предложением, используя при этом поддеревья, соответствующие продукциям грамматики. Существует несколько подходов к решению этой задачи. Можно начать с вершины (символа предложения) и
50
2.8. Задача синтаксического анализа
1. <вхрг> Т VAL ::= <expr> ( VAL1"+" <term> T VAL2
[правило: Т VAL = Т VAL1 + Т VAL2]
2. <expt> T VAL ::= <tem?> T VAL
3. <expr> T VAL ::= <term> T VAL1 "*" <factor> T VAL2
[правило: Т VAL = Т VAL1 х Т ¥АЩ
4. <fenrc> T VAL ::= <factor> T VAL
5. <faefo/> T VAL ::= constant Т VAL
6. <factor> Т VAL ::= "{"<вхрг> Т VAL")"
{
ответствия. В принципе, несложно создать атрибутную грамматику на
основе контекстно-свободной программы синтаксического анализа, дополненной определенными действиями. В то же время наивная реализация атрибутной грамматики для типичного языка программирования будет крайне неэффективной, хотя бы потому, что потребуется копирование (возможно больших) таблиц.
Как будет показано далее, атрибутные грамматики также можно использовать для определения метрик исходного кода таким способом, что
будет легко создать инструментальные средства для измерения значений
метрик. Выразительная сила атрибутных грамматик равна силе грамматик 0-го типа, но при этом первые интуитивно значительно понятнее.
Впервые атрибутные грамматики были определены Кнутом и использовались в качестве основы создания сред программирования, для определения аномалий в программах и как основа парадигмы разработки программного обеспечения.
Глава 2. Определение языка
51
I i
!!
двигаться к предложению внизу страницы (нисходящий синтаксический
анализ (top-dawn parsing)), а можно начать с предложения в основании
страницы и двигаться к символу предложения на вершине (восходящий
синтаксический анализ (bottom-up parsing)). Можно применять смешанный
подход (mixed approach), а также горизонтальные и диагональные подходы, например, слева направо или справа налево, или даже с правой верхней части к левой нижней. Решения задачи могут быть детерминированными (если какая-то часть дерева нарисована, уничтожать ее запрещено)
или недетерминированными (при необходимости можно стереть уже нарисованные части дерева).
В истории реализации языков программирования было множество разработанных методов синтаксического анализа, включающих названные выше
характеристики. Ранние методы нисходящего синтаксического анализа часто
были недетерминированными и достаточно неэффективными, но в наши дни
синтаксический анализ высокоэффективен, а время его выполнения
пропорционально длине анализируемого предложения. Практически всегда
программы анализируются слева направо, хотя иногда привлекательной кажется идея обратного анализа (справа налево). Нисходящий синтаксический
анализ обладает большей степенью наглядности, чем восходящий, однако,
последний обладает большей общностью и более мощной инструментальной
поддержкой. Каждый из методов имеет своих сторонников и будет рассмотрен в главах 4 и 5 соответственно.
2.9. Определение семантики
Помимо знакомства с понятием семантики в начале этой главы, было
очень мало сказано о семантике языка и ее определении. Статические
семантики (проверка типов и т.д.), рассмотренные в разделе 2.7, трактовались (в большей или меньшей степени) как расширение синтаксиса.
Способность определять значение части программы (результат ее выполнения) так же важна, как и способность определять, из чего состоит корректная программа. Таким образом, изучение семантики находит свое
применение как в проверке правильности программы, так и в технологии
компиляции. На данный момент не существует универсального, общепринятого метода описания семантики. Перечислим существующие распространенные методы.
• Денотационная семантика. Основывается на функциональных вычис
лениях, в которых операции в языке отображаются в однозначные ма
тематические понятия, которые затем применяются для описания ре
зультата программы через входные и выходные сигналы.
• Аксиоматическая семантика. Базируется на исчислении предика
тов, где результат вычислений описывается через взаимоотноше
ние между значениями переменных до и после применения опре
деленных операций.
52
Глава 2. Определение языка
• Операционная семантика. Здесь операции в языке описываются через
деятельность некой абстрактной машины, выполняющей программу.
Все рассмотренные выше методы использовались для определения некоторых или всех языков программирования. В то же время многие определения языков по-прежнему используют неформальные (и возможно неоднозначные) методы определения семантики. Несмотря на очевидные опасности
такого подхода, он будет использован и в этой книге, поскольку в настоящее
время отсутствует полностью удовлетворительный и простой в реализации
метод описания семантики языка программирования.
2.10. Резюме
Данная глава посвящена языкам программирования и их определению. В
частности, было сделано следующее.
• Введено понятие грамматики и показано, как с ее помощью мож
но генерировать язык.
« Определено и рассмотрено значение иерархии Хомского.
• Проиллюстрированы понятия левого и правого порождений, а
также синтаксического дерева.
• Обсуждено значение неоднозначности в грамматиках.
• Рассмотрены ограничения регулярных и контекстно-свободных
грамматик.
• Определено, что программа синтаксического анализа должна вы
полнять проверку неконтекстно-свобрдных аспектов языка.
• Введено понятие атрибутной грамматики.
• Сформулирована задача синтаксического анализа и общие подходы
к ее решению.
• Выделены три возможных метода описания семантики языка.
Дополнительна! литература
Практически во всех книгах по компиляторам для определения языков
используются грамматики. Контекстно-свободные грамматики были введены в работе [Chomsky, 1956] по отношению к естественным языкам.
Регулярные выражения были описаны в книге [Kleene, 1956]. Однозначная грамматика для оператора if представлена в [Aho, Sethi and Ullman,
1985]. Атрибутные грамматики были разработаны в работе [Knuth, 1968a],
а применены к описанию языка Pascal в [Watt, 1977]. Сравнение различных методов описания семантики для языков программирования было
выполнено в [Terry, 1997].
2-10. Резюме
53
Упражнения
2.1.
Опишите на русском языке следующие строки.
а) {а"|л>0}
б) {a"V|/n,nai}
в)
{яу2"|л20}
г) m>0, п> 1} , n, р>0)
д) 2.2. Опишите на русском языке следующие выражения.
а) / /
б) х х >/
в) (х|у)*
г) (а\Ь)а'Ь*
Д) (а*|дТ
2.3. Укажите, какие из данных грамматик являются регулярными. Обоснуйте свой ответ (S — символ предложения; используется принятая
договоренность об обозначении терминалов и нетерминалов).
a) S->aX
S->bY
б) S->aX
S-ybY
напишите правое порождение следующего выражения.
х+х+х+х
Объясните, почему это порождение будет единственным.
2.6. Для грамматики
statement -»if expr then statement e/se statement |
If expr then statement
other покажите,
что предложение
if expr then If expr then other else other
имеет два правых и два левых порождения. Покажите, что данную
строку также можно образовать с помощью грамматики со следующими продукциями.
statement -» matched |
unmatched matched --> if expr then
matched else unmatched |
other unmatched -4 if
expr then statement |
if expr then matched else unmatched
2.7. Запишите грамматику, которая для алфавита {0, 1} генерирует все
строки, включая пустую.
2.8. Рассмотрите
грамматику
со
следующими
продукциями
(PROGRAM — символ предложения).
PROGRAM -» begin DECS; STA TS end
DECS-ч* d;DECS \d
STATS -> s; STATS
I*
в) S-->AB
А—>а
Л ? пА
В- >Ь
В-> ЬВ
г) Я -> xSy
Т > ху
2.4. Укажите, какие из грамматик в упражнении 2.3 генерируют регулярные
языки. Если сгенерированный язык является регулярным, а граммати
ка — нет, постройте регулярную грамматику для этого языка.
2.5. Для грамматики
54
Глава 2. Определение
языка
Создайте правое и левое порождение для следующего выражения.
begin d; d; s; s end
2.9. В языке FORTRAN идентификатор состоит из последовательности
(до шести знаков) букв и цифр, причем первым знаком является бу
ква. Постройте:
а) регулярное выражение для идентификатора;
б) грамматику 3-го типа, которая будет генерировать все иденти
фикаторы языка FORTRAN.
2.10. Большинство языков имеют условные операторы, подобные опи
санным в упражнении 2.6. Укажите, как избегают или разрешают
проблему неоднозначности в известных вам языках.
Упражнения
55
Глава 3
Лексический анализ
3.1. Вступление
В этой главе на концептуальном уровне рассматривается, что представляет собой первая фаза компиляции — лексический анализ — основные
функции которой состоят в группировке последовательностей знаков исходного кода в символы языка. В частности., будут рассмотрены следующие вопросы.
• Основные черты типичного лексического анализатора.
• Построение простого лексического анализатора с помощью регу
лярных выражений и связанных с ними автоматов.
• Использование генератора лексических анализаторов Lex для соз
дания лексических анализаторов.
• Некоторые лексические "проблемы", возникающие в хорошо из
вестных языках программирования.
8.2. Основные понятия
Как уже говорилось, на этапе лексического анализа происходит формирование языковых символов из последовательностей знаков. Например, в
языке С содержится шесть типов символов.
1.
2.
3.
4.
Ключевые слова, например, const, char, if, else, typedef.
Идентификаторы, например, sum, main, printf.
Константы, например, 28, 3.141529, 017 (восьмеричная система).
Строковые литералы, например, "Katherine", "bannockburn".
5. Операторы, например, +, -, ++, », /=, &&.
6. Знаки пунктуации, например, {, ],..., ,•.
Каждый из этих типов символов формируется лексическим анализатором
в процессе лексического анализа.
Лексический анализ является достаточно прямолинейным процессом, который подобен автоматически производимому человеком при чтении. Благо-
даря сравнительно простой природе символов, их всегда можно представить с
помощью регулярных выражений или, эквивалентно, грамматик 3-го типа.
Как будет показано далее, на основе соответствующих регулярных грамматик
не составляет особого труда построить необходимые устройства распознавания. Действительно, процесс создания лексического анализатора легко автоматизируется, а инструментальные средства для его создания на основе регулярных грамматик (или регулярных выражений) всегда доступны. Изучение
лексического анализа в будущем поможет глубже понять более сложную задачу синтаксического анализа.
Помимо распознавания символов языка, лексический анализатор
также выполняет некоторые другие задачи.
• Удаление комментариев.
• Введение номеров строк.
• Вычисление констант.
Впрочем, существуют аргументы за то, чтобы последнюю задачу выполнял' машинно-зависимый постпроцессор компилятора.
:
3.3. Распознавание символов
Важно понять, что лексический анализатор всего лишь распознает символы
языка для передачи их синтаксическому анализатору. Порядок следования
символов для него абсолютно не важен. Например, лексический анализатор
не обнаружит никакой ошибки в следующей последовательности символов.
64 const char typedef >> +
Причина — каждый отдельный символ полностью корректен. То, что
данная последовательность не составляет начала (или хотя бы фрагмента)
какой-либо программы, будет обнаружено синтаксическим анализатором.
Лексический анализатор не придает значения области видимости переменных, т.е. он не различает использование идентификатора sum для
представления двух различных переменных в различных функциях. Где
бы ни появлялся идентификатор, для лексического анализатора он является одним и тем же. В фазе лексического анализа он (в отличие, например, от имени функции) даже не будет определен как переменная.
Для лексического анализа регулярные выражения представляют собой
удобный метод представления символов, таких как идентификаторы и
константы. Например, идентификатор может быть представлен следующим образом.
letter (letter | digit)'
Подобным образом можно (в некоторых языках) представить и действительное число.
(+| -1 )digit*.digit digit*
В любом случае можно достаточно легко написать программу распознавания символов. Для идентификатора, она будет иметь следующий вид.
5S
Глава 3. Лексический анализ
finclude
<stdio,h> tinclude
<ctype.h>
main()
{char in;
in = getchar <);
if (isalpha(in))
in = getchar();
else error();
while (isalpha(in)
in = getchar();
isdigit(in))
Здесь in — значение только что считанного знака; функции isalpha {)
и isdigit () осуществляют проверку аргумента на предмет принадлежности к буквам и цифрам, соответственно; error () выполняет некоторые операции при возникновении ошибки. Написать код довольно просто: проверять поступающие символы и использовать цикл while для
реализации оператора *.
Подобным образом можно написать программу для распознавания
действительных чисел.
#include <stdio.h>
#include <ctype.h>
ma in()
{char in;
in = getchar();
if (in=='+'| | in=='-')
in = getchar();
while {isdigit(in))
in = getchar (}; if
<in==1.')
in = getchar();
else error();
if (isdigit(in))
in = getchar();
else error();
while (isdigit(in))
in = getchar();
printf("ok\n");
}
Обратите внимание, что возможны три ситуации и три способа их представления в программе.
1, Необязательные знаки ("+", "-"). Если их нет — это не ошибка» просто переходим к считыванию следующего символа.
3,3. Распознавание символов
59
1ИЩЯИ
2. Обязательные знаки (десятичная точка и одна цифра после нее). Если
их нет — вызывается функция error.
3. Знаки, которые могут появиться нуль или большее число раз (цифра пе
ред точкой или после первой цифры за точкой) — инициируется цикл
while для проверки каждого знака без обращения к функции error.
Написать код очень просто — он пишется с такой же скоростью, с какой вы
можете набирать! Более того, его создание можно легко автоматизировать.
Вместо того, чтобы писать программу с помощью регулярных выражений,
можно использовать соответствующий конечный автомат. Конечный автомат
состоит из конечного множества состояний и переходов между ними, которые определяются считьшаемыми знаками из входной строки. При этом одно
состояние определяется как начальное, а одно или более состояний — как конечные. Считается, что конечный автомат принял входную строку, если, начав
работу с начального состояния и выполнив соответствующие переходы при
считывании каждого знака исходной строки, автомат переходит в конечное
состояние, когда строка полностью считана. Более формально конченый автомат определяется как следующая пятерка Элементов.
Iл
М=(К, I, 8, S, F), где
К — множество состояний;
2 — алфавит, на основе которого формируются входные строки;
8 — множество переходов;
S(S<z К) — начальное состояние;
F(FQ К) — множество конечных состояний.
Переходы 8 можно определить как таблицу (или графически), и они для
каждого состояния будут указывать следующее состояние и все возможные
входные знаки. Например, конечный автомат для распознавания идентификатора можно определить, как показано на рис. 3.1, где изображено два состояния: состояние 1 (начальное) и состояние 2 (конечное). Считывание буквы в состоянии 1 приводит к переходу в состояние 2, после чего считывание
любого числа цифр или букв снова приводит в состояние 2,
int main{) {int
state; char in;
state = 1; in =
getchar();
while {isalpha (in)
{switch (state)
isdigit (in))
{case 1:
if (isalpha(in))
state = 2; else
error () ,-break;
case 2:
state = 2;
break ,-}
in = getchar{);
return (state ==
}
Цикл while обеспечивает прекращение работы программы
при считывании любого знака, который не является буквой или цифрой.
Опера- тор switch имеет по элементу для каждого состояния автомата,
причем в каждом элементе представлены все возможные переходы из
данного со- стояния. Во втором элементе оператора switch уже не нужно
проверять вход, поскольку (из-за условия цикла while) переход к нему
невозмо- жен, если последний считанный знак не является буквой или
цифрой.
Присвоение этому состоянию значения 2 не является
обязательным — оно просто делает переход явным.
Действительное число, определенное в этом разделе как регулярное
выражение, можно представить с помощью конечного автдмата (рис. 3.2)
и запрограммировать способом, подобным приведенному выше.
цифра
буква,
цифра
буква
Рис. 3.1
При создании распознавателей удобно использовать представление
конечных автоматов. Например, следующая программа осуществляет
распознавание идентификаторов.
♦include <stdio.h>
#include <ctype.h>
60
Глава 3. Лексический анализ
цифра
3.3. Распознавание символов
61
in = getchar();
♦include <stdio.h>
#include <ctype.h>
return(state == 4);
int issign (char sign)
{return (sign == 4+' |j sign == '-');
}
Здесь error () имеет то же значение, что и ранее; sign () принимает значение
true, если его параметр равен + или -, и false — в остальных случаях. .
int main()
3,4. Lex
{int state;
char in;
Конструирование устройств распознавания символов из регулярных выражений
или конечных автоматов, принимающих эти выражения, является до того
простым, что может быть легко автоматизировано с помощью соответстГлава 3. Лексический анализ
62
state = 1;
in = getchar();
while (isdigit(in) | |issign(in)|jin =='.')
{switch (state)
case 1:
case
•
if (isdigit(in)||issign(in))
state = 2; else if (in == ' . ' )
state = 3;
break;
„
2:
if
(isdigit,
/•' «-"У4
(in)
)
state = 2 ;
\
else if (in =='.')
i
state = 3;
1
else error();
break;
case
|
3:
■
,
if
(isdigit(in))
state = 4,i
else error();
j
break;
;
case 4:
if (isdigit(in))
state = 4;
else error();
break;
вующих инструментальных средств. Наиболее известным и широко
используемым для этих целей инструментом является Lex, изначально
разработан-jibut для использования совместно с генератором программ
синтаксического анализа YACC в среде Unix. В настоящее время
существует общедоступный эквивалент Lex, именуемый Flex; кроме
того, имеются версии для других сред, таких как Windows и Macintosh.
Изначально для Lex необходимо было записывать действия,
сопровождающие анализ, на С или на RATFOR ^версия FORTRAN),
но современные версии Lex позволяют использовать и другие языки,
например, Turbo Pascal и C++. Различные версии Lex очень похожи
между собой, хотя и не идентичны. В примерах данной книга будет
использоваться версия Lex под Unix.
Для определения символов, поступающих на вход Lex,
используется форма записи, весьма подобная применяющейся для
регулярных выра- / „ ясений. Отпнчнттгпслктгбл дпух ппщгтггттгпттыгьгг
моментов.
1. Форма записи Lex допускает более эффективное представление
(с
■ - точки зрения числа знаков, используемых в представлении)
некоторых типов символов.
2. Кроме того, в определенных обстоятельствах форма записи Lex
рас
ширяет выразительную силу обозначений регулярных
выражений.
В качестве иллюстрации первого утверждения: обозначения
регулярных яырахсений не позволяют представить понятие "любой мак
алфавита, за исключением одного" без перечисления этих знаков
алфавита! В качестве иллюстрации последнего утверждения можно
сказать, что форма записи Lex позволяет представить понятие символа,
появляющегося только в определенном контексте, что является
необходимым для анализа определенных (возможно, не самых
удачных) функций языка FORTRAN,
Чтобы определить идентификатор в обозначениях Lex, для начала
можно определить нетерминалы letter и digit.
letter
[a-z] digit
[0-9]
Это называется определениями в Lex. Отметим, что совсем
необязательно .перечислять каждый знак в диапазоне a-z или 0-9.
Теперь можно определить идентификатор.
identifier
{letter}{{letter}|{digit})*
Здесь | и * означают то же, что и в регулярных выражениях, а
фигурные ..'скобки применяются для обособления уже определенных
величин! ЁСли какое-то действие должно выполняться всякий раз,
когда встречается ■идентификатор (обычная ситуация для
лексического анализатора), то оно выражается в виде следующего
правили—
{identifier}
'
{printf("идентификатор опознан\п");}
При
каждом
распознавании
идентификатора
{identifier}
выполняется простой оператор (может быть составным оператором,
но не может быть последовательностью операторов).
3,4. Lex
63
Г"44
{printf ("идентификатор опознан\п");}
Полным входом для создания анализатора, распознающего идентифика торы и при этом каждый раз выполняющего приведенную выше коман - ;
ду, будет следующий код Lex.
letter [a-z] digit [0-9]
{letter}({letter}|{digit})*
%%
{identifier}
%%
identifier
{printf("идентификатор опознан \п"};}
\-"
правила
пользовательские функции
^„У
Здесь вторая часть является обязательной, а остальные используются по
„j*epe необходимости. Различные части должны отделяться посредством
(строки, которая в крайнем левом положении содержит символы
■у %%
Вход Lex для создания анализатора по распознанию и распечатке дей Пусть этот код содержится в файле firstlex.l, тогда анализатор создаствительных чисел, ранее определенных регулярным выражением
ется с помощью следующей команды.
i (+| - |)di git '.digit digit*
,lex firstlex.l
Результатом данной команды будет написанный на С анализатор, поме- убудет иметь следующий вид.
%
digit
[0-9]
щенный в файл lex.yy.c. Далее его можно откомпилировать
r
e
a
l
n
o
t + S - 3 ?{digit}*\.{digit}+
'ее -о fifst;.lex lex.yy.c -11
%%
и поместить целевой код в файл f irstlex (параметр, следующий за мет{realno} {printf("действительное число%з в строке
кой -о). Отметим, что присутствие параметра библиотеки (-11) является
%d\n", yytext,yylineno);}
обязательным. Код £ irstlex можно выполнить с данными из файла, соВ этом примере иллюстрируются два момента.
держащего программу на С, например, cprog.
■|. Перед знаками ввода, которые являются частью системы обозначений '
firstlex <cprog
("-" и "."), необходимо употреблять знак " \" (или брать в двойные
Здесь вход перенаправлен с клавиатуры (стандартного канала ввода) на
кавычки). Следует отметить, что при первом появлении нет нужды .'
файл cprog (отсюда — знак "<"). Выход будет отображаться на экране,
выделять подобным образом знак "+", поскольку здесь неоднозначности не
хотя его также можно перенаправить в файл (например, idents).
имеется.
firstlex <cprog >idents
?<2.
Знак
"+"
(как
часть
системы
обозначения)
используется
для
указания,
Более интересный анализатор можно создать с помощью следующего
что предшествующий знак употребляется один или более раз. Это не
кода Lex.
увеличивает выразительную силу обозначения по сравнению с регуletter [a-z]
лярными выражениями, но делает запись немного компактнее и легче
"/ digit
[ 0-9]
для понимания.
identifier {letter}({letter}] {digit})*
%%
{identifier} {printf("идентификатор %s в строке %d\n",
yytext, yylineno);}
%%
Данный анализатор использует две переменные Lex: yytext, значение
которой является текстовым представлением последнего распознанного
символа, и yylineno, которая содержит текущий счетчик концов строк и
значение которой представляет номер текущей строки. В Lex и YACC
используется достаточно много переменных такого рода. Наиболее полезные из них приведены в табл. 3.1 (раздел 3.6).
Выходом анализатора, созданного из рассмотренного выше кода, будет последовательность строк следующего вида.
На данном этапе будет полезным показать на примерах основные
§свойства обозначений, используемых на входе Lex:
представляет отдельный знак;
представляет а, если а — знак, используемый в системе
обозначений (для устранения неоднозначности);
также представляет а, если а — знак, используемый в
системе обозначений;
представляет а или Ь;
представляет нуль или одно вхождение а;
представляет нуль или более вхождений а-;
представляет одно или более вхождений а;
представляет от m до п вхождений а;
идентификатор х в строке 1
64
Глава 3. Лексический анализ
WALex
Общий вид входа, ожидаемого Lex.
определения
а
\а
"а"
а|Ь
а?
а*
а+
a{m,n}
65
[a-z]
[a-zA-z]
(Ла-г]
{name}
Л
а
а$
представляет набор знаков (алфавит);
также представляет набор знаков (больший);
представляет дополнение первого набора знаков;
представляет регулярное выражение, определенное
идентификатором паше;
представляет а в начале строки;
представляет а в конце строки;
ab\xy
представляет ab, следующее перед ху.
Некоторые из приведенных выше обозначений иллюстрируются в
следующем примере, а именно: создание (несколько упрощенного) анализатора для распознавания констант, идентификаторов, строк и некоторых слов в программе Pascal (другие слова языка будут распознаваться
как идентификаторы). Ниже приводится вход Lex.
[0-9]
digit
intconst
realconst
letter
identifier
whitespace
stringch
string
otherch
othersymb
[+\]?{digit}+
[ + \ - ] ? {digit}+\.{digit } + ( e [ + \ - ] ? {digit} + ) ?
[A-Za-z]
{letter}{{letter}|{digit})*
[[3
\t\nj
g£g \J
' {stringch}+ '
[AQ--9a-zA-Z+\~
{otherch}+
СГ
зованному ранее — указывает, что n u t являются не обычными знаками, а
знаками системы обозначений. Следует отметить, что подобное
(непоследовательное) использование символа перехода не приводит к путанице, string определяется как любая последовательность знаков в кавычках, исключая кавычки, a othersymb — как любая последовательность ранее не упомянутых знаков. Обратите внимание, что действие
"нет действия" связано как с whitespace, так и с othersymb. Это объясняется тем, что если эти символы не были распознаны во второй части
входа Lex, то Lex выводит их в стандартный канал вывода вместе с другими выходными данными, представляя неоформленный результат.
Предположим, что на вход анализатора, созданного с помощью Lex,
поступает следующая программа Pascal.
program double (input, output);
var i: 1..10;
begin
writeln('число':10, 'удвоенное число':10);
for i:= 1 to 10 do
writeln (i:X0, i*i:10);
writeln
end.
В этом случае выход анализатора будет иметь следующий вид:
\t\n]
printf("опознано слово program\n");
printf("опознано слово var\n");
printf ("опознано слово beginW);
printf("опознано слово for\n");
printf("опознано слово to\n");
printf ("опознано слово do\n"),;
printf("опознано слово end\n");
yytext.
printf("целое число %s в строке %d\n
yylineno); {.realconst}
printf("действительное число %s в строке
%d\n", yytext, yylineno);
{string}
printf(жстрока%з в строке %d\n", yytext,
yylineno); {identifier}
printf("идентификатор %s в строке %d\n",
yytext, yylineno);
{whitespace} ; /*нет действий*/
{othersymb} ; /*нет действий*/ &&
program
var
begin
for
to
do
end
{intconst}
Отметим, что whitespace может представлять собой любое количество
пробелов, новых строк и символов табуляции, а \п и \t применяются
для указания новой строки и знака табуляции соответственно (подобно
тому, как они используются в операторах printf в С). Отметим также,
что символ перехода \ применяется в смысле, противоположном исполь-
опознано слово program идентификатор double в
строке 1 идентификатор input в строке 1
идентификатор output в строке 1 опознано слово
var идентификатор i в строке 2 целое число 1 в
строке 2 целое число 10 в строке 2 опознано слово
begin идентификатор writeln в строке 4 строка
'число' в строке 4 целое число 10 в строке 4
и т.д.
Отметим, что слова языка распознаются как таковые, а не как идентификаторы. Это объясняется тем, что для поиска соответствия Lex прежде всего обращается к разделу правил, а поэтому важно, чтобы именно
там были определены слова языка. Отметим также, что double правильно распознается как идентификатор, а первые две его буквы не распознаются как слово языка do. Это объясняется тем, что для сопоставления
Lex всегда подыскивает более длинное слово, и только в случае, если два
слова имеют одинаковую длину, анализатор выбирает первое.
С помощью функции yylexO Lex может вызываться из программы
на С. Следующий пример входа Lex показывает, как код С можно интегрировать в анализатор, созданный Lex.
Глава 3. Лексический анализ 3.4. Lex
67
Отметим использование yyleng для получения длины последнего считанного символа. Если необходимо, чтобы анализатор окончил работу в конце
первого предложения, то вход Lex можно записать следующим образом.
int chars = 0, lines = 0;
%{
\n ++lines; ++chars; %%
main() {yylexO ; printf("число знаков = %d, число строк =
%d\n", chars,
lines) ; }
word letters
[a-zA-z]+
= о,
space [ \П]
ws
{space}+
f t y .1
Функции, находящиеся в третьей части программы, а также объ- e o s
letters
"
=
явления или другие элементы программы могут появляться и в ее %%
первой части при условии, что будут выделены отступом или окружены yi
th > 1еП) len
{word}
ra:?f yywrap(;
парой "%{"-"%}". Любые такие элементы будут скопированы в
words « 0, len = о
length;
letter S+ i;ngm . <eos}
- 1-gth;}
-'/♦ничего не делатьсозданную Lex программу на С. При. этом более предпочтительно пользоваться ;/*ничего
/
не
парой "%{"-"%}", а не отступом, поскольку цель последнего не всегда main()
бывает ясна. В любом случае, если символы используются, они должны {yylexO ;
делать*/
находиться на своем месте в начале строки,
Следует также помнить, что все строки с отступом игнорируются Les
тана
слова == *a, средняя длина
.......
_ _,,.v/aa
P rint f * "максимальная дли.
слова = %f\n", len, letters/words);
и копируются в программу на С без изменений. Незнание этого мо мента
часто является причиной ошибок.
■■ywrap () означает прекращение
Необходимо отметить, что обозначенное точкой "соответствующее всему"3
одним простым примером является
регулярное выражение в данном случае будет сопоставляться с любым зна
ком, кроме знака новой строки. В общем случае оно будет сопоставляться !1тия инструмента добавления номе "1'яется использования Lex для созВ С1|ЭОК в
любым предопределенным, но еще не сопоставленным символом.
Рим слеДующий вход Lex.
исходный код, РассмотС помощью следующего входа Lex можно получить максимальное i *{
lnt
среднее значения длин слов для некоторой части программы.
lineno = i;
}
выз
е
°в yywrap () означает прекращени
°дним просты
налша
-
%{
int letters = 0, words = 0, len = 0, length;
%}
word
[a-zA-Z]+
space [ \n]
ws
{space}+
%%
{word} (++words; length = yyleng;
letters = letters+length;
if
(length >
> len)
if (length
len) len
len = length;}
line[A\n]*\n %%
{line}
{printf("%d %s", lineno++, yytext);} main () {yylexO ;
}
выходом будет исходный код, в котором каждая строка (включая пустые) ЗДинается с
номера строки, считая от начала исходного кода.
Может показаться удивительным, но распознать комментарии в зыке обычно нелегко. Проблема
main()
заключается в знаках, используемых Ля обособления комментария,
{yylex();
которые, следовательно, не должны оявляться внутри комментария.
printf("максимальная длина слова = %d, средняя длина Разумеется, для комментария можно Пределить регулярное выражение
слова = %£\n", len, letters/words); }
(например, в С), но, как будет по-*зано, сделать это тяжело, и данный
процесс подвержен ошибкам. °лее простое решение — написать код,
который бы распознавал на68 Глава 3. Лексический анаМ, iex
;/*ничего не делать*/
;/*ничего не делать*/
69
чало комментария, а затем пропускал все знаки вплоть до замыкаюся также строчные литералы — вспомните рассмотренный выше пример
щего знака (в конце концов, содержание комментария не важно для
из Pascal (а в С это еще сложнее!).
компилятора). В следующем ниже фрагменте Lex показан один из методов работы с комментариями в С.
%%
» / * « {char in;
Существует множество способов применения Lex для анализа исходного
for ( ; ; )
кода. В то же время следует помнить, что многие типы анализа кода
{
лучше проводить с помощью инструментальных средств, созданных на
while ((in = getchar(J) ! = '*');
основе генераторов синтаксического, а не лексического анализа. Те типы
/* ничего не делать */
анализа, что связаны скорее с синтаксической, а не лексической структуwhile ((in = getcharO) =='*'); /*
рой программы, проще осуществлять с помощью инструментов с синтакггоопустить *'s */ if (in == V)
сической, а не лексической основой. Это вовсе не означает, что синтакbreak;
сическая структура программы не может быть эффективно проанализи/* окончание комментария*/
рована с помощью инструментов лексического анализа, однако, многие
}
типы анализа проще и естественнее осуществляются посредством синтаксических инструментов. Например, анализ степе ни использования
Приведенный выше код должен игнорировать последовательность знаков операторов, вложенных структур, перекрестных ссылок и т.д. более отно *з, за которой не следует а/, и последовательность /s, перед которой нет а*. сится к синтаксической, чем лексической структуре языка. Как правило,
Этот код может также быть доработан для нахождения внутри комментария если анализ на основе Lex оказывается сложным, дальнейшее рассмотрепоследовательности EOF (end of file — конец файла).
ние производится с использованием YACC. При этом практически не
Комментарий можно определить как_ регулярное выражение для возникает потребности в использовании стеков, флажков и других
входа Lex.
средств, что часто применяются при попытке перейти от синтаксической
comment « /* "" /" *( £ Л */1 [ [ Л *]"/" ["*" [ Л / ] ) *«*'<*»*/" Сложность
задачи к лексической.
Приведем типы анализа, связанные с лексической структурой программы.
данного комментария требует некоторых пояснений. Последовательности « / * " в начале и " * / " в конце просто указывают пары
• Идентификация слов языка.
знаков, что должны находиться в начале и в конце комментария. Далее
• Идентификация и вычисление констант.
• Определение всех отдельных идентификаторов программы (не пе
остается то, что описывает содержимое комментария.
ременных!).
« / " * ( [ " * / ] |[ " * ] « / " | " * " [ Л / ] ) * « * » *
• Определение числа строк комментариев в программе.
Последовательность " / " * слева указывает, что в начале комментария
• Определение числа и средней длины литералов в программе.
может быть любое (включая нулевое) число вхождений знаков " /", а поДопустим, мы хотим подсчитать количество строк в программе, котоследовательности " *" * справа — что в конце может находиться любое рые не содержат комментариев (non-comment lines of code — NCLOC).
(включая нулевое) число вхождений знаков "*" (два применения "*" Для этого нужно подсчитать полное число строк программы и вычесть из
могут сбивать с толку). Средняя часть представляет последовательность него число пустых строк и строк, содержащих только комментарии. Код
Lex для этой цели может иметь следующий вид.
из нуля или большего числа сегментов, каждый из которых может:
%{
• не содержать " /" или " *";
Int ncloc =0, count =0;
• содержать только " / ", перед которым нет " * ";
3.5. Другие применения Lex
• содержать только м *", за которым не следует " /".
Так что не все так страшно, как кажется на первый взгляд! Непонят но,
правда, является ли сложность структуры комментария следствием его
определения или ограничением формы записи Lex. Как было показано
выше, язык комментария является регулярным, но регулярность -это
еще не простота. Во многих языках сложными для описания являют
comment «/*»«/'
space
f \tJ
newline \n
{comment}
70
; /*ните го
Глава З. Лексический ана0 3,5 nnvruo __,
J- Другие применения Lex
не делась*/
71
{space}
;/*ничего не делать*/
{newline} {if (count > 0) ncloc
count = count + 1;
ncloc+1; count = 0;}
main()
{yylexO ;
printf("число строк программы, не содержащих
комментариев, = %d", ncloc);
}
Здесь count увеличивает свое значение при каждом знаке, отличном от
пробела, символа табуляции и знака комментария. Если он имеет ненулевое значение в конце строки, то увеличивается значение ncloc.
В качестве полезной метрики программы можно использовать среднее
число знаков в строке. Это число и параметр NCLOC позволяют получить
более полное представление о размере программы, чем NCLOC сам по себе.
Чтобы быть последовательными, подсчитаем среднее число знаков для строк
без комментариев (исключая комментарии), также не учитывая пустые строки. Код Lex для этой цели может иметь следующий вид.
%{
int nochars = 0, ncloc = 0, count =0;
%}
comment
«/*""/"*( [Л*/] | ["*]»/"|"*"["/])*«*»*«*/"
space
[ \t]
newline
\n
%%
{comment} ;/*ничего не делать*/
{space}
;/*ничего не делать*/
{newline} {if (count > OS ncloc = ncloc+1; count = 0;}
{count = count+1; nochars = nochars+1);}
main ()
{yylex{) ;
printf("число знаков на строки NCLOC = %£",
ncloc/nochars);
}
В процессе лексического анализа можно вычислять и другие метрики,
такие как общее число строк, число строк программы с комментариями,
а также общее число знаков в программе. Такие метрики, как количество
функций, число операторов, количество операторов на строку и т.д.,
лучше вычислять в процессе синтаксического анализа, причем сделать
это удобно с помощью YACC. Как будет показано далее, более сложные
метрики также удобнее вычислять в процессе синтаксического анализа.
В процессе лексического анализа помимо проведения определенных
вычислений можно осуществлять поиск нежелательных свойств программы (иногда называемых дефектами). Считается, например, что
слишком длинные или слишком короткие идентификаторы вредят читабельности программы. Читабельность — это важная качественная характеристика кода, который, возможно, придется читать многократно. Как
72
будет показано, опознать короткий или длинный идентификатор совсем
не сложно. Разумеется, желательно, чтобы идентификатор был еще и
значимым, но проверить это автоматически намного сложнее!
Ниже приводится код Lex для определения коротких и длинных идентификаторов.
%%
letter
[a-zA-Z]
digit
[0-9]
identifier
{letter}({letter|{digit})*
{identifier} {if (yyleng == 1)
printf(
"идентификатор %s состоит из одного
знака\п", yytext);
if (yyleng > 8)
printf (
из более чем
"идентификатор %s
■}
восьми знаков\п",
состоит
yytext)
Чтобы убедиться, что длина констант не превышает установленной, ее
можно проверить подобным образом. Впрочем, часто оказывается, что
препроцессор компилятора не подходит для выполнение такой проверки,
так как препроцессор должен (насколько это возможно) быть машиннонезависимым и независимым от реализации.
В исходном коде можно осуществить большое количество качественных проверок, подобных перечисленным ниже.
• Некорректное использование оператора goto.
• Большая сложность потока управления.
• Чрезмерная глубина вложенных структур.
• Произвольные константы в выражениях.
Последний пункт списка представляет менее желательную ситуацию, чем
сопоставление идентификатора с постоянным значением. Впрочем,
большинство подобных проверок все же лучше производить в процессе
синтаксического анализа. Часть из них будет рассмотрена в главе 5.
Рассмотрим еще один пример, призванный проиллюстрировать широту применимости инструментов, созданных с использованием Lex. Автору не известен ни один язык программирования, который бы позволял
использовать римские цифры для представления целых чисел. Однако,
для лексического анализатора это совсем не сложно сделать, хотя, возможно, это запутает людей-пользователей языка!
Отметим вначале, что расширенное множество римских цифр можно
определить посредством следующего регулярного выражения.
М-(СМ | CD | DC* | С)(ХС | XL | LX* | Х")(1Х \ I V \ V r \ r )
Этот набор легко получить, рассмотрев, как выражаются тысячи, сотни,
Десятки и единицы. Приведенное выражение создает все римские цифры,
а
также строки, которые ими не являются, например,
Глава 3. Лексический man» 3-5- Другие применения Lex
73
Vllllll
(В данном выражении содержится слишком много /.) Таких строк можно
избежать, если заменить последнюю часть регулярного выражения следующим выражением.
IX \ V\ VI \ VII\ V I I I \ I V \ l \ l l \ l l l \ e
К данному выражению можно, по желанию, добавить еще //// (что, пожалуй, встречается только на циферблатах часов). Подобная трактовка первых трех частей регулярного выражения также позволит избежать появ ления некорректных строк. Таким образом, можно записать (достаточно
сложное) регулярное выражение, представляющее точно все римские
цифры. Строго говоря, написанное выше выражение следует еще не сколько модернизировать, чтобы пустая строка е не воспринималась как
римская цифра. Поскольку мы работаем с Lex, преобразования не являются обязательными, т.к. проверки, необходимые для устранения некор-
ректных строк, можно запрограммировать в действия. Ниже приводится
код Lex для работы с римскими цифрами.
%{
int value = 0;
thousands
hundreds
tens
units
%%
value);
Для включения проверок слишком большого числа цифр с, X или L
можно добавить что-то, подобное приведенному ниже.
{hundreds}{if{!strcmp(yytext, "CM")) value = value + 900;
else if {!strcmp(yytext, "CD")) value = value+400;
else if (yytext[0] = 'D') if (yyleng > 4)
printf("слишком много C\n"); else
value=value+500+(yyleng-1)*100; else
if(yyleng>4)printf("слишком много С\п"); else
value = value + yyleng*100;}
Использование strcmp понятно интуитивно, поскольку он должен
давать значение 0, если две строки, являющиеся его параметрами, идентичны. В С это эквивалентно значению false, поэтому значение нужно
инвертировать с помощью оператора !.
3.6. Взаимодействие с YACC
CM|CD|DC*|C*
XC|XL|LX*(X*
IX|IV|VI*|I*
{thousands} {value = value + yyleng * 1000;}
{hundreds} {if { !strcrnp{yytext, "CM")) value =
value + 900; else
if(!strcmp(yytext, "CD"))
value = value+400;
else if (yytext [0] == VD')
value = value+500+(yyleng -1}* 100; else value =
value + yyleng * 100;} {if{!strcmp(yytext» "XC"))
{tens} value = value+90; else if (!strcmp(yytext, "XL"))
value = value+ 40; else if (yytext [0] ==
lL')
value = value+50+(yyleng-1)*IQ;
else value = value + yyleng*10;}
{units} {if{!strcmp(yytext, "IX")) value = value + 9;
else if (!strcmp(yytext, "IV"))
value = value + 4;
else if(yytext [0] == 'V)
value = value+5+{yyleng-1);
else value = value + yyleng;}
74
main()
{yylexO ;
printf("значение нумерала paBHO%d\n"
Глава 3. Лексический анализ
yylexO можно использовать для возвращения значения любой вызывающей его функции. В приведенных выше примерах это обычно функция mainO, хотя часто вызывающей функцией является программа синтаксического анализа, созданная YACC. Поэтому, соответствующие действия по распознаванию символов языка могут иметь следующий вид.
">="return symbol(GE);
Здесь symbol () отображает GE в уникальное целочисленное представление. Словам языка, таким как if, else, for, можно подобрать подобные
сопоставления.
if
return symbol (if)
else return symbol (for)
Отметим, что данные правила должны появляться до правил, касающихся идентификаторов. Отметим также, что с точки зрения возможности
расширения кода стоит использовать следующий способ распознавания
всех строк подобного типа.
{letter}{letter|digit}* return lookup (yytext)
Здесь lookup() — функция поиска значения yytext в. таблице слов
языка и возвращения целочисленного представления идентификатора
или распознанного слова языка.
3-6.
Взаимодействие с YACC
75
Иногда необходимо передать программе синтаксического анализа
текст из только что распознанных символов или его синтаксический
класс. Предположим, для примера, что программа синтаксического анализа анализирует арифметические выражения и содержит действия по
вычислению их значений, тогда лексический анализатор должен будет
передать синтаксическому анализатору значение каждого считанного
числа. Соответствующий код Lex может иметь следующий вид.
{number} {yylval = atoi (yytext); return NUMBER;} Здесь
yylval — переменная YACC, используемая для сопоставления атрибута с
текущим символом. Функция С, именуемая atoi, конвертирует строку в
целое число и доступна в stdio.h.
Вопросы интеграции выходов Lex и YACC будут рассмотрены в главе 5, а пока перечислим упоминавшиеся на данный момент специфические имена Lex (табл. 3.1).
Таблица 3.1.
Имя Lex Использование
yytext
yyleng
yylval
lex. уу. с
Текст последнего распознанного символа
Число знаков в последнем распознанном символе
Значение, соотнесенное с последним распознанным символом
Файл С, создаваемый Lex
3,7. Лексические затруднения
Несмотря на общую простоту процесса лексического анализа, существует
небольшое количество языковых характеристик, что усложняют создание
лексических анализаторов. Большинство из таких характеристик принадлежит к одному из следующих классов.
• Слова языка доступны для использования в качестве идентифика
торов.
• Интерпретация некоторых последовательностей знаков является
контекстно-зависимой.
Языки FORTRAN и PL/1 допускают использование слов языка в качестве идентификаторов, определяемых пользователем. Преимущество
такого подхода заключается в том, что пользователю языка не нужно
знать все слова языка перед написанием программы. С другой стороны,
язык COBOL содержит более 100 слов (точное количество зависит от используемой версии), при этом ни одно из них не может быть переопределено пользователем. Возможность использования слов языка в качестве
идентификаторов приводит к возникновению трудностей в лексическом
анализаторе и, возможно, затрудняет чтение программы человеком. Рассмотрим, к примеру, фрагмент кода на языке FORTRAN.
Глава 3. Лексический анализ
IF(I! = 1
Данный фрагмент можно интерпретировать только как присвоение значения массиву с именем IF, НО ТОЧНО ЭТОГО сказать нельзя, пока, не будет
достигнут конец строки — подобным же образом может начинаться, например, оператор if.
IF( I)
1,2,3
Помимо того, что FORTRAN допускает использование слов языка в качестве
идентификаторов, определяемых пользователем, он также не придает значения пробелам внутри идентификаторов и (в отличие от большинства языков)
не использует пробелы для разделения символов. Выражение
DO 7 I = 1,5
является началом оператора do. В то же время, пока не считан знак ",",
начало строки может представлять идентификатор
DO 7 I
Чтобы избежать подобной неоднозначности, лексический анализатор .для
языка FORTRAN должен уметь выполнять некоторый предпросмотр, и,
по-видимому, именно поэтому Lex предлагает средства сопоставления
строк. Предпросмотр в FORTRAN обычно ограничен и никогда не простирается далее текущего оператора. По меньшей мере, в ранних версиях
FORTRAN в качестве нижних индексов массива можно было использовать только ограниченные (и короткие) формы выражений, что помогало
ограничивать требуемый объем предпросмотра.
В языке PL/1 слова языка также (в основном) не резервируются. Вследствие более структурированной природы языка (по сравнению с FORTRAN)
для устранения локальных неоднозначностей может требоваться произвольный объем предпросмотра. Рассмотрим следующее выражение.
IF ( I ) = THEN .,+ THEN; Оно представляет оператор
присваивания. В то же время, выражение
IF
(I)
= THEN + THEH + THEN;
является оператором if. Подобным образом
DO WHILE
(P=0) ;
является оператором (do while), a
DO WHILE(P)
= 0. . .
является оператором do с управляющей переменной WHILE ( P ) .
В ALGOL 68 (языке, о котором практически ничего не слышно в наши дни) существуют проблемы, связанные с контекстной зависимостью
группировки некоторых последовательностей знаков в символы. Например, последовательность
или же может возникнуть в
может означать операторный символ
объявлении оператора "<".
3 7 Лтстеские заТруДнент
77
o p < =...
В последнем случае "<" и "=" будут двумя отдельными символами (с
точки зрения синтаксического анализа). В показанном примере, чтобы
различить две интерпретации последовательности знаков, необходимо
знание контекста, а именно: появления оператора ор непосредственно
перед двумя знаками. Впрочем, различить две интерпретации совсем не
просто, если последовательность знаков появляется позэке в списке объявлений операторов, как, например, в следующей ситуации.
ор =>...,<=...
Еще одной интересной (с лексической точки зрения) особенностью
ALGOL 68 является вопрос форматов. Форматы, контролирующие вход и
выход, выделяются значками $. Внутри форматов лексическая структура
языка достаточно отличается от структуры любой другой его части. Более
того, сами по себе форматы могут содержать выражения с лексической
структурой, эквивалентной структуре выражений вне форматов, а выражения внутри форматов также могут содержать форматы и т.д. до произвольной степени вложенности. Приведем пример формата.
Здесь второй х означает пробел (это его обычное значение внутри формата), а первый х является частью выражения, на что указывает предшествующее п.
Лексический анализатор для ALGOL 68 должен уметь работать в двух
режимах: один для "нормального" текста, а второй — для текста внутри
форматов. Кроме того, два режима обязаны уметь рекурсивно вызывать
друг друга. Это может показаться очень сложным, но реализовать это на
практике не так уж и тяжело.
Вопросы лексического анализа хорошо освещаются во всех вступительных трудах по компиляторам, которые были приведены в конце главы 1.
Традиционным справочником по Lex является [Lesk, 1975]. Кроме того,
Lex подробно описывается в документации по Unix, однако, собственно
учебников по этому средству не так уж много. Основной из них уже
упоминался ранее: [Levine, Mason and Braun, 1992]. Многие работы по
компиляторам содержат общие сведения об инструментальных средствах,
но лишь немногие из них могут предоставить достаточно информации,
которую можно использовать в серьезной работе. Наиболее полную информацию по метрикам исходного кода (подобным NCLOC) можно найти
в работе [Fenton and Pfleeger, 1996].
3.1, Объясните, почему лексический анализ обычно является относи тельно медленной фазой процесса компиляции.
3.2.- Необходимы ли значения констант в процессе лексического
анализа? Ответ аргументируйте.
3.3. Предложите конечный автомат для распознавания идентификаторов
FORTRAN не длиннее шести знаков (букв и цифр), которые начи
наются с буквы.
3.4. Для каждого из следующих регулярных выражений определите ко
нечный автомат:
а) W
б)
cf.eW*
в) (а|Ь|с)хх*(а|Ь|с)
3.5. Укажите грамматику 3-го типа для каждого из регулярных выраже
ний в упражнении 3.4.
3.8. Резюме
Данная глава была посвящена лексическому анализу, как части процесса
компиляции, а также его использованию в других областях. В частности,
было сделано следующее.
• Показано, как можно создать лексический анализатор, используя
регулярные выражения и конечные автоматы.
• Продемонстрировано, как используется Lex для создания лексиче
ского анализатора.
• Показано, как можно использовать Lex для создания инструментов
вычисления метрик текстов.
• Рассмотрено взаимодействие Lex и YACC.
• Приведены примеры языковых характеристик, которые усложняют
процесс лексического анализа.
78
Дополнительная литература
3.6. Из регулярного выражения для определения действительных чисел (+ | -
\}digif.digit digif(e(+ | - \)dlglt.cHgW)
получите:
а) конечный автомат, принимающий действительные числа;
б) код С для распознавания действительных чисел на основе ко
нечного автомата.
3.7. Из регулярного выражения для определения действительных чисел в
упражнении 3.6 создайте грамматику 3-го типа для генерации действительных чисел.
Глава 3. Лексический анализ ' Цопопнмтельиая литература
79
3.8. Рассмотрите двоичные строки, которые состоят из четного числа
единиц. Получите конечный автомат, который принимает такие
строки, а затем образует:
а) регулярные выражения для таких строк;
б) код Lex для определения таких строк.
3.9. Предложите методы восстановления после лексических ошибок.
ЗЛО. Предложите альтернативу взаимно рекурсивным процедурам работы
с двумя лексическими режимами ALGOL 68. Обсудите преимущества и недостатки такого подхода по сравнению с подходом рекурсивных процедур.
Шва 4
Нисходящий синтаксический
анализ
4.1. Вступление
В данной главе будут рассмотрены принципы нисходящего синтаксического анализа, а также их применение на практике. Методы нисходящего
синтаксического анализа являются более интуитивными, чем методы
восходящего анализа. Поэтому вначале будет рассмотрен первый из указанных случаев. Впрочем, для восходящего синтаксического анализа разработано больше инструментальных средств, и он может применяться
более широко, чем нисходящий анализ. Подробно методы восходящего
синтаксического анализа будут описаны в главе 5.
В настоящей главе будут рассмотрены следующие вопросы.
• Критерии принятия решений, применяемые при нисходящем син
таксическом анализе.
• Контекстно-свободные грамматики, на которых может основы
ваться нисходящий синтаксический анализ.
• Простые методы создания нисходящих синтаксических анализато
ров с использованием соответствующих грамматик.
• Преобразование грамматики в форму, подходящую для нисходя
щего анализа.
» Преимущества и недостатки нисходящего синтаксического анализа.
• Использование контекстно-свободных грамматик как основы про
цессов времени компиляции.
4.2. Критерии принятия решений
Напомним, что задача синтаксического анализа состоит в нахождении
порождения (если таковое существует) конкретного выражения с использованием данной грамматики. Ори нисходящем анализе в большинстве
случаев требуется найти левое порождение. При обратном порядке разбора
чаще всего искомым является правое порождение. Следует помнить, что
80
Глава 3. Лексический анализ
I
I
при нисходящем синтаксическом анализе мы начинаем с символа предложения и генерируем предложение, тогда как при восходящем анализе
имеется предложение, которое сворачивается в символ предложения, Далее будем предполагать, что предложения, которые предстоит сгенерировать или свернуть, читаются слева направо (хотя теоретически возможны
и обратные проходы, когда предложения читаются справа налево).
В разделе 2.5 был рассмотрен язык
{х"У \т,п> 0), сгенерированный
следующими продукциями.
S-+XY
Х->хХ
Х->х
Y-*yY
Y->y
Было показано, что предложение
хххуу
можно породить (или сгенерировать) с помощью левого порождения, а
именно:
S=>XY=> xXY=> xxXY=> xxxY=> xxxyY^> хххуу
Первый шаг порождения очевиден., поскольку символ предложения S находится с левой стороны только одной продукции.
S=>XY
Следующий шаг несколько сложнее, поскольку крайний левый нетерминал в сентенциальной форме (X) входит в левую часть более одной продукции (в данном случае — в две продукции). В то же время следует
помнить, что при синтаксическом анализе конечный результат
(сгенерированное предложение) всегда известен. В данном случае результат будет следующим.
хххуу
Поскольку в указанном выражении более одного х, следующей используемой продукцией должна быть такая.
Y->yY
Использование именно этой продукции объясняется тем, что далее также
требуется сгенерировать у. Поскольку в дальнейшем уже не требуется
генерировать у, то последний шаг порождения
xxxyY=> хххуу подразумевает использование
следующей продукции.
В приведенном выше примере найти порождение было нетрудно, так
как было известно выражение, которое следовало сгенерировать, хотя на
большинстве этапов было необходимо знать два символа помимо уже
сгенерированных. При использовании некоторых грамматик для нахождения правильного порождения требуется более двух символов (в некоторых случаях это число может быть произвольным). В дальнейшем нас
интересуют такие грамматики, которым для определения правильного
порождения требуется не более одного символе предпрослютра на каждом
этапе порождения.
Еще один наглядный пример, демонстрирующий этапы нисходящего
порождения, приводится в табл. 4.1. Знаки предложения рассматриваются по одному и используются для управления процессом синтаксического
анализа. После генерации знак предложения зачеркивается (в столбце
"входная строка"). Каждому этапу синтаксического анализа соответствуют три позиции таблицы: входная строка с вычеркнутыми символами,
текущая продукция, а также текущее состояние сентенциальной формы.
В конце синтаксического анализа все знаки входной строки зачеркнуты,
а сентенциальная форма соответствует исходной заданной строке.
Таблица 4, L
Входная строка
Сентенциальная форма
тхуу XY
шхуу xXY
юшуу xxXY
Продукция
хххуу
Х-*хХ
Подобным образом на третьем шаге порождения должна использоваться
та же продукция. В результате получим следующее.
xxXY Четвертый шаг
имеет вид
xxXY=^ xxxY
Видим, что, поскольку требуется сгенерировать последний х, в первый
раз используется продукция
82
На пятом шаге
xxxY=>xxxyY используется
следующая продукция.
Глава 4. НИСХОДЯЩИЙ синтаксический анализ
тщ/у XKX Y
xxxyY
Х ~ »х Х
X -> хХ
Y~>yY
у _»у хххуу
На каждом этапе первый незачсркнутый символ входной строки определяется как входной символ и используется для разбора. Если в сентенциальной форме генерируется терминал, появляется еще один зачеркнутый символ. По определению, символ предпросмотра (lookahead
4.2, Критерии принятия решений
83
r symbol) — это либо текущий входной символ, либо маркер конца
У (специальный символ, стоящий в конце строки; обычно обозначается как
\ 1). Принятие решений при нисходящем синтаксическом разборе, как
Правило, основывается на символе (или последовательности символов)
предпросмотра. Кроме того, существуют более общие методы, в которых
учитывается история синтаксического анализа.
4.3. Щ1)-грамматики
В данном разделе рассматриваются свойства грамматик, поддерживающих методы нисходящего синтаксического анализа с одним символом
предпросмотра. Будем считать, что грамматики являются однозначными,
так что каждому предложению языка соответствует единственное левое
порождение. В данном случае для каждого нетерминала, который находится в левой части нескольких продукций, необходимо найти такие непересекающиеся множества символов предпросмотра, чтобы каждое
множество содержало символы, соответствующие точно одной возможной правой части. Выбор конкретной продукции для замены данного нетерминала будет определяться символом предпросмотра и множеством, к
которому принадлежит данный символ. Объединение различных непересекающихся множеств для заданного нетерминала не обязательно должно
составлять алфавит, на котором определен язык. Если символ предпросмотра не принадлежит ни одному из непересекающихся множеств,
можно сделать вывод о наличии синтаксической ошибки.
Множество символов предпросмотра, соотнесенных с применением
определенной продукции, называется ее ^
d
i
J
П
еред определением данного понятия,
определим вначале следующие два.
1. Стартовый символ данного нетерминала определяется как любой сим
вол (например, терминал), который может появиться в начале строки,
генерируемой нетерминалом.
2. Символ-последователь данного нетерминала определяется как любой
символ (терминал или нетерминал), который может следовать за нетерминалом в любой сентенциальной форме.
Вычисление множества стартовых символов может быть достаточно трудоемким и вычислительно сложным процессом, и оно всегда выполняется в
процессе генерации программы синтаксического анализа, а не при каждом
запуске, этой программы. Впрочем, возможны ситуации когда упомянутые
вычисления относительно просты. Пусть, например, (используется принятая
ранее договоренность об обозначении терминалов и нетерминалов) для нетерминала Т грамматика содержит только две продукции.
Т-> aG
В этом случае имеем следующие множества стартовых символов.
Продукция Множество стартовых символов
T-*aG
{a}
T-ibG
{b}
В общем случае, если продукция начинается с терминала, ее множество
стартовых символов просто состоит из этого терминала. В то же время,
если продукция не начинается с терминала, для нее все равно нужно вычислить множество стартовых символов. Пусть в рассматриваемой грамматике имеются следующие продукции для нетерминала R,
R-+BG
R-+CH
Тогда множество стартовых символов для этих продукций нельзя определить "с ходу". В то же время, пусть имеются только следующие продукции для нетерминала В.
Б-4 TV
Тогда можно заключить, что множеством стартовых символов для продукции
R-+BG будет
набор {а, Ь, с},
состоящий из всех стартовых символов для В.
Введение символа с в множество стартовых является очевидным (см.
первую продукцию для Б), а введение набора {a, to} объясняется тем, что
эти символы являются стартовыми для Г. В общем случае ситуация может быть значительно сложнее. Пусть имеется следующая последовательность продукций.
А -> ВО, В --» DE; D ~~> FG; F-» HI; H-> xY
Из данных продукций следует, в частности, что х является стартовым
символом для продукции
А-^ВС
Еще одним источником сложностей можно назвать нетерминалы, которые могут генерировать пустые строки. Допустим, имеются следующие
продукции.
А-^ВС
В-it
В этом случае множество стартовых символов для продукции А —» ВС будет включать стартовые символы С, а также стартовые символы Б
(определяемые не приведенными здесь продукциями). Если оба нетерминала В и С могут генерировать пустые строки, то на использование
т-> bG
Глава 4, НИСХОДЯЩИЙ синтаксический анализ
^
■ 85
данной продукции будут указывать символы предпросмотра, являющиеся
последователями А и стартовыми символами ВС. Множество первых порождаемьос символов продукции выбирается как множество всех терминалов, которые, выступая как символы предпросмотра, указывают на использование данной продукции. Таким образом, множество первых порождаемых символов для продукции
А~>ВС будет включать все символы-последователи А, а также стартовые
символы ВС.
Рассмотрим грамматику со следующими продукциями.
Т->АВ
aA
А —> е
В-»е
Ниже приводятся множества первых порождаемых символов для различных продукций дайной грамматики.
Продукция Множество первых порождаемых символов
Г-» АВ
(а, Ь, у) Т-> sT
Ы
[Ь, у)
{Щ
{у}
Мно жество пер в ых поро ждаем ых символ ов дл я про ду кции В ~> е р ав но
{у}, поскольку у может следовать за В; подобным образом, множество
первых порождаемых символов для продукции А -»е равно {Ь, у], поскольку b u y могут следовать за А, если В генерирует пустую строку.
Существует алгоритм (см. раздел дополнительной литературы в конце 1
главы) поиска множеств первых порождаемых символов для всех продук -]
ций грамматики. Сложност ь этого алгоритма, в основном, связана с тем, I
что символы могут генерировать пустые строки; в данной книге этот ал г о р итм пр ив о дить ся не бу дет. П о сл е в ычисл ения в сех м но ж еств пер в ых
порождаемых символов их можно проверить на предмет пересечения.
ЬШУгшмматику можно определить как гршметику^^котлрай-дла-каЖг
W^^^^--^S2^^i^!^~^^^ R21~^tx
П ОД
Р УКЦИЙ>
в
которых
полщае_тся_ этот нетермгаад^тадан^
Термин
СЦ1) имеет~сле"дуюп1ёё"происхождение; пёрвоеТГозначает чтение слева
(left) направо, второе L означает использование левых (leftmost) порождений, а 1 — один символ предпросмотра.
86
Глава 4. НИСХОДЯЩИЙ синтаксический анализ
Описанная выше грамматика, очевидно, является 1Х(1)-грамматикой,
поскольку множества символов предпросмотра для Г, Л и 8 не пересекается. 1Х(1)-грамматики формируют основу методов нисходящего анализа, описываемых в данной главе. Если вычислены все множества первых
порождаемых символов для всех возможных правых частей продукций, то
языки, которые описываются 1А(1)-грамматикой, всегда анализируются
детерминировано, т.е. без необходимости отменять продукцию после ее
применения. Существуют более распространенные классы грамматик,
которые могут использоваться для детерминированного нисходящего
анализа, но обычно используются именно 1Х(1)-грамматики. Недетерминированный нисходящий анализ, основанный на откате (backtracking),
уже не считается эффективной процедурой, хотя в начале эры компиляторов он широко использовался в языках, подобных FORTRAN. Грамматики LL(/c), требующие к символов предпросмотра для различения альтернативных правых частей, также уже не считаются практичными с точки
зрения синтаксического анализа.
JffiLJJHMi^5IS!EMEM2
?£S!SS?
!9P?fl59M
1 ^ £
^
_
любого ПГ(1)~языка возможен
й
й
^
нисходящий синтаксический анализ смотра. Рассмотрим некоторые
"теоретические" результаты, связанные с ЦЦ1)-грамматиками и языками,
после чего перейдем к более практическим вопросам реализации.
Во-первых, как было сказано выше, существует алгоритм определения, относится ли данная грамматика к классу LL(1), поэтому грамматику можно проверить на "1Х(1)-ность" прежде, чем создавать на ее
основе программу синтаксического анализа. В то же время, что может
несколько удивить на первый взгляд, не существует алгоритма определения, относится ли данный язык к классу LL(1), т.е. имеет он LL(1)~
грамматику или нет. Это означает, что не-Ы,(1)-грамматика может
иметь или не иметь эквивалентную LL(1), генерирующую тот же язык,
и не существует алгоритма, который для данной произвольной грамматики определит, является ли генерируемый ею язык LL(1) или нет. Разумеется, существуют алгоритмы, которые могут использоваться для частных случаев, например, если грамматика является LL(1) — язык также является LL(1); также можно выделить определенные классы
грамматик, которые никогда не будут генерировать 1Х(1)-языки. В то
же время, в общем случае задача является неразрешимой в том же
смысле, как неразрешимы задача определения однозначности языка и
проблема остановки для машин Тьюринга.
Приведенный выше результат является важным, поскольку далее будет показано, что имеются грамматики, не являющиеся LL(1), которые,
тем не менее, генерируют 1Х(1)-языки, т.е. грамматики имеют эквивалентные 1Х(1)-грамматики. Это означает, что грамматики часто нужно
преобразовывать, прежде чем использовать с методами нисходящего синтаксического анализа. Фактически, грамматики, которые обычно используются в определениях языков или в учебниках, редко являются LL(1) и,
4.3. Щ1)-грамматики
87
следовательно, не могут непосредственно использоваться для эффективного нисходящего анализа. Тем больше оснований сожалеть, что не существует алгоритма определения, имеет ли грамматика эквивалентную
LL(1), а это означает, что в любом случае не существует алгоритма поиска
эквивалентной 1Х(1)-грамматики, если даже такая грамматика и существует. Впрочем, в этом несовершенном мире иногда приходится иметь дело
с несовершенными алгоритмами, так что, если нет алгоритма выполнения
преобразования в общем случае (т.е. для всех случаев), имеются
алгоритмы, работающие для большого числа групп частных случаев, которые никогда не дают неверного результата. В то же время эти алгоритмы
иногда могут зацикливаться.
Не стоит пугаться, что существует одно свойство грамматики, которое
(если оно присутствует) препятствует тому, чтобы грамматика была
LL(1), и это — левая рекурсия. Рассмотрим следующие продукции.
D —> Dx
D~^ у
/ Обозначив через DS множество __
symbol set), можем записать следующее:
fU Dx) = {у}
Здесь второе множество первых порождаемых символов следует непосредственно из продукции, а первое следует из того, что D является стартовым символом правой части. Очевидно, что никакая граммат ика,
имеющая подобную левую рекурсию, не может быть LL(1). Предположим, впрочем, что для D имеется единственная продукция.
D->Dx
В этом случае, разумеется, в алгоритме по определению принадлежности к
классу LL(1) вообще не будут найдены первые порождаемые символы для D.
Использование данной продукции никогда не даст ни одной строки терминалов, поскольку не существует способа избавиться от нетерминала D, если
таковой появится в сентенциальной форме. Грамматика с продукциями, которые не могут использоваться или (по каким-то причинам) не являются необходимыми, часто называется нечистой (unclean); далее предполагается, что
все рассматриваемые грамматики являются "чистыми".
Левая рекурсия может быть непрямой, включающей две или более
продукции. Рассмотрим, например, следующий набор продукций.
А -» ВС В»0£
D->FG F+AH
Здесь имеет место непрямая левая рекурсия, в которой задействованы нетерминалы А, В, D и F. Разумеется, должны существовать нерекурсивные правила, по крайней мере, для некоторых нетерминалов, гарантирующие чистоту
грамматики. Как и для прямой левой рекурсии, любая грамматика, имеющая
непрямую левую рекурсию, будет характеризоваться пересекающимися мно
жествами первых порождаемых символов для некоторых нетерминалов и, ?=&"
следовательно, такая грамматика не может быть LL(1). Шш^т^щцз-МШ^/ и
KVE££ffiH?"t грямматшга-цд ЯИ>Щ£1££-ЬШ)..Это не представляет такой уж серь-/*
езной проблемы, как может показаться на первый взгляд, поскольку можжЛ л»
показать (см. раздел 4.5), что все левые рекурсии грамматики можно заме- )^
нить правыми, не затронув генерируемый язык.
(
Преобразования грамматик, выполняемые автоматически или вручную, являются неотъемлемой частью ЬЦ1)-анализа и, как говорят многие, одним из его ограничений. Впрочем, при наличии соответствующей
грамматики написание программы синтаксического анализа является
простой задачей. Кто-то может даже сказать, что написать эту программу
можно с той же скоростью, с которой вы набираете на клавиатуре, используя при этом известный метод рекурсивного спуска, который будет
описан в следующем ниже разделе.
4.4. Рекурсивный спуск
Синтаксический анализ методом
ий анализ методом £ек^£си^що_£п^а^^ксишче_йс&сспх)^
включает использование рекурсивных процедур и работает нисходящим
образом — отсюда и название! Предположим, например, что в грамматике
языка программирования имеется символ предложения PROGRAM И
единственное правило с символом предложения с левой стороны.
PROGRAM-* begin DECUSTcomma STATEUSTend Как обычно, слова из
прописных букв представляют нетерминальные символы, а слова из
строчных букв — терминальные символы. Использование синтаксического
анализа методом рекурсивного спуска состоит из последовательного
определения всех символов правой части. Появление слова begin
определяется посредством вызова лексического анализатора, DECUST
определяется вызовом функции (для удобства названной DE- CLIST ),
comma определяется непосредственно, с помощью лексического
анализатора, STATEUST определяется посредством вызова функции,
именуемой STATELIST , наконец, end определяется непосредственно,
снова с помощью лексического анализатора.
Пусть другие продукции грамматики имеют следующий вид,
DECLIST-* d semi DECUST d
STATELIST~¥ s semi STATELIST s
Здесь d можно рассматривать как объявление (declaration), as — как оператор (statement), но на данный момент оба символа считаются просто
терминалами.
Метод рекурсивного спуска может применяться только к LL( 1 )грамматикам, но данная грамматика, очевидно, такой не является, поскольку
Глава 4. Нжхоятим синтаксический анализ 4,4, Рекурсивный спуск
89
ir
DS(DECUST-> dsemi DECUST} = {d)
DS(DECLIST -»d) = {$
Данные множества не являются непересекающимися; то же можно сказать и
для продукций с нетерминалом STATELIST. Итак, данную грамматику требуется преобразовать. Для выполнения необходимого преобразования продукций для нетерминала DECLIST вначале следует отметить, что данные две
продукции генерируют последовательности следующего вида:
d
d semi d
d semi d semi d
и т.д., причем последний терминал d генерируется вторым правилом для
нетерминала DECLIST, а остальные — первым. Полное множество подобных последовательностей можно записать как регулярное выражение.
d(semi d)
Каждое предложение можно рассматривать как начинающееся с ё, за которым следует либо пустая строка, либо символ semi, за которым идет
что угодно, составляющее DECLIST. Следовательно, две продукции можем переписать в следующем виде.
Чтобы показать, что данная грамматика относится к классу LL(1), достаточно рассмотреть множества первых порождаемых символов (DS) для
двух продукции X и двух продукций У. Для продукций, имеющих в левой
части нетерминал X, имеем следующее.
semi DECLIST) - {semi) e)
= {comma)
Последнее множество определено путем рассмотрения последователей X.
Терминал comma является последователем DECLIST, что видно из следующего выражения.
PROGRAM-* begin DECLIST comma STATEUST end
В то же время любой последователь DECLIST является последователем X,
что следует из продукции
DECLIST-¥ dX
Таким образом, comma является последователем X, а значит, первым порождаемым символом продукции
Подобным образом
DECLIST-* dX
X -» semi DECLIST
e
DS(Y~~* semi STATEUST} = {semi}
Итак, в грамматике появился новый нетерминал X.
Подобным образом можно переписать продукции для STATELIST.
STATEUST-* sY
_
„,.
Y-* semi&ectfST si ATE LIS i
E
Получили еще один новый нетерминал — Y.
Преобразования грамматики не являются совсем очевидными. Простейшим путем их определения является рассмотрение языка, генерируемого продукциями, подлежащими преобразованию, как это было сделано
выше. В то же время данный тип преобразования является настолько
общим, что его легко определить и. выполнить вручную или автоматически. Данный процесс часто нюыьшшся_факторитцией, по аналогии с солезно рассматривать как некоторую алгебру с присущими ей правилами
преобразования, не затрагивающими язык в целом.
Перечислим продукции преобразованной грамматики.
PROGRAM-* begin DECLIST comma STATELIST end
DECLIST ~» dX X -* semi DECLIST
e STATELIST+sY
90
Y-* semi STATELIST e
поскольку end является последователем STATELIST, что ввдно из продукции
PROGRAM -» begin DECUST comma STA TELIST end
а любой последователь STATELIST является последователем Y, что следует из продукции
STATELIST-* sY
Таким образом, в каждом случае оба множества первых порождаемых символов являются непересекающимися, и грамматика относится к классу LL(1).
Ниже приводятся функции для каждого нетерминала грамматикиPROGRAM, DECUST, X, STATELIST, Y, Код написан на языке С, но
можно использовать любой другой язык, разрешающий применять рекурсивные функции.
void PROGRAM!) /*соответствует PROGRAM*/ {
if (token! » begin)
error{);
token = lexical(),•
DECLIST();
if,(token! = comma)
error();
token = lexical();
STATELIST();
if (token! = end)
Глава 4. Нисходящий синтаксический анализ 4.4. Рекурсивный спуск
С им должны предшествовать следующие прототипы функций (это по зволит применять функции до их объявления).
error():
void DECLIST();
void STATELISTO ;
void X () ; void Y{)
;
void DECLIST() {
if (token! = d)
error();
token = lexical();
XO;
В продукциях грамматики присутствует (хотя и неявно) рекурсия,
следовательно, она имеется и в программе синтаксического анализа. Разумеется, если ни одна из продукций не содержит рекурсии, генерируемый язык будет крайне офаниченным и будет состоять только из конечного числа предложений. В то же время рекурсия в программе синтаксического анализа может быть дорогой, и ее можно избежать следующим
образом. Продукции грамматики следует переписать с использованием
расширенной формы записи, которая включает знак * с его обычным
значением (нуль или более вхождений предшествующего элемента). Затем продукции могут записываться следующим образом.
void X{)
{ if (token == semi)
{ token = lexical 0;
DECLIST{);
}
else if (token == coirnia)
; /*ничего не делать*/
else error();
PROGRAM-* begin DECLIST comma STATELIST end
DECLIST -> d (semi d) * STATELIST-* s (semis)"
void STATELISTO {
if (token! = s)
error();
token = lexical
Данное представление грамматики компактнее и, пожалуй, читабельнее, С,
чем приводившееся ранее. Использованная форма записи иногда называ- /
ется расширенной формой Бэкуса-Наура, исходная форма записи экви- "*"
валентна форме Бэкуса-Наура, изначально использованной для определения
языка ALGOL 60.
Программа синтаксического анализа, созданная на основе приведен-
void Y()
{ if (token == semi)
{
token = lexical( ) ;
ных продукций,^iML£SI}S2t^2SS--^^~iiS^SSI^BiS^3J^SB! in- Функции main () и'PROGRAM О аналогичны приведенным выше, а функции
DECLIST () и STATELISTO можно переписать следующим образом.
Функции х и Y не нужны.
STATELIST();
}
else if (token == end)
; /*ничего не делать*/
else error();
void DECLISTО {
if (token! = d)
e r r or ( ) ;
token = lexical();
while (token == semi)
(token = lexical(};
if (token! = d)
error ();
token = lexical О;
main ()
{ token = lexical ();
PROGRAM();
}
Вызов lexical () вынуждает лексический анализатор передать следующий символ синтаксическому анализатору, a error () вызывается
при появлении синтаксической ошибки. Предполагается, что при вызове
функции error () инициируются определенные процедуры восстановления после ошибок (здесь не конкретизируются), semi, comma, begin и
end являются предварительно заданными константами, значениями которых являются представления данных символов этапа, следующего за
лексическим анализом.
Порядок, в котором записаны функции, соответствует порядку продукций грамматики. Для компиляции посредством компилятора Borland
92
Глава 4, Нисходящий синтаксический анализ
void STATELISTO {
if (token! = s)
error();
token = lexical О;
while (token == semi)
44~
Рекурсивный спуск
93
{token = lexical();
if (token! s s)
error {);
token = lexical();
Здесь lexical!), error( ) , semi, comma, begin и end имеют то же
значение, что и ранее.
С помощью описанного способа правую рекурсию всегда можно
превратить в итерацию; кроме того, данный процесс можно автоматизировать. Левая рекурсия не может появляться в 1Х(1)-грамматике и,
как было показано выше, ее можно преобразовать в правую рекурсию. Таким образом, правую и левую рекурсии можно рассматривать
как итеративные, а не рекурсивные процедуры, поэтому программу
синтаксического анализа создать возможно всегда.
В то же время грамматики для языка 2-го (но не 3-го) типа будут содержать среднюю рекурсию (например, для сопоставления с шаблоном),
и это нельзя (точнее, нельзя легким способом) заменить итерацией. Рассмотрим, например, грамматику для выражений со следующими продукциями, в которых терминалы заключены в кавычки, чтобы не возникало
путаницы между терминалами "*", "(" и ")" и теми же знаками, использованными как метасимволы.
Е'+"Т
Т
Т-* r*"F T>F F-» "{"£"}"
F»» "*"
Данную грамматику можно преобразовать к виду LL(1).
£-» ТХ
Впрочем, остается (непрямая) средняя рекурсия, в которой фигурируют Е, Т и F, устранить которую нельзя. Функции рекурсивного спуска
соответствуют (что неудивительно) также продукциям, содержащим не
только итерацию, но и рекурсию (см. продукции для Е и 7). Следующая
ниже реализация основана на приведенных выше продукциях, дополненных продукцией, которая вводит символ предложения, не появляющийся
в правой части ни одной продукции.
S-+E Е-*
7|"+"7)*
"("£")"
функции имеют следующий вид.
void E() С
Т () ;
while (token == plus)
{token = lexical(),■
}
void TO
{ F () ;
while (token == times)
{token = lexical О;
}
void F{)
{ if (token == obracket)
{token=lexical();
if (token == cbracket)
token=lexical();
else error(|;
else if (token == x)
token = lexical(); else
error();
Заменяя, где это возможно, рекурсию итерацией, получаем следующее.
main ()
{ token = lexical I
->«(-£■)-
94
Глава 4. Нисходящий синтаксический анализ
4-4. Рекурсивный спуск
95
Здесь plus, times, obracket, cbracket их — представления на этапе,
следующем за анализом, символов + , * , ( , ) и х соответственно. Как и ранее, для компиляции функции в начале кода должны находиться прототипы ДЛЯ Е, F, Т.
Одним способом реализации эффекта средней рекурсии яьляется введение в программу синтаксического анализа явного стека, который можно использовать для хранения адресов возврата для входа и выхода функции. Данный подход, похоже, эффективнее, чем более общий механизм
обработки рекурсии, предлагаемый высокоуровневыми языками программирования.
4.5. Преобразования грамматик
Одним из основных ограничений анализа методом рекурсивного спуска,
как и других методов ЬЦ1)-анализа, является необходимость преобразо■ вания грамматики. При этом применяются два типа преобразования.
1. Удаление левой рекурсии.
2. Факторизация.
4.5.1. ¥даление левой рекурсии
Левую рекурсию, как показывалось ранее, всегда мощш_щщщ1ь-из-1ащ;
jreKCXugsCBiiQggwuj^ytmaxiducuu В то же время этот процесс следует проводить аккуратноТТт^скшьк^тт^и этом изменяются значения строк, генерируемых изменяемыми продукциями. Например, требуется преобразовать левые рекурсии следующих продукций в правые.
Е->£ + Т
Е-> Т
Может показаться, что данные продукции следует изменить таким образом.
Е- » Т+Е
Е~» Г
При этом генерируемые строки затронуты не будут. Действительно, это может быть все, что требуется, В то же время, если значение строк важно
(например, при создании компилятора или нахождении значения. выражения), то приведенная выше леворекурсивная форма генерирует предложение
т+т+т+т
следующим образом.
£=> Е+ Т=> Е+ 7 + Т=> Е+ Г+ 7+ Т=> Т+ Т+ Г+ Т В данном случае
подразумевается вычисление выражения слева направо, что ниже
представлено посредством скобок.
{(((Т+ 7) + 7) + Т) + Т)
96
'4.1
g то же время праворекурсивная форма генерирует выражение следующим образом.
£=> 7+ £=> 7+ 7+ £=> 7+ 7+ 7+ Е=> 7+ 7+ 7+ 7
Здесь имеет место вычисление выражения справа налево.
Влияет ли порядок вычисления выражения на его значение, зависит
от значения оператора +; для арифметического оператора + (по крайней
мере, для целых чисел) порядок вычисления значения не имеет. Впрочем, компилятор обычно определяет конкретный порядок вычисления
арифметических выражений, который, скорее всего, является простейшим и легчайшим в реализации. Считается, что в большинстве случаев
проще реализовать вычисление слева направо, так что из приведенных
выше продукций предпочтительнее леворекурсивные (по крайней мере, с
указанной точки зрения).
Пожалуй, стоит сказать, что в тех случаях, когда невозможно подвести
вычисление выражения слева направо под приведенные выше рекурсивные правила, реализовывать это будет неудобно и неестественно, поэтому следует что-либо изменить, дабы избежать такой ситуации.
Что действительно требуется — так это праворекурсивная грамматика,
подразумевающая вычисление слева направо, и вот здесь находит применение использованное р°.нее преобразование, поскольку правила
Е-> ТХ
Х-»е
являются праворекурсивными и предполагают вычисление слева направо.
Пусть генерируется следующее выражение.
Т+Т+Т+ Т Тогда
порождение
£=> ТХ=> Т+ ТХ=> Т+ 7+ ТХ=> 7 + Т+ Т+ ГХ=> Г+ Т+ Т+ Т
предполагает следующую расстановку скобок.
( ( «7 + 7 ) + 7 ) + 7 ) + 7 ) К сожалению,
преобразование продукций
£-> Е+ Т Е»Т
в продукции
ТХ +
ТХ
может показаться не слишком естественным. Очевидно, что оно не на столько просто, как обращение порядка символов в первой продукции. В
Глава 4. Нисходящий синтаксический анализ 4.5. Преобразования грамматик
97
то же время уже отмечалось, что данное преобразование не будет сложным, если рассматривать его с точки зрения языка, генерируемого правилами грамматики. В общем случае правила
Р->Ра
это преобразование называется факторизацией, а его иллюстрация приводится ниже. Рассмотрим грамматику со следующими продукциями.
Р->аРЬ
Р-* аРс
Р-*Ь
генерируют язык
Ьа* Данные правила также могут генерироваться следующими
продукциями.
Очевидно, что данная грамматика не является LL(1), поскольку две первых продукции в качестве первого порождаемого символа имеют а. Проблема устраняется путем преобразования данных продукций в такие.
Р-*ЬХ
Полученный результат легко обобщить. Пусть имеется множество леворекурсивных продукций для нетерминала Р и множество продукций для
Р, которые не являются леворекурсивными.
Р«,
Здесь символы 3 не содержат Р. Данные продукции генерируют следующее.
(Pi I Ра | Эз I ... | Эт)<«1 | оег I «з! ... I се„)*
Данный язык можно также сгенерировать следующими группами про дукций.
р-» ptZ, p~> p2z, р~> p3z,.... р p
Z~» ой, Z-» аг, Z—> аз, .... Z—> ап Z-»
<x%Z, Z-» azZ, Z-~» (X3Z,..,, Z—> anZ
Здесь Z— новый нетерминал, а продукции стали праворекурсивными. Следует
отметить, что более общим, чем рассмотренный, случаем является непрямая
рекурсия. Алгоритм устранения непрямой левой рекурсии заключается в
первоначальной замене непрямой левой рекурсии прямой левой рекурсией (это
можно сделать всегда, но соответствующий алгоритм мы приводить не будем),
после
чего
задача
сводится
к
рассмотренной
выше.
Ц
^JЭcilOlЩЫ.м_ДJ|ЩвsдeJ^нди_в>шi£^)бcy^aeнии является наличие алгоритма
удаления левой рекурсии из грамматики и замены ее правой рекурсией и то, •то
данный процесс можно автоматизировать, т.е. поручить программе. Использование программы делает процесс более надежным, менее подверженным
человеческим ошибкам. Разумеется, не следует забывать о возможности
совершить ошибку в самой программе преобразования, но частое использование
программы должно подтвердить ее правильность.
4,5.2,
\/ £
Перейдем ко второму типу
котором^ должны подвергаться продукции
превращения ее в LL(1). Как говорилось выше,
преобразований,
грамматики для
Здесь было факторизовано аР, и появился новый нетерминал X. В качестве другого примера рассмотрим продукции
Р-» abQ
Их можно преобразовать в следующие продукции.
Р~¥вХ
X->cR
Процесс выглядит достаточно простым, так что может показаться, что
произвести его можно всегда и что существует алгоритм преобразования
грамматики, требующей факторизации, в 1Х(1)-грамматику. Впрочем, из
следующего примера видно, что это не так. Рассмотрим продукции
Р~4 Qx
Р-4 Ry
sQm
Данная грамматика не является LL(I), поскольку первые две продукции в качестве первого порождаемого символа имеют s. Перед тем,
как станет возможной факторизация, нетерминалы О и R в первых
Двух продукциях нужно заменить, используя последние четыре продукции. Таким образом, вместо первых двух продукций получаем следующее.
Р -»sQrnx
P~>qx
P~>sRny
Р-цу
Полученные продукции можно факторизовать, что дает такой результат.
Глава 4. Нисходящий синтаксический анализ 4.5, Преобразования грамматик
99
текстно-свободные их аспекты) относятся к классу LL(1), следовательно,
поддаются анализу методом рекурсивного спуска. Проблема заключается
в том, что грамматики, используемые для представления языков программирования, не относятся к классу LL(1), так что перед разработкой
программ синтаксического анализа методом рекурсивного спуска обычно
необходимы преобразования грамматики. В следующем разделе этот вопрос рассматривается полнее, также обсуждаются другие достоинства и
недостатки синтаксического анализа LL{1).
P-4QX
P->ry
Здесь
Pi ~> Qmx
Pi -> Ялу Перечислим все полученные
продукции.
P~>sPi Р-¥
4.6. Достоинства и недостатки Щ1)-анализа
qx
Р% -> Qmx
Pi -» Япу
Q -> sQm
Q-»q
R-» sFtn
Данные продукции по-прежнему не дают 1Х(1)-грамматики, поскольку
обе продукции для Р{ в качестве первого порождаемого символа имеют s.
Проблема идентична первоначальной, так что можем попытаться
продолжить аналогичным образом. Для замены нетерминалов Q и R будут
использованы четыре последние продукции, результат будет фактори-зован
введением новой переменной Рг — и все это только для того, чтобы
обнаружить в точности ту же проблему с Р 2, которая ранее была связана с
Р и Р,. Процесс не прекратится никогда, но грамматика будет
становиться все больше и больше. В разделе 4.3 уже отмечалось, что не
существует алгоритма преобразования любой грамматики для LL(1)языка в форму LL(1). Таким образом, неудивительно, что факторизация
возможна не всегда, даже если язык является LL(1). Кстати, то, что алгоритм зацикливается и не дает 1Х(1)-грамматики, еще ничего не говорит о
языке — является он LL(1) или нет.
Язык, генерируемый приведенной выше грамматикой, не является
LL(1) и его можно выразить в следующем виде.
{s'qm'x | s'rriy]
Данный язык нельзя проанализировать нисходящим образом, слева направо, поскольку при прочтении символа s невозможно определить, появится далее такое же число символов т или символов л, не используя
неопределенное число символов предпросмотра, чтобы обнаружить следующий за s символ q или г.
Приведенный выше пример был искусственным, подобные свойства
маловероятны в реальных языках программирования. Например, в языках
программирования при наличии открывающей скобки обычно имеется
единственный символ, представляющий соответствующую закрывающую
скобку. Большинство языков программирования (точнее, конГлава 4. Нисходящий синтаксический анализ
100
Причина привлекательности синтаксического анализа LL(1) заключается
в его естественности, данный метод является крайне наглядным и удобным для создания основы для последующей компиляции языка программирования. Кроме того, его легко реализовать и убедиться в корректности
его работы. Помимо выполнения собственно синтаксического анализа
написанный код может содержать функции по выполнению проверки
соответствия типов и других проверок, а также действия этапа синтеза,
такие как распределения памяти и генерация кода.
В то же время можно определить и некоторые недостатки синтаксического анализа методом рекурсивного спуска, такие как неэффективность
вызовов функций и необходимость преобразования грамматики, дажг не
зная, существует ли подходящее преобразование. Проблема заключается
не только в нахождении преобразования, но и в проверке корректности
его применения. Таким образом, имеются веские причины использовать
при преобразовании надежные инструментальные средства, а не зависеть
от ручного подхода. Среди других недостатков синтаксического анализа
методом рекурсивного спуска можно выделить следующие.
• Часто создаются очень большие программы синтаксического анализа.
• Существует тенденция к появлению в теле одной функции опера
ций, относящихся к разным фазам процесса компиляции.
К сожалению, последняя особенность не сильно улучшает общую структуру компилятора.
Для эффективного использования рекурсивного спуска требуется следующее.
• Хороший преобразователь грамматики, который в большинстве слу
чаев сможет трансформировать грамматику в форму LL(1) — ранее
показывалось, что, по теоретическим причинам, преобразователь
не сможет выполнить свою работу для всех возможных входов.
• Возможность представить эквивалент программы синтаксического
анализа методом рекурсивного спуска в табличной форме. Это оз
начает, что при проверке входного текста программа будет не вхо
дить в функции и покидать их, а просто перемещаться по таблич4.6. Достоинства и недостатки ЩЦ-гналюа
101
ному эквиваленту грамматики, при необходимости занося в стек
адреса возврата.
Хорошие преобразователи существуют и временами объединяются
с инструментальными средствами и дают "таблицы" LL(1). Кроме
того, те же инструменты могут позволять пользователям определять
(причем в исходной грамматике) операции, которые следует выполнять на определенных этапах синтаксического анализа. Существенным преимуществом является задание операций именно относительно исходной, а не преобразованной, грамматики, поскольку пользователю удобнее мыслить понятиями исходной, более естественной
грамматики, чем понятиями менее естественной 1Х(1)-грамматики,
порожденной преобразователем. В частности, если в преобразованной
грамматике будет отсутствовать левая рекурсия, она может быть в исходной грамматике, обеспечивая, таким образом, более естественную
основу для определения операций времени компиляции, таких как
генерация кода для вычисления выражений слева направо. На прак тике неестественная природа преобразованной грамматики никоим
образом не должна существенно мешать создателю компилятора. В
следующем разделе показывается, как в грамматике можно опреде лить операции по выполнению действий во время компиляции.
4,7. Введете действий в грамматику
Классический способ анализа арифметических (и других) выражений перед генерацией машинного кода заключается в формировании постфиксной записи. В качестве примера постфиксной (иногда называемой об^THoftjo^bc^J^^rcvejseJJolish)] формы записи рассмотрим следующее
(инфиксное) выражение.
(а + Щ * (с + df
В постфиксной форме данное выражение имеет следующий вид.
ab + cd+ *
Отметим, что при такой форме записи отсутствуют скобки и понятие
приоритета оператора. Кроме того, если постфиксное выражение вычисляется слева направо, операнды каждого оператора известны до появления оператора. Эта особенность постфиксной формы записи делает ее
относительно простой для создания выходного кода.
Рассмотрим грамматику, имеющую следующие продукции.
S-*EXP EXP-^ TERM
ЕХР-*ЕХР + TERM
EXP-* EXP- TERM
TERM-} FACT TERM-*
TERM*PACT
102
TERM-* TERM/FACT FACT-* -FACT FACT-* VAR FACT-* (EXP) V A R * a \ b \ c \ d \ e В число выражений, генерируемых данной грамматикой, входят
следующие.
(а + Ь)*с
а*й + с
а*Ь + с*Фе
Далее будем предполагать существование среды, в которой действия,
введенные в грамматику, выполняются каждый раз, когда соответствующей частью грамматики генерируется код анализа. Для генерации постфиксных выражений в грамматику необходимо ввести три действия, которые обозначим через А1, А2 и -43.
S-tEXP
ЕХР-* TERM
EXP ~* EXP + <А f > TERM<A2>
EXP -» EXP - <A 1> TERM<A2>
" TERM-* FACT
TERM-* TERM* <A1>FACT<A2>
TERM--* TERM/<A1>FACT<A2>
FACT--* -<A1>FACT<A2>
FACT-* VAR<A3>
FACT-* (EXP)
VAR-*a\b\ c \ d \ e Здесь для обособления действий были
использованы угловые скобки.
Все операторы нужно занести в стек (действие <А1>) в том порядке, в
котором их можно напечатать в соответствующее время (действие <А2>).
С другой стороны, переменные (VAR) только читаются и печатаются
(действие <АЗ>). Иные действия отсутствуют. Стек можно определить
и инициализировать следующим образом.
char stack [3] ;
int ptr = 0;
Кроме того, определяется переменная, значение которой равно послед нему символу.
c h a r i n ; В результате получаем такие три
действия.
С stack[++ptr] = in;
}
<А2> {
[ptr—] ) ;
Глава 4. НИСХОДЯЩИЙ синтаксический анализ
printf("%c",
^- 7. Введение действий в грамматику
stack
103
<АЗ>
{
printf ("%C, in);
}
Действия кажутся удивительно простыми, и, если быть честными, ситуация была несколько упрощена, поскольку по использованному определению переменные состоят только из одного символа. На первый
взгляд, удивляет еще одно — действия не учитывают различные уровни
приоритетов возможных операторов. Впрочем, понятие приоритета считается внедренным в исходную грамматику, так что нет необходимости
что-либо знать о приоритете операторов или об использовании скобок.
Видим, что действия, связанные с продукциями, содержащими скобки,
отсутствуют.
Рассмотрим в качестве примера, как действия преобразуют следующее
выражение грамматики.
(-а + Ь)* (с + d)
Чтобы показать использование различных действий, полезно продемонстрировать эффект генерации приведенного выше выражения грамматикой, содержащей действия.
(~<А1>а<АЗ><А2> + <А1>Ь<АЗ><А2>)* <А1>(скАЗ> + <А1>СкАЗ><А2>)<А2>
Здесь показано, как при чтении строки в предложение вводятся действия. Результат действий можно представить в виде таблицы (табд. 4.2).
Таблица 4.2,
Считываемый символ Действие
А1
A3
А2 - (минус) b
А1
A3
А2
- (минус) а
Содержимое стека
Выход
- (минус)
А2
*+
*+
A3
А1
A3
Полный выход А2 представляет прочтение последнего столбца
сверху вниз, а А2 верх стека предполагается находящимся справа.
Достаточный
размер стека — три элемента, поскольку существует
всего три различных уровня приоритета операторов (унарный, аддитивный
и мультипликатив-
104
Глава 4. НИСХОДЯЩИЙ синтаксический анализ
ный). Большее число уровней приоритета операторов потребует большего
стека. Кроме того, при стеке произвольного размера может потребоваться
правая рекурсия!
Разумеется, алгоритм зависит от доступности средств генерации
синтаксических анализаторов, позволяющих создать код для чтения
входа и соответствующего выполнения действий. Впрочем, такие средства имеются, и они предлагают мощное средство написания программ
синтаксического анализа для чтения и выполнения действий над любым входом, который можно представить посредством контекстносвободной грамматики. Типичным примером такого входа является исходный код; операции, которые можно произвести над этим кодом,
разнообразны и их насчитывается множество — генерация выходного
кода, использование перекрестных ссылок, проведение различных измерений и т.д. Мы пытались показать, что многие операции, обычно
производимые над исходным кодом, простым и естественным образом
выражаются как действия в контекстно-свободной грамматике. Выразив их именно таким образом и используя подходящие инструментальные средства, можно значительно упростить создание компиляторов и
связанных с ними инструментальных средств. Становится не только
просто писать компиляторы, облегчается их понимание, а значит, компиляторы проще модифицировать, а также проще отследить, насколько
корректно они работают.
В следующих главах подробнее рассматриваются методы восходящего
синтаксического анализа, а также связанные с этим процессом инструментальные средства. В процессе рассмотрения станут понятнее выгоды
использования грамматики как основы действий времени компиляции.
4.8. Резюме
Данная глава посвящена нисходящему синтаксическому анализу. В частности, в ней было сделано следующее.
• Определены 1Х(1)-грамматики и языки.
• Показано, как на основе Щ1)-грамматик могут создаваться про
граммы синтаксического анализа методом рекурсивного спуска.
• Показано, как определенные грамматики можно привести к виду
LL(1), используя удаление левой рекурсии и факторизацию.
• Определены принципиальные преимущества и недостатки анализа
методом рекурсивного спуска.
• Показано, как в программу синтаксического анализа можно ввести
действия времени компиляции.
^.8. Резюме
105
Дополнительная литература
Терминология грамматик LL(1) вводится в книге [Knuth, 1971], а свойства таких грамматик — в книге [Foster, 1968]. Идея компиляция методом
рекурсивного спуска уходит корнями в 1960-е, и ее авторство приписывается Лукасу [Lucas, 1961]. Она широко использовалась в 1970-х для
создания переносимых компиляторов Pascal [Wirth, 1971]; пример компилятора для языка Pascal приводится в работе [Welsh and Hay, 1986].
Существует множество работ по компиляторам, созданных на основе
метода рекурсивного спуска, и среди них можно выделить [Ullmann,
1994]. Инструментальные средства синтаксического анализа методом рекурсивного спуска описаны в работе [Тепу, 1997]. Алгоритм, дающий ответ на вопрос принадлежности грамматики к классу LL(1), а также создающий для грамматики таблицу синтаксического анализа, описывается
в [Aho, Sethi and Ullman, 1985].
Упражнени!
а) {Уау" ( л > 0}
б) {х"ау"их"аг" | п >= 0}
в) {x"afu/'ay" j n >= 0}
(и — оператор объединения множеств)
4.4. Обсудите относительные преимущества и недостатки левой рекур
сии в грамматиках с точки зрения синтаксического анализа.
4.5. Рассмотрим два набора продукций.
б)
в) {а | а состоит из равного числа нулей и единиц}
4.7. Определите, относится ли грамматика со следующими продукциями
к классу LL(1). Ответ аргументируйте.
S-+AB
S->PQx
А-* ху
А-* т
В-*ЬС
С-^ЬС
С-4Е
Р-* £
4.1. Приведите пример грамматики, не относящейся к классу LL(1), но
генерирующей 1Х(1)-язык.
4.2. Объясните следующие факты.
а) Неоднозначная грамматика не может быть LL(1).
б) Грамматика, содержащая левую рекурсию, не может быть LL(1).
4.3. Покажите, что следующие языки относятся к классу LL(1), опреде
лив для каждого случая LL( 1 )-грамматику.
а)
4.6. Приведите 1Х(1)-грамматики для следующих языков.
а) {0па12"\п>0}
б) {а| а принадлежит {0, 1}* и не содержит двух последовательно
идущих единиц}
DECLIST-* (I semi DECLIST
DECLIST-* d
DECLIST -* DECLIST semi d
DECLIST-* d
Здесь S — символ предложения.
4.8. Опишите язык, генерируемый грамматикой из упражнения 4.7.
4.9. Преобразуйте грамматику со следующими ниже продукциями в
форму LL(1).
S -» ЕХР ЕХР~¥ TERM
ЕХР -» ЕХР + TERM
ЕХР-> ЕХР-TERM
TERM-* FACT TERM-^
TERM"'' FACT TERM-*
TERM/FACT FACT-* FACT FACT-* (EXP)
FACT-* VAR
VAR~}a\b\c\d\e
4.18. Покажите, где в преобразованной грамматике упражнения 4.9 появятся действия, определенные в разделе 4.7 для производства постфиксной записи.
и предложение
d semi d semi d
Для каждого набора продукций а) и б) определите, какой терминал
d порождается второй продукцией.
DECLIST-* d
II
106
1%
Глава 4. НИСХОДЯЩИЙ синтаксический анализ
Упражнения
107
Глава 5
Восходящий синтаксический
анализ
5.1. Вступление
:
,*i
!
В данной главе описывается восходящий синтаксический анализ и его
реализация. В частности, будут рассмотрены следующие вопросы.
• Этапы восходящего синтаксического анализа и критерии принятия
решений.
• Использование таблицы синтаксического анализа для направления
процесса синтаксического анализа.
• Ключевые свойства восходящего синтаксического анализа.
• Генератор синтаксических анализаторов, именуемый YACC, и использование данного средства для создания различных простых
синтаксических инструментов.
• Некоторые практические вопросы, связанные с использованием
YACC.
5.2, Основные понит»
Задача синтаксического анализа заключается в нахождении порождения
(если таковое существует) конкретного предложения, используя данную
грамматику. При восходящем синтаксическом анализе искомым обычно
является правое порождение, и далее будет показано, как этот анализ
может выполняться с помощью грамматики, представленной, в разделе 4.2, для иллюстрации левых порождений. Напомним, что язык
{/У | m, n > 0} генерируется следующими
порождениями.
'■'.
S~>XY
Х-^хХ
Как и ранее, рассматривается, как можно найти порождение (в данном
случае — правое) следующего предложения.
хххуу Искомое порождение
выглядит так.
S => XY => Ху У => Хуу => хХуу => ххХуу => хххуу
Отметим, что порождение проходит в столько же этапов, что и при левом
порождении, и каждая продукция используется такое же число раз, хотя
порядок использования продукций несколько отличается. Впрочем, при
восходящем синтаксическом анализе этапы порождения определяются не
в показанном, а в противоположном порядке, а правый синтаксический
анализ, который соответствует приведенному выше порождению, можно
записать в следующем виде.
хххуу => ххХуу => хХуу => Хуу => ХуУ =>XY=$S
На каждом этапе применяется продукция грамматики; правая часть продукции заменяется ее левой частью, которая состоит из одного символа.
Пусть предложение
ствие переноса включает удаление символа из исходной строки и занесение его в стек, а действие свертки — изменения на вершине стека и в
сентенциальной форме. В начале синтаксического анализа сентенциальная форма — это в точности читаемое предложение, а стек пуст; в конце
синтаксического анализа сентенциальная форма — это символ предложения, строка прочитана, а стек содержит только символ предложения.
•Таблица S.1.
Входная строка Стек Продукция Свнттцшпьнт форт Переноф)/свертка(Я)
хххуу
myy
mxyy
myy
myy
myy
myy
0№ff
хххуу
читается елею направо, тогда совсем не очевидно, почему первый х не заменяется X на первом этапе синтаксического анализа, используя соответствующую продукцию грамматики, или почему первый у подобным образом на
четвертом этапе не заменяется Y. Очевидно, что для выполнения синтаксического анализа требуется некоторая дополнительная информация, отличная от
предоставляемой продукциями грамматики. Далее процесс восходящего синтаксического анализа рассматривается более подробно.
При восходящем синтаксическом анализе правые части продукций не
распознаются, пока не будут полностью считаны, следовательно, существует
необходимость хранения частично распознанных правых частей продукций,
пока они не будут заменены соответствующими левыми частями. Для запоминания частично распознанных строк подходящей структурой является
стек. Таким образом, подробное описание процесса восходящего синтаксического анализа включает отображение содержимого этого стека, а также информации, указанной в связи с нисходящим синтаксическим анализом,
представленным в разделе 4.2. Этапы процесса синтаксического анализа могут рассматриваться как состоящие из двух типов действий.
1. Перемещение последнего считанного символа в стек — действие пере
носа (shift action).
2. Замена строки наверху стека посредством применения продукции
грамматики — действие свертки (reduce action).
Теперь можно перейти к более подробному рассмотрению представленного выше синтаксического анализа. Различные этапы показаны в
табл. 5.1. Вершина стека расположена справа, а в последнем столбце показано, когда применяется действие переноса (S) или свертки (Я). Дей-
110
Глава 5. ВОСХОДЯЩИЙ синтаксический анализ
жюшы
№№ff
mtuff
X
XX
XXX
xxX X->x
xX
X-*xX
X
X-¥xK
Xy
Xyy
XyY Y-*y
XY
Y-*yY
S
S~*XY
myy
myy
xxxyy
myy
xxXyy
xXyy
Xyy
xyy
xyy
XyY
XY
S
(S)
(Я)
(S)
(S)
(Я)
(Щ
(R)
Хотя в приведенном выше представлении явно показано каждое действие
синтаксического анализатора, в нем, указывается, но не объясняется, когда
должны применяться действия переноса и свертки и как производится выбор, если возможными являются несколько операций свертки. Необходимым
условием действия свертки является наличие правой части некоторой продукции на вершине стека, в противном случае производится перенос, и на
вершине стека появится следующий символ. В то же время, появление правой части продукции на вершине стека не является достаточным условием
для применения свертки. Проиллюстрируем данный момент: если на первых
этапах синтаксического анализа на вершине стека появляется х, он не сворачивается в X по неясным, на первый взгляд, причинам.
Кроме того, в строке символов на вершине стека возможно будут определены правые части более одной продукции, так что на определенном
этапе синтаксического анализа могут существовать две или более возможных свертки. Итак, если, в определенный момент кажутся возможными действия свертки и переноса, говорят, что имеет место конфликт
перенос/свертка (shift-reduce conflict). Если возможными кажутся несколько операций свертки, говорят, что имеет место конфликт свертка/свертка (reduce-reduce conflict). С целью выработки детерминированного метода синтаксического анализа могут применяться различные
стратегии разрешения названных конфликтов. На практике данные конфликты разрешаются с использованием следующей информации (один
пункт или оба).
5.2. Основные понятия
111
• Предшествующая история синтаксического анализа.
• Информация, полученная путем предпросмотра.
Как и при нисходящем синтаксическом анализе, для разрешения конфликтов обычно используется один символ предпросмотра. Кроме того, для
разрешения конфликтов может использоваться информация, касающаяся истории синтаксического анализа. В приведенном выше примере символом
предпросмотра, определяющим применение продукции
был у; подобным образом, символ предпросмотра 1 (маркер конца) определяет применение такой продукции:
Грамматика, все конфликты которой, возникающие при восходящем
синтаксическом анализе слева направо, могут быть разрешены с использованием фиксированного объема информации, касающейся уже проведенного анализа, и конечного числа символов предпросмотра, называется
LR(k)-грамматикой. Здесь L означает чтение слева (Left) направо, R —
правые порождения (/fightmost), a k обозначает количество символов
предпросмотра. Языком LR(k) называется язык, который можно сгенерировать посредством ЬИ(А;)-грамматики,
Если (довольно частая ситуация) требуется только один символ предпросмотра, грамматика и язык относятся к классу LR(1).
Проиллюстрируем синтаксический анализ на еще одном примере.
Пусть имеется грамматика со следующими продукциями.
1 £-» Е+Г
2
. £-> Г
.3 7-4 FF
.4 7-» F
.5 FH> (£)
.6
X
.
Здесь £ — символ предложения. Грамматика (как она есть) может использоваться в качестве основы для восходящего синтаксического анализа, и в
табл. 5.2 показан синтаксический анализ следующею предложения.
X + X + X* X
Первый х, помещаемый в стек, сворачивается в F, затем в Г, затем в
Е, тогда как второй и третий х сворачиваются в F, потом в Г, а четвертый
х — только в Р, Первый и второй символы х имеют одинаковые символы
предпросмотра, и различная их трактовка основана на предшествующей
истории синтаксического анализа. Для третьего и четвертого символов х
символы предпросмотра отличаются (* и 1, соответственно), и снова
имеем различную трактовку — и снова, основанную на истории синтаксического анализа. Критерий принятия решения относительно предпри112
Глава 5. Восходящий синтаксический анализ
нимаемого действия — переноса или свертки (или выбора из нескольких
возможных сверток) — может содержаться в таблице, называемой таблицей синтаксического анализа. Отложим пока вопрос формирования данной таблицы и рассмотрим, как она может использоваться для определения действий синтаксического анализатора. Таблица синтаксического
анализа формируется единожды, при создании компилятора, и с этого
момента используется для управления каждым синтаксическим анализом.
Поскольку таблица синтаксического анализа создается инструментальным средством, наподобие YACC, пользователю (и даже разработчику
компилятора) нет нужды понимать принципы ее формирования. Впрочем, некоторые вопросы ее использования все же стоит рассмотреть, что
и будет сделано ниже.
Таблица 5,2.
Входная строка
Стек Продукция Сентенциальная форма Перенос (S)/cBepna(R)
X + X + X X X + X + X* X f + X + X* X
X
T + x + x* x £ + x + x*x E + x + x* x £ + x + x* (S)
F
(Я)
x £ + F + x* x £ + Г + x* x
(Я)
Т £->£+T
(Я
£
Е
)(S)
F~»x
E+
(S)
(S)
£+Px
(S)
E+x
£+F
Е+Г
£+?
Е+Гх
E + rF
E+T
E
Е+Гх
Е+Гх
Е+Гх
£+TF
(S)
(S)
Е+Т
E
(Я)
5.3. Использование таблицы синтаксического
анализа
Таблица синтаксического анализа, которая используется в восходящем
синтаксическом анализе, является прямоугольной, каждому состоянию
анализатора (всегда конечное число) соответствует одна строка, а каждому терминалу и нетерминалу грамматики соответствует один столбец.
Простой пример таблицы синтаксического анализа приведен в табл. 5.3.
5.3. Использование таблицы синшсичесгаго анализа
113
Таблиц 5.3.
а
Е
1
S2
2
3
4
5
б
7
8
9
10
11
12
г
S5
F
S8
S4
S8
S?
S10 S5
S8
+
(
S9
S3
R1
R2
S6
S6
R3
R4
R3
R4
S3
R5
R6
R5
R6
S9
S9
S9
;
X
1
S12
R1
R2
R3
R4
S11
R5
R6
S12
S12
S12
R1
R2
R3
R4
RS
R6
В начале процесса синтаксического анализа анализатор находится в
состоянии 1, а входным символом является первый введенный символ.
Каждый шаг анализа определяется позицией таблицы, соответствующей
текущему состоянию, и входным символом. Позиция таблицы может принадлежать к одному из двух типов.
1. Позиция переноса вида Srn, вынуждающая анализатор выполнить дей
ствие переноса и изменить текущее состояние на состояние т.
2. Позиция свертки вида Rn, вынуждающая анализатор выполнить дей
ствие свертки, используя продукцию п.
Пустые позиции таблицы соответствуют синтаксическим ошибкам во
вводе, и, при желании, с каждой такой позицией можно соотнести индивидуальное сообщение об ошибке. На практике таблицы синтаксического
анализа могут быть очень большими, но они часто сжимаются, в основном, за счет удаления различных пустых позиций и увеличения времени
обращения к элементам таблицы. В любом случае точные сообщения об
ошибках, предоставляемые анализатором, часто немногого стоят, поскольку анализатор не предполагает, что будет делать пользователь при
обнаружении ошибки.
Таблица синтаксического анализа представляет зависимую от языка часть
синтаксического анализатора, остальная часть анализатора является полностью или преимущественно независимой от языка и состоит из драйверной
программы, которая интерпретирует данные в таблице синтаксического анализа и выполняет подходящие действия. Если зависимая от языка часть анализатора (таблица синтаксического анализа) может быть довольно большой,
независимая от языка часть, скорее всего, невелика и переносится с одного
компьютера на другой. Ниже описываются действия драйвера.
На каждом этапе синтаксического анализа анализатор находится в
одном из конечного числа состояний, и это состояние плюс входной символ (либо символ предпросмотра, либо только что свернутый нетерминал)
определяют элемент в таблице синтаксического анализа. Предполагая отсутствие синтаксических ошибок, этот элемент — действие переноса или
свертки. В начале синтаксического анализа анализатор находится в состоянии 1, а входной символ — это первый символ анализируемого
предложения. Если позиция таблицы определяет действие переноса, имеют место следующие операции.
• Символ, соответствующий столбцу, в котором находится данная
позиция таблицы, заносится в стек символов.
• Анализатор переходит в стек, который определяется позицией пе
реноса, и это состояние заносится в стек состояний.
• Если входной символ является терминалом, он принимается, и
входным символом становится следующий терминал предложения
(или маркер конца).
Если позиция таблицы определяет действие свертки, имеют место
следующие операции.
» Из стека символов удаляются п символов и из стека состояний
удаляются и состояний, где п — число символов в правой части
продукции, фигурирующей в свертке.
• Анализатор переходит в состояние на вершине стека состояний.
• Входной символ становится символом в левой части продукции,
определенной в позиции свертки.
Проследим анализ следующего предложения. х
+ х + х* х
Как и ранее, все шаги анализатора определяются табл. 5.3. На каждом
шаге будет приводиться содержимое стеков символов и состояний. Итак,
изначально имеем.
Входная строка
Х+ Х+ X* X
Стек СОСТОЯНИЙ
Стек стволов Сентенциальная форма
Х+ Х+ X* X
1
Состояние — 1, входной символ — х; из таблицы синтаксического анализа находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в
стеке символов; принимается х.
Входная строка Стек состояний Стек символов Сентенциальная форма
■х+х+х"х
1,12
х
х+х+х*х
Состояние — 12, входной символ — +; из таблицы синтаксического анализа находим: свертка согласно продукции 6, удаление одного состояния
из стека состояний (поскольку в правой части продукции 6 находится
только один символ) и одного символа из стека символов (по той же
причине); входным символом становится Р.
Входная строка
Стек состояний
Стек символов Сентенциальная форма
-X + X + X* X
X + X + X* X
t'
114
Глава 5. Восходящий синтаксический анализ ' 5.3. Использование таблицы синтаксического анализа
115
ч
Состояние — 1 (снова), входной символ — F; из таблицы синтаксического анализа находим: перенос в состояние 8, стек 8 в стеке состояний
и стек F в стеке символов.
Входная строка Стек состояний Стек символов Сентенциальная форма
■х + х + х*х
1,8
F
F+x+x*x
Состояние — 8, входной символ — +; из таблицы синтаксического анализа находим: свертка согласно продукции 4, удаление одного состояния
из стека состояний и одного символа из стека символов; входным символом становится Т.
Входная строка
.„
. . . . .
*
-
Стек состояний
Стек символов Сентенциальная форма
-*
Состояние — 1 (снова), входной символ — Т; из таблицы синтаксического анализа находим: перенос в состояние 5, стек 5 в стеке состояний
и стек Т в стеке символов.
Входная строка Стек состояний Стек символов Сентенциальная форма
м + х+х*х
1,5
Т
Т+х+х*х
Состояние — 5, входной символ — +; из таблицы синтаксического анализа
находим: свертка согласно продукции 2, удаление одного состояния из
стека состояний и одного символа из стека символов; входным символом
становится Е.
Входная строка Стек состояний Стек символов Сентенциальная форма
■х +х +х*х
1
Т+х + х*х
Состояние — 1 (снова), входной символ — Е; из таблицы синтаксического анализа находим: перенос в состояние 2, стек 2 в стеке состояний
и стек £ в стеке символов.
Входная строка Стек состояний Стек символов Сентенциальная форма
* + х + х*х
1,2
Е
Е+х+х*х
Состояние — 2, входной символ — +; из таблицы синтаксического анализа находим: перенос в состояние 3, стек 3 в стеке состояний и стек + в
стеке символов; принимается +.
Входная строка Стек состояний Стек символов Сентенциальная форма
мч-х + х"х
1,2,3
Е+
Е + х+х*х
Состояние — 12, входной символ — +; из таблицы синтаксического анализа находим: свертка согласно продукции 6, удаление одного состояния
из стека состояний и одного символа из стека символов; входным символом становится F.
Входная строка Стек состояний Стек символов
x~t~x+x*x
1,2,3
£+
Сентенциальная форма
Е+х+х*х
Состояние — 3, входной символ — F; из таблицы синтаксического анализа находим: перенос в состояние 8, стек 8 в стеке состояний и стек Рв
стеке символов.
Входная строка Стек состояний Стек символов
#+-*+х*х
1,2,3,8
E+F
Сентенциальная форма
E+F+x*x
Состояние — 8, входной символ — +; из таблицы синтаксического анализа находим: свертка согласно продукции 4, удаление одного состояния
из стека состояний и одного символа из стека символов; входным символом становится Т.
Входная строка Стек состояний Стек символов
х-+-х+х*х
1,2,3
Е+
Сентенциальная форма
E+F+x*x
Входная строка
#ч-*+х*х
Сентенциальная форма
Е+Т+Х'х
Состояние — 3, входной символ — Г; из таблицы синтаксического анализа находим: перенос и состояние 4, стек 4 в стеке состояний и стек Т в
стеке символов.
Стек состояний Стек символов
1,2,3,4
Е+Т
Состояние — 4, входной символ — +; из таблицы синтаксического анализа находим: свертка согласно продукции 1, удаление трех состояний из
стека состояний (поскольку в правой части продукции 1 находятся 3
символа) и трех символов из стека символов (по той же причине); входным символом становится £
Входная строка Стек состояний Стек символов Сентенциальная форма '
#+~#+х*х
/
Е+Т+х*х
Состояние — 1, входной символ — Е; из таблицы синтаксического анализа находим: перенос в состояние 2, стек 2 в стеке состояний и стек £ в
стеке символов.
Входная строка
Стек состояний
Стек символов
лч~# + х* х
1,2
Е
Сентенциальная форма
Е + х* х
Состояние — 2, входной символ — +; из таблицы синтаксического анализа находим: перенос в состояние 3, стек 3 в стеке состояний и стек + в
Состояние — 3, входной символ — х; из таблицы синтаксического анализа стеке символов; принимается +.
Входная строка Стек состояний Стек символов Сентенциальная форма
находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в
стеке символов; принимается х.
х + х +х * х
1 ,2 ,3
Е+
Е+Х*х
117
Входная строка Стек состояний Стек символов Сентенциальная форма
........ х * х
1,2,3,12
Е+х
Е+х+х*х
116
Глава 5. Восходящий синтаксический анализ S.3. Использование таблицы синтаксического анализа
Состояние — 3, входной символ — х; из таблицы синтаксического анализа находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в
стеке символов; принимается х.
Состояние — 12, входной символ — 1; из таблицы синтаксического анализа находим: свертка согласно продукции 6, удаление одного состояния
Входная строка Стек состояний Стек символов Сентенциальная форма
х+х+х*х
1,2,3,12
Е+х
Е+х*х
Состояние — 12, входной символ — *; из таблицы синтаксического анализа находим: свертка согласно продукции 6, удаление одного состояния
из стека состояний и одного символа из стека символов; входным символом становится F.
Входная строка Стек состояний Стек символов Сентенциальная форма
х+х+х*х
1,2,3
Е+
Е +х *х
Состояние — 3, входной символ — F; из таблицы синтаксического анализа
находим: перенос в состояние 8, стек 8 в стеке состояний и стек F в
стеке символов.
Входная строка Стек состояний Стек символов Сентенциальная форма
х+х+х'х
1,2,3,8
E+F
Е+Рх
Состояние — 8, входной символ — *; из таблицы синтаксического анализа находим: свертка согласно продукции 4, удаление одного состояния из
стека состояний и одного символа из стека символов; входным символом
становится Т.
Входная строка Стек состояний Стек символов Сентенциальная форма
х + х + ж* х
1, 2, 3
Е+
£+Рх
Состояние — 3, входной символ — Г; из таблицы синтаксического анализа находим: перенос в состояние 4, стек 4 в стеке состояний и стек. Т в
стеке символов.
Входная строка Стек состояний х+ Стек символов Сентенциальная форма
х+ х* х
1, 2, 3, 4 Состояние Е + Т
— 4, входной символ
за находим: перенос в состояние 6, стек 6 в стеке состояний и стек * в
стеке символов; принимается *.
Входная строка Стек состояний х +
х + х*х ■ 1 , 2 , 3 , 4 , 6
Состояние — 6, входной символ
- *; из таблицы синтаксического анали-
Стек символов Сентенциальная форма
Е+Т*
Е+Тх
- х, из таблицы синтаксического анализа находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в
стеке символов; принимается х.
Входная строка Стек состояний Стек символов
х+х+х*х
1,2,3,4,6,12 Е+Тх
Сентенциальная форма
Е+Гх
Е+Тх
лз стека состояний и одного символа из стека символов; входным символом становится F.
Входная строка Стек состояний Стек символов Сентенциальная форма
х+х+х'х
1,2,3,4,6
Е+Г
Е +Г х
Состояние — 6, входной символ — F; из таблицы синтаксического анализа находим: перенос в состояние 7, стек 7 в стеке состояний и стек FB
стеке символов.
Входная строка Стек состояний Стек символов Сентенциальная форма
х+х+х'х
1,2,3,4,6,7
E+TF
Е+ГР
Состояние — 7, входной символ — 1; из таблицы синтаксического анализа находим: свертка согласно продукции 3, удаление трех состояний из
стека состояний и трех символов из стека символов; входным символом
становится Т.
Входная строка Стек состояний Стек символов Сентенциальная форма
х + х+х*х
1,2,3
Е+
E+TF
Состояние — 3, входной символ — Г; из таблицы синтаксического анализа находим: перенос в состояние 4, стек 4 в стеке состояний и стек Т в
стеке символов; принимается х.
Входная строка Стек состояний
118
Стек символов
Сентенциальная форма
х+х+х'х
1,2,3,4
Е+Т
Е+Т
Состояние — 4, входной символ — .1; из таблицы синтаксического анализа находим: свертка согласно продукции 1, удаление трех состояний из
стека состояний и трех символов из стека символов; входным символом
становится Е,
Входная строка Стек состояний Стек символов Сентенциальная форма
х+ х+ х* х
1
Е+Т
Состояние — 1, входной символ — £; из таблицы синтаксического анализа находим: перенос в состояние 2, стек 2 в стеке состояний и стек Е в
стеке символов; принимается х.
Входная строка Стек состояний Стек стволов Сентенциальная форма
х+х*х*х
1,2
Е
Е
Когда в стеке символов находится символ предложения Е и считано все
предложение, анализ успешно завершается.
Хотя стек символов позволяет удобно проиллюстрировать ход анализа, его содержимое не влияет на решения, принимаемые синтаксическим
анализатором, — на прогресс анализа влияет содержимое стека состояний. На любом этапе анализа состояния стека соответствуют частично
Распознанным правым1 частям продукций, которые через некоторое время будут свернуты в соответствующие левые части.
Глава 5, Восходящий синтаксический анализ 5.3. Использование таблицы синтаксического анализа
119
5.4. Создание таблицы синтаксического
анализа
Итак, рассмотрев, как синтаксический анализатор использует одноименную
таблицу, пришло время определить принципы ее формирования из контекстно-свободной грамматики. Вначале проиллюстрируем роль состояний, рассмотрев создание таблицы синтаксического анализа для простой грамматики,
генерирующей конечный язык. Далее в этом разделе приведен пример формирования таблицы для более реалистичной грамматики.
Рассмотрим грамматику со следующими продукциями (Р — символ
предложения).
I p -»jbj
2. D -»2d'td
3. s-» 4 s;s
Подобным образом вводим еще восемь состояний, в которых может на ходиться синтаксический анализатор.
1.
2.
3.
S—» 4S10'llS12
Синтаксический анализ предложения
bd;d;s;se
1. P->bD;Se
2. D-xt.d
будет проходить, как показано в табл. 5.4.
3. S->s;s
Таблица S.4.
Единственное генерируемое предложение выгладит так.
Входная строка
Стек состояний
bd;d;s;se
bd;d;s;se
y;d;s;se
fedfd;s;se
b$4;s;se
M^;s;se
1
1
,1
,
1
1
,
,
1
,
1
1
,
1
,
1
,
,
1
1
,
1
,
,
1
1
,
1
bd;d;s;se
Как обычно, предполагается, что синтаксический анализатор изначально
находится в состоянии 1. Для отображения этого факта следует несколько изменить форму записи грамматики (в данном случае символ предложения обозначается не S, а Р).
1. Р-* jbD;Se
M^fs;se
2. D->d]d
hfj'ifi.'.c со
3. S-*s;s
После считывания символа b грамматика будет находиться в состоянии
(скажем) 2.
1. Р-» ,62D;Se
2. D->d;d
3. S^s;s
Анализатор находится в состоянии 2 до распознавания символа О, так
что соответствующее обозначение помещаем и в начало продукции для
нетерминала D.
2,
3.
Состояния 3 и 4 также соответствуют считыванию символов продукции
1. Поскольку анализатор находится в состоянии 4 до распознавания символа S, оно также соответствует началу продукции для S.
/20
Глава 5. ВОСХОДЯЩИЙ синтаксический анализ
witj и s ij'pasS
2
2 7
2
, 7,8
2
, 7,8,9
2
,
2
,2
2
,
2
,
,
2
,2
2
,
,
2
,
Стек символов
b
bd
bd;
bd;d
3
3,4
b
bD
bD;
3, 4, 10
3,4, 10, 11
3,4, 10, 11,12
bD;s
bD;s;
bD;s;s
3,4
bD;
3,4,5
3, 4, 5, 6
bD;S
bD;Se
P
Сентенциальная форма
bd;d;s;se
bd;d;s;se
bd;d;s;se
bd;d;s;se
bd;d;s;se
bd;d;s;se
bD;s;se
bD;s;se
bD;s;se
bD;s;se
bD;s;se
bD;s;se
bD;Se
bD;Se
bD;Se
P
Вся информация, необходимая для направления процесса анализа,
содержится в представленной выше аннотированной грамматике, но
Удобнее ее представить в табличной форме (табл. 5,5).
Позиции переноса заносятся в таблицу легко. Например, из нача ла правой части продукции 1, состояние 1, при входном символе b
очевидным является перенос в состояние 2, другие позиции переноса
также очевидны. Рассмотрим теперь позиции свертки. Состояние в
конце продукции — это состояние, в котором должна происходить
свертка, так что из аннотированной версии продукции 1 видим, что в
состоянии 6 должна иметь место свертка согласно продукции 1. Действия свертки вносятся в каждый столбец состояния свертки, если в
5,4. Создание таблицы синтаксического анализа
121
рассматриваемой строке нет действий переноса; по этой причине
действия переноса всегда заносятся в таблицу до действий свертки.
Позднее будет рассмотрен вопрос о том, что происходит при возник новении конфликта между действиями переноса и св ертки (конфликт
перенос/свертка) или между двумя действиями свертки (конфликт
свертка/свертка). В приведенном выше примере конфликты отсутст вуют, и грамматика обозначается LR(0) (L — чтение слева направо;
R — используя правое порождение; 0 — при отсутствии предпросмотра для разрешения конфликтов). Табпща 5.5.
R1
R2
R2
R2
R2
R1
R1
R1
R2
R2
R1
R2
44
_,
оз
R3__P>2»—«~~JSt~—■
В большинстве случаев действия свертки поместить в таблицу не так
12
RJL__«-J£~~»—^
просто, как может показаться из приведенного примера. Для ил люстра■"-*"""""*"*—-~~~~~~~
. гпамматики приведенного вь
ц и и эт ого вернемся к рассмотренной выше грамматике.
Другим способом представления ip
Это пре д Ставле примера является ориентированный граф (рис
;
ав т о т „ом I. £ -> Е + гГ
/ и сcharacteristic
называется
finite характерист*'
state machm. ) е ««*
д а л е е _ ХК А . Есл н 2 . £ ^ г
"передать
" c h a r a c t eуправление
r i s t i c f i n i t e «нтаксич
s t a t e m a cеh™
m. ) ^7Zu данному конечном;
е
™
автомату иуправление
соотнести с«нтаксич
каждым ^то
при действии переноса этот номер
В
е время при д е и с ™/
ер
состояния к
Г-» F
его номер состояния, _
автомату и соотнеси» v —;*"■_ ,
заноситься в стек состоянии. 4. , -г,
при действии переноса этот номер будет за
анаЛ изаторабуде 5. F _ (E)
В то же время, пр и д е и с ™/^ ^тека буД« извлекаться требуемое
несколько иным. В этом случае измлека ^уд^ ^^ о б р а т н о п 0 к 0 (
число состояний и проходиться так.
юши д входной символ — э1 Аннотирование грамматики происходит следующим образом. В первую
след
нечному автомату. Как и ранее,
^
что ИСП ользованной пр( очередь устанавливается состояние 1.
сим во л в пр а во й ч ас т и пр о да ши ,^
в
атику добав-
свертке, ХКА Ф°Р м и РУ е Т ^ "™ю Т что создать ХКА, проше, по
t * ,Е +
i(£)
согласно описашь» Р«.ее принципам.
Глава 5. Восходящий синтаксически
f22
S.
5
Создание таблицы синтаксического анализа
123
Каждая позиция, в которую было помещено состояние 1, — это пример конфигурации грамматики. По определению, конфигурация — это позиция в правой части продукции перед первым символом, после последнего символа или между двумя любыми символами.
Конфигурации, соответствующие одному состоянию, неразличимы с
точки зрения синтаксического анализатора. Далее вводим в грамматику
состояние 2, которое соответствует единственной конфигурации.
1.
2.
3.
.4.
5.
6.
£-->,£,+ 7
£-ч,Г
7-~>,rF
7-»,F
F-*,(£)
Р—> ,х
Состояние 3, поскольку оно появляется перед нетерминалом, соответствует нескольким конфигурациям.
По подобной поич
донце продукции 2 (дейст-6 СОСТОЯНИе 5 также появляется в двух м
потенциальная проблема?) И1 свергки) и в продукции 3 (перенос) (еше**
„еред Тв правой части nnL, СТИМ) что в продукции 3 состояния 1
идущим после Г те поел лГ яются в по Рядке, соответствующем сост
"
' ''
' вдет состояние 5, а после 3 — состо
2.
3.
4.
5.
6.
Состояние
6 появляется в нескольких местах, поскольку оно предшествует
нетерминалу.
1 р_д .Р ... т
2. £-4,7
3. 7-»,..
4. 7-> l i 3 F
5. F-4 ,,,(£)
6. F—>, Зх
ТТ
состояния 7 и 8.
»
1.
На первый взгляд может показаться странным наличие двух состояний,
соответствующих одной конфигурации грамматики (например, ой 2. Е—^1у}
:, 3^5, 4*6
состояния 1 и 3 появляются в начале правой части продукции 3). В то ж( 3. .время, если учитывать историю синтаксического анализа, два состояние ■"->, ,F.
различимы, поскольку состояние 3 всегда появляется после символа +, i '
1, 3, 1
состояние 1 — нет. Вводим далее состояние 4.
|С
7
Состояние 9 несколько сложнее, так как оно появляется перед нетерминалом, поэтому должно находиться в начале правил для этого нетерминала и т.д. рекурсивно.
Состояние 4 появляется в двух местах: в конце продукции 1, где оЩ.
соответствует действию свертки, и (поскольку оно определено как с|
стояние, в которое переходит состояние 3 при считывании символа , ~
после Т в продукции 3. Уже видно, что состояние 4 приведет к некоТ' ■
рому конфликту перенос/свертка!
124
2.
3.
4. 7-»,. 3 F
5. F-»,,,(£)
6. £-»
F—> 1,Г
>3x
Глава 5. ВОСХОДЯЩИЙ синтаксический айв? ' ьЩание таблицы синтаксического анали
125
' 1.3.9 7*5,
AF7 I,
3, 9 / С 8
i. 3,
6, д(9Е)
Далее
вводятся с
1. 3, 6, просто
9
х
(
I
Состояние 10 следует вводить аккуратно. Состояние 10 следует поезде
£ в состоянии 9, таким образом, появляется после £ в продукциях 1 и 5.
В то же время, в продукциях 2 и 3 уже не нужно вводить новые состояния, которые бы "помнили", появилось это состояние из продукции в
состоянии 1 или в состоянии 9. По этой кажущейся несколько условной
причине любое новое состояние, введенное после Г в данных продукциях, будет соответствовать набору конфигураций, идентичному существующему в уже введенном состоянии. Хотя если не ввести такие состояния, может возникнуть определенный конфликт, но на практике это бывает редко, поэтому пока такую возможность будем игнорировать. В
конце концов, зачем делать таблицу синтаксического анализа большей,
чем это необходимо!
1,9^2, 10 £—» 1,9^
I.
4 j
'
*
2.
3.
fj 1
,
4,
3 , 6 , ! 1,3.6, <
5.
6. В продукциях 4-6 уже не требуются новые состояния, следующие за 8
(по причинам, сходным с приведенными выше для продукций 2 и 3).
Оставшиеся состояния грамматики вводятся без каких-либо проблем.
<
£- .Е,
»
£- 1,3, 9 '5. 4
* 1, 3, 9' 8
i
1, 3, 6, 9А12
Т-» ХКА для полученной аннотированной грамматики приведен
F-» на|
рис, 5.2. Переносы (табл. 5.6) легко получаются из грамматики или из j
F-»Кроме того, в таблицу просто (не порождая конфликтов) вводятся!
ХКА.
некоторые свертки (табл. 5.7)
j
Замечаем, что свертки в состояниях 4 и 5 не являются очевидными, >
поскольку в строках, которые соответствуют данным состояниям, у** 1
имеются несколько переносов. Итак, данная грамматика не относится к
классу LR(0), и при определении нужного действия (перенос или сверТ"
ка) следует принимать во внимание символы предпросмотра. Рассмотрим
первую ситуацию, возникающую' в состоянии 4, где могут иметь место
как перенос, так и свертка. Из табл. 5.7 или ХКА видим, что перенос воЗ'
можен, только если символ предпросмотра — *. Таким образом, делаеМ;
вывод, какие символы предпросмотра соответствуют свертке.
щ>
126
Рис, 5.2.
Глава 5. восходящий синтаксический атя$ Ч Создание таблицы синтаксического анализа
127
Таблица 5.6.
S2
1
S5
2
S4
3
4
5 S12 S12
6
7
8 S12
9 S9
10
S10
S5 S11
11
12
Таблица 5.7.
1
2
3
4
5
6
7
8
9
10
11
12
S2
S8 1
S8
0оскольку в данных продукциях отсутствуют действия переноса, все
конфликты перенос/свертка разрешены.
S9
S3
S6
S6
S7
Таблица 5.8.
S9
S9
S8
S12
S3
S5
S8
$4
S8
S3
S9
S12
S9
S12
Хотя строго это и не обязательно, позиции других действий свертки
также можно вычислить, приняв во внимание символы предпросмотра.
Например, свертка согласно продукции 3 будет уместной, только если символ
S9
S7
S12
предпросмотра — это симьол, который может следовать за Т. Поскольку (из
R3
R3
R3
R3
R3
R3
R3
R3
га
продукции 3) за Т может следовать символ *, а (из продукции 2) любой
R4
R4
R4
R4
R4
R4
R4
R4
R4
последователь Е может быть последователем Т, полное множество
S10
S9
S12
S5
S8
символов-последователей 7" имеет вид [*, +, X, )]. Таким образом, действие
S3
S11
R3 в состоянии 7 уместно только в этих столбцах. Подобным образом, для
Я5
R5
R5
RS
R5
R5
R5
R5
R5
состояния 8 (где свертка также выполняется в Т) действие R4 появляется
R6
R8
R6
R6
R6
R6
R6
R6
да
только в столбцах, которые соответствуют множеству [*, +, 1,)].
_______
В состояниях 11 и 12 свертка производится в символ F, и снова рассматСвертка, если она имеет место, будет происходить в символ Е, так что
мы рассматриваем символы, которые могут следовать за Е (будущие воз- ривая символы, которые могут следовать за F и Т (согласно продукции 4),
получаем множество [*, +, JL, )]. Таким образом, действия R5 и Я6 должны
можные символы предпросмотра при свертке согласно продукции 1). Из
продукции 1 получаем, что за Е может следовать символ +, а из продук- появляться только в этих столбцах. Окончательно получаем табл. 5.3, которая
приводилась ранее без объяснения принципов формирования.
ции 5 находим символ). Кроме того, за £ также может следовать символ
Если для внесения в таблицу синтаксического анализа действий
1, поскольку Е — это символ предложения; таким образом, единственсвертки
учитываются все возможные символы-последователи нетерминалов
ные символы, которые могут идти за Е, — это [+, 1, )]. Итак, действия
свертки в состоянии 4 помещаются только в те столбцы таблицы синтак- левой части продукции, таблица называется простой (simple) LR(1)-шблицей
или 8ИЯ(1)-таблицей, а использованный алгоритм именуется алгоритмом
сического анализа, что соответствуют трем указанным символам.
SLR(1). Если созданная таким образом таблица не содержит Конфликтов, то
Рассмотрим теперь состояние 5. Снова символом предпросмотра для грамматика, на основе которой получена таблица, называется SLR(' 1)действия переноса является *, а символы предпросмотра, соотнесенные с грамматикой. Очевидно, все Ы1(0)-грамматики относятся к классу
действием свертки, — это [+, JL, )] (последователи символа £). Теперь SLR(l), хотя, как можно видеть из рассматриваемого примера, не все
можно поместить действия свертки для продукций 1 и 2 в соответствую- 5ИК.(1)-грамматики относятся к классу LR(0). Отметим также, что Даже
щие столбцы для состояний 4 и 5, в результате чего получим табл. 5.8.
если грамматика не является SLR(l), оставшиеся конфликты, воз-Чожно,
удастся разрешить каким-то иным способом, и грамматику можно будет
назвать LR(1).
128
«4.1
S6
S6
Глава 5. Восходящий синтаксический анализ
Создвние таблицы синтаксического
аиа.
шиза
129
В качестве синтаксической таблицы для рассматриваемой грамматики
может использоваться как табл. 5.8, так и табл. 5.3. Первую легче получить, но вторая обеспечивает более эффективную поддержку процесса ;
синтаксического анализа, но только в объясненном ниже смысле.
:
Может случиться так, что использование табл. 5.8 приведет к приме
нению действия свертки, тогда как использование табл. 5.3 приведет к
выдаче сообщения о синтаксической ошибке. В таких случаях фактиче
ски имеет место синтаксическая ошибка, а свертка, на которую указыва
ет табл. 5.3, некорректна. В то же время использование табл. 5.8 приведет
к выдаче сообщения о синтаксической ошибке чуть-чуть позже — в том
смысле, что синтаксический анализатор успеет произвести на несколько
действий больше, чем это было бы при использовании табл. 5.3, но
ошибка будет выявлена до считывания следующего входного символа. С
точки зрения пользователя в обоих случаях синтаксическая ошибка будет
выявлена на одном этапе анализе, т.е. при одинаковом- числе считанных
символов. В обоих случаях синтаксическая ошибка будет обнаружена при
первом неприемлемом символе; вообще, такое свойство является жела
тельным для анализаторов LR(1) (и LL(1)). Описанные выше идеи иллю- ;
стрируются ниже, на примере из уже использованной грамматики.
i
Рассмотрим следующую синтаксически некорректную строку ввода.
хх
i I
I, '
j
\
Изначально.
Входная строка Стек состояний Стек символов Сентенциальная форма
хх
1
хх
Состояние — 1, входной символ — х; используя либо табл. 5.3, либо ;
табл. 5.8, получаем: перенос в состояние 12, стек 12 в стеке состояний и
стек х в стеке символов; принимается х.
;
Предложение
т
Стек состояний Стек стволов Сентенциальная форма j
1,12
х
хх
;
Состояние — 12, входной символ — х. Согласно табл. 5.3 имеем сиитак-!
сическую ошибку; в то же время, исходя из табл. 5.8, следует выполнить;
свертку согласно продукции 6. Чтобы увидеть результат, сделаем, как:
предлагает табл. 5.8, — удалим одно состояние из стека состояний и один :
символ из стека символов. Следующим входным символом является F.
Предложение Стек состояний Стек символов Сентенциальная форма
«
1
хх
Состояние — i s входной символ — F; используя табл. 5.8, получаем: пе-
ренос в состояние 8, стек 8 в стеке состояний и стек F в стеке символов.
Предложение Стек состояний Стек символов Сентенциальная форма
жх
1,8
F
Рх
Состоя
ние —
8,
входно
й
символ
— к,
снова
исполь
зуя
табл.
5.8,
имеем
указан
ие на
свертк
у, на
этот
раз
соглас
но
продук
ции 4. Удаляем одно состояние из стека состояний и один символ из стека
символов; входным символом становится Г.
Предложение Стек состояний Стек символов Сентенциальная форма
хх
1
Fx
Состояние — 1, входной символ — Т; согласно табл. 5.8 следует выполнить перенос в состояние 5, стек 5 в стеке состояний и стек Г в стеке
символов.
Предложение Стек состояний Стек стволов Сентенциальная форт
хх
1,5
Т
Тх
Состояние — 5, входной символ — х, только теперь в табл. 5.8 имеем
указание на синтаксическую ошибку.
Хотя синтаксическая ошибка может обнаруживаться позже (через несколько действий анализатора), использование таблицы с дополнительными свертками (подобной табл. 5.5) не увеличивает время анализа
программы, не имеющей синтаксических ошибок. Следовательно, обычный подход при формировании таблицы — помещать операции свертки в
каждый столбец состояния свертки, если это не приводит к конфликтам;
рассматривать символы-последователи, если конфликты возникают.
Как было показано ранее, алгоритм LR(0) успешен, если вообще нет
нужды рассматривать символы-последователи, и все действия свертки
130
можно поместить во все столбцы. Алгоритм SLR(l) успешен, если конфликты, выявленные алгоритмом LR(0), разрешаются с использованием
символов-последователей описанным выше способом. В то же время
иногда даже этого подхода недостаточно для разрешения всех конфликтов, и требуется более общий подход. В этом случае оставшиеся конфликты пытаются разрешить посредством алгоритма LALR(l) (LALR(l) —
lookahead LR(1), LR(1) с предпросмотром). Алгоритм LALR(l) ограничивает
число символов-последователей, которые нужно рассматривать при
конкретной свертке, используя контекстную (т.е. касающуюся состояний)
информацию для определения только тех последователей, которые
корректны в рассматриваемом состоянии.
Все конфликты может не разрешить даже алгоритм LALR(l). Это, разумеется, объясняться тем, что грамматика не относится к классу LR(l),
так что нельзя создать бесконфликтную синтаксическую таблицу описанного выше типа. Например, к классу LR(1) не относятся неоднозначные грамматики. В то же время существуют грамматики, являющиеся
LR(1), но не LALR(I). Для этих грамматик таблица синтаксического анализа формируется путем введения в аннотированную грамматику дополнительных состояний в тех местах, где мы этого не делали в приведенном выше примере, т.е. когда одному набору конфигураций грамматики
соответствует более одного состояния. Такие состояния бывают иногда
нужны, возможно, их даже окажется много, что значительно увеличит
Глава 5. ВОСХОДЯЩИЙ синтаксический анализ -5,4. Создание таблицы синтаксического анализа
131
i
таблицу синтаксического анализа, К счастью, описанной трактовки требуют лишь несколько грамматик, представляющих скорее академический, чем практический (языки программирования) интерес, поэтому соответствующие примеры приводиться не будут.
Рассмотрим общий алгоритм формирования таблицы 1Д(1)-анализа,
Он имеет несколько этапов.
1. Для создания таблицы пытаемся использовать алгоритм LR(0). Если
он успешен (т.е. конфликты отсутствуют), алгоритм прекращается.
2. При неудаче пытаемся применить алгоритм SLR(l). Если он увенчал- !
ся успехом, алгоритм прекращается.
денными с практической точки зрения. На первый взгляд они даже каукутся противоречивыми. В то же время, из того, что для фиксированного
1( существует алгоритм (подобный описанному для /г= 1) определения,
является ли грамматика LR(A), не следует, что существует конечный алгоритм, позволяющий определить наименьшее к (если оно существует),
при котором грамматика является LR(A). Другими словами, существуют
алгоритмы, позволяющие определить, относится ли грамматика к классу
LR(1), LR(2) И Т. Д .; НО ХОТЯ МЫ можем доказать, что для некоторого
большого к грамматика не является LR(k), всегда может оказаться, что
j
3. При неудаче пробуем алгоритм LALR(l). Если он достиг цели, алго- j
для какого-то большего к грамматика будет относиться к классу LR(k).
ритм прекращается.
Таким образом, алгоритм не будет конечным.
4. При неудаче испытываем алгоритм LR(1).
;
Несколько типичных языков программирования имеют явные неОсновная идея описанного подхода: первым применяется простейший ■ LR(1) свойства. Рассмотрим, например, фрагмент грамматики со слеалгоритм, далее сложность алгоритмов идет по возрастающей. Реально к : дующими продукциями.
классу LR(0) принадлежат немногие языки программирования, но мно- .
гие языки принадлежат к классу SLR(l), оставшиеся практически наверняка 1. S-*aL,S
■ это списки списков следующего вида.
будут LALR(l). Состояния LR(0)-, SLR(l)- и LALR{1)-грамматик , будут 2.
\aL
почти одинаковыми, но в 1Ж(1)-грамматике их будет значительно больше.
Генерируемые
Последнее замечание: не имеет значения, какой алгоритм при- i меняется 3. L-¥x,L предложения ах,
br
для создания таблицы синтаксического анализа, синтаксический .4.
х, х, х, ах, х
анализатор все таблицы использует одинаково.
5.5, Особенности LR-анализа
На данном этапе стоит упомянуть некоторые теоретические результаты.
• Существует алгоритм определения, относится ли грамматика к
классу LR(A) при данном к.
• Не существует алгоритма определения, существует ли к, при кото
ром данная грамматика относится к классу LR(/r). В общем случае
данная задача не решается.
• Любой язык, относящийся к классу LR(Ar) при данном к, также от- !
носится к классу LR(1).
j
Впрочем, ни один из приведенных результатов не является особенно
важным с практической точки зрения. Третий, например, означает, что с ■
точки зрения языка нет смысла рассматривать более одного символа предпросмотра, поскольку если при некотором к для языка существует грамматика
LR(A), также имеется и грамматика LR(i). Менее очевидно (и не доказывается), что на практике грамматика обычно является LR(l), хотя ниже будут
приведены несколько примеров 1Л(2)чграмматик. Впрочем, что также будет:
показано, эти грамматики легко преобразовываются в LR(1).
Принимая во внимание существование 1Ж(1)-грамматики для всех ;
][Д(£)-язык0в, первые два результата также не являются особо сущест-1
132
__...... „,„,,,• А. п» uuvux уровнях списка используется один разделитель.
Данная
грамматика не относится к классу LR(I), поскольку при считывании фрагмента
ах
Важный момент: на обоих уровнях списка используется один разделитель.
Данная грамматика не относится к классу i . u / n ~~-—-—
и предпросмотре символа "," неизвестно, следует применять перенос
(как в продукции 3) или свертку (согласно продукции 4). В то же время,
два символа предпросмотра разрешают конфликт, поскольку из предпросмотра последовательности ",х" следует перенос, соответствующий продукции 3, а из предпросмотра пары ",а" следует свертка, соответствующая концу продукции 4. Это объясняется тем, что ",$" является последователем L (из продукции 1), а "а" — стартовым символом S (снова из
продукции 1). Кроме того, "х" — стартовый символ для L
Согласно представленной выше теории LR(2) -грамматику можно преобразовать в LR(1)-грамматику. В этом случае "простое" преобразование
Дает грамматику, генерирующую один список из элементов "х" и "ах"
(после первого элемента, которым всегда является ах). Приведем продукции новой грамматики.
1. s~>axF
S.
2. F->,j F
3.
u
Глава 5. ВОСХОДЯЩИЙ синтаксический анализ 54 ru«A», „»» ш
** Особенности LR-атшза
133
Доказательство того, что данная грамматика действительно является
LR(1), предлагается в качестве одного из упражнений в конце данной
главы (решение этого и других упражнений приводится в конце книга).
Особенность исходной грамматики, "благодаря" которой она не относится
к классу LR(1), — это использование правой рекурсии плюс двойное
использование запятой. В то же время, хотя грамматики с левой рекурсией
не могут быть LL(1), утверждение, что 1Л(1)-грамматики не могут содержать правой рекурсии, в общем случае неверно. Следует сказать, что
только в относительно редких случаях правая рекурсия порождает про- '
блемы в Ц1(1)-грамматиках, а если и порождает, то, в основном, из-за ;
наличия в грамматике помимо правой рекурсии каких-либо иных осо- \
бенностей. В то же время в большинстве программ синтаксического ана- I
лиза левая рекурсия предпочтительнее правой, так как позволяет исполь- :
зовать свертку входа по мере чтения, не занося его в стек.
Неоднозначные грамматики, разумеется, не могут быть LR(1). Часто
неоднозначность в грамматике — это наличие нескольких порождений
для пустой строки, е. Проиллюстрируем это на довольно тривиальном
примере. Пусть в грамматике присутствует следующий фрагмент.
S-*A\
В\ е
А-> аА
е Таким образом, в данной грамматике есть два
порождения для е.
S => А => е Одно порождение можно легко удалить из
грамматики, заменив
А —> е
следующей продукцией:
А-* а
Вообще проблемы подобного рода обычны в грамматиках, но, как правило, с ними удается легко справиться.
Подведем итоги: LR-анализ имеет следующие желательные особенности.
• Его можно применить к широкому классу грамматик и языков.
• Необходимые преобразования грамматик обычно минимальны.
• Время, требуемое для анализа, прямо пропорционально длине входа.
• Синтаксические ошибки выявляются на первом недопустимом
символе.
• LR-анализ имеет хорошую инструментальную поддержку.
Более того, инструментальная поддержка обычно означает, что разработчик компилятора не обязан точно знать, как формируется таблица син-
134
таксического анализа. В следующем разделе будет подробно рассмотрено,
как используется распространенное средство генерации синтаксических
анализаторов.
5.6. Введение в YACC
В данном разделе на примерах будет показано, как генератор синтаксических анализаторов YACC используется для создания анализаторов из
контекстно-свободных грамматик. Также будет показано, как эти синтаксические анализаторы могут использоваться в качестве основы разнообразных средств анализа, в том числе компиляторов и инструментов вычисления метрик, для чего в правила грамматики будут внедряться действия исходного кода. YACC (Yet Another Compiler-Compiler — "еще один
компилятор компиляторов") был разработан в Bell Laboratories и используется для создания LR-анализатора из любой ЬАЬК(1)-грамматики. Это
средство полиостью совместимо с Lex, фактически оно появилось на несколько лет раньше Lex, гак что в первые годы использования YACC
пользователи должны были вручную создавать лексические анализаторы
для применения с автоматически создаваемыми синтаксическими анализаторами. Впрочем, сейчас этого уже не требуется, так что ниже будет
показано, как Lex и YACC используются совместно. Вход YACC всегда
имеет следующий вид:
объявления
%%
правила
%%
функции, определенные пользователем
В то же время, разделы объявления и функции, определенные
пользователем, могут быть пустыми. Если раздел функции,
определенные пользователем, пуст, второй разделитель %% можно
опустить, так что минимальный вход YACC имеет следующий вид:
%%
правила
Выход YACC — это программа на языке С, компилировать которую
можно обычными средствами. Изначально средство YACC разрабатывалось для поддержки языков RATFOR (версия FORTRAN) и С, в настоящее время доступны версии YACC, поддерживающие различные языки,
такие как Turbo Pascal от Borland. YACC весьма подобен Lex по многим
пунктам, хотя, конечно, поддерживает более общие языковые конструкции. Анализируемый язык выражается как контекстно-свободная грамматика, при этом используется форма записи, подобная применяющейся
в
контекстно-свободных грамматиках, хотя и с некоторыми расширениями, которые будут объяснены позже, на примерах. Эти расширения не
Увеличивают выразительной силы формы записи в том смысле, что она
Шва 5. Восхоттй синтаксический анализ Ш Введете в YACC
135
может использоваться для описания языков, которые нельзя описать посредством контекстно-свободной грамматики, но они упрощают описание некоторых языков, а также иногда сокращают описание языка.
В качестве примера покажем, как в виде входа YACC можно представить язык арифметических выражений.
Приведенный выше пример можно расширить, включив унарные
операторы, приоритет которых может отличаться от приоритетов бинарных операторов, представленных теми же символами. Если нужно включить унарный минус с приоритетом выше, чем у операторов умножения,
вход YACC будет иметь следующий вид.
%left ' + ' * - '
%left ' * ' ' / '
%left UMINUS
%le£t ' + ' " - '
%left ( * ' V
expr
l
expr
expr
expr
expr
expr
expr expr expr
!
expr
/' 1('
expr num,- ')'
+'
expr expr ex pr
'-' expr '*' ex pr
e x p r '/ ' ex pr
' - ' e x p r ex pr
1
(' expr num;
Отметим, что терминалы берутся в одинарные кавычки, а для разделения
правой и левой частей продукций используется знак : вместо более привычного ->. Для разделения альтернативных правых частей продукции, гак и ранее, используется знак |. Расширение формы записи, использованной для
контекстно-свободных грамматик, связано с отображением уровней приоритетов операторов. В первой строке предсташенного выше кода определено,
что операторы + и - имеют одинаковый уровень приоритета, поскольку находятся в одной строке, а во второй «роке то же делается для операторов * и
/, Кроме того, поскольку * и / находятся в строке, расположенной ниже
строки с + и -, их приоритет определен более высоким. Появление left перед каждой парой операторов обозначает, что они являются левоассоциативными. Значит, выражение
3+4+5
нужно вычислять следующим .образом: ((3
+ 4)4-5)
Данный метод вычисления является наиболее привычным и соответствует
использованию в грамматике левой рекурсии. Более высокий приоритет
операторов умножения перед операторами сложения можно выразить j
альтернативно (будет показано ниже), определив, что выражение должно |
быть суммой термов, каждый из них является произведением множите- \
лей. В то же время, во входе YACC этого не требуется, так что граммати- ■
ка YACC короче и, возможно, читабельнее. Сами по себе грамматиче- [
ские правила являются неоднозначными. Например, существует более '
одного порождения для приведенного выше выражения. 3 + 4 + 5
В то же время правила ассоциативности и приоритетов полностью разрешают эту неоднозначность. Из сказанного видно, что грамматики
YACC могут быть неоднозначными (несмотря на то, что не существует
неоднозначных 1Л(1)-грамматик), если имеются правила, которые позволяют разрешить неоднозначность. Такие правила называются правылети разрешения неоднозначности (disambiguating rules).
%prec UMINUS
Теперь мы почти готовы
написать вход YACC для
создания программы, вычисляющей математические выражения. Перед
этим, правда, нужно кое-что сказать о введении действий во вход YACC.
Как и для Lex, действия пишутся на С и обычно (хотя и не всегда)
располагаются в конце синтаксических правил, таким образом
соответствуя приведению выражений. Например, к синтаксическому
правилу
expr : expr '+' expr; можно добавить действие, в результате
чего получится следующее.
expr : expr ' + ' expr {$$ = $l+$3;} ;
Здесь код С замкнут в фигурные скобки. Переменные со знаком долла ра
— это отличительная особенность YACC, которая является крайне полезной. $п — это численное значение (атрибут), соотнесенное с /7-м символом правой части продукции, а $$ — численное значение, которое будет
соотнесено с символом в левой части. Таким образом, в приведенном
примере значение переменной со знаком доллара, соотнесенное с выражением в левой части правила, будет равно сумме значений переменных со
знаком доллара, соотнесенных с первым и третьим символами правой части
правила. Подобным образом значения метут передаваться от правых
частей продукций левым частям или (с другой точки зрения) от основания
синтаксического дерева к вершине. Нахождение значений терминальных
узлов синтаксического дерева является задачей лексического анализа, и, в
этом случае, значение на вершине синтаксического дерева является
значением всего выражения, которое можно напечатать.
Вход YACC, включающий комментарии, которые выделены знаками
/ и */, имеет следующий вид.
%token NUMBER
%left '+' '-'
%left < * ' « / '
%left UMINUS
/"■приоритет унарного минуса выше приоритетов доугих
137
136
Гжва 5. Восходящий синтаксический анадш - " Введение в YACC
операторов*/ %%
/*раздел правил*/ s
: expr
(printf("%d\n", $1);};
expr : expr '+' expr
{$$=$l+$3;} |
expr '-' expr
{$$=$l-$3;} |
expr **' expr
{$$=$1*$3;} |
expr s/' expr
{if ($3 == 0) yyerror («divide by 0")
else $$=$l/$3;} | '-' expr
%prec
UMINUS
{$$=-$2;} j '('
expr % > '
{$$=$2;} |
NUMBER;
#Include "lex.yy.c"
yyerror{s!
char *s;
Cprintf("%s\n
1
I
s);
main()
{return yyparseO;
}
Для связи с лексическим анализатором, созданным Lex, перед пользовательскими функциями "включается" lex.yy.c. Отметим, что в случае
деления вначале выполняется проверка, не равен ли нулю делитель; если
равен — вызывается yyerror, и программа прекращается. Пользователь
должен сам поддерживать свою версию функций yyerror и main (которая
должна возвращать значение, полученное при вызове yyparse ()). Эти
функции могут быть простыми (подобно приведенным) или сложными, по
желанию. Отметим также, что терминалы в грамматике, которые не соответствуют одному знаку, "объявляются" как %token. Это обеспечивает
связь с лексическим анализатором. Терминалы, состоящие из одного знака, могут передаваться Lex автоматически, без необходимости явного
"распознавания ".
Вход Lex для распознавателя выражений будет иметь следующий вид.
%%
[ 0 - 9 ] + {yylval = atoi (yytext); return NUMBER;}
[ \t]
; /"""игнорировать пробелы*/
r et u r n y yt e x t f O ] ;
Здесь yytext — массив, содержащий знаки символа, с которым только
что было найдено соответствие. Для конвертирования этой строки в це138
лое число используется функция С, именуемая atoi, а для обеспечения
связи с синтаксическим анализатором возвращается символ NUMBER. Значение числа присваивается целому yylval, посредством которого это
значение и передается синтаксическому анализатору. Отметим, что переменные, имеющие специальное значение, в YACC обычно начинаются с
уу. Это помогает избежать путаницы с пользовательскими переменными.
Предполагая, что входами Lex и YACC являются, соответственно,
nums. 1 и nums. у, для генерации синтаксического анализатора потребуется следующий ввод.
lex nums.1
уасс muns.у
ее -о nums у.tab.с -11 -1у
Опция -о указывает, что синтаксический анализатор должен быть создан
в файле nums, а опции -11 и -1у гарантируют включение библиотек Lex
и YACC. Для выполнения программы синтаксического анализа требуется
ввести следующее. nums <dat
Здесь dat содержит данные (выражение), значение которых требуется
вычислить.
Хотя обычной практикой является введение действий в конец правил
УАСС, можно также ввести действия в середину правил, если это не сделает грамматику неоднозначной. Например, можно представить, что при
введении действия, внутрь правила грамматика подвергается простому
преобразованию — вводится новый нетерминал, который генерирует
только пустую строку непосредственно перед позицией действия. Если
такая преобразованная грамматика остается LALR(l), таблицу синтаксического анализа можно создавать, как обычно. Рассмотрим, например,
продукцию (из LALR(l)-rpaMMaTHKH)
Х-» aLMb
с действием, введенным между 1 и М
X -»аЦасГюп();}МЬ Если замена данной
продукции продукциями
aLNMb
t{action();}
не мешает грамматике оставаться LALR(1), действие можно вводить так,
как сделано выше.
Теперь можно вернуться к примеру из раздела 4.7, где действия вводились в LL(l)-i-paMMaTHKy с целью создания из обычной (инфиксной)
формы записи постфиксной. Грамматику (в виде входа YACC) можно теперь записать следующим образом.
S
ЕХР
: ЕХР;
: TERM
| ЕХР+{А1();} TERM ДО();}
Глава 5. Восходящий синтаксический адалйЗ : 5.6. Введение в YACC
139
I EXP-{Al{);} TERM {A2();}
TERM : FACT
■}
TERM * {A1();}FACT {A2()
TERM/{Al{);}FACT {A2();} FACT :
-{Al();}FACT {A2{);} VAR {A3();}
( EXP ) ; VAR: a|b|c|d|e;
ехрг
Здесь Al () и т.п. — вызовы функций, соответствующие действиям, определенным ранее, в разделе 4.7. Пользователю не нужно заботиться о преобразованиях, задействованных при введении нового нетерминала, который генерирует пустую строку для каждого действия, не помещенного в
конец правила. Со всем этим автоматически справится YACC
5.7. Вычисление метрик
В разделе 3.5 было рассмотрено вычисление метрик исходного кода, основанное на инструментах компилятора. Также отмечалось, что некоторые метрики по своей природе являются лексическими и их вычисление
легко выполнять средствами на основе Lex. Указывалось, что природа
других метрик является синтаксической, и для вычисления таких метрик
удобно использовать средства YACC. В качестве примера простого средства вычисления метрик, которое можно создать с использованием
YACC, допустим, что сложность арифметического выражения определяется (условно) следующей формулой.
А + 2В+5С
Здесь А — число бинарных операторов сложения и вычитания, В — число унарных операторов сложения и вычитания, а С — число операторов
умножения и деления.
Ниже приводятся входы Lex и YACC для создания требуемого инструмента.
var
space
morespace
[ \n\t]
{space}+
{more space}
{return VAR;}
{var}
{return yytext[0];}
%token VAR
%left >+' '-'
%left '8' V
%%
s
: expr
{printf("%d\n", $1);},
: ехрг '+' ехрг
{$$ = $1+$3+1;}
| ехрг '- ' ехрг
{$$ = $1+$3+1;}
| ехрг '*' ехрг
{$$ = $1+$3+5;}
| е х р г '/ ' е х р г
{$$ = $1+$3+5;}
| ' - ' е х р г {$$ =
$2+2;}
1
+' ехрг {$$
= $2+2;} | 'С
ехрг •) ' {$$ =
$2;} | VAR {$$
= 0;};
%%
#include "lex.yy.c"
yyerror(s)
char *s;
{printf("%s\n", s);
main!)
{return yyparseO;
}
Для простоты выше предполагалось, что выражения составлены из
идентификаторов, скобок и операторов, каждый из которых включает
один знак. Отметим также, что в действительности не требуется подсчитывать количество операторов каждого типа.
Существует еще одна синтаксическая метрика, именуемая метрикой
Мак-Кейба (McCabe's metric). Эта метрика основана на использовании
теории графов и эквивалентна ишкломатической сложности ориентированного графа. Если управляющая структура программы (или ее части)
представлена как ориентированный граф, значение метрики Мак Кейба — это число линейно независимых частей графа управления что, в
свою очередь, равно числу решений в программном модуле плюс один. Поскольку данное понятие достаточно просто для понимания, ниже показывается, как создать средство вычисления числа решений в программном блоке. Для иллюстрации будет использован следующий модуль Pascal, определенный грамматикой в записи YACC.
proc ;
procheading block;
block constdec varciee prodecs stmpart;
stmpart :
compoundstat;
compoundstat : BEGIN stmtseq END;
140
Глава 5. В ОСХОДЯЩИЙ синтаксический анализ
Sl Выштнт ттрщ
141
stmtseq : statement | stmtseq
{$$ = 0;}
|procstat
{$$ = 0;};
statement;
statement :
compoundstat
structstat /*пустой
оператор*/
assignstat procstat;
structstat :
condstat
{$$ = $1;}
|whilestat
{$$ = $1;};
structstat :
condstat
|whilestat;
condstat :
condstat :
IF co nditio n
THEN statement
ELSE statement |IF
condition THEM
statement;
IF condition
THEN statement
ELSE statement {$$
= $4+$6+l;} |IF
condition THEN
statement {$$ =
$4+1;};
whilestat :
WHILE condition DO statement;
whilestat :
Некоторые нетерминалы приведенной выше грамматики, такие как
condition, для простоты не раскрываются. Если приравнять решение к
условию (далекс не все так интерпретируют решение!), то в грамматику
можно включить действия, вычисляющие число решений, и вывести значение метрики Мак-Кейба.
proc :
procheading block
{printf(%d\n, $2+1);};
block :
constdec vardec prodecs stmpart
{$$ = $4;};
stmpart compoundstat
{$$ = $1;};
compoundstat BEGIN stmtseq END {$$
= $2;};
stmtseq : statement {$$ =
$1;} stmtseq
{$$ = $l+$3;}; statement
statement
t и
WHILE condition DO statement
{$$ = $4+1;};
Отметим, что все переменные со знаком доллара соответствуют
"количеству решений", связанных с нетерминалом.
5.8. Использование YACC
В данном разделе рассматриваются некоторые ситуации, которые могут
возникнуть при использовании YACC, Больше узнать о том, как YACC
формирует таблицу синтаксического анализа, можно, запустив YACC в
"подробном режиме" (verbose mode), после чего получить достаточно
подробное описание в следующем файле.
у. output В среде Unix для получения этого файла нужно вызвать YACC
с опцией -v.
уасс -v nums.y
Файл у.output содержит подробности относительно состояний синтаксического анализатора. Например, вход
compoundstat
{$$'= $1;}
jstructstat
{$$ = $1;}
(/•пустой оператор*/
{$$ = О,-} Iassignstat
%token HUM
%left ' + '
%left '*'
expr : expr *+' expr
expr '*' expr
1(' expr
')'
NOM;
даст следующий выход.
state 0
$accept: _expr $end
142
Глава 5. Восхотщй синтаксический анализ
5.8. Использование УАСС
143
NUM shift 3
{ shift 2
.error
expr goto 1
state 1
$accept: expr_$end
expr: expr_+ expr
expr: expr_* expr
$end accept
+ shift 4 ;
* shift 5
.error
„expr
State 2
expr :
expr : (expr_)
+ shift 4
* shift 5
) shift 9
.error
state 7
expr : expr_+expr
expr : expr+expr_
expr : expr_* expr
* shift 5
.reduce 1
state 8
expr : expr_+ expr
expr : expr_* expr
expr : expr * expr_
(2)
.reduce 2
NUM shift 3
{ SHIFT 2 .
error
expr goto 6
state 3
expr : NUM_ (4)
state 4
expr :
. reduce 4
expr
+_expr
MUM shift 3
{ shift 2 ,
error
expr goto 7
state 5
expr : expr *_expr
NUM shift 3
(shift 2
.error
expr goto 8
state 6
expr : expr_+ expr
expr : expr_* expr
state 9
expr : { expr )_
.reduce 3
(3)
7/3000 terminals, 1/1000 nonterminals
5/2000 grammar rules, 10/5000 states
0 shift/reduce, 0 reduce/reduce conflicts reported
5/1400 working sets used
memory: states,etc. 106/40000, parser 3/70000
5/600 distinct lookahead sets
3 extra closures
14 shift entries, 1 exceptions
4 goto entries
0 entries saved by goto default
output 218/70000
Optimizer space used: input 37/40000,
218 table entries, 206 zero maximum
42
spread: 257, maximum offset:
Этот выход согласуется с грамматикой,
дополненной одним
правилом (ruleO) и снабженной аннотациями — состояниями 0-9.
accept :oexpri
expr : о,г,А,&вхрг%,б,7,в"+"
о, г, 4, $"("гехргв")"9
Вместе с информацией относительно ассоциативности и приоритетов
(которая представлена во входе YACC) данная грамматика соответствует
приведенной таблице синтаксического анализа (табл. 5.9).
144
I1 I
Глава 5. Восходящий синтаксический анализ
58. Использование YACC
145
expr : expr_* expr
+shift 4
* shift 5
.reduce 1
shift/reduce conflict (shift 4 red'n 2} on + 8;
shift/reduce conflict (shift 5 red'n 2) on *
state 8
expr : expr_+ expr expr :
expr_* expr expr : expr *
expr_
(2)
+ shift 4 *
shift 5 .reduce 2
Отметим, что в состоянии 1 свертка выполняется только при
символе предпросмотра 1. Учет ассоциативности и приоритетов операторов
приводит
к тому, что в состоянии 8 всегда выполняется свертка. Отметим, впрочем, что в
состоянии ? свертка производится не всегда (при предпросмотре символа *).
Отметим' также, что в файле у. output различается goto, за которым следует
нетерминал, и shift, за которым следует терминал, хотя ранее в главе и в
приведенной выше таблице оба случая трактовались одинаково. Должно быть
очевидно, что в файле у.output подчеркнутый символ указывает конфигурации грамматики, к которым принадлежит состояние, а символ . (точка)
указывает действие при всех неуказанных входных символах, во многих случаях — синтаксическую ошибку. Числа в скобках справа указывают соответствующие номера продукций.
Последняя часть выхода у,output содержит статистику, связанную с
грамматикой и созданной таблицей синтаксического анализа. Например:
5/600 grammar rules, 10/1000 states
Это означает, что из 600 возможных грамматических правил в грамматике присутствует 5, а из 1000 возможных состояний синтаксического
анализатора требуются 10. Размеры таблицы, используемой YACC, можно увеличить, применив флаг -N, где за н следует целое число (> 40 000 в
некоторых версиях).
Если выход порождает конфликты перенос/свертка или свертка/свертка, содержимое у.output является просто неоценимым для их
разрешения. Например, если в приведенный пример не включать определение приоритетов операторов, у,output будет идентичен полученному выше до состояния S включительно, но выход для состояний 7 и 8 будет иметь следующий вид.
7: shift/reduce conflict {shift 4 red'n 3) on + 7:
shift/reduce conflict (shift. 4 red'n 1) on *
axpr_+expr expr
state?
expr +expr_
Отметим, что состояния в обоих случаях совпадают, единственное отличие
— появление конфликтов перенос/свертка в состояниях 7 и 8, приводящих к
возникновению неоднозначности в грамматике, которая (в данном
случае) не разрешена правилами приоритетов.
Как упоминалось выше, интересная и широко известная неоднозначность появляется при использовании оператора If, следующим образом
определенного в С.
i f - st at e m e nt :i £ ' ( ' e x pr e s s i on' ) ' s t a t e m e nt
| i f ' ('e xpr e ssi on' )' s tate me nt el se
state m e nt;
При таком определении может возникнуть следующее неоднозначное
выражение.
if (x==3) if (y==5) z = б; else w = 7;
Неоднозначность заключается в том, что неясно, к какому If относится
else — к первому или второму. Легко показать, что приведенное выше
предложение имеет два левых и два правых порождения и два синтаксических дерева (см. упражнение 2.6). Язык С — не единственный, в котором присутствует подобная неоднозначность; она имеется в ряде других
языков, включая COBOL и C++, Для иллюстрации того, как YACC
справляется с такой неоднозначностью, рассмотрим следующих вход.
%token IF, ELSE, EXP '
%start statement
%%
statement: if„statement
I;
if-statement: IF '('EXP1)' statement
| IF ' ( ' E X P ' ) ' statement EXP statement; Здесь, для
простоты, EXP предполагается терминалом, а как единственная альтернатива
оператора if включается пустой оператор (частный случай
147
146
Глт 5, Востщл ташатпйтшт
5.8. Иштоввит YACC
выражения-оператора в С). При обработке подобного входа YACC содержимое у.output выгладит следующим образом.
state О
$accept: „statement $end
statement;_
statement goto 7
if-statement goto 2
7: shift/reduce conflict (shift 8, red'n 3) on ELSE
IF shift 3
.reduce 2
state 7
if_statement: IF(EXP) statement_
if_statement: IF(EXP) statement_ELSE statement
statement gotol
if„statement goto 2
ELSE shift 8
.reduce 3
state 1
$accept: statement_$end
state 8
if_statement: IF(EXP) statement ELSE_ statement
statement: _(2)
Send accept
.error
state 2
statement: if_statement_ (1)
.reduce 1
state 3
if_statement: IF_(EXP) statement
if„statement: IF_(£XP) statement ELSE statement
( shift 4
.error
state 4
if„statement: IF(_EXP) statement
if_statement: IF(_EXP) statement ELSE statement
EXP shift 5
,error
state 5
if_statement: IF(EXP_J statement
if_statement: IF{EXP_J statement ELSE statement
) shift б
.error
state 6
if_statement: IF(EXP)_ statement
if__statement: 1"P(EXP)_ statement ELSE statement
statement : _, (2)
IF shift 3
.reduce 2
Глава 5. Восходящий синтаксический анализ
IF shift 3
.reduce 2
statement goto 9 ifstatement goto 2
state 9
if„statement: IF(EXP) statement ELSE statement. (4)
.reduce 4
7/3000 terminals, 2/1000 non terminals
5/2000 grammar rules, 10/5000 states
1 shift/reduce, 0 reduce/reduce conflict reported
etc.
Это соответствует грамматике, дополненной одним правилом (продукция
0), которая, следовательно, аннотируется следующим образом.
„„„—ь.
ostatementi
accept:
о, 6, eif_statement2 о,
statement:
б, в | ;
о, «, 8lF 3 % ('jEXPs') 'в statement-; |о, в, sMV
{ ' 4 E X P s ' ) ' 6 statement? ELSEg statement 9 Получаем
табл. 5.10, где указаны действия переноса и все действия свертки,
кроме свертки согласно продукции 3 (в состоянии 7).
Алгоритма SLR(l) достаточно для разрешения любого конфликта в
табл. 5.10. В то же время внесение действия R3 в состояние 7 несколько
сложнее. Если рассмотреть последователей if_statement, то в их число
войдут ELSE и 1, и если с помещением свертки в столбец 1 никаких проблем
не возникает, в столбце ELSE обнаруживаем действие переноса. Более того,
использование более общих алгоритмов LALR(l) и LR(1) в данном случае
конфликта не разрешает. Наличие неразрешимого конфликта в таблице синif„statement:
5,8. Использование YACC
149
/48
таксического анализа не должно удивлять, поскольку, как отмечалось ранее,
исходная грамматика была неоднозначной. Напротив, если неразрешимых
конфликтов не было обнаружено, то ни для одного предложения грамматики
невозможно будет найти альтернативное порождение, что противоречит утверждению о неоднозначности грамматики.
Таблиц
а
State
0
1
2
3
4
5
6
7
8
9
5.10.
st
if-st
IF
SI
RO
R1
S2
S3
m
R1
EXP
ELSE
(
)
1
R2
R1
R1
R2
R1
S4
R1
R1
S5
■■
S6
S7
S2
S3
69
1:4
S2
R4
S3
R4
R2
S8
R2
R4
R4
R2
R4
R4
R2
R4
Xiarf представленная выше грамматика является неоднозначной, язык
таковым не является; т.е. существует однозначная грамматика, которая
генерирует тот же язык, и ее можно использовать в качестве основы ЬЯ(1)анализатора. Такая грамматика была представлена в разделе 2.6, но
для многих людей подобный способ представления языка является не
слишком естественным. Таким образом, вместо создания анализатора на
основе ухищренной и неестественной грамматики лучше воспользоваться
свойством YACC, разрешающим создавать анализаторы из неоднозначных грамматик. Анализатор для неоднозначной грамматики, синтезированный YACC, будет использовать следующее соглашение: если позиция
таблицы содержит действия переноса и свертки, во всех случаях перенос
имеет преимущество перед сверткой. Таким образом, получаем эффекTHFIHVT > )',\()п 5 11, созданную с помощью YACC.
if-st
о
1
2
3
4
да
R1
IP ELSE
S2
S3
R1
R1
EXP
7
8
л
R1
SS
S7
S2
S3
R2
S9
S2
R4
S3
Н4
S8
R2
R4
5
6
part
(
R2
R1
IVi
R4
Перенос в состоянии 7 (столбец ELSE) В состояние 8 означает, что
соотносится с ближайшим предшествующим If. Поскольку именно уако
соглашение принято в С, да и практически во всех языках с подобной
неоднозначностью, анализатор YACC, к счастью, поступает правильно.
Несмотря на возникновение конфликта в файле у.output, этот конфликт
можно просто игнорировать, если оператор if трактуется так, как было
указано. В то же время, этой практике не стоит следовать повсеместно, все
остальные конфликты нужно тщательно изучать и, по возможности,
устранять. То, что YACC имеет предопределенное действие, которое он
предпринимает при возникновении конфликта, — и хорошо, и плохо,
поскольку появляется соблазн проигнорировать конфликт, но при этом
существует вероятность неверной трактовки входа. Большинство
конфликтов следует устранять, если только предопределенное действие
YACC не является заведомо правильным. На практике, единственным
обычно игнорируемым конфликтом является описанный выше
конфликт, связанный с оператором if.
Стоит отметить, что, как видно из приведенных примеров, неоднозначные грамматики иногда предлагают наиболее простой и естественный способ представления особенностей языка. Таким образом, неоднозначные грамматики — это не так уж плохо, а иногда даже хорошо, если
имеются правила разрешения неоднозначности.
Помимо сообщений о конфликтах перенос/свертка YACC иногда выдает сообщения о конфликтах свертка/свертка. Эти конфликты встречаются намного реже, и их причина обычно кроется в каких-либо необычных свойствах языка, так что простой пример конфликта свертка/свертка
привести затруднительно. Они могут проистекать из неоднозначности
грамматики или появляться в однозначной грамматике, генерирующей
предложения, левая часть которых имеет более одного порождения. Таким образом, не всегда возможно различить альтернативные порождения
левой'части, зная левую часть плюс один символ предпросмотра (или
даже некоторое фиксированное число символов предпросмотра). Предположим, например, что на вход YACC подан следующий код.
R2
R
S
1
4
R1
R1
listl
S6
R
4
ints
reals
R4
R2
R3
R
2
R4
Iist2
ell
el2
mts з;
real
rlistl v
Iist2 '
listl s
ell;
Iist2 '
el2;
• 'a' 'b'
' int;
' real
' ell '
el2
'p';
p*
150
Глава 5. ВОСХОДЯЩИЙ синтаксический анаши
5.8, Использование YACC
151
1
С введенными состояниями, определенными YACC, получаем следующее.
$accept: opai
(part :ointS2
ints
reals
olist2 5 isreal M ;
listl : olistl*
| oell6;
Iist2 : 0list25
| 0el27;
ell
: o'a
el2
: o 4 x ' u | o 4 y ' i 2 |o*p'io
Конфликт свертка/свертка, разумеется, имеет место в состоянии 10,
где р можно свернуть либо согласно ell, либо согласно е12, и для разрешения конфликта требуется произвольное число символов предпросмотра (до определения int или real). В то же время грамматика является однозначной, поскольку для каждого предложения имеется единственное порождение. Фактически, синтаксический анализ идеально
производится путем обрат» ;ого прохода! Если запустить YACC с опцией v, файл у.output будет содержать следующее.
10: reduce/reduce conflict (red'ns 11 and 14) on :
10: reduce/reduce conflict (red'ns 11 and 14} on ,
state 10
ell : p_ (11)
e.12 : p_ (14)
.reduce 11
Как и для конфликтов перенос/свертка, YACC содержит предопределенные действия, которые предпримет анализатор, если конфликт разрешить не удастся. В данном случае это свертка согласно первой из двух
(или большего числа) продукций. В приведенном примере такие продукции — это 11 и 14, так что анализатор всегда будет выполнять свертку
согласно продукции 11, что может иметь смысл только при отсутствии
ситуаций, когда требуется альтернативная свертка. Рассмотрим следующее предложение, которое генерируется исследуемой грамматикой.
р , х , у : real
Согласно правилу р сворачивается в ell, которое, в свою очередь,
сворачивается в listl. Затем считывается запятая, и ожидается, что следующий символ также будет ell. В то же время х не является экземпляром ell, и получаем указание на синтаксическую ошибку, хотя во введенном предложении ее нет! Мало того, трудно сказать, как может по -
/52
Глава 5. Восходящий синтаксический анализ
YACC для улучшения ситуации. Понятно, что постоянная свертка согласно продукции 14 приведет к аналогичной кажущейся синтаксической ошибке. Похоже, что единственный подход к решению проблемы — использовать произвольное число символов предпросмотра, хотя,
возможно, корни проблемы лежат в языке, предъявляющем неразумные
требования к синтаксическому анализатору! Конфликты свертка/свертка
всегда должны исследоваться; предопределенные действия YACC, скорее
всего, окажутся неудовлетворительными.
Если созданный YACC синтаксический анализатор работает, как ожидалось, подробную информацию по его работе можно получить, используя YACC в режиме отладки с установкой флага -t при выполнении.
Полученная информация объемна, и ее не стоит использовать постоянно. Впрочем, иногда она является единственной возможностью разобраться в проблемах, возникающих при использовании YACC.
Действия, введенные в грамматику YACC, могут использоваться для
обнаружения не-контекстно-свободных ошибок. Хотя, как отмечалось
ранее, восстановление после подобных ошибок обычно легче восстановления после контекстно-свободных, можно сделать так, что при появлении не-контекстно-свободных ошибок YACC будет действовать так же,
как и при появлении контекстно-свободных, вызвав макрос YYERROR.
Восстановление после ошибок — это сложный вопрос, который далее
рассматриваться не будет. Отметим, впрочем, что YACC предоставляет
средства реализации относительно изощренных стратегий восстановления после ошибок, а также достаточно простой и редко удовлетворительной стратегии прерывания анализа при выявлении первой контекстносвободной ошибки.
Итак, представлены основные особенности YACC. Вообще, свойства
YACC несколько меняются в зависимости от реализации, так что не все,
сказанное здесь, может быть применимо к вашей версии YACC. Кроме
того, могут иметься свойства, вообще не описанные здесь. В целом вход
YACC достаточно переносим, если избегать некоторых архаичных функций, использованных в ранних версиях YACC.
ctynmb
5,9, Резюме
В данной главе было сделано следующее,
• Введена концепция восходящего синтаксического анализа,
« Показано, как процесс анализа может направляться таблицей синтаксического анализа.
• Введены понятия конфликтов перенос/свертка и свертка/свертка.
• Показано, как состояния синтаксического анализатора могут опре
деляться через конфигурации грамматики.
5.9. Резюме
153
•
•
Продемонстрированы алгоритмы LR(0) и SLR(l) формирования
таблицы синтаксического анализа.
Представлено средство генерации синтаксических анализаторов
YACC.
•
Показано, как в грамматику YACC можно ввести действия време
ни компиляции.
« Показано, как можно разрешить проблемы, возникающие при использовании YACC.
Дополнительная литература
I
I
Понятие LR-анализа было введено Кнутом в работе [Knuth, 1965], а
практически было развито в книге [De Reemer, 1971]. Использование
LR-анализа с неоднозначными грамматиками было рассмотрено в работе
[Aho, Johnson and Ullraan, 1975].
YACC — это лишь один (хотя, пожалуй, наиболее известный) из
множества генераторов синтаксических анализаторов, доступных в наши
дни через Internet. Он был разработан Джонсоном [Johnson, 1975] и описан (хотя и не всегда достаточно подробно) во множестве работ, посвященных компиляторам, например, [Aho, Sethi and Ullman, 1985] и
[Benett, 1990J. Нельзя сказать, что работа [Levine, Mason and Brown, 1992]
целиком посвящена компиляторам, но в ней присутствует достаточно
подробное описание YACC (и Lex). Создание компилятора с использованием YACC освещается в работе [Schreiner and Freidman, 1985].
Родственное YACC средство Bison было представлено в работе [Stallman,
1994], a Gnu Bison доступно (благодаря Free Software Foundation) на многих
Internet-сайтах (см. раздел дополнительной литературы в главе 1).
1. S->axF
2. F-+.JF
3. |е
4. J-* ах
5. |х
5.6. Покажите, что грамматика со следующими ниже продукциями не
относится к классу LR(1).
S-> 1SO S>0S1
S-4 10
S->01
5.7. Покажите, что грамматика со следующими ниже продукциями не
относится к классу LR(1), но относится к классу LR(2).
S-> V:=E
S-+LS £,>/:
Каждый знак считается отдельным символом. Предложите, как
можно проводить лексический анализ для грамматики, не являющейся LR(1).
5.8. Изучите, как ваша версия YACC работает с грамматиками, представленными в упражнениях 5.6 и 5.7. В каждом случае исследуйте
содержимое файла у. output.
5.9. Приведите аргументы за и против использования макроса YYERRQR.
5.10. Приведите пример однозначной грамматики, которую не сможет
обработать YACC.
Упражнения
5.1. Объясните, почему восходящий синтаксический анализ применим
более широко, чем нисходящий.
5.2. Что подразумевается под конфликтами перенос/свертка и сверт
ка/свертка в восходящем синтаксическом анализе?
5.3. Объясните, почему неоднозначная грамматика не может относиться
к классу LR(1).
5.4. Объясните, почему действительно необходимым для управления
процессом синтаксического анализа является один из двух стеков
(стек состояний и стек символов).
5.5. Создав таблицу синтаксического анализа, докажите, что грамматика
с указанными ниже продукциями относится к классу LR(1) (см.
раздел. 5.5),
154
Глава 5. ВОСХОДЯЩИЙ синтаксический анализ I Упражнений
155
Глава 6
Семантический анализ
6.1. Вступление
Как уже упоминалось в главе 1, некоторые характеристики языков программирования не являются контекстно-свободными, следовательно, их
нельзя определить с помощью контекстно-свободных грамматик. В этой
главе рассматриваются аспекты семантического анализа, в частности,
анализируются не-контекстно-свободные аспекты языков программирования. В этой главе будут рассмотрены следующие вопросы.
• Характеристики языка, не являющиеся контекстно-свободными.
• Усовершенствование контекстно-свободного синтаксического анали
затора путем введения протабулированных действий для проверки не
контекстно-свободных характеристик языков программирования.
• Практические методы реализации таблиц символов и типов.
• Некоторые моменты, относящиеся к объектно-ориентированным
языкам.
1.2. Не-контекстно-св
изыка
характеристик!
Каждая программа данного языка будет иметь, по меньшей мере, одно
синтаксическое дерево (и по меньшей мере, одно левостороннее и правостороннее порождение), которое может быть использовано для отображения ее порождения. В то же время не каждое синтаксическое дерево,
которое можно сгенерировать грамматикой языка, соответствует корректной программе. В подтверждение этого существуют синтаксические
деревья, основанные на созданной ANSI контекстно-свободной грамматике для С, которые соответствуют обеим следующим программам.
tinclude <stdio.h>
main
() {
int first, second;
first = 4;
second = 5;
printf <"%d",
int p = 4.3;
real x = 2; int
x = •a'; int x
= NULL;
first + second);
iinclude <stdio.h>
main {)
first = 4;
second = 5;
printf <«%d", first + second);
При этом первая программа компилируется и выполняется, а вторая J
приводит к появлению сообщений об ошибках (в Borland Turbo С).
j
Error 622.С 5: Undefined symbol 'first' in function mainO
\
Error 622.С 6: Undefined symbol 'second' in function mainO !
Первая программа корректна в С, а вторая — нет (переменные first и •
second не были объявлены). Другими словами, появление в программе пе- J
ременных подразумевает, что в каком-то месте программы должны быть их j
объявления. Следовательно, существуют ограничения на способ производства
порождения. Контекстно-свободные грамматики не имеют механизма определения таких ограничений (в действительносги, этого еще никто не доказал!), следовательно, не могут применяться для точного определения, что составляет программу на С. В то же время контекстно-свободные грамматики
можно использовать для определения расширенного множества всех про- (
грамм на С, расширенного множества всех корректных программ, а также
расширенного множества всех программ, некорректных вследствие неконтекстно-свободных сбоев, подобных отсутствию объявления переменных.
Проиллюстрируем другую категорию не-контекстно-свободных дефектов
на следующем примере.
iinclude <stdio.h>
tinclude <stdio.h>
int bigger{int nol,
main () {
int first;
int 'second [5] = {6,8,4,5,2};
first = second;
printf (vv%d", first); }
int no2)
if {nol > no2) return nol;
else return no2;
Очевидно, что проблема этой программы заключается в несовместимости
типов по обе стороны оператора присваивания:
first = second;
В целом, язык С достаточно терпим к так называемым ошибкам ти*
пта; ни один из следующих операторов присваивания не породит сообщения об ошибке.
158
В приведенных выше примерах при необходимости осуществляются преобразования целого числа в действительное и действительного в целое. Также
выполнялось преобразование знака в целое — замена знаков их ASCIIэквивалентом (который, в любом случае, используется для внутреннего представления знаков). При этом философия С заключается в выполнении всех
преобразований типов, которые требуются по контексту, так что при появлении величины с несоответствующим контексту типом ее тип будет преобразован в более подходящий. Значение NULL В приведенном выше примере
преобразовывается в целое 0. Если "разумную" замену произвести невозможно, имеем ошибки компиляции, как в приведенном примере присвоения
целому числу целочисленного массива.
В других языках, таких как Ada и ALGOL 68, типы трактуются строже, поэтому неявные преобразования либо вообще отсутствуют, либо их
очень мало. Это связано с тем, что при таком подходе в процессе компиляции будет обнаружено большинство программных ошибок. Такие языки называются языками со строгим контролем типов (strong typing).
Другие языки имеют не статические, а динамические типы, когда тип величины неизвестен в процессе компиляции и должен определяться уже
во время выполнения программы. Это означает, что во время выполнения программы также должны осуществляться преобразования типов, для
чего следует сгенерировать код еще в процессе компиляции. Позже, при
рассмотрении генерации кода, будут приводиться примеры действий, что
могут выполняться во время компиляции, а также действий, для которых
в процессе компиляции нужно сгенерировать код, который позволит
произвести действия во время выполнения программы.
На следующем примере иллюстрируется еще один тип не-контекстносвободных дефектов, которые могут возникнуть в программе.
Глава 6. Семантический шл№
main{
int first, second;
first = 4; second =
5;
second = bigger (first)
printf ("%d", second);
6-2, Не-конгекстно-свабодные характеристики языка
159
Функция bigger определена с двумя параметрами, но из main она вызывается только с одним параметром, что приводит к выдаче следующей
ошибки в процессе компиляции.
Error 624.С 14: Too few parameters in call to "bigger' in
function main
Типы параметров в вызове функции также должны соответствовать
типам в объявлении. В языке С, разумеется, изменение типов между int,
float и char осуществляется автоматически. В то же время несоответствие между параметром типа int в описании функции и массивом чисел
типа int, используемым в качестве данного параметра функции, будет
ошибочным.
Подобного рода ошибки могут возникнуть в индексах массивов, как
это показано на следующем примере.
#include <stdio.h>
данного кода будет иметь такой вид.
локальное р = 4
более локальное р = 11
глобальное р =7
В то же время следующая ниже программа на С некорректна, поскольку
было удалено объявление глобального р, и ни одно из других объявлений
р не находится в области видимости £un2 () :
#include <stdio.h>
void funl()
{int p = 4;
printf ("локальное р = %d\n", p) ;
{int p = 11;
printf ("более локальное р = %d\n", p) ,-
main ()
{
int number;
int matrix [3][2] = {{4,5},
{8,9},
{11,12}};
number=matrix [1,1,1];
printf <"%d", number); }
void fun2()
Здесь к массиву matrix, определенному как двумерный, обращаются как
к трехмерному массиву.
Несколько иная разновидность контекстно-свободных дефектов возникает в связи с определенными в языке правилам области видимости.
Проиллюстрируем это на следующем примере. #include <stdio.h>
{printf ("глобальное p = %d\n", p) ;
main{)
{funlO ;
fun2(); }
Эта программа даст при компиляции следующую ошибку.
Error 628.С 14: Undefined, symbol
int p = 7;
void funl()
{int p = 4;
printf ("локальное р = %d\n",p);
{int p = 11;
printf ("более локальное р = %d\n",p);
void fun2()
{printf ("глобальное р = %d\n", p)
main()
/60
{funlO;
£un2(); }
Глава 6. Семантический анализ
ч
р' in function £un2
Ошибки времени компиляции, связанные с рассмотренными выше
"программами", не могут выявляться программой синтаксического анализа, основанной исключительно на контекстно-свободной грамматике.
Иначе говоря, синтаксический анализатор, построенный с помощью
YACC, не сможет обнаружить ни одной из подобных ошибок, обратившись к "пустой" позиции (соответствующей синтаксической ошибке) в
созданной УАСС таблице 1АЬЩ1)-анализа. Таким образом, для обнаружения этих ошибок потребуются дополнительные проверки. Тот факт,
что данные типы ошибок не приводят к появлению ошибочной записи в
таблице синтаксического анализа, значительно упрощает восстановление
после них, поскольку для продолжения анализа не нужно делать какихто предположений об ошибках программист*.. Операция, выявляющая
ошибки, может просто сообщать о проблеме и продолжить синтаксический
анализ. Обычно программисту проще произвести подробную диаг2. Не-контекстно-свободные характеристики языка
161
ностику на предмет наличия не-контекстно-свободных ошибок, чем найти контекстно-свободные ошибки.
В следующем разделе рассмотрим природу действий, которые нужно
добавить к программе синтаксического анализа для обнаружения ошибок
типов и области видимости. В принципе, эти типы дефектов легко обнаружить, поскольку вся необходимая информация считывается анализатором до места появления ошибки. Если же информация, требуемая для
обнаружения дефекта, находится после места его проявления, потребуется дополнительный проход или проходы. Впрочем, чтобы требуемая информация была доступна, следует использовать таблицы с информацией
о типах и области видимости. Создание и структура таких таблиц рассматриваются в двух последующих разделах.
6.3. Таблицы компилятора
В процессе компиляции анализатору требуются две основные таблицы.
• Таблица символов.
• Таблица типов.
Подробно данные таблицы рассматриваются ниже. Кроме того, необходимыми являются следующие таблицы.
« Таблица функций.
• Таблица меток.
Эти таблицы рассматриваются в разделе 6.3.3.
6.3.1. Таблицы символов
Основная задача таблицы символов — установить соответствие между переменной и ее типом. С таблицей символов связаны следующие две основные операции.
• Соответствующая определяющему вхождению переменной, например,
int х
Имя переменной и ее тип помещаются в таблицу символов.
• Соответствующая применимому вхождению переменной, например,
х = 5;
Исследуется таблица символов для нахождения типа переменной.
Сложность таблицы символов и процедур работы с таблицей зависят от:
• языка реализации;
• важности эффективной компиляции.
Необходимо отметить, что неверным будет предположение о том, что
только одна переменная в программе может быть представлена иденти-
162
фикатором х, поскольку, в общем случае, в программе может находиться
произвольное количество переменных с именем х. Таким образом, для
каждого применимого вхождения переменной х определяется позиция
таблицы символов, соответствующая подходящему определяющему вхождению переменной х.
Форма таблицы символов, требуемой для анализа одной функции
языка С, является простой. Рассмотрим схему функции С.
void scopes()
{int a,b,c;
/* уровень 0 */
{int a,b;
/* уровень la */
}
{float c,d; /* уровень lb */
{int m; /* уровень 2 */
Таблицу символов можно представить с помощью растущих вверх
стеков, если поиск в ней определяющего вхождения идентификатора
осуществляется сверху вниз, и позиции удаляются из стека после выхода
переменной из области видимости. Используя в качестве иллюстрации
приведенную выше схему функции, покажем состояния стека на различных этапах анализа.
Изначально таблица символов пуста:
После обработки первых трех объявлений таблица имеет следующий вид.
с int
b
int
а
int
После обработки объявлений уровня 1а таблица имеет такой вид.
b
int
а int
с int
Ь int
а
int
Поскольку поиск в таблице символов осуществляется сверху вниз, будут идентифицированы позиции, соответствующие наиболее недавним
(или наиболее глубоким) определяющим вхождениям а или Ь. После
прохождения области видимости, соответствующей объявлениям 1а, позиции, соответствующие этим объявлениям, должны удаляться из таблицы символов, таким образом, таблица вернется к прежнему виду.
Глава 6. Семантический анамШ 6.3. Таблицы компилятора
i ■
I
с
b
a
int
int
int
В это же время значение указателя стека уменьшается до значения,
имевшегося перед обработкой объявлений уровня 1а. Чтобы сделать это,
требуется поддерживать массив указателей стека. После обработки объявлений уровня 1Ь стек принимает следующий вид.
float
d
float
с
с
b
а
int
int
int
После обработки объявлений уровня 2 таблица имеет такой вид.
т. int
float
d
float
с
с
Ъ
а
int
int
int.
После прохождения области видимости объявлений
вращается к прежнему значению.
а
с
с
b
а
уровня ? стек воз-
float
float
int
int
int
После прохождения области видимости объявлений уровня 1Ь он вновь
имеет следующий вид.
с
b
int
int
int
а
После выхода из функции таблица символов снова становится пустой
Существует несколько моментов, на которые стоит обратить внимание.
Во-первых, для глобально объявленных переменных (между определениями
функций) необходим нижний (или внешний) уровень стека, существующий
во время всего процесса компиляции. Отметим также, что при рассмотрении
операций таблицы символов внешняя переменная (для которой память выделяется в другом1 исходном файле) трактуется так же, как и глобальная.
С точки зрения таблицы символов статическая переменная трактуется так же, как и автопеременная (подобная рассмотренным в примере),
хотя при распределении памяти работа с этими переменными достаточно
отличается.
164
Глава 6. Семантический анализ
На практике таблица символов может иметь более двух полей. Например, дополнительное поле может использоваться для указания того,
ФТНОСИТСЯ ли идентификатор к переменным или к константам. Еще одно
роле можно использовать для хранения констант или адресов переменных времени компиляции, хотя значение этого поля будет неизвестным
до момента распределения памяти.
Если линейный поиск оказывается неэффективным для нахождения
определяющего вхождения идентификатора, более эффективные алгоритмы поиска могут дать более сложные структуры данных, такие как
бинарные деревья для различных уровней стека. Методы, рассмотренные
здесь в связи с лексическим и синтаксическим анализом, требуют времени, пропорционального длине программы, К сожалению, того же нельзя
сказать для не-контекстно-свободного анализа, который часто называют
статическим семантическим анализом. Чем больше программа, тем больше некоторые ее таблицы (например, таблица символов) и тем больше
времени будет занимать поиск в них. Это означает, что полное время
компиляции может быть нелинейной функцией размера программы и
для длинных программ может оказаться несоразмерно большим.
Стековое представление таблицы символов, подходящее для языка С,
будет неадекватным для языков с более сложными правилами обзора,
например, для языка Ada. Рассмотрим следующий фрагмент программы
на языке Ada.
procedure main is
x: integer;
procedure inner is
x:character;
begin x := 'A'; put
(x); put
(main.x); end inner;
begin x:= 4;
inner;
put (x);
end main;
Результат вызова процедуры main будет иметь следующий вид. А
4 4
Видно, что объявленная в процедуре main целая переменная х не является полностью скрытой внутри процедуры inner, и доступ к ней можно
Получить посредством обращения main.x. Очевидно, что этот факт должен отражаться в таблице символов для реализации языка Ada, возможно, посредством присваивания имен разделам таблицы символов.
Еще одной интересной особенностью Ada, касающейся структуры
таблицы символов, является оператор use, например,
uee stack
Таблицы компилятора
165
Здесь stack — имя пакета, содержащего процедуры добавления и удале.
ния элементов из стека, имена которых оператор use делает видимыми,
не показывая при этом подробностей реализации стека. Из правил языка
Ada следует:
• оператор use может сделать идентификатор непосредственно ви
димым, только если тот не является непосредственно видимым
при отсутствии этого оператора;
• идентификатор, ставший непосредственно видимым при использо
вании оператора use, должен объявляться в одном и только одном
пакете, указанном в операторе use.
Учитывая эти правила, необходимо вставить корректную реализацию
оператора use в видимую часть сегмента таблицы символов, указанную в[
операторе use "вверху" таблицы символов (таблицу символов представ-1
ляем как стек, а "верх" — это текущее значение стека). После того, как : '
завершится область видимости оператора use, данную часть таблицы'
символов можно будет удалить.
Типы соотносятся не только с переменными и константами; каждый эле- \
мент выражения имеет соответствующий ему тип, в том числе это относится:
• к литералам, таким как 3, 2 3 . 4 , true;
« к выражениям, таким как 3 + 4.
Типы литералов обычно определяются при лексическом анализе
(это не всегда справедливо для языка Ada, где для определения типа!
литерала может потребоваться достаточно сложный анализ). Например'
(предполагается использование языка С), 3, очевидно, имеет тип int, a!
2 3 . 4 — float. Типы выражений определяются по типам их компонен-:
тов. Поскольку 2 3 . 4 и 3 4 . 2 имеют тип float, то выражение
166
>
23.4+34.2
|
также будет иметь тип float. Подобным образом выражение
!
23.4+5
будет иметь тип float. Это можно объяснить двумя способами, в зави-'
симости от рассматриваемого языка.
• Переменная типа float плюс переменная типа int дает перемен-;
ную типа float.
• Сложение определено только для переменных одного типа, по -]
этому перед сложением целое 5 должно преобразовываться 8 1 '
тип float.
В большинстве языков программирования имеет место неявное
изменение типов (иногда называемое приведением типов (coercion))1
Реже встречаются языки, подобные Ada, в
Глава 6. Семантический анал# f 3. Таблицы компилятора
которых большинство из-"' менений типов
должно быть явным. В языке С явно
заданные изме*-
нения называются приведением типов (cast)', примером подобного
^ожет служить следующее выражение.
х - float (m)
Здесь значение m (предполагаемое целым) преобразовывается в тип
float перед присвоением его значения переменной х.
В языках со статическими типами, например С, все типы известны во
время компиляции, и это относится к типам выражений, идентификаторам и литералам. При этом не важно, насколько сложным является выражение: его тип может определяться во время компиляции за определенное количество шагов, исходя из типов его составляющих. Фактически, это позволяет производить контроль типов во время компиляции и
находить заранее (в процессе компиляции, а не во время выполнения
программы!) многие программные ошибки.
6.3.2. Таблицы типов
В компиляторе должен существовать способ уникального представления
каждого типа конкретной программы. Если исходный язык содержит
только конечное число типов, для представления разрешенных типов
можно использовать различные целые числа. Некоторые ранние языки,
такие как FORTRAN, подобное позволяли, однако, более поздние языки
в общем случае уже нельзя рассматривать так просто. При рассмотрении
подходящего представления типов в программе необходимо принять во
внимание следующие факторы.
• Высокая структурированность и рекурсивная природа многих типов.
• Общие операции, которые компилятор должен будет производить
над, типами.
Общими операциями над типами в С являются следующие.
• Нахождение типа поля элементов struct или union.
• Нахождение типа элемента массива.
• Нахождение типа результата функции.
Основные типы, такие как int, float и char, могут представляться в С
посредством целых чисел, а составные типы, например, array и union,
могут представляться как структуры. Например, тип typedef t y p e d e f
struct {
int day;
int mth;
int year;
}dob;
На русский язык слова "cast" и "coercion" переводятся одинаково —
"Ривсдание , хотя происхождение этих терминов разное: "cast" используется при
писании языка С, a "coercion" — при описании языка ALGOL 68. - Прим ред.
167
можно представить с помощью структуры, изображенной на рис. 6.1,
тип typedef
int
day
г
int mth
r
int year
Рис. 6,1.
typedef long int [ 9 ] { 1 9 ] matrix; можно представить с помощью
структуры, показанной на рис. 6.2, и т.д.
агг
9
агг
19
чг
long
int.
Рис. 6.2.
Этот способ представления позволяет сравнительно легко выполнять
обычные операции над типами. Остается всего лишь определить массив
(таблицу типов), который отображает имена типов в указатели на структуры типов. Поскольку используемые в программе имена типов, как и
другие имена, имеют области видимости, в таблице также должна существовать возможность отображения областей видимости подобно тому,
как это сделано в таблице символов. В простых случаях достаточной является стековая структура таблицы.
6.3.3. Другие таблицы
Некоторым образом функция подобна типу, с ней соотнесены подтипы,
т е. типы параметров и результатов функции. В процессе генерации кода
ей также выделяется адрес, а вся информация об этом хранится в таблице времени компиляции, обзор которой производится согласно соответствующим правилам языка.
Как будет показано в главе 8, управляющие структуры в языках высокого
уровня должны бьпъ представлены в целевом коде посредством переходов
(условных или иных) и меток. Таким образом, целевые версии исходных
программ будут содержать как метки, определенные пользователем, так и
метки, определенные компилятором. Структура многих языков позволяет
использовать таблицу стекового типа для связывания определяющего вхождения метки и применимого вхождения метки. Из этого следует, что, повидимому, в процессе компиляции (и во время выполнения) придется использовать несколько стеков. Управление стеками, в смысле выделения каждому из них достаточной памяти, становится сложным, если число стеков
больше двух, так что значительные преимущества дает возможность объединения множества стеков в единую стековую структуру. К счастью, правила
обзора многих языков такие возможности предостаатяют.
6.4. Реализация наследования в C++
На данном этапе будет уместно немного рассказать о реализации объектно-ориентированных языков программирования на примере C++. Основными характеристиками объектно-ориентированных языков программирования являются:
• абстракция данных и инкапсуляция;
• полиморфизм;
• наследование.
Абстракцию данных можно проиллюстрировать с помощью классов C++.
Рассмотрим следующий пример, class complex {
float real, imag;
public: complex (float = 0 . 0 , float =
0 . 0 ) ; complex {const complexfe); float
get__real () const; float get_imaq {) co nst; };
Класс complex определяется вместе с некоторыми операциями для создания числа типа complex и для нахождения его действительной и мнимой частей. Полиморфизм можно проиллюстрировать с помощью конструкторов (constructor function), которые ведут себя по-разному, в зависимости от того, имеют ли они два параметра типа float или один типа
complex. Полиморфизм позволяет различать функции или процедуры с
Число других таблиц, необходимых в процессе компиляции, определенным образом зависит от компилируемого языка. Обычно используются
следующие таблицы.
• Таблица функций.
• Таблица меток.
168
Глава 6. Семантический ана/йй \ 6.4. Реализация наследования в C++
169
одинаковыми именами, поскольку типы и число их параметров не совпадают. Это означает, что в данном примере конкретную функцию можно
определить посредством типа ее параметров. Понятие полиморфизма уже
применяется во многих языках для операторов, которые могут иметь различные значения в зависимости от типов их операндов. Простым примером может служить оператор + в С, который имеет разные значения в зависимости от того, имеют ли его операнды тип float или int. Полиморфические функции — это просто расширение понятия функции,
реализация которого является более сложной.
Понятие наследования проиллюстрируем на следующем примере:
Г;
class addcom: public complex
{public: adds (const complex&, const complex&) , }
Здесь определяется новый класс addcom, который наследует все свойства
класса complex, а также имеет дополнительный оператор.
Если новый класс является наследником только одного порождающего
класса, как в языке Java, имеем дело с единичным наследованием, при этом новый класс имеет все поля и методы своего порождающего класса плюс поля
и методы, которые относятся только к нему самому. В языке Java класс может содержать описание метода с таким же названием, что и метод порождающего класса. В этом случае метод порождающего класса не будет наследоваться, а будет переопределяться как метод класса с таким же названием
(предполагается, что метод порождающего класса не является абстрактным).
Реализация наследования скорее основывается на содержании объекта,
чем на его типе, следовательно, имеет динамическую (проявляется во
время выполнения), а не статическую (проявляется во время компиляции) сущность. При единичном наследовании это достаточно просто. С
другой стороны, множественное наследование, при котором класс может
иметь несколько порождающих классов, более сложно в реализации.
Примером множественного наследования может служить реализация стека, который наследует методы абстрактного класса stack и конкретного
класса array. Читателю, интересующемуся данными вопросами, стоит
обратиться к соответствующей литературе, которая приводится ниже.
6,5, Резюме
Настоящая глава посвящена тому, как можно анализировать и реализовать характеристики языка, которые нельзя описать с помощью контекстно-свободной грамматики. В частности, было сделано следующее.
• Показано, что не-контекстно-свободные характеристики языка
обычно связаны с типами и правилам обзора языка.
• Показано, как можно использовать таблицу символов для хране
ния информации, необходимой для нахождения не-контекстносвободных дефектов программы.
ПО
Глава 6, Семантический анализ
Продемонстрировано, каким образом структура таблицы символов
зависит от компилируемого языка, а также приведен ее вид для
компилятора С.
Предложена оптимизация таблицы символов.
Рассмотрена роль, которую таблица символов играет в компиляторе, и предложена форма, удобная для выполнения этой роли.
Обсуждена необходимость других таблиц в процессе компиляции.
Указано, как можно реализовать типичные характеристики объектно-ориентированных языков, таких как С и Java.
Дополнительная литература
Использование таблиц символов описано во всех вводных книгах по
компиляторам, указанных ранее. Структуры данных для таблиц символов, а также алгоритмы их поиска обсуждаются в [Aho, Hopcroft and Uliman, 1974]. Эквивалентность типов (очень важный и непростой вопрос
при рассмотрении таблицы типов) и ее проявления для языка Pascal рассмотрены в [Welsh, Sneeringer and Hoare, 1977J. Реализация единичного и
множественного наследования изложена в [Wilhelm and Maurer, 1995].
Упражнени!
6.1. Напишите список не-контекстно-свободных характеристик С или
любого другого известного вам языка программирования.
£2. Будет ли, по вашему мнению, таблица символов для программы на
С иметь множество различных уровней стека на любом этапе синтаксического анализа?
6.3. В FORTRAN тип идентификатора можно определить из первой бу
квы его имени. Исчезает ли при этом необходимость в таблице сим
волов? Ответ аргументируйте.
6.4. В некоторых языках, например, ALGOL, переменные и т.д. могут
объявляться уже после их использования. Как это влияет на проце
дуры работы с таблицей символов?
6.5. Опишите рекурсивные типы в знакомом вам языке.
6.6. Назовите основные характеристики объектно-ориентированных
языков.
6.7. Приведите аргументы за и против использования множественного
наследования.
Дополнительная литература
171
Глава 7
Распределение памяти
7.1. Вступление
До этого момента рассматривался, в основном, этап анализа процесса
компиляции, а сейчас мы переходим к рассмотрению этапа синтеза. Этап
синтеза тесно связан с генерацией кода, а важным и родственным этой
теме вопросом является распределение памяти. Связь между генерацией
кода и распределением памяти следующая; распределение памяти обычно
рассматривают как отдельную фазу процесса компиляции, которую, по
необходимости, вызывает генератор кода. Поэтому (а еще потому, что с
данной темой связано много интересных вопросов) данная глава полностью посряшена распределению памяти. В данной главе будут рассмотрены следующие вопросы.
• Типы объектов, для которых нужно выделять память.
• Влияние ожидаемого времени существования объекта на механизм
выделения для него памяти.
• Распределение памяти для конкретных языковых характеристик.
• Основные используемые модели распределения памяти.
7.2. Память
Для начала обсуждения следует определить отличие объектов от их значений. Переменная х является объектом, который в данное время может
иметь соотнесенное с ним значение, занимающее определенный объем
памяти. Память, выделенная значению х, имеет адрес, который позволяет
обращаться к х, причем адрес должен иметь следующие свойства.
• Быть достаточно (но не слишком) большим, чтобы вместить любое
из значений, которое может принимать х.
• Быть доступным в течение всего времени существования х.
• Должна существовать возможность его выражения в такой форме,
чтобы генератор кода мог пользоваться адресом для получения
доступа к значению х во время выполнения программы.
Относительно первого требования следует сказать, что целые значения обычно занимают меньше памяти, чем действительные, а сим вольные значения могут занимать меньше памяти, чем целые. В то
же время, для обеспечения эффективного доступа не всегда имеет
смысл уплотнять в памяти значения до максимально возможной степени (в любом случае экономия получается мизерная). Отметим также, что некоторые языки позволяют пользователю определить, требуется ли уплотнять значения.
Память требуется для значений записей, массивов и указателей. Память,
требуемая для записей, обычно равна сумме объемов памяти, требуемых для
каждого поля записи. Для массивов требуется больше памяти, чем для составляющих их элементов; избыток зависит от способа хранения массива.
Кроме того, некоторые языки допускают наличие у массивов динамических
границ, следовательно, в процессе компиляции размер массива неизвестен и
будет определен уже во время выполнения программы. Объем памяти, необходимой для указателей, зависит от реализации.
В связи с, тем, что память выделяется на все время жизни переменной, возможны следующие ситуации.
• Время жизни переменной равно времени жизни программы. В
этом случае выделенная для переменной область памяти уже не
может быть освобождена. Такую память называют статической.
• Переменная объявляется в каком-то конкретном блоке, функции
или процедуре. В этом случае после завершения выполнения бло
ка, функции или процедуры выделенную для переменной память
можно освободить. Такую память называют динамической.
« Память может выделяться значениям, не обязательно соотнесенным с переменными, в определенный момент выполнения
программы, не обязательно совпадающий с началом блока или
входом процедуры. Таким образом, память выделяется в этот
момент времени и существует до тех пор, пока не будет освобождена — либо посредством соответствующего механизма
языка, либо после того, как просто станет недоступной для
программы. В то же время сам момент освобождения памяти, в
общем случае, может не определяться при компиляции, а ста нет известным только во время выполнения программы. Такую
память называют глобальной.
Требования к статической памяти полностью определяются во
время компиляции, так что необходимый объем может быть выделен.
Поскольку выделенную статическую память освободить невозможно,
общий объем такой памяти является суммой ее частных составляющих, при этом какое-либо "совместное использование" этой памяти
невозможно. Управление статической памятью является простым. К
примеру, для программ на языке FORTRAN все требования к памяти
являются статическими.
174
Глава 7. Распределение памяти
Требования к динамической памяти программы сложнее, поскольку
память распределяется на входе функции (блока или процедуры в зависимости от рассматриваемого языка), а освобождается после выполнения
функции (блока или процедуры). В этом случае существует возможность
совместного использования этой памяти значениями, относящимися к
различным функциям и т.д. Оказывается, что управление этим типом
памяти не настолько сложно, как может показаться на первый взгляд, и
его легко осуществить посредством механизма стека, который увеличивается и уменьшается при выделении и освобождении памяти. Несколько
подробнее данный вопрос будет рассмотрен ниже.
Распределение глобальной памяти осуществляется достаточно просто:
область пространства (обычно называемая кучей) увеличивается настолько, насколько это необходимо. Освобождение этой области памяти осуществляется намного сложнее, поскольку данный процесс трудно связать
с процессом распределения памяти. Существует два основных вопроса,
связанных с распределением и освобождением глобальной памяти.
• Доступность памяти для освобождения определяется во время вы
полнения программы, что неизбежно приводит к некоторого рода
служебным издержкам при выполнении программы.
• После освобождения некоторого участка памяти в куче возникают
чистые участки, которые обычно требуют сжатия для более эф
фективного использования памяти.
Позже в этой главе (раздел 7.5) будет рассмотрено, как в куче определяется пространство для повторного распределения и как происходит
сжатие кучи. На данный же момент просто отметим, что стек и куча могут удобно сосуществовать вместе, если их увеличение происходит по направлению друг к другу (рис. 7.1). В этом случае область статической памяти может размещаться на одном или другом конце пространства памяти, как это изображено на рисунке. Вмешательство извне потребуется
только в том случае, когда взаимное расширение стека и кучи приведет к
их "встрече", т.е. нехватке памяти. Обычно в подобном случае определяется недоступное пространство кучи и происходит ее сжатие.
Статическая
Стек
Куча
память
РИС. 7. ?.
При рассмотрении адресов переменных и т.д. следует отметить, что
иногда (например, при использовании статической памяти) адреса времени выполнения известны во время компиляции. В то же время чаще
имеем обратную ситуацию, когда адреса времени выполнения должны
вычисляться, исходя из множества факторов, часть которых известна в
процессе компиляции, а часть неизвестна до начала выполнения про 7.2. Память
175
"Т
граммы. В этих случаях аспекты адреса, известные при компиляции, называются адресом времени компиляции.
В языке С для переменных имеется четыре возможных класса памяти:
static, auto, extern и register. Для статических переменных память выделяется
на все время программы. Дта переменных класса auto (класс по умолчанию)
память выделена до момента завершения работы составного оператора
(термин С соответствует блоку в некоторых других языках), в котором были
объявлены данные переменные. Таким образом, более удобной для данных
переменных является память в стеке. Для переменных класса extern память
выделяется в другом файле. Значения переменных класса register хранятся в
регистре, если компилятор способен организовать это удобным образом, в
противном случае такие переменные эквивалентны переменным auto.
Помимо памяти, необходимой переменным программы на С, с помощью та 11 ос можно выделить память для значений, к которым обращаются посредством указателей, например:
Р = malloc(sizeof(int));
I>
u
В данном выражении выделяется достаточно памяти для целого значения, а
р является указателем на это значение. Эта память может освобождаться после того, как ни одна переменная программы (в том числе р) не будет указывать на данную область памяти. В то же время, поскольку это невозможно
определить в процессе компиляции, область, выделяемая посредством malloc, обязательно должна располагаться в куче.
7.3. Статаческа! и динамическая память
Ранние языки программирования, такие как FORTRAN, имели статическую память, размер которой был известен во время компиляции. Выделенный объем памяти уже не освобождался, поэтому применялась очень
простая модель распределения памяти — необходимая память выделялась
от одного края доступного пространства по направлению к другому. Более современные языки, начиная с ALGOL 60, обычно имеют блочную
структуру, что позволяет переменным, объявленным в различных блоках,
совместно использовать одну область памяти. Таким образом, удобными
являются основанные на стеках модели распределения памяти, которые
позволяют повторно использовать ранее выделенную память. Используемый в этих моделях стек времени выполнения в некотором смысле подобен описанной в разделе 6.3 таблице символов, но с одним важным отличием: стек времени выполнения — это структура времени выполнения
программы, а не времени се компиляции. В то же время, как будет показано далее, многие операции над таблицей символов во время компиляции являются копиями операций над стеком времени выполнения.
Для иллюстрации выделения памяти в стеке времени выполнения обратимся к схеме функции С, которая использовалась при изучении таблицы символов. Часть стека, необходимую одной функции, называют
176
Глава 7. Распределение памяти
сгпековым
фреймом (stack frame), и ниже показывается, как можно выделить память для фрейма, соответствующего функции scopes.
void scopes()
{int a,b,c;
{int a,b;
/*уровень О*/
/*уровень la*/
{float c,d; /*уровень lb*/
{int in; /*уровень 2*/
}
В контексте таблицы символов нас интересовала информация о типах
и хранении типов переменных, т.е. вопросы, относящиеся к периоду
компиляции. Во время выполнения программы важнее значения, а не
типы, и это отражается на структуре стека времени выполнения, который
запоминает значения так, как таблица символов запоминает типы. По мере выполнения представленного фрагмента программы проследим, как
может выделяться память в стеке времени выполнения — во многом этот
процесс подобен изменению содержимого таблицы символов в период
компиляции программы.
Изначально стек времени выполнения пуст.
LJ
После объявления а, Ь, с (уровень 0) стек выглядит следующим образом.
Здесь а представляет область памяти для хранения значения переменной
а и т.д. После объявления уровня 1 стек может выглядеть так.
После прохождения уровня 1а во время выполнения программы стек
возвращается к предыдущему состоянию.
7.3. Статическая и динамическая память
177
сb
сЪ
В начале уровня 1Ь он может преобразоваться к такому виду.
а
Здесь для значений с и d типа float выделено вдвое больше памяти, чем
для значений а, Ь и с типа int. В начале уровня 2 стек принимает вид
Vi
После выхода с уровня 2 стек становится таким.
# вновь становится пустым после завершения функции scopes.
Согласно данному выше описанию после завершения выполнения составного оператора область вьщеленной ему памяти стека просто освобождается. Для этого может использоваться массив указателей, каждый
элемент которого указывает на основание сегмента стека, который соответствует выполняемому в данный момент составному оператору.
Для определения адреса переменной по отношению к основанию стекового фрейма необходимо всего лишь знать объем памяти, занимаемый
значениями каждой из переменных, расположенных в стеке ниже рассматриваемой переменной; эта информация (по меньшей мере, для простых переменных) известна в процессе компиляции.
На практике стековый фрейм может не расширяться и сжиматься при
входе и выходе из каждого составного оператора или блока, как это было
выше. Вместо этого при вызове каждой функции может выделяться максимально необходимое пространство памяти для фрейма.
Описанная модель достаточна для удовлетворения требозаний к памяти одной простой функции, но не программ, содержащих множество
функций, которые могут вызывать друг друга. Таким образом, требуется
более общая модель. Поскольку рассматривается динамическая память,
то на любом этапе выполнения программы память необходима лишь тем
функциям, что используются в данный момент. Кроме того, выход из
функций будет происходить в порядке, противоположном порядку их вызова, так что модель не будет сильно отличаться от уже рассмотренной.
Основное отличие заключается в том, что последовательность вызовов
функций, в общем случае, во время компиляции неизвестна.
Рассмотрим следующий фрагмент программы на С.
main() { first
(} ; second
{);
first ()
{ second{);
}
Как видно, функцию second () можно вызвать любым из двух способов.
1. Непосредственно из main {).
2. Из first (), которая вызывается из main ().
После прохождения уровня 1Ь стек возвращается в состояние
178
Глава 7, Распределение памяти
На рис. 7.2 и 7.3 изображены соответствующие стеки времени выполнения. Через second {) помечена область стека, соответствующая функции second (), и т.д.
3. Статическая и динамическая память
179
Массив динамических
указателей
second!)
Рис. 7.2.
main ()
Стек времени выполнения
second!) first () main()
Рис. 7.3.
Как говорилось ранее,
адрес переменной по отношению к
основанию стекового фрейма, в котором она хранится, известен в процессе
компиляции. В то же время расположение стекового фрейма по отношению к
осноюншо стека, в общем случае, во время компиляции неизвестно и должно
определяться уже во время выполнения программы. Программа на языке С
имеет одно характерное свойство: во время выполнения доступ к
переменным (это не относится к переменным класса extern и глобальным
переменным) возможен только из простой функции (функции, которая
активна в данный момент). Следовательно, если во время выполнения
программы имеется указатель на начало текущего стекового фрейма, то
информации о значении указателя и адреса переменной внутри секции
стека (известен во время компиляции) достаточно для нахождения адреса
переменной по отношению к основанию стека.
Указатели на начало каждого стекового фрейма, которые соответствуют
активным в данный момент функциям, называются множеством динамических указателей стека. После завершения выполнения функции, соответствующей верхнему стековому фрейму, управление возвращается функции, чей
фрейм располагается ниже, при этом становятся доступными любые ее переменные. Следовательно, для каждого стекового фрейма, находящегося в
данный момент в стеке, необходимо запоминать значения динамических указателей, указывающих на этот фрейм. Как показано на рис. 7.4, это можно
осуществить с помощью массива указателей.
Для поддержания массива динамических указателей необходимы следующие действия времени выполнения программы.
• Включение начального адреса нового стекового фрейма в массив
указателей при вызове каждой новой функции.
« Удаление верхнего значения массива указателей каждый раз при
окончании работы с функцией, соответствующей покидаемому
стековому фрейму.
second ()
f
i
r
s
t О
main ()
Рис. 7.4.
В качестве альтернативы вместо использования массива указателей можно запоминать динамические указатели в самом стеке.
В языке С указатели на основания фреймов в стеке времени выполнения требуются только для того, чтобы после прекращения работы с
вызванной функцией среда вызова могла быть создана заново. В Pascal,
Ada и многих других языках также возможен (хоть и не очень практикуется) доступ к переменным, объявленным в процедурах или функциях,
которые статически вложены в текущую процедуру. Рассмотрим для
примера следующую схему программы на языке Pascal.
program demo (output);
var x, у: real;
procedure first; var
c, d: integer;
procedure second;
var p, q: integer;
begin
end;
procedure third;
var m, n: integei
begin
end;
begin
second;
third
end;
180
Глава 7. Распределение памя^1
13. Статическая и динамическая память
181
begin
second;
begin
first
end.
В момент вызова процедуры second стек времени выполнения может
выглядеть подобно изображенному на рис. 7.5.
Стек времени выполнения
t
end;
begin
second;
third
end;
begin first
end.
Дисплей
Стек времени выполнения
f
Рис. 7.5.
Чтобы облегчить доступ к переменным, объявленным во внешних областях видимости, модель памяти Pascal, возможно, должна будет иметь
указатели (называемые статическими указателями) к каждому из доступных в данный момент внешних блоков. Массив таких указателей обычно
называют дисплеем, и его вид подобен изображенному на рис. 7.6. В то
же время он не обязательно соответствует массиву указателей ко всем
процедурам, которые сейчас выполняются. Предположим, например, что
в процедуре third также имеется обращение к процедуре second.
program demo (output); v*r х, у: real; procedure first; var c,
d:
integer; procedure second; ■vmx p, g: char; begin
}
!:":
РИС. 7.6.
Ситуация непосредственно перед вызовом second из third изображена на рис. 7.7, а сразу же после вызова — на рис. 7.8. Из иллюстраций видно, что в дисплее содержатся только указатели на блоки с
переменными, доступными в данный момент, следовательно, по одному указателю к каждому статическому уровню, к переменным которого возможен доступ. Данная ситуация показана на рис. 7.8, где
отсутствует возможность доступа к переменным third после вызова
second из third. Это связано с тем, что second и third располагаются на одном статическом уровне!
end;
procedure third;
var m, n: integer;
-'■'>■;
is,'** ii
182
Глава 7. Распределение памяти
7.3. Статическая и динамическая память
183
Стек времени выполнения
Дисплей
1
7.4. Адреса времен! компиляции
Рис, 7.7.
Дисплей
Стек времени выполнения
ч
р
у
у
У
у
п
m
d
с
У
X
Рис. 7.8.
Ш
В модели распределения памяти с использованием дисплея, если
функция (или процедура) объямена на том же статическом уровне, что
л вызываемая в данный момент функция, происходит обновление значена указателя на вершине дисплея; если же функция статически объявлена внутри вызываемой в данный момент функции, то на дисплей поступает новое значение. Подобное имеет место и при завершении работы
функции. После завершения функции возможно возвращение или к
функции того же статического уровня, или к функции вмещающего
уровня. В первом случае происходит обновление верхнего значения дисплея, а во втором — верхний элемент дисплея удаляется.
Возможен еще один случай: функция (или процедура) вызывает саму
себя — рекурсивный вызов, возможный во многих языках, включая Pascal и языки, родственные С. В этом случае вызывающая среда и вызываемая среда статически находятся на одном уровне, и верхний элемент
дисплея должен обновляться при начале и завершении каждого вызова.
Чтобы восстановить элементы дисплея, можно хранить значения динамических указателей в основании каждого стекового фрейма.
Глаза 7. Распределение пам0
В общем случае, в процессе компиляции адреса переменных неизвестны.
Укажем некоторые причины, почему это происходит.
• Во время выполнения программы расположение стекового фрей
ма, который соответствует конкретной функции или процедуре,
зависит от порядка вызова функций (процедур),
• В процессе компиляции значение индексов массива обычно неиз
вестно и будет вычисляться при выполнении программы.
• Доступ к некоторым переменным осуществляется посредством
указателей, значения которых в процессе компиляции неизвестны.
Хотя в процессе компиляции адреса неизвестны, часть информации о
них обычно имеется. Например, известны следующие параметры.
• Смещение простого значения относительно основания стекового
фрейма,
• Смещение начала массива относительно основания стекового
фрейма.
• Статическая глубина функции, в которой объявлена переменная.
Статическая глубина (пункт 3) относится к языкам Pascal и Ada (в С такого понятия нет).
В языке С адрес простой переменной в процессе компиляции представляет собой смещение по отношению к осноюншо стекового фрейма. Это же
относится и к полю записи, так как поля записи всегда запоминаются последовательно, и предполагается, что объем требуемой памяти для каждого из
. Адреса времени компиляции
185
полей известен. Для языка Pascal или Ada адрес времени компиляции простой переменной или поля записи будет состоять из пары:
(номер уровня, офсет)
Здесь номер уровня — номер статического уровня функции или процедуры, в
котором была объявлена переменная или запись, а термин "офсет" употребляется с тем же значением, что и в языке С (смещение от начала фрейма).
Для массивов со статическими границами (значение границ известно
в процессе компиляции) адрес элемента массива, в зависимости от применяемого языка, можно также выразить через номер уровня и офсет
или просто через офсет. Смещение элемента массива по отношению к
основанию стекового фрейма состоит из двух частей.
• Смещение начала массива по отношению к основанию стекового
фрейма.
• Смещение элемента массива по отношению к началу массива.
Для массивов со статическими границами значение первой части известно в процессе компиляции, а второй, в общем случае, — нет, поскольку, повторимся, в процессе компиляции обычно неизвестно значение индексов массива.
При нахождении адресов элементов массива часть вычислений осуществляется во время выполнения программы с использованием информации, известной при компиляции. Как будет показано далее, объем вычислений зависит от размерности массива. Проиллюстрируем сказанное
с помощью следующего примера на языке Pascal.
Рассмотрим объявление массива.
«ur table;
array
[I..10,1..20]
of integer
Элементы массива обычно записывают построчно или, точнее, согласно
лексикографическому порядку индексов. Например, значения элементов
приведенной таблицы будут занесены в память в следующем порядке.
table
t a b le
[1,1],
[2,1],
table
[10,1],
table
t ab le
table
[1,2],...,
[2,2],...,
table
t ab le
[10,2],...,
[1,20],
[2,20],
table
[10,20]
Адрес конкретного элемента массива вычисляется как смещение от адреса первого элемента массива.
адрефаЫв [/, /]) = адрес (table [1 Ь &]) + (иг - k + ? ) * ( ' - А) + (/ - k) Здесь /, и
ц — нижняя и верхняя границы первого измерения и т.д., а каждый
элемент массива предполагается размером в одну ячейку памяти. В
приведенном выше примере нижние границы в каждом случае равны 1,
а верхние — 10 и 20 соответственно. Для трехмерного массива arr3i
объявленного как
var аггЗ: array [lj.. . u j . ,
186
I2..U2,
I3..U3]
обшая формула для адреса элемента массива arr3[i,j,k] имеет следующий вид.
адрес(а1Щи; k]) = adpec(arr3[h, k, Щ + (Uz~k+ 1)*(ua-h +1) *(/- /1)
+ (U3-b + 1)*(j-k) + (k-l 3 )
Выражение (ur-lr+1) представляет число различных значений, которые может принимать r-й индекс, т.е. (и3- /3 + 1) — это число значений, что может
принимать третий индекс, а также расстояние между элементами массива,
которые отличаются на единицу во втором индексе. Подобным образом
(tft-fe + 1)* (из-/з + 1)
представляет число различных пар значений, которые могут образовать
второй и третий индексы, а также расстояние между элементами массива, которые отличаются на единицу в первом индексе. Расстояние между
элементами массива, которые отличаются на единицу в /-ом индексе, называют шагом по i-ому индексу (Ah stride). Таким образом, в приведенном
выше примере шаг по первому индексу равен
(и г -1г+ 1)*(й)-/з + 1), по второму и третьему — (и3 /, + 1) и 1 соответственно.
Из приведенной выше формулы для нахождения смещения элемента
массива относительно адреса первого элемента массива понятно, что вычисления становятся достаточно простыми, если известны шаги по индексам. Например, для аггЗ адрес элемента arr3[i,j,k] выражается
следующим образом.
адрефггЩ /, к]) = адрефпЗУь Ь, Щ + st * (/- /,) + вг * (/- k) + s3 * (к- 1Э)
Здесь s( , Sj, s} — шага по соответствующим индексам, равные следующему,
(иг- 4 + 1 ) * {из- 4 + 1 )
(U3-/3+1)
1
На рис. 7.9 показано применение шагов по индексам для нахождения
адреса элемента массива из следующего массива, объявленного таким
образом (язык Pascal).
N [ 1 , 1 , 1 ] N [ 1 , 1 , 2] . N [ 1 , 1 , ЮЗ,N[1,2,1 ] . . . N [ 2 , 1,1]..N[10,10,10]
S,
of integer
Глава 7. Распределение пам0 ?-4. Адреса времени компиляции
РИС. 7.9.
187
var N: array [ 1. . 1 0 , 1 . . 1 0 , 1 . . 1 0 ] of integer; Для языков, в которых границы
массива известны во время компиляции, значения шагов по индексам
могут вычисляться сразу же (во время компиляции), что сокращает
количество вычислений времени выполнения программы при каждом Программы данный указатель увеличивается, чтобы представлять адрес
обращении к массиву. В то же время, дальше упростить приведенную конкретного элемента массива.
формулу уже невозможно, поскольку разность (/- /,), в общем случае, во
Стек времени
Дисплей
выполнения
время компиляции неизвестна. Для языков с динамическими границами
(до выполнения программы они неизвестны) шаги по индексам можно
найти после объявления массива и занесения его в стек, что опять же
уменьшит количество вычислений, выполняемых при каждом
обращении к массиву. Хотя значения шагов по индексам в процессе
компиляции могут быть неизвестны, практически всегда будет известен
Динамическая
Динамическая
объем памяти, которую будут занимать шаги по индексам, и память для
часть
часть
них может быть выделена в процессе компиляции. В то же время память
фрейма
массива
для самих элементов массива может выделяться только при выполнении
программы, поскольку при компиляции значения границ могут быть
неизвестны.
Статическая
Статическая
Для рассмотрения динамических массивов (массивов с динамическими
часть
часть
границами) требуется более общая модель стека времени выполнения, чем
фрейма
массива
рассмотренная ранее. В общем случае неизвестно расположение начала
РИС. 7,10,
массива в стековом фрейме. Поэтому каждый стековый фрейм
В процессе компиляции адрес массива в целом — это просто уровень и
удобно разбить на две части: статическую часть, в которой содержатся
значения, известные во время компиляции, и динамическую часть, со- офсет, соответствующий началу статической части массива. Для нахождения
держащую значения, неизвестные в процессе компиляции. Все значения j адреса во время выполнения требуются вычисления, общий вид которых
динамической части можно будет получить (с помощью указателей) из | приведен выше. Очевидно, что доступ к элементам массива занимает много
значений статической части. Следовательно, в статической части фрейма времени, в особенности для многомерных массивов. Это время можно
уменьшить, если производить вычисление шагов по индексам только один
будут содержаться следующие значения.
рз. Для массивов с динамическими границами (в отличие от массивов со
• Все простые значения (типы integer, float и т.д.).
статическими границами) при выполнении программы время таосе тратится
• Статические части массивов (границы, шаги по индексам, указате на каждое обращение к дополнительному указателю.
ли на элементы массива).
• Статические части записей (поля, размеры которых известны во
время компиляции).
Как уже говорилось, куча используется для хранения значений, к которым
<» Указатели на глобальные значения — хотя глобальные значения Иожет потребоваться доступ от момента вьщеления для них памяти и до забудут храниться не в стеке, а в куче.
вершения программы. Не существует механизма языка, подобного выходу из
С другой стороны, в динамической части фрейма будут находиться эле блока или функции, который сделает область памяти недоступной. На перменты массива. При использовании этой модели на практике даже эле вый взгляд схема распределения для такой памяти должна выделять память
менты массива со статическими границами будут храниться в динамиче °т одного конца линейного пространства до другого, пока свободная память
ской части фрейма. Описанная более общая модель стекового фрейма 116 будет распределена полностью. Может показаться, что при этом никаких
: проблем с перераспределением или чрезмерным использованием памяти
изображена на рис. 7.10.
В этой модели для доступа к элементам массива (по сравнению с дос-j вникнуть не должно. В то же время данный подход имеет существенный
Не
тупом к элементам, не входящим в массив) необходимы дополнительный; достаток, а именно; после первого полного распределения памяти примеуказатель и офсет. Значение номера уровня фрейма дает первый указа-; нение следующего оператора, например, s t r i n g = m a l l o c ( 4 ) ;
тель с дисплея. К этому добавляется офсет указателя (в статической част»;
массива) относительно начала массива, кроме того, во время выполнений
Глава 7. Распределение пшяЩ7-5- Куча
188
7.5. Куча
189
который попытается выделить четыре бита памяти и вернуть указатель на
эту область, вызовет ошибку в программе. Впрочем, прежде чем смириться с этим, следует вспомнить, что область памяти может стать недоступной вследствие таких операций программы, как переназначение указателей и т.д. Например, выделенное ранее пространство может стать не.
доступным для переменной string после следующего присваивания.
^р
в восстановлении любой его части. Вследствие этого во многих
случаях для сборки мусора времени вообще не требуется. Если (и когда)
сборка мусора все-таки требуется, этот процесс происходит в две фазы.
• Фаза маркировки, в которой (посредством введения значений в би
товую карту) помечается память кучи, доступная для переменных
программы.
• Фаза сжатия, в которой все доступное пространство сдвигается в
один конец кучи, а память, подлежащая повторному использоза нию, образует непрерывный блок в другом конце кучи. При этом,
разумеется, следует аккуратно проверить, чтобы соответствующим
образом изменились все значения указателей.
Из этих двух фаз фаза маркировки наиболее интересна и допускает
меньше альтернативных способов реализации. Требуются некоторые средства
"маркировки" ячеек памяти, к которым при необходимости могут обращаться переменные программы. Для этого может использоваться битовая карта с
достаточным числом битов для сопоставления с каждой ячейкой кучи. Битовая карта не является частью кучи и располагается отдельно от нее. Каждый
бит в битовой карте может принимать одно из двух значений.
string = newstring;
В то же время данная операция позволяет получить доступ к рассматриваемому пространству некоторой другой переменной. Рассмотрим результат выполнения присваивания
stringl = string;
между двумя указанными выше операторами. Считая, что другие операторы, связанные с данными, отсутствуют, подобные действия приведут к
тому, что переменной stringl будет доступно пространство, выделенное
оператором та 11 ос.
Поскольку, в общем случае, в процессе компиляции неизвестно, как
будет выполняться программа, то при компиляции невозможно узнать,
когда станет недоступной память, выделенная оператором malloc. Это
означает, что код для восстановления области кучи не может быть сгенерирован в процессе компиляции, хотя недоступными могут стать большие области, выделенные в куче. Один из способов преодоления такой
трудности заключается в том, чтобы программисты (исходя из своих знаний о том, как будет выполняться программа) предугадывали момент,
когда память кучи становится недоступной, и вводили в исходный код
явные инструкции для перераспределения памяти. Например, в С для
освобождения области памяти, отведенной переменной string, можно
записать следующее.
fr e e ( s tr i n g ) ;
В то же время данный подход требует от программиста большого профессионализма и ответственности. Итак, какие-либо автоматизированные методы освобождения памяти применять нежелательно. В языке Java
предполагается, что ответственность за освобождение недоступной памяти должна возлагаться на реализацию Java, а не на программиста; таким
образом, любая реализация Java должна иметь соответствующие механизмы. Примечательно, что в отличие от ранее описанных моделей памяти, Java сохраняет"в куче массивы. Все объекты также хранятся в куче.
В связи с восстановлением недоступных областей памяти существуют
два возможных метода управления кучей.
• сборка мусора (garbage collection);
• использование счетчиков ссылок (use of reference counters).
Первый метод, пожалуй, является более популярным, но и более необхо
димым. Преимущество этого подхода заключается в том, что до полного
распределения всего доступного пространства памяти не возникает по-
т
Глава 7, Распределение пам0
1. О — соответствующая ячейка памяти не доступна для переменных
программы.
2. 1 — соответствующая ячейка памяти доступна для переменных про
граммы.
В начале процесса сборки мусора все элементы битовой карты имеют
значение 0, а при выполнении алгоритма различным элементам карты
присваивается значение 1. В завершение сборки мусора значение "1" получат все элементы битовой карты, которые соответствуют ячейкам памяти, доступным для переменных программы.
Простой алгоритм сборки мусора использует стек (называемый стеком сборки мусора) и заключается в следующем.
Сборка мусора 1
1. Стек времени выполнения линейно просматривается, пока не будет
обнаружена переменная, указывающая на непомеченную ячейку кучи.
Это может быть или собственно переменная, которая является указателем (в кучу), или компонент записи, который является указателем.
В дальнейшем, все ячейки кучи, на которые указывают подобные переменные, маркируются посредством включения соответствующих бит
в битовую карту.
*. Некоторые ячейки, в свою очередь, могут быть указателями на непомеченные ячейки кучи. В этом случае их адреса помещаются в стеке
сборки мусора.
•>■ Далее следуют адреса с верха стека сборки мусора или (если стек
сборки мусора пуст) адреса, содержащиеся в следующем указателе на
-5. Куча
7
191
Г
стек времени выполнения. Затем маркируются все непомеченные
Компромиссом между двумя описанными алгоритмами будет алго ритм,
ячейки кучи, на которые указывает куча, и их адреса помешаются в
придерживающийся стратегии 1 при достаточно свободной памяти к
стек сборки мусора,
стратегии 2 — в противоположном случае. Например, если стек достаточно
4. Третий шаг повторяется до тех пор, пока освободится стек сборки му - большой, то алгоритм может использовать стек фиксированного размера и
сора, и все указатели в стеке времени выполнения будут обработаны придерживаться первой стратегии. Как только при увеличении стека станет
описанным образом.
реальной угроза его переполнения, из стека (из нижней час-хн> может
Поскольку на третьем шаге всегда маркируются непомеченные ячейки, удаляться одно значение. Удаленное таким образом нижнее значение стека
запоминается и используется для начала второй фазы алгоритма, которая во
то, в конце концов, выполнение алгоритма прекратится.
Описанный выше алгоритм является наглядным, простым для понимания многом будет подобна сборке мусора 2,
В другом хорошо известном алгоритме (см. раздел дополнительной
и эффективным. Однако, у него имеется один существенный недостаток —
он нереальный, поскольку требует использования стека произвольного разме- литературы в конце главы) куча рассматривается как древовидная структура с
ра в момент наибольшей загруженности памяти. Другими словами, сборка указателями от вершины к основанию. Сборка мусора начинается с
мусора просто не будет инициирована' Безусловно, никто не ожидает, что вершины дерева и идет по направлению вниз. Вместо использования стеков
чистка памяти будет выполняться при отсутствии пространства для работы. В для запоминания указателей, требующих последующей обработки, алгоритм
то же время, поскольку для сборки мусора требуется небольшой (и извест- использует указатели самого дерева, временно обращая их для обеспечения
?M'if
ный) объем памяти, то при нехватке памяти ее можно инициировать в пер- пути возврата вверх по дереву. Этот алгоритм эффективнее; и с точки зрения
вую очередь. Фактически, существует алгоритм сборки мусора с предельно времени, и с точки зрения требуемой памяти.
Другие схемы очистки памяти включают различные схемы сборки мусора с
малыми запросами рабочего пространства.
учетом поколений (generational garbage collection), в которых производится
Сборка мусора 2
разделение:
1. Пометить все ячейки кучи, на которые прямо указывают значения из « между глобальными объектами, которые существуют относительно долго
стека времени выполнения.
еще до инициации процесса сборки мусора, и память для которых очищать
2. Просмотреть кучу, начиная с низших адресов, чтобы найти первый не обязательно;
помеченный указатель, указывающий на непомеченную ячейку. По • локальными объектами, которые существуют меньшее время, и память которых
метить эту ячейку.
постоянно требуется возвращать в доступную область.
3. Продолжить просмотр кучи, помечая непомеченные ячейки, на которые
Очевидно, что такая схема уменьшает время сборки мусора и может быть
указывают помеченные ячейки. Выделить адрес ячейки с наименьшим достаточно эффективной. В других схемах для уменьшения времени сжатия
адресом, помеченным таким способом. Назвать этот адрес низшим.
кучи используются две глобальные области. Ссылки на некоторые приводятся
4. Повторять шаги 2 и 3, уже начиная с низшего адреса, пока при про в разделе дополнительной литературы в конце главы.
смотре будет помечаться хотя бы одна ячейка. Поскольку число ячеек,
Какой бы метод сборки мусора не использовался, может случиться так,
которые необходимо пометить, конечно, то, в конце концов, выпол что программа просто исчерпает доступную память и будет вынуждена
нение алгоритма прекратится.
завершить работу, если только система не разрешит эту проблему каким-то
Помимо пространства, необходимого для битовой карты, алгоритму иным способом. Память программы может также ограничиваться за счет
сборки мусора, если при используемом алгоритме большая часть времени
также требуются три переменные, представляющие:
уходит именно на чистку памяти — вскоре после завершения сборки мусора,
• текущую позицию при просмотре;
когда программа уже кажется готовой к продолжению работы, куча снова
• ячейку, к которой идет обращение;
переполняется, что вновь требует проведения очистки памяти. В такой
с
• низший адрес, к которому должно идти обращение при текущем ситуации служебные издержки на проведение борки мусора могут быть
очень значительными, и именно здесь будет Уместным альтернативный
просмотре.
подход — использование счетчиков ссылок. Этот метод позволяет
В то же время, с точки зрения затрачиваемого времени этот алгоритм (достаточно часто) заменить непредсказуемые издержки на сборку мусора
может быть крайне неэффективным. В частности, это может быть в том издержками постоянными и предсказуемыми.
случае, когда в .куче содержится много обратных указателей, и это явля- При использовании счетчиков ссылок предпринимается попытка очинить
ется ценой за неиспользование стека.
каждый элемент памяти кучи сразу же после прекращения обращений к
нему. Каждая ячейка памяти в куче имеет счетчик ссылок, в котоГлава 7. Распределение памят* 7-5. Куча
193
ром фиксируется число значений, обращающихся к данной ячейке. По.
явление каждой новой переменной, обращающейся к данной ячейке
увеличивает значение счетчика, а исчезновение ссылки уменьшает его
Когда значение счетчика становится нулевым, ячейка может быть возвращена в область свободной памяти для дальнейшего распределения
Этот метод удачен, но имеет некоторые ограничения.
• Не может очищаться память, которая связана со структурами дан
ных, подобными кольцевым спискам.
• Постоянные издержки, связанные с использованием счетчиков
ссылок, могут сильно уменьшать эффективность программ с пре
дельно малыми запросами относительно памяти.
В заключение отметим, что второй пункт противоречит принципу Бауэра,
который утверждает, что "простые программы" не должны платить за
неиспользование существующих дорогих характеристик языка.
?Х\ Резюме
В этой главе рассмотрен процесс распределения памяти в типичных языках программирования. В частности, было сделано следующее.
• Определено различие между статической, динамической и глобаль
ной памятью.
• Описана модель стека времени выполнения для динамической па
мяти, включая использование стековых фреймов и дисплея.
» Введено понятие адреса времени компиляции.
• Описаны механизмы хранения массивов и доступа к массивам.
• Обсуждено использование кучи для хранения глобальных
значений.
• Описаны альтернативные методы сборки мусора для очищения
требуемой памяти кучи.
• Рассмотрено использование счетчиков ссылок для контроля памя
ти кучи, а также рассмотрены преимущества и недостатки исполь
зования счетчиков ссылок в качестве альтернативы сборке iMycopa
В следующей главе будут рассмотрены методы и принципы генерации кода.
n(j Russell, 1964]. Понятие кучи появилось несколько позже и впервые
^"требовалось в таких языках, как SNOBOL 4, LISP 1.5 и ALGOL 68. В
fjCnuth, 19686] хорошо представлен ранний обзор алгоритмов сборки мусора, включая метод Шорра и Вейта, основанный на обращении указателей в древовидной структуре данных. В учебнике [Appel, 1997] описываются основные методы сборки мусора, а более подробные описания
можно найти в [Cohen, 1981] (обзорная работа) и [Jones and Lins, 1996]
(специализированный учебник). Реализация Java рассмотрена в работе
[Lindholm and Yellin, 1996].
Упражнения
7.1. Во многих реализациях языков знаки (тип char) занимают столько
памяти, как и целые числа (тип int). Приведите аргументы за и
против такого решения.
7.2. Предложите подходящие механизмы для хранения констант.
7.3. В некоторых языках программирования применяются массивы с ло
кальной областью видимости, размер которых может меняться в
процессе выполнения. Укажите, какой тип механизма распределе
ния памяти подойдет для таких массивов. Рассмотрите все вопросы,
возникающие в связи с таким решением.
7.4. Можно ли заменить механизм использования дисплея указателями,
вложенными в стек?
7.5. Одной из проектных целей при разработке Pascal было создание эффек
тивного целевого кода для современных компьютеров. Объясните, как
этому способствовало отсутствие динамических массивов.
7.6. Укажите, почему сжатие кучи (часть процесса сборки мусора) явля
ется нетривиальным?
7.7. Обоснуйте подход Java проведения сборки мусора вместо возложе
ния ответственности за восстановление недоступной памяти на
программиста.
7.8. Обсудите, что является более предпочтительным в среде реального вре
мени — управление посредством счетчика ссылок или сборки мусора?
Дополнительна! литература
В большинстве книг по компиляторам вопросы распределения памяти
рассматриваются достаточно хорошо, например, можно порекомендовать
книга [Loudon, 1997] и [Terry, 1997].
Понятие стека времени выполнения впервые было использовано в
ранних компиляторах ALGOL 60 и описывается в [Naur, 1964] и [Randell
194
Глава 7. Распределение памяти
Упражнения
195
Глава 8
Генерация кода
8.1. Вступление
I
f •!
В этой главе будет изучена фаза генерации кода при компиляции. В частности, будут рассмотрены следующие вопросы.
• Различные типы промежуточного кода, создаваемого компилято
ром, и то, как они генерируются.
• Основные типы современных машинных архитектур и создание
кода для них.
• Оптимизации кода и ее осуществление в различных фазах процес
са компиляции.
<» Некоторые моменты, связанные с генераторами генераторов кода.
В то же время вместо всестороннего рассмотрения всех аспектов генерации кода основное внимание будет уделено вопросу создания кода. Детальное рассмотрение того, как может выполняться код, созданный для
какой-то конкретной машины, скорее запутает, чем прояснит ситуацию.
Как обычно, в конце данной главы можно будет найти ссылки на литературу, которая поможет при рассмотрении более сложных моментов.
8.2» Создание промежуточного кода
Как уже говорилось в разделе 1.4, существует ряд причин для создания
компиляторами промежуточного кода как первого шага к созданию кода
для реальных машин. Перечислим эти причины.
• Обеспечение четкого разделения меду машинно-независимой и
машинно-зависимой частями компилятора.
• Минимизация усилий для переноса компилятора в новую среду.
« Минимизация усилий для реализации т языков на п машинах.
• Простота оптимизации.
Промежуточный код может выглядеть по-разному. Он может связываться с реализуемым языком, например Р-код для языка Pascal, Diana
для языка Ada, байт-код для языка Java. В качестве альтернативы он мо-
жет также связываться с машинами, на которых осуществляется реализация. Пример: язык CTL (Compiler Target Language), который применялся
в 70-х годах в Манчестерском университете в качестве промежуточного
языка для машины MU5. Промежуточный язык может быть близким к
реализуемым языкам или к машинам, на которых осуществляется реализация. В любом случае он представляет собой линеаризацию синтаксического дерева, созданного в процессе синтаксического и семантического
анализа, и формируется посредством разбиения древовидной структуры
на последовательность инструкций, каждая из которых эквивалентна одной или нескольким (небольшому числу) машинным командам. Машинный код может генерироваться, исходя только из промежуточного кода,
хотя при этом может потребоваться доступ к таблице символов или другая информация, относящаяся к процессу компиляции.
В качестве примеров промежуточных кодов рассмотрим три хорошо
известных кода.
1. Трехадресный код.
2. Р-код — ориентированный на конкретный язык промежуточный код,
на котором основано большинство реализаций Pascal.
3. Байт-код, используемый Java Virtual Machine.
8.2.1. Трехадресный код
Примером трехадресного кода (three-address code) является строка
а = b op с
Здесь ор — арифметический (или другой) оператор, b и с — его операнды (или их адреса), а а — адрес результата применения оператора к операндам. Арифметическое выражение
(а + Ь)*(с + d)
можно представить в виде последовательности таких инструкций трехадресного кода
ti = а + b
нО й
записи, которая описана в разделе 5.6 (на основе примера из раздела 4.7). Как и ранее, грамматика YACC с действиями выглядит следуюшим образом.
S
: ЕХР;
ЕХР : TERM;
} TERM {A2{);}
{А1() } TERM {A2{);};
{А1{)
| ЕХР + | ЕХР
-TERM : FACT
TERM*{Al();} FACT {A2();}
TERM/{Al();} FACT {A2{);}; FACT :
- {Al();} FACT {A4();} | VAR {A3
<);} I ( EXP ); VAR : a|b|c|d|e;
Здесь, однако, действия отличаются от приведенных ранее. Необходимо,
чтобы многоцелевой стек мог хранить операторы и операнды (в том числе временные имена). Действиями являются: А1 — занести оператор в
стек; А2 — следующим образом напечатать инструкции трехадресного
кода:
напечатать имя следующей распределяемой временной величины
напечатать "="
напечатать три верхних элемента стека снизу вверх
занести в стек только что распределенное имя временной величины; A3
— занести в стек "операнд; А4 — следующим образом напечатать
инструкции трехадресного кода:
напечатать имя следующей распределяемой временной величины
напечатать "="
напечатать два верхних элемента стека снизу вверх
занести в стек только что распределенное имя временной величины.
Трехадресный код может также применяться для представления других аспектов типичных языков программирования, например, присваиваний, обращений к массивам, условных и безусловных переходов. Все
следующие выражения являются примерами трехадресного кода.
t2 = с + а t3 = ti
* t2
Здесь t — создаваемые компилятором временные имена. Можно также
применять унарные операторы (monadic operator). Например, оператор
-т создаст
инструкцию
ti = -m
а : = tj ti =
(хотя, безусловно, в этом случае трехадресный код будет содержать всего
c[i] goto L if
лишь два адреса!).
ti goto L
Преобразование выражений в последовательность инструкций
трехадресного кода легко осуществляется с помощью анализатора на
Каждый оператор трехадресного кода имеет максимум три адреса,
основе YACC, подобного использованному при создании постфикс - также существуют формы инструкций для присваивания, включающего
адреса и указатели, вызовы процедур, вычисление параметров и т.д. Высокоуровневые управляющие структуры, такие как циклы, условные операторы и операторы выбора для создания трехадресного кода, сводятся к
проверкам условий и переходам.
Приведем примеры того, как управляющие структуры компилируются
в трехадресный код.
198
Глава 8. Генерация кода 8.2. Создание промежуточного кода
199
.1
1. If (выражение) оператор i else олвратор2
Приведенный выше оператор If можно реализовать путем добавления
к грамматике следующих действий.
В результате будет создан следующий код: L|:
код для вычисления выражения t, = not
выражение
if (выражение) <11> оператора <I2> else операторг <13>
if t[ goto L 2
Здесь действиями являются:
11 увеличить номер метки
образовать код для перехода к метке, если выражение ложно
поместить номер метки в стек
goto L,
12
увеличить номер метки
образовать код для перехода к метке
извлечь из стека метку, допустим, Ц
установить Lk в коде
поместить в стек метку, которая выше применялась в
безусловном переходе
13
извлечь из стека метку, скажем, Ц
установить Lj в коде
tj goto Lj
код для оператора i
goto Lj
код для оператора2 ь2
2, while (выражение) оператор
Приведенный выше оператор while можно реализовать путем добавления к грамматике следующих действий.
while <W1> (выражение) <W2> оператор <W3>
Здесь действиями являются;
W1 увеличить номер метки
установить метку в коде
поместить метку в стек
W2 увеличить номер метки
образовать код для перехода к метке, если выражение ложно
поместить метку в стек
W8 извлечь ш стека метку, скажем, L/ извлечь из
стека метку, скажем, Lk образовать код для
безусловного перехода к Lk установить Ц в
коде
200
код
Процесс назначения меток нетривиален и требует использования стека времени компиляции. Он включает в себя переходы вперед и назад по
коду, а применимое и определяющее вхождения меток обычно вкладываются "обычным образом". В то же время следует быть осторожными в
тех случаях (например, внутри действия W3), когда порядок появления
меток в стеке не совсем соответствует порядку, в котором они требуются.
Стек меток может быть отдельным стеком времени компиляции или может объединяться с другими стеками времени компиляции.
8.2.2. Р-код
При использовании данной грамматики будет создан следующий код.
код для вычисления выражения
t, = not выражение if
L|
для оператора
Шва 8. Генерация еда
Перейдем к рассмотрению другого типа промежуточного кода, а именно Ркода. Этот код является промежуточным кодом на основе стека, созданным
специально для реализации языка Pascal и широко используемым для этой
цели. Каждая инструкция Р-кода имеет следующий формат. F р Q
Здесь F — код функции, а р или (иногда — и) Q могут отсутствовать в зависимости от конкретного кода. При наличии этих параметров р может
применяться для определения уровня статического блока, a Q — для определения офсета внутри фрейма, или промежуточного операнда
(например, константы). Инструкции без параметров применяются к
верхним элементам стека и включают следующие инструкции,
• AND применяет булев оператор AND к верхним двум элементам сте
ка, удаляет их и оставляет результат действия оператора (истина
или ложь) на вершине стека,
• DIF применяет оператор разности множеств к верхним двум эле
ментам стека, удаляет их и оставляет результат действия опера
тора (множество) на вершине стека.
• NGI изменяет знак целого значения на вершине стека.
• FLT преобразует значение на вершине стека из целого в действительное.
• FLO преобразует значение второго сверху элемента стека из целого в
действительное.
• INN проверяет на предмет принадлежности к множеству, используя
верхние два элемента стека как параметры и оставляя вместо них
значения "истина"или "ложь".
8.2. Создание промежуточного кода
201
Для загрузки значения на вершину стека или сохранения адреса на вершине
стека используются одно- или двухадресные инструкции. Например,
LDCI
4
загружает целую константу 4
LODI
°
5
загружает целое значение по адресу (0,5)
LDA
0
б
загружает адрес (0,6)
STRI
1
4
сохраняет целое значение по адресу (1,4)
Здесь адреса времени компиляции представлены в виде пары целых чисел.
(статический уровень, офсет) Р-код также включает в себя
инструкции переходов, например,
L7
безусловный переход к ц
Ц
переход к hs, если значение на вершине стека — ложь Метки
могут устанавливаться в коде, например, U
Единичные инструкции определяются таким образом, чтобы к значению на
вершине стека можно было применить стандартные функции, например,
CSP
ATAH
Здесь функция arctg применяется к значению на вершине стека, оставляя
результат действия функции на месте этого значения. Другой пример:
CSP
WLN
Здесь к файлу, который определяется верхним элементом стека, применяется инструкция writeln.
Приведем Р-код, создаваемый для операторов if и while, описанных
ранее в этом разделе. Предполагается, что при вычислении выражения
подсчитанное значение выражения оставляется на вершине стека.
1. И'{выражение) оператор, else оператор2
будет генерировать:
код для помещения значения выражения на вершине стека
FJP
Li
код для выполнения оператора^
OJP
Li
будет генерировать:
L,
код для помещения значения выражения на вершину стека
Ьг
код для выполнения оператора
ill
LODI
1
7
будет загружать значение целой переменной с адресом (1, 7) на вершину стека.
Реализация присваивания, в простейшем случае, включает в себя копирование значения верхнего элемента стека в адрес второго сверху элемента стека. После этого два верхние элемента стека удаляются. Это
можно сделать с помощью простой адресной инструкции.
STOI
В более общем случае наличия массивов и записей, когда необходимо
скопировать множество последовательно расположенных значений, присваивание осуществляется с помощью инструкции MOV m
Она переносит т значений, начиная с исходного адреса, в соответствующее
число адресов, начиная с целевого адреса (целевой и исходный адреса располагаются на вершине стека). В это же время два адреса удаляются из стека.
Несмотря на то, что Р-код в дальнейшем может быть откомпилирован
в машинный код для конкретной машины, чаше он выполняется посредством использования интерпретатора. Широко распространенный в конце
70-х перенос Pascal из одной среды в другую во многом был связан с тем,
что при наличии компилятора Pascal, написанного на языке Pascal, который
требовалось использовать в новой среде, нужно было всего лишь
написать интерпретатор Р-кода для этой среды, что занимало около месяца
работы. В действительности, многие компиляторы "немного не доходят" до
создания действительного машинного кода. В некоторых случаях,
например, генерируется ассемблерный код, который позже (посредством
системного ассемблера) преобразуется в машинный код.
8.2.3. Вайт-код
2. while (выражение) оператор
202
0ри каждом применимом вхождении переменной образуется код для пощешения в стек адреса или значения переменной (в зависимости от обстоятельств). Например,
LDA 1 7 будет загружать адрес (1, 7) на
вершину стека, а
1,2
код для выполнения оператора',
ь2
FJP
la GJP
Ьг
Глава 8. Генерация кода
Байт-код представляет собой промежуточный язык для Java Virtual Machine (JVM). Он, подобно Р-коду для языка Pascal, основан на использовании стека. Отметим, что Java Virtual Machine разрабатывалась, чтобы
реализации Java были:
* эффективными;
* защищенными;
* переносимыми;
8.2. Создание промежуточного кода
203
что отражено в системе времени выполнения Java, основные компонент^
которой перечислены ниже.
• Механизм выполнения (execution engine), который выполняет инструкции байт-кода.
• Модуль управления памятью (memory manager), который управляет
кучей, где хранятся все объекты и массивы.
• Модуль управления обработкой ошибок и исключительных ситуаций
(error and exception manager), который используется для планомер
ного и систематического нахождения ошибок периода выполнения.
• Интерфейс потоков (threads interface), который управляет парал
лельной работой.
.. • Загрузчик класса (class loader), который загружает, связывает и устанав
ливает классы в исходное состояние (инициализирует классы).
ся
инструкций по-разному поддерживают различные типы данных! Основные типы инструкций байт-кода можно выделить в такие группы:
• работа со стеками;
• выполнение арифметических операций;
• оперирование объектами и массивами;
• поток управления;
• вызов методов;
• обработка исключительных ситуаций и параллельная работа.
,
Модуль управления защитой (security manager), который препятству
Например, как и в Р-коде, существуют инструкции для помещения
ет запуску "враждебных" программ.
констант
и локальных переменных в стек, собственно работы со стеком и
Для каждого класса инструкции байт-кода находятся в классификацизапоминания
значений из стека в локальных переменных. !
Инструкция
онном файле Java (Java class file). В каждом файле содержится виртуальный
Значение
машинный
код
для
используемых
классом
методов
incos t_4 загружает в стек целую константу 4
(функций/процедур), информация таблицы символов (набор констант в
inload_4
загружает в стек значение локальной переменной номер 4
Java), соединений с суперклассами и т.д. Для эффективной работы файл
pop
отбрасывает верхнее значение стека
имеет двоичный формат, но для удобства просмотра его можно преобраdup
копирует
верхний элемент стека
зовать в символьную форму. Существенной особенностью реализаций
swap
меняет местами два верхних элемента стека
Java является наличие верификатора классификационного файла (class file
istore_4 заносит значение верхнего элемента стека в локальную
verifier), который, помимо всего остального, контролирует, чтобы файл,
переменную номер 4
поступивший с ненадежного источника, не вызвал сбой в работе интерпретатора, оставив его в неопределенном состоянии, или аварию хоста. В
Примеры инструкций для выполнения арифметических операций.
частности, верификатор байт-кода используется для проверки байт-кода
Инструкция Значение
внутри методов на предмет:
iadd
суммирует две целых величины на вершине стека
£
add
суммирует
две величины типа float на вершине стека
• наличия команд ветвления, обращающихся к неправильным адресам;
fmul
умножает
две
величины типа float на вершине стека
• ошибок типов в кодах инструкций;
Доступ к массивам осуществляется с помощью инструкций, подобных
• некорректного управления стеком по отношению к условиям пе
приведенной ниже.
реполнения и опустошения;
Инструкция Значение
• методов, вызываемых с неправильным числом или типом аргументов.
iaload
помещает значение элемента массива на вершину
Важной особенностью реализаций Java является то, что верификация
стека (предполагается, что ссылка массива и индекс
происходит до выполнения программы, что позволяет избавиться от поиндекса уже находятся в стеке)
Существуют инструкции условного и безусловного ветвления, а также
тенциально трудоемкой проверки в процессе выполнения. В то же время
инструкции входа в подпрограммы и инструкции таблицы переходов. К
верификация обходится недешево: она основывается на разновидности
каждой из этих инструкций относится один или несколько параметров
средства доказательства теорем, имеющего некоторые теоретические огметки. Например,
раничения.
Инструкция
Значение
Существует более 160 различных инструкций байт-кода, многие из
if
eg
L,
переход к L,, если целое значение на вершине стека
них отличаются только типами операндов. Для верификации важным явравно нулю if_ianpne ц переход к L,, если два целых
ляется хранение информации о типах в байт-коде, множество имеющихзначения на вершине
204
стека различны
. Генерация кода
goto L,
переход к L t
•
8.2. Создание промежуточного кода
205
Выше названы лишь несколько из богатого набора инструкций, предлагаемых JVM. JVM может использоваться (и в некоторой степени используется) как промежуточный этап для языков компиляции, отличных
от Java (например, Ada).
Приведем байт-код, генерируемый для рассмотренных ранее в этом
разделе управляющих структур языка С.
1. It (выражение) оператор, else оператор2
Данный оператор в байт-коде представляется в следующем виде.
байт-код для помещения значения выражения на вершину стека
ifeq Li
байт-код для выполнения оператора^
. goto h2
Li
байт-код для выполнения оператора^
Ь2
2. while {выражение) оператор
Данный оператор в байт-коде будет представлен в следующем виде.
L,
байт-код для помещения значения выражения на вершину стека
ifeq L2
байт-код для выполнения оператора
goto Li
La
Следует отметить, что в байт-коде значению "ложь" соответствует 0, а
значению "истина" — 1. Отсюда— использование ifeq, а не обратной
инструкции i £ne. В языке Java булевский тип отсутствует.
В подходе, принятом Sun по отношению к реализации Java, были
представлены некоторые интересные и существенно новые идеи для реализации языка. Подобно самому языку, методы его реализации продолжают дорабатываться и улучшаться.
В следующем разделе рассмотрены вопросы, связанные с генерацией
реального машинного кода, для чего изучаются характеристики некоторых типичных машинных архитектур.
3.3. Создание машинного кода
Прежде всего рассмотрим два основных существующих типа машинных
архитектур.
•
•
206
CISC (complex instruction set computer — компьютер с полным на
бором инструкций).
RISC (ceduced instruction set computer — компьютер с сокращен
ным набором инструкций).
Глава 8. Генерация еда
дрхитектуры CISC разрабатываются с прицелом на последующую реализацию языков высокого уровня, следовательно, на первый взгляд являются идеальными кандидатами для использования в компиляторах. Они
имеют множество мощных инструкций и ассоциируются с компактным
целевым кодом. Их типичными характеристиками являются:
• широкий диапазон режимов адресации для обеспечения доступа к
массивам, записям, спискам, стековым фреймам и т.д.;
• небольшое число регистров (обычно 16 или меньше);
• большое число регистров особого назначения, например, может
быть выделен регистр для индексации;
• двухадресные инструкции, такие как А+в—»А, где А, в могут быть
сложными адресами;
• инструкции переменной длины;
„
• инструкции с побочными эффектами, например, команды автома
тического приращения;
• существенно разное время, затрачиваемое на выполнение инструкций;
• управление, реализованное микропрограммой.
С другой стороны, RISC имеет следующие характеристики:
• простые режимы адресации (обычно — только использующие ре
гистры);
• большое количество регистров, по меньшей мере, 32;
• все регистры являются "универсальными";
• трехадресные инструкции, использующие только регистры, напри
мер, г, = г, + г2;
• инструкции фиксированной длины (32 бита);
• отсутствие у инструкций побочных эффектов;
® примерно одинаковое время, затрачиваемое на выполнение любой
инструкции;
• более сложное управление по сравнению с микропрограммным.
Архитектуры RISC выигрывают по сравнению с CISC с точки зрения
простоты и наличия сравнительно меньшего числа способов достижения необходимого результата (меньше вариантов для вычислений и альтернатив
выбора в процессе компиляции). К числу преимуществ относятся также инструкции с фиксированной длиной и наличие большого числа универсальных регистров. Мы не будем детально обсуждать соответствующие достоинства архитектур RISC и CISC, отметим только, что архитектура целевых машин — это самый важный вопрос при выборе стратегии генерации кода.
При рассмотрении генерации кода имеется два важных момента.
1. Выбор инструкций.
2. Распределение регистров.
8.3. Создание машинного кода
207
8.3.1. Выбор инструкций
Основной целью при выборе инструкций является создание целевой программы, семантически эквивалентной исходному коду, из которого получена,
В общем случае существует более одного способа достижения этого, а также
более одной целевой программы, эквивалентной исходной. Основной целью
хорошего генератора кода должно быть решение поставленной задачи с малыми затратами, где затраты определяются либо размером образуемого целевого кода, либо его эффективностью. Выбор инструкций не является независимым от распределения регистров, поскольку хороший метод выбора инструкций может иногда оказаться мало результативным из-за нехватки
регистров. Впрочем, в первую очередь внимание обращается на выбор инструкций, а относительно регистров предполагается (по крайней мере, на начальной стадии), что их имеется достаточное количество.
Предполагая, что преобразование осуществляется из трехадресного
кода, прямой подход к выбору инструкций состоит в том, чтобы сопоставить схему кода с каждым типом трехадресной инструкции. Тогда преобразование каждой трехадресной инструкции в машинный код будет заключаться в создании кода, основанного на соответствующем элементе
схемы. В то же время полученный таким образом результат будет неэффективным по следующим причинам.
• Возможно появление ненужных инструкций загрузки и сохранения.
• Не используются преимущества наличия эффективных команд
приращения.
• Не используются преимущества наличия потенциально полезной
контекстной информации.
В некоторой степени от ненужных команд загрузки и сохранения можно
избавиться, если разрешить генератору кода доступ к содержимому регистров и
т.д. Генератор кода также способен распознавать ситуации, когда
"эффективные" инструкции, такие как инструкции автоприращения, заменяют последовательность инструкций в одном или более элементах кода.
Помимо этого генератор кода также может использовать таблицы контекстной
информации для облегчения создания высококачественного кода. Для выбора
из нескольких альтернативных кодов часто применяется метод динамического
программирования. Отметим, что все названные моменты стоит рассматривать не
как оптимизацию (к которой мы перейдем несколько позже), а как
неотъемлемые признаки качественного генератора кода.
8.3.2, Распределение регистров
При создании качественного целевого кода критичным процессом является
распределение регистров. Можно предположить, что в общем случае операции
с использованием содержимого регистров будут занимать меньше времени по
сравнению с соответствующими операциями с использованием значений,
208
Глава 8. Генерация кода
находящихся в основном пространстве памяти. Это означает, что для операндов команд объектного кода необходимо (насколько это возможно) использовать именно регистры. Существуют три основных типа значений, которые
лужно помещать в регистры при любой возможности.
• Часто используемые указатели на структуры данных времени вы
полнения, например, на стек времени выполнения.
• Значения параметров функций и процедур.
• Значения временных переменных, которые применяются при вы
числении выражений.
Для архитектуры RISC не составит проблемы использовать регистры
для указателей на стек времени выполнения, поскольку в этой архитектуре имеется большое количество регистров, и для указанных целей может свободно выделяться блок регистров. Для архитектуры CISC, где количество регистров ограничено, такое использование регистров не всегда
будет возможным. Обычным решением данной проблемы для архитектуры
CISC является использование регистров только для наиболее часто
употребляемых указателей на стек времени выполнения, например, указателей на основание стека, указателей на текущий стековый фрейм и
указателей на вершину стека. Благодаря этому, доступ к локальным переменным, чьи значения содержатся в текущем стековом фрейме, становится более эффективным по сравнению с доступом к переменным, чьи
значения содержатся в других фреймах. Это обстоятельство может быть
использовано программистами, знающими подробности реализации.
Конечно, даже в архитектуре RISC может случиться так, что блока
регистров, выделенного для указателя кадра, окажется недостаточно
(вследствие динамической глубины вложения функций и процедур).
Обойти проблему нельзя никак, поскольку в наличии имеется только ограниченное число регистров и, если их недостаточно, значения из одного
или большего числа регистров нужно будет сбросить (spill) в область памяти, отличную от выделенной под регистры.
Там, где это возможно, параметры функций и процедур передаются посредством регистров. В то же время, по причине непредсказуемого характера
структуры динамических вызовов, наивный подход к распределению регистров для параметров в процессе компиляции не позволяет хранить значения
параметров (или локальных переменных) одного вызова в регистрах при вызове другой процедуры (внутри текущей процедуры). В архитектуре RISC
часть или все такие регистры могут быть доступны только посредством регистрового окна, выполняющего задачу распределения конкретных регистров во
время выполнения программы. В этом случае параметры и локальные переменные вызываемых и вызывающих процедур не обязательно перекрываются, по
меньшей мере, пока глубина вызова и число параметров при вызове не выходят за определенные границы. Знание типичной максимальной глубины
вызовов можно использовать для уменьшения количества необходимых периодов сброса регистров.
8.3. Создание машинного кода
209
Эффективное распределение временных величин (которые, например
используются при вычислении значений выражений) по регистрам не
является тривиальным. В трехадресном коде новые временные величину
распределяются каждый раз, когда это необходимо, но при наличии ре.
гистров такой подход работать не будет, поскольку имеется только ограниченное число регистров. К счастью, многие временные величины могут заноситься в один регистр (хотя и не одновременно), и процесс сброса регистра происходит редко (по меньшей мере, для архитектуры RISC),
В то же время для определения, какие временные величины могут совместно сосуществовать в одном регистре, требуется некоторый анализ промежуточного кода. Грубо говоря, нужно определить те значения в регистрах, которые в дальнейшем не будут нужны, и освободить регистр для
других целей. Следует помнить, что в процессе компиляции в общем
случае неизвестно, какие конкретные значения будут нужны в дальнейшем. Следовательно, необходимо руководствоваться консервативным подходом, т.е. значение регистра, которое может потребоваться в дальнейшем, не должно перезаписываться,
Для дальнейшего рассмотрения вопроса следует определить несколько
терминов. Рассмотрим последовательность трехадресных кодов.
=а
=с
В этой последовательности значения t, и t2 должны храниться до вычисления t3. Впрочем, рассмотрим вычисление следующего выражения.
a*b + c*d + e*f Имеем такую последовательность
трехадресных кодов.
a
bt
с
td2
(1) п=0
(2) sum2 = 0
(3) зшпЗ = 0
(4
(5
)
(б
)
(7
)
(8)
)
(9)
(10)
(11)
(12)
(13)
L,: 1
t, =
if t
n =
m ~
sum2
t3 =
sum3
goto
L5:
;, =
n<10tj
not
2 goto
L2 + 1
n
n*n
= sum2
m*n
= sum3
L,
m
Этот код соответствует фрагменту на языке С.
П = 0;
l
sum2 = 0;
sum3
whil
{n
e =
m =
sum2
n
sum3
t2 ts
=
t5 =
t4
В данной последовательности используются пять временных величин,
но, очевидно, при этом необязательно использовать пять различных регистров. Например, значения t, и t 2 не будут использоваться после
третьей инструкции, поэтому приемлемым будет следующее распределение промежуточных величин.
Временная величина Регистр
t,
1
t2
2
t3
3
t4
1
t5
2
Определим переменную/временную величину как живую (live), если
она содержит значение, которое (предполагая консервативный анализ)
210
храниться для возможного дальнейшего использования. Таким
образом, t, жива от момента выполнения инструкции 1 до выполнения
инструкции 3 и не дольше; подобным образом переменная t2 жива от
иН струкции 2 до инструкции 3; t3 жива от инструкции 3 до инструкции 5
Анализ живучести (liveness analysis) для фрагмента кода осуществляется от
его конца к началу.
Как правило, в промежуточном коде присутствуют циклы и условные
операторы, и нужно изучить, как они влияют на анализ живучести. Кро ке того, анализ живучести также может применяться по отношению к
переменным, которые постоянно используются и должны находиться в
регистрах. Рассмотрим следующий фрагмент кода с циклом, представленного в трехадресной форме.
Глава 8. Генерация кода
= 0;
(n <
10)
=
=sum2
sum3
+ m;
+ m*n;
n +
1;
*n;
Чтобы проанализировать живучесть переменных в цикле, необходимо
определить, какие переменные живы на входе в цикл. На входе цикла
должны быть живыми переменные n, sum2 и sum3, поскольку их значения будут использоваться в цикле. В то же время вход в цикл происходит
не один раз, а при каждом выполнении цикла. Следовательно, n, sum2 и
sum3 должны быть живыми при выполнении инструкции
goto Li
Кроме того, после окончания цикла могут понадобиться живыми некоторые другие переменные, хотя при последующем рассмотрении такая воз-
8.3. Создание машинного кода
211
можность будет игнорироваться. Дальнейший анализ позволяет получить
следующую информацию о живучести переменных и их возможном рас пределении по регистрам.
Регистр
Жива
Переменная
1-12
n
1
sum2
sum3
t,
m
2-12
3-12
4-5
8-10
5-6
10-11
2
3
Из приведенной
выше таблицы видно, что требуется
4
только четыре
регистра. 1 Обобщая, живучесть
переменной определяется 4уравнениями вида
:
inln] = и$в[п]и(ои1{п] - del[n])
4
ои%п] = и /n[s]
Здесь
4
use[n] совокупность всех переменных, значения которых используются
в операторе п;
чьи индексы неизвестны в процессе
только к очень консервати явным зассива информации.
„
...... "^оей 11 "" пеО еменных по
регистрам можно расНадежное Раок ^ е нИ« "Даосической задачи раскрашивания
сматривать каж 4>* н ° С оаС красить узлы графа, чтобы не было
графа: в какие щ^ иУ*н J>ro ивета? Частным случаем зажачи расдвух соседних уад 0 лак
т а к о г о р а с кр а ш и ва н и я г о с уд а р с т в
лаков
такого
с о п р и ка с а ю щ и хс я го с уд а р с т в
еТсЯ зал
раскрашивания
обЫ не
государств
крашивания граф, т
-« зад на двумерной к^ а одинакового цвет\?е'
доступ к:
компиляцин
ключенияи
и
8.3.3. Распре%и1(е Р^СТ^°В "V™ Раскрашшв* ния
регистров, который базируется на
Опишем алгоритм
, которую следует поместить в в виде узла
раскрашивании грщ«
неориентирюванного graph). Посредством
'. р е г и с т р , п о во з мо ^
дуги со-находиться в одном регистре. Это,
графа, графа
^ ^«лл^.^и ул/»;!
рус • переменные
дм
единяются два узла
совокупность всех переменных, которые живы после завер- :
шения оператора п;
живы одновременно,
del|jn] совокупность всех переменных, определенных в операторе л;
например, соответЦ«с;1 учаЛ^им обрязом. Предположим, что в наin[n] совокупность всех переменных, которые живы при вхождении в
Алгоритм реалНэ^ ^е^лЫ узел N граничит с менее чем
оператор л;
личии имеется т #п соседя-тр00' „заммодействия уже раскрашены, то
s
преемник оператора п.
3 a
Второе уравнение необходимо для того, чтобы учесть все возможные ми, а оставшиеся данно-rt? $ *■ который должен отличаться от ( < т)
цве-му
ум
у можно прис^и^'зтог узел, вместе с
преемники оператора п, которые, при наличии цикла, будут включать в
соответствующими
юв
соседей.
Сле^ 1 ' чЬ цо> поместить в стек, а к
себя начальный оператор цикла, который рассматривается как преемник
оставшейся час-дугами, можно удгС* гРа£Ра й же ПОдХОд. Так можно
последнего оператора цикла.
до
Для каждого оператора промежуточного кода существуют приведен- продолжать
™v^? E ™f Н ° "4nH f b ,?o один узел. Его следует раскрасить в лю ные выше уравнения, одновременное решение которых дает совокупTCV г,^^ гт„,^ „„,о. .„«.
^ вернуть из стека
другие узльж вместе с
ность переменных, что живы на входе и выходе каждого оператора. Ре- тех пор, пока бой
описывалось
выше.
каК
шение данной системы уравнений — это пример анализа потока инфор- доступный
их дугами и раекра^""^ лее т более m соседей, то при его извлемации, применяемого, вообще-то, для различных целей. Впрочем, этот
9
Если какой-то </
В то же время сброс
процесс может быть довольно трудоемким, его время, в худшем случае, чении из стека мо. не {pflO ^'йтй сброс регистра.
ПОСКОЛЬК
'■^прО^^рацией,
У
ивета соседей дан4
пропорционально л (а обычно — гг), где п — число задействованных является ного
\ц0$ ^ л жн ы отличаться друг от друга. На
операторов.
слеПомимо названных, существенными являются следующие моменты.
узла не обяз 5 , 0 Д° л яет с я к а к возможный
• Вызовы функций и процедур требуют, чтобы их входные парамет
кандидат
для
ры были живы на входе.
дующем этапе эт^'* ; , »^ гра фа в стек > ка к и остальные. Когда
сброса, а затем пец е тсЯ и. ^ с т е к а > . т о > во зможно , о н будет
дело доходит до ег>^рр а ^ ча етс я от цветов всех его соседей, а
:
окрашен в цвет, * С: , ,ft ° ' регистр для него не выделяется, а вы• Значения выходных параметров должны быть живы после завер
т0
может
быть
и
нет.
^
'/схтт
0 еГ,
сброс
peraCT pa.
шения вызовов функций и процедур.
деляется адрес в п^^ ц w г ра фа в з аи М0 действия для рассмотренНа рис. 8.1 H3o6v ' jip» ромежуточного кода.
|
дет проходить следу с; Для
данного
при« Доступ к (анонимным) переменным посредством указателей нель- ной
ранее последо^ остй «
стра А> в> с
алгоритм
бузя изучить посредством анализа потока информации.
и
D>
мера, предполагая, к
"
ouffn]
212
Глава 8. Генерация тда\ &3- Создание машинное h
213
.J Узел зшпЗ извлекается из стека. У него четыре соседа, все окрашенные в цвет А, следовательно, его можно окрасить в цвет в.
12 Узел sum2 извлекается из стека. У него пять соседей, четыре окрашены в
цвет А и один — в цвет в, следовательно, его можно окрасить в цвет с.
j3. Узел п извлекается из стека. У него шесть соседей, четыре окрашены
в цвет А, один — в цвет в и один в цвет с, следовательно, его можно
окрасить в цвет D.
Алгоритм распределения завершился успешно, и окончательное распределение совпадает с распределением, которое мы рассмотрели ранее.
Переменная Регистр
a
sum2
sum3
t,
in
Рис. 8.1,
1. У узла п имеется шесть соседей, и он может быть сброшен. Он выде
ляется и помещается в стек. После удаления узла и его дуг из графа,
последний уменьшается.
2. У узла зшп2 имеется пять соседей (узел п уже удален из графа), и он
может быть сброшен. Он выделяется и помещается в стек. После уда
ления узла и его дуг граф уменьшается.
3. У узла зшпЗ имеется четыре соседа, и он может быть сброшен. Он
выделяется и помещается в стек. После удаления узла и его дуг граф
уменьшается.
4. Узел tl не имеет соседей. Он помещается в стек, вследствие чего
граф уменьшается.
5. Узел t2 не имеет соседей. Он помещается в стек, вследствие чего
граф уменьшается.
6. Узел m не имеет соседей. Он помещается в стек, вследствие чего граф
уменьшается.
7. Узел t3 не имеет соседей. Это последний оставшийся узел. Он окра
шивается в цвет А.
8. Узел m извлекается из стека (вместе с дугами, с которыми он был туда
помещен). У него нет соседей, и он также окрашивается в цвет д.
9. Узел t2 извлекается из стека. У него нет соседей, и он также окраши
вается в цвет А.
10. Узел tl извлекается из стека. У него нет соседей, и он также окраши
вается в цвет А.
214
Глава 8. Генерация кода
D
С
В
A
A
8.4.
Оптимизация кода
Задача оптимизации кода состоит в создании эффективного (с точки зрения размера памяти и времени выполнения) целевого кода. Желаемая
степень оптимизации будет зависеть от обстоятельств. Иногда она не
нужна, например, если у программы малое время выполнения, умеренные запросы к памяти и, возможно, малый срок жизни (программы студентов обычно такого типа). Необходимость оптимизации может требоваться для программ с большим временем выполнения либо значительными запросами к памяти и, возможно, с длительным временем
существования. Стоимость оптимизации главным образом оценивается в
терминах времени компиляции. Некоторые виды оптимизации могут
быть дорогостоящими в смысле времени компиляции, другие — сравнительно дешевыми. Обычно более дешевые типы оптимизации всегда стоит осуществлять, а более дорогие — не всегда.
Некоторые компиляторы, в зависимости от требуемой степени оптимизации, могут работать в более чем одном режиме. Компилятор Borland
C/C++ обладает широким диапазоном возможностей. Например, пользователь может выбрать для генерации или быстрый, или компактный код.
Некоторые ранние компиляторы PL/J осуществляли обширную оптимизацию кода посредством очень большого числа (до 30) дополнительных
проходов по исходному коду, на что, соответственно, уходило много времени. В средах, где основной является качественная диагностическая
информация, лучше всего полностью отказаться от оптимизации, чтобы
избежать возможной путаницы вследствие некорректных сообщений!
8-4, Оптимизация кода
215
т
Оптимальным оптимизатором является тот, который будет создавать оптимальный код выполнения программы для всех возможных входов, однако
это неосуществимо. Создание такого оптимизатора эквивалентно решение
проблемы остановки для машины Тьюринга, которая, безусловно, является
неразрешимой, В действительности многие "хорошие" оптимизаторы буду;,
давать we самый оптимальный (возможно, даже ухудшенный) код для некоторых входов! Разумеется, оптимизация не должна изменять значение кода, а
для этого иногда требуется обширный анализ кода, В большинстве случаев
такой анализ основывается на анализе потока информации, который будет
подробно рассмотрен в этой главе. Будут также рассмотрены различные преобразования кода с сохранением семантики.
Оптимизация может быть:
• локальной и основываться на относительно простом анализе и пре
образованиях кода;
• глобальной и основываться на относительно сложном анализе и
преобразованиях кода.
Рассмотрим вначале локальные оптимизации, которые обычно базируются на небольшом числе последовательных инструкций. Они всегда
дешевы для выполнения и могут быть очень эффективны, особенно, если
выполняются внутри внутренних циклов программ, которые занимают
много времени при выполнении программы.
Приведем примеры хорошо известных локальных оптимизаций:
• дублирование констант (constant folding);
• снижение стоимости (strength reduction);
• исключение ненужных инструкций.
Дублирование констант состоит в выполнении в процессе компиляции
арифметических операций, которые должны были бы выполняться при
выполнении программы. Например, последовательность
limit = 10
index = limit - 1
можно заменить последовательностью
limit = 10
index = 9
Примером снижения стоимости является замена произведения или
деления соответствующими инструкциями сдвига.
Примером исключения ненужных инструкций может служить удаление
инструкции LOAD, если регистр уже содержит необходимое значение, а
также инструкции STORE, если соответствующий адрес памяти уже имеет
значение в регистре.
Данные типы оптимизации, которые применялись для кодов, не содержащих циклов или ветвлений, можно было бы вообще не рассматривать как оптимизации, а считать их просто признаком качественной генерации кода.
216
Глава 8. Генерация ком
Анализ потоков управления и информации дает более амбициозные
оптимизации:
удаление бесполезного кода (dead-code elimination);
исключение общих подвыражений (common
subexpression
elimination); »
оптимизация циклов.
Удаление бесполезного кода заключается в удалении кода, который не
будет выполняться при любом выполнении программы. Такой код, возможно, требовался в ранней версии программы, но в последующей версии уже является избыточным. Возможно, конечно, что факт недоступности кода указывает на ошибку в программе, а компилятор не в состоянии обнаружить это. Для определения нерабочих участков кода
необходим анализ потока информации. Анализ живучести может показать, например, что имеется трехадресный код вида
х = а + b
Здесь переменная х "мертва" (dead) после завершения инструкции. Понятно, что инструкции такого вида избыточны и должны быть удалены
для уменьшения размера кода. Таким образом, исключение бесполезного
кода является оптимизацией пространства, тогда как большинство рассматриваемых оптимизаций являются оптимизациями процесса выполнения программы.
Исключение общих подвыражений заключается в определении тех значений в правой части трехадресного кода, что уже были вычислены и которые Ае нужно вычислять снова. Такое определение может (или даже
должно) базироваться на анализе потока информации, для которого требуется больше информации, чем для простого определения живучести
переменных. Присвоение значения переменной
а = b + с
называется определением переменной а. Это определение а распространяется и на некоторые другие инструкции: d s b + с
если анализ потока информации показывает, что за это время ни а, ни d
не могли измениться, Если это условие выполняется, то приведенную
выше инструкцию можно заменить инструкцией d = a
(не вычисляя повторно b + с),
В данном контексте полезно ввести понятие доступности (availability), Если выражение вычисляется на каждом пути потока упрашения к оператору и
ни один из его операторов не был определен на любом из этих путей, то выражение доступно в операторе и не должно вычисляться повторно. Приведенные выше уравнения потока данных несложно обобщить для отслеживания доступных выражений. Особое внимание следует уделить рассмотрению
8,4, Оптимизация кода
217
псевдонимов, или альтернативных имен (alias), — разным именам для и той
же переменной. Использование псевдонимов может привести к тому что
изменение одной переменной автоматически будет означать изменение
другой. Псевдонимы могут появиться, например, в таких случаях.
• Вызов параметров посредством ссылки в языках, подобных Pascal,
в которых фактические и формальные параметры являются аль
тернативными именами друг друга.
• Присвоение адресов.
• Использование одинаковых фактических параметров для двух
формальных параметров.
Примером последнего случая является вызов процедуры в Pascal .
comp (x,x)
Это соответствует объявлению процедуры
p r o c e d u r e c o mp ( v a r p , q ) ; Здесь
р и q.— псевдонимы друг друга.
Анализ псевдонимов (alias analysis) может определить возможные псевдонимы и устранить ненадежную оптимизацию. В языках со строгой типизацией может подразумеваться, что переменные различных типов не
могут быть псевдонимами друг друга, что потенциально увеличивает надежность возможного проведения оптимизации.
Оптимизация циклов выполняется над исходным кодом или близким к
нему представлением и обладает широким диапазоном возможностей. Например, в следующем коде а * b вычисляется в каждой итерации цикла:
int v[10];
void f(void)
int i»x,y,z; for (i =
0; i < 10; vfi] = a *
b;
Этот код можно оптимизировать следующим образом:
int v[10]; void £(void) {
int i,x,y,z,tl ;
tl = a * b;
for (i = 0; i < 10; i++)
Vti] = tl; }
Другими оптимизациями по отношению к циклам являются:
• замена остаточной рекурсии (tail recursion) итерацией;
• удаление ненужных проверок границ массивов;
• развертка цикла (замена цикла фрагментом последовательного кода);
2/8
Глаеа 8. Генерация кода
„ многие другие. Несмотря на то, что нашей целью не было всестороннее рассмотрение полного диапазона возможностей оптимизации кода,
„риведенные примеры должны были дать хорошее общее представление
ф этих возможностях. Компиляторы очень отличаются по степени оптимизации кода, начиная от простого создания хорошего (или иногда не
совсем хорошего) кода с малыми локальными оптимизациями и заканчирзя всесторонней глобальной оптимизацией по функциям и процедурам.
3.5. Генераторы генераторов кода
Весьма привлекательна идея — создать генератор генераторов кода, который будет создавать генератор кода с заданными:
• описанием промежуточного кода;
• описанием машинного кода, который должен быть образован.
Использование такого инструментального средства вместе с генератором
лексических анализаторов и генератором синтаксических анализаторов
будет значительным продвижением по пути автоматизации создания
компиляторов.
Сопоставление шаблонов промежуточного и целевого кодов можно
рассматривать как задачу синтаксического анализа. Например, префиксное представление (оператор, операнд, операнд) синтаксического
дерева может описываться контекстно-свободной грамматикой, которая
может содержать действия по созданию целевого кода. Соответствующая грамматика является обычно крайне неоднозначной; следователь но, необходимо решать возможные конфликты перенос/свертка и
свертка/свертка. Их можно решать, используя информацию о стоимо сти — своеобразных весовых коэффициентах, определяющих (во время
выполнения программы) стоимость создания альтернативной последовательности кода. Для архитектур RISC количество альтернативных вариантов обычно не очень велико, а для CISC' количество альтернативных вариантов может быть настолько большим, что время, затрачиваемое на их рассмотрение — огромно.
При отсутствии информации о стоимости предпочтительной считается генерация кода с как можно меньшими шагами. Это означает, что при
конфликте перенос/свертка предпочтение отдается операциям переноса, а
при конфликте свертка/свертка — более длинной свертке Данный подход
направлен на создание "мощных", в противоположность "менее мощ ным", машинных инструкций. Например, он пытается создать инструкции с более сложными способами адресации.
В дополнение к уже рассмотренным очевидным преимуществам, генераторы генераторов кода также обеспечивают сравнительно простой
подход к переносу генераторов кода. В то же время существует ряд сложностей, из-за которых генераторы генераторов кода не нашли широкого
применения. Перечислим основные из них.
8.5. Генераторы генераторов кода
219
8-6. Резюме
j. Назовите относительные преимущества использования в качестве
промежуточных
кодовпреимущества
Р-кода и трехадресного
кода.
4.
Какие
относительные
имеет
трансляция
Р-кода над
интерпретацией?
0. Укажите
причину, по которой
Java Virtual
Machine
не поддерживает
тип
Boolean.
0. Какими преимуществами обладают:
а) архитектуры CJSC
б) архитектуры RISC
g,7. Укажите области, в которых живы следующие переменные, предполагая, что после выполнения кода жива только переменная р.
1. а = с + d;
2. m = 2 * а;
3. п = а + ш;
4. к = т + п;
5. р =переменные
с * к * 3;живы на входе кодовой последовательности в
5.8. Какие
упражнении
8.7?
1.8. Рассмотрим фрагмент кода С
п = О; Siutt2 = 0;
while (n < 10) {п =
п + 1; m = 2*п;
зшп2 = suni2 + m; }
Укажите, каким образом его можно оптимизировать. 8,10. Приведите
пример того, как "оптимизация" может увеличить время выполнения
программы.
221
Упражнения
Приложение А
решения упражнений
Глава 1
1.1. Соседствующие символы в различных Т диаграммах, находящиеся
на одном вертикальном уровне, должны быть одинаковыми. Исход
ный и целевой языки для Т-диаграмм, находящиеся на одном вер
тикальном уровне, должны быть одинаковыми (см. рис. 1.5).
1.2. Нет, отсутствует контекстная информация, чтобы отличить эти пе
ременные.
1.3. См. рис. АЛ.
Рис, А. 1.
1.4. Подсчет числа знаков исходного кода.
Подсчет числа символов исходного кода.
Подсчет числа процедур и функций исходного кода.
1.5. Лексический анализ.
1.6. Семантический анализ.
1.7. Глобальная память.
1.8. Малое время работы. Повторное использование существующих
компонентов, использованных в других проектах.
1.9. Возможность сбоя компилятора при текущей компиляции.
1.10. За — совместимость с Lex и YACC.
Против — плохой контроль типов и других небезопасных моментов.
Глава 2
2.1.
а. Каждая строка языка состоит из последовательности нуля или
большего числа знаков а.
Каждая строка языка состоит из последовательности одного или
б. большего числа знаков а, за которыми следует один или более
знаков Ь.
Каждая строка языка состоит из последовательности нуля или
в. большего числа знаков х, за которыми следует некоторое число
знаков у, а далее идет такое же число знаков z. Каждая строка
языка состоит из последовательности нуля или большего числа
г. знаков х, одного или нескольких знаков у, за которыми
следуют знаки z, число которых равно числу знаков х. д. Каждая
строка языка состоит из последовательности нуля или большего
числа знаков х, за которыми следует нуль или более знаков у,
после чего идет нуль или более знаков г.
Нуль или более знаков х, за которым следует нуль или более
знаков у.
2.2.
Знак х, за которым следует нуль или более знаков х, далее идет у,
а, за которым следует нуль или более знаков у. Знак х чли у нуль или
более раз.
б. Знак а или Ь, далее идет нуль или более вхождений а, за которыми следует нуль или более вхождений Ь. Знак х или у нуль или
более раз.
в.
г. Регулярна, поскольку грамматика является праволинейной.
Нерегулярна, поскольку есть как праволинейные продукции, так
и леволинейные.
2.3.
Нерегулярна, поскольку первая продукция не является ни право-,
ни леволиненой.
Нерегулярна, поскольку ни одна из продукций не имеет приемлемой формы.
Генерирует регулярный язык, поскольку грамматика является
регулярной.
Замена
последней продукции продукцией
г.
Y-4-yY
2.4.
делает грамматику регулярной, поэтому генерируемый язык
также является регулярным.
а. Генерируемый язык является регулярным, поскольку ниже при-
водятся продукции регулярной грамматики, генерирующей ана-
б. логичный язык.
А -» аА
224
Приложение А
г. Генерируемый язык не является регулярным, поскольку не существует генерирующей его регулярной грамматики. 0. Правое
порождение имеет такой вид.
x
Порождение единственное, поскольку на каждом этапе имеется
простое правило определения, какую продукцию использовать:
применять продукцию 2, если требуется завершить порождение, в
противном случае использовать продукцию 1. Ни одна иная стратегия не позволит получить заданную строку. Первое правое
1,6. порождение. statement => ifexpr then statement е/зш statement
=> if expr then statement else other
=> if expr then If expr then statement else other
=> if expr then if expr then other else other
Второе правое порождение. statement => ifexpr then
statement
=> if expr then if expr then statement else statement
=> // expr then If expr then statement else other
■=> if expr then if expr then other else other
Первое левое порождение. statement => if expr then
statement else statement
=> if expr then if expr then statement else statement
=> if expr then if expr then other else statement
=> if expr then if expr then other else other
Второе левое порождение. statement => if expr then
statement
=> if expr then if expr then statement else statement
=> if expr then if expr then other else statement
=> if expr then if expr then other else other
Еще одно порождение. statement => unmatched
=> if expr then statement
=> if expr then matched
=> if expr then if expr then matched else matched
=> if expr then If expr then other else matched
=> if expr then if expr then other else other
Решения упражнений
225
2.7. G=({0,1},{S},P,S)
Здесь P имеет следующий вид.
S0 S |
| | e
2.8.
Левое порождение.
3,3. Конечный автомат для распознавания идентификатора языка FORпоказан на рис. А.2
l.d
PROGRAM^ begin DECS; STATS end =>
uesf/л tf; DECS; STATS end => begin d; d;
STATS end => begin d; d; s; STATS end =>
begin d; d; s; s; end Правое порождение.
PROGRAM => begin DECS; STA TS end =>
begin DECS; s; STATS end => begin DECS; s;
s; end => begin d; DECS; s; s; end => begin d;
d; s; s; end
TRAN показан на пис А о
Рис. А.2.
Все состояния, за исключением одного, являются конечными. Переход из первого состояние во второе происходит при прочтении
буквы (/), все остальные переходы —
при поочтении fimu .*."• цифры (d).
при прочтении буквы или
3.4.
а. См. рис. А.З. Состояние 2 является конечным.
2.9.
letter (letter\ digit\ ){letter\ digit\ )(!etter\ digit]) (letter | digit\)
{letter]
digit I)
, %/<}, {S, R, T, U, V, Щ, P, G = {{letter, digit}, {S, R, T,
U, V, Щ, P, S)
S Здесь Р включает следующие
11
>
продукции. 5~»/e<ter|/0/ter/?
ter|/0/ter/?
l.d
Рис. А.З. б.
См. рис. А.4. Состояние 3
является конечным.
/effer T\ digit T\ letter \ digit T->
letter U | cfrjgr/Г UI /e/ter | cf^/1 t/~>
/e#ef I/1 £%# V|' /efferf ofrg/Y V->
tefter W| digit W\ letter \ digit W-*
letter {digit
2.10. В языке С отсутствует оператор then, но существует правило: оператор
else относится к ближайшему "предшествующему if, с которым (при
чтении елею направо) еще не соотнесен ни оператор else. Данное правило существует в большинстве языков. Контрпример: язык ALGOL 60,
где запрещена комбинация then if. Впрочем, все, что требует подобной
структуры, можно создать, используя составные операторы.
Гяаеа 3
3.1. Лексический анализ является относительно медленным, поскольку
он включает чтение исходного кода знак за знаком, а не символ за
символом.
3.2, Значения констант не нужны синтаксическому анализатору, они
важны только для генератора кода. Синтаксический анализатор
должен знать только то, что некий символ является константой и не
требует значения этой константы.
Рис. АЛ в. См. рис. А.5.
Состояние 4 является конечным.
упражнений
227
Рис. 4.5.
226
Приложение к
Решения
3.5. Для каждого
предложения.
а
.
б
.
в
.
case 4: if (isdigit (in))
state = 4;
else if (in == 'e'l
state = 5;
else error();
break; case 5: if
(isdigit (in))
state = 7;
else if (issign(in))
state = 6;
else error();
break; case 6: if
(isdigit (in))
state = 7;
else error();
break; case 7: if
(isdigit (in))
state = 7,else error();
break;
S->IT\I '
T-*IT\dT\f\d
S-*dS\.T
Г-» dT\ d
S-*aT\bT\cT
Г~> xT\ xU
U->a\h\c
3.6. См. рис. А.6. Состояния 4 и 7 являются конечными.
in = getchar();
}
return(state == 4
цифра
3.7. R — символ предложения, а продукции имеют следующий вид.
R->+A\-A\digitA\.P
Ниже приводится код С для реализации автоматизации.
int r eal ( )
{int state;
char in;
state = 1,in = getchar{);
while(isdigit(in) j| issign(in) jj in =='.'
{switch (state) {
case 1: if (isdigit (in) || issign (in))
state = 2;
else if (in == ' . ')
state = 3;
else error() ;
break;
case 2
if (isdigit (in))
state -- 2;
3: else if (in == ч.')
state 3;
else error();
break;
if (isdigit (in))
state = 4;
else error();
break;
||
in ==
'e'
А~> digit A\ ,P Р->
digitQ\ digit Q-^eE\
digitQ\ digit E-++F \-F\
digit G\ digit G ~» digit G
j digit
3J. Конечный автомат приведен на рис. А. 7. Состояние 1 является начальным и конечным. Считывание единицы приводит к изменению
состояния, считывание нуля не меняет состояния.
I
Рис.А.7,
а. Регулярное выражение.
{0*10"1 О*)*
б. Вход Lex.
valid
случая приведены только
продукции. S — символ
Цифра
state ==7)
Ишемия упражнений
цифра
Рис. А.6.
Приложение А
(0*10*10*)*
229
3.9. Лексические сбои соответствуют недопустимому знаку или последо.
T-*OS\ 11/1/
вательности недопустимых знаков. Восстановление — это пропуск
U~*\S\QTT
знаков, пока не будет найден допустимый знак.
4.g. Нет, поскольку приведенные ниже множества первых порождаемых
ЗЛО. Могут использоваться совместные процедуры. Позволяет избежать возсимволов пересекаются.
можньк (но крайне маловероятных) случаев интенсивного использоваDS(S -¥ АВ) = {х, т}
ния рекурсии, но, пожалуй, такой подход менее интуитивен.
4.9. Строки языка принадлежат к одному из двух типов.
Глава 4
•
4.1. Грамматика с продукциями S-»
S+x
•
S-4X
(поскольку она содержит левую рекурсию).
Строки из нуля или более знаков р, за которыми следует строка
из нуля или более знаков q, за которыми идет знак х.
Строки ху или строки гп, за которыми следует строка из одного
или более знаков Ь.
4.10.
4.2.
S-*EXP
ЕХР-* TERM MORETERMS MORETERMS
-» +TERM MORETERMS | - TERM
MORETERMS
I e TERM-*
FACTMOREFACT
MOREFACT-* 'FACTMOREFACT |
/FACT MOREFACT
|e
FACT-* - FACT
FACT-* (EXP) FACT-*
VAR VAR~*a\b\c\
d\e
а. Поскольку для некоторых предложений языка может быть более
одного правого порождения, множества первых порождаемых символов для некоторых нетерминалов должны быть непересекающимися — должна существовать возможность неоднозначной замены
нетерминала (на некотором шаге порождения). См. раздел 4.3.
б.
В каждом гдучае приведены только продукции.
4.3.
S-> xSy
a S-» а
.
S-*xSW\a
б W-+y\z S.
*R\ T R~*
в xRy Я-» a
.
4.11.
EXP -* TERM MORETERMS MORETERMS -» +<A 1>
TERM <A2> MORETERMS | - <A 1> TERM <A2>
MORETERMS
Т—> а
Преимущества — предлагает левоассоциативность (вычисление слева направо), требуется хранить меньше промежуточных результатов.
Недостатки — не подходит для LL-анализа, в том числе, для анализа методом рекурсивного спуска.
а. Последний.
б. Первый.
Продукции грамматик приводятся ниже (в каждом случае S •
вол предложения).
а. S-»0S11|a
б, S - > O S | i r | e
T->OS|e
в. S-И Г|О1/|е
|£
TERM-^ FACTMOREFACT
MOREFACT-* *<A1>FACT<A2> MOREFACT |
/<A2>FACT <A2> MOREFACT
FACT-* - <A1> FACT <A2>
FACT-* (EXP) FACT■-» VAR
<A3> V A R ~* a\ b \c \c l\ e
Решения упражнений
сим- »
Приложение I
231
продукцию 3 или 4), опираясь на историю произведенного синтаксического анализа и одного символа предпросмотра.
Глава 5
232
5.1. В первую очередь потому, что не требуется распознавать каждую
продукцию, руководствуясь одним терминалом, пока она не будет
полностью сформирована в стеке.
5.2. Конфликт перенос/свертка — на определенном этапе синтаксиче
ского анализа кажутся возможными перенос и свертка.
Конфликт свертка/свертка — на определенном этапе синтаксиче
ского анализа кажется возможной свертка по более чем одной про
дукции.
5.3. Чтобы грамматика была неоднозначной, должно существовать пред
ложение, которое можно породить (используя правые порождения)
несколькими способами. Таким образом, в ходе правого порожде
ния на некотором этапе должны быть возможны несколько дейст
вий, т.е. в таблице LR-анализа должен присутствовать конфликт пе' ренос/свертка или свертка/свертка. Следовательно грамматика не
может относиться к типу LR(1).
5.4. Потому что содержимое стека символов ни на каком этапе не влия
ет на прогресс синтаксического анализа.
5.5. Если грамматика аннотирована так, как показано ниже, соответст
вующая таблица SLR(l)-aHara3a имеет вид, приведенный в табл. АЛ
1. S--» ia2x3F4
23.
/3,463,6
J —» 4.
5.
/5xw
Таблица А, 1,
1
6.2.
S2
R3
R1
6.3.
R3
6.4.
S8
R2
Состояние S
1
t*jj!|
г
з
S4
S5
S7
S5
4
5
S3
8
S6
7
8
S10
9
S9
i£
5.6.
R4
RS
6.5.
6.6.
R4
R5
Грамматика не может быть LR(1), поскольку не -существует способа
определения "середины" предложения (когда следует применять
Приложение А
5,7.
Ес
ли
гр
ам
ма
ти
ка
ан
но
ти
ро
ва
на
та
к,
ка
к
по
ка
за
но
ни
же
,
пр
ис
ут
ст
ву
ет
яв
н
ы
й
ко
н
ф
ли
кт
пе
ре
но
с/с
ве
рт
ка
в
со
ст
оянии 8, который невозможно разрешить, используя единственный
символ предпросмотра — предпросмотр символа : может указывать
как на перенос, так и на свертку! В то же время, два символа
предпросмотра позволяют разрешить конфликт, поскольку :=
указывает на свертку, а все остальное — на перенос.
Следовательно, грамматика относится к классу LR(2).
Имеющуюся проблему можно устранить на этапе лексического
анализа, трактуя := как единый символ.
S—»i
5.8. Решение не приводится — зависит от локальной среды.
5.9. За — единообразный подход к восстановлению после ошибок.
Против — не используются преимущества того, что восстановление
после контекстно-зависимых ошибок часто .возможно без влияния
на действия синтаксического анализатора.
5.8. Грамматика, приведенная в конце раздела 5.8 для иллюстрации
конфликтов свертка/свертка.
Глава б
6.1.
Для языка С:
• согласованное число индексов массива;
• согласованное число параметров функции;
• правила видимости;
• совместимость типов при присваивании и т.п.
Нет, поскольку составные операторы обычно не вкладываются до
произвольной глубины.
Нет, поскольку данная договоренность позволяет всего лишь отличать целые от действительных и, в любом случае, ее можно обойти.
Информацию нужно вводить в таблицу символов в ходе прохода, предшествующего проходу, в котором эта информация будет использована.
В языке Pascal рекурсивные типы могут использоваться только вместе с указателями, а тип указателя должен определяться непосредственно перед типом, на который указывает.
•
•
•
Абстракция данных и инкапсуляция.
Полиморфизм.
Наследование.
Решения упражнений
233
6.7. За — позволяет наследование различных типов методов из различных источников.
Против — делает программы сложнее с точки зрения разработчиков
и пользователей.
Глава 7
7.1. За — упрощает адресацию.
Против — требует больше памяти.
7.2. Единственная возможность — использовать одномерный символьный
массив плюс таблицу, каждая строка которой включает следующее.
• Указатель в массив, соответствующий началу константы.
• Целое число, соответствующее количеству знаков в константе.
7.3. Может использоваться куча, но такой подход не является полностью
7.4.
7.5.
7.6.
7.7.
7.8.
удовлетворительным, поскольку момент, когда память можно будет
использовать повторно, предсказуем во время компиляции. В качестве альтернативы может использоваться динамическая часть стека,
но тогда придется разрешить массиву увеличиваться и уменьшаться
во время выполнения программы. Это, в свою очередь, может повлечь перемещение других значений в динамический стек!
Да.
Требуется на один указатель меньше при доступе к элементам мас
сива —- см. раздел 7.4.
Необходимо следить, чтобы при перемещении значения не записы
вались одно поверх другого.
Программист не должен заботиться об освобождении памяти. Кро
ме того, заранее нельзя быть уверенным, что он проведет этот про
цесс корректно!
В средах реального времени более предпочтительным является фик
сированный и предсказуемый объем служебных издержек, связан
ных со счетчиками ссылок.
Глава 8
tl = X * у
t 2 = t! * Z
tj = -p
tt = t3 * q
t5 = t2 + t4
8.2. Разделение зависимых и независимых от языка аспектов компилятора;
легкость переносимости; меньше работы при реализации нескольких
языков и т.д.
8.3. Трехадресный код не является ориентированным на язык, Р-код —
является.
Р-код основан на использовании стеков (трудно сказать, это достоинство или ограничение!).
8.4. Интерпретация — облегчение переносимости, хорошая поддержка
диагностических средств.
Трансляция — эффективнее.
8.5. Помогает ограничить число типов инструкций.
8.6. Архитектуры CFSC имеют более мощные инструкции, разработанные для реализации высокоуровневых языков. Архитектуры RISC
— это больше регистров и более простой набор инструкций.
§.7.
Переменная
a
. с
d
m
n
к
P
J. c, d.
.9. Заменить
кодm = 0;
sum2 = 0;
while (m <
8.1.
Инструкции
1-3
1-5
1
2-4
3-4
4-5
5следующим.
20)
{m = m + 2;
а.
sum2 = sum2
ti = а
§.
tl
= aС -f~ b
t2 ™
d
+
t3 = t l * t2
ti = e + £
t5 = tj
234
в.
+ ГО;
8.10. Удаление оператора из цикла увеличит время выполнения программы, если цикл выполняется нуль раз.
+
1
Приложение А : Решения упражнений
235
Глоссарий
I
Java Virtual Machine. Виртуальная машина, используемая при реализации Java.
Lex. Генератор лексических анализаторов.
1А(1)-грамматика. Грамматика, в которой множества первых порождаемых символов всех продукций, в левой части которых фигурирует
один нетерминал, не пересекаются.
1Х(&)-гра!«матнка. Обобщение 1Х(1)-грамматик, в котором множества
первых порождаемых символов становятся строками длины к.
Ыь(к)-яшк. Язык, для которого существует генерирующая его грамматика LL(k).
1Ж(&)-1рамматлка. Грамматика, в которой все конфликты восходящего
синтаксического анализа слева направо можно разрешить, используя
фиксированный объем информации, касающейся уже проведенного
анализа, и ограниченный объем (не более к символов) предлросмотра.
1Л(&)-язык. Язык, генерируемый грамматикой LR(A).
Р-код. Промежуточный язык, широко используемый для реализации
языка Pascal.
81Ж(1)-грамматика. Подмножество 1,Щ1)-грамматик, в котором все потенциальные конфликты разрешаются путем изучения только символов предпросмотра.
YACC (Yet Another Compier-Compiler). Еще один компилятор компиляторов (генератор синтаксических анализаторов).
Абстрактное синтаксическое дереш. Древоподобная структура, используемая
для представления необходимых аспектов структуры программы (из
представления обычно исключаются знаки пунктуации, скобки, и т.д.).
Абстракция данных. Описание данных через операции', которые могут
производиться с этими данными, в противоположность внутреннему
представлению.
Автомат магазинного типа. Конечный автомат плюс стек, содержимое которого может влиять не переходы и зависеть от переходов.
Адрес. Положение в памяти.
Адреса времени компиляции. То, что известно во время компиляции об
адресах (времени выполнения) переменных (или других значений).
Аксиома. См. символ предложения.
Аксиоматическая семантика. Разновидность определения семантики, основанная на исчислении предикатов, в которой результаты вычислений выражаются через отношения между значениями переменных до
и после применения определенных операций.
Алфавит. Конечное множество символов.
Анализ живучести. Анализ того, какие переменные "живы" (активны) в
конкретном месте программы.
Анализатор. Часть компилятора, анализирующая исходный код.
Атрибутная грамматика. Контекстно-свободная грамматика, дополненная
атрибутными правилами, обычно используемыми для ограничения
предложений, которые генерируются грамматикой.
Байт-код. Промежуточный язык, обычно используемый в реализациях Java.
Виртуальная машина. Цель компилятора, реализованная в программном виде.
Восстановление после ошибки. Действия, позволяющие программе синтаксического анализа продолжать работу после прочтения некорректного входа.
Восходящий синтаксический анализ. Метод анализа, заключающийся в сокращении предложений языка до символа предложения генерирующей его грамматики.
Входной символ. Символ, считываемый компилятором.
Выбор инструкций. Часть фазы генерации кода, в которой компилятор
выбирает конкретные инструкции целевого кода или последовательности инструкций целевого кода.
Геиератор лексических диализаторов. Программный модуль, который может использоваться для создания лексического анализатора.
Генератор синтаксических аиализаторов. Программный модуль, который
может использоваться для создания синтаксического анализатора.
Генерация машинного кода. Создание машинного кода.
Генерация машинно-независимого кода. Создание машинно-независимого
кода.
Генерировать. Создавать посредством применения продукций грамматики.
Глобальная память. Память, которую может потребоваться поддерживать
до завершения программы.
Грамматика. Система для производства предложений языка, состоящая из
четверки (Vp VN, P, S), где VT— алфавит, элементы которого называются
терминальными символами; VN— алфавит, элементы которого называются
нетерминальными символами (или нетерминалами); Р — множество
продукций; S — нетерминал, именуемый символом предложения.
Грамматика 0-го тина. См. рекурсивно перечислимая грамматика.
Грамматика 1-го типа. См. контекстно-зависимая грамматика.
238
Глоссарий
т
грамматика 2-го типа. См. контекстно-свободная грамматика.
Грамматика 3-го типа. См. регулярная грамматика.
Граф взаимодействия (регистров). Граф, узлы которого представляют переменные, а ребра соединяют переменные, которые не могут использовать один регистр.
Действие переноса (в восходящем синтаксическом анализе). Действие, связанное с принятием терминала.
Действие свертки (в восходящем синтаксическом анализе). Действие, связанное с замещением правой части продукции ее левой частью.
Денотационная семантика. Разновидность определения семантики, основанная на функциональном исчислении.
Детерминированный синтаксический анализ. Метод анализа без необходимости отменять уже сделанные шаги.
Динамический анализ (программного обеспечения). Анализ программного
обеспечения посредством его эксплуатации.
Динамическая память. Память, требования к которой будут динамически
меняться во время выполнения.
Динамические указатели. Указатели (в стек времени выполнения), отражающие структуру вызовов во время выполнения.
Динамический анализ (программного обеспечения). Анализ программного
обеспечения посредством его эксплуатации.
Драйверная программа. Часть синтаксического анализатора, независимая
от языка.
Единичное наследование. Наследование, включающее один родительский класс.
Живая переменная. Переменная в определенном месте программы, значение
которой может потребоваться позже при выполнении программы.
Задача раскрашиваиня графа. Задача покраски каждого узла графа конкретным цветом, выбранным из конечного множества цветов так,
чтобы два соседних узла всегда были окрашены в различные цвета.
Задача синтаксического анализа. Задача нахождения порождения (если таковое существует) конкретного предложения с использованием данной грамматики.
Звездочка Жлини. Символ "*", используемый в регулярных выражениях
для обозначения нуля или большего числа вхождений предшествующего элемента.
Иерархия Хомского грамматик/языков. Классификация грамматик и языков согласно типам продукций, используемых в грамматике.
Инкапсуляция даиных. Обособление элементов реализации функций,
операторов, и т.д. от их использования.
Глоссарий
239
Интегрированная среда разработки. Среда программного обеспечения для
разработки программного обеспечения и поддержки сопутствующих
действий.
Интерпретатор. Транслятор языка, выполняющий каждую инструкцию
целевого кода, как она создана.
Исходный текст или исходный код. Первоначальный вход компилятора,
обычно — программа, написанная на языке высокого уровня.
Компилятор. Программный модуль, выполняющий процесс компиляции.
Компилятор компиляторе):;. Программный модуль для автоматического
создания компиляторов.
Конечный автомат. Конечный набор состояний плюс множество переходов между состояниями, определяемыми входными символами.
Контекстно-зависимая грамматика. Грамматика, для всех продукций которой
длина (число символов) левой части не больше длины правой части.
Контекстмо-завлсимый язык. Язык, который можно сгенерировать посредством контекстно-зависимой грамматики.
Контекстно-свободная грамматика. Грамматика, все продукции которой
содержат в левой части единственный символ.
Контекстно-свободный язык. Язык, который можно сгенерировать посредством контекстно-свободной грамматики.
Конфигурация в грамматике. Положение перед, после или между символами правой части продукции грамматики.
Конфликт перенос/свертка (в восходящем синтаксическом анализе). Ситуация, при которой кажутся возможными и действие переноса, и действие свертки.
Конфликт свертка/свертка (■ восходящем синтаксическом анализе). Ситуация, при которой кажутся возможными несколько действий свертки.
Левая рекурсия. Разновидность рекурсии, при которой символ из левой
части продукции грамматики может генерировать (посредством применения одной или нескольких продукций) строку символов, крайним левым символом которой будет тот же порождающий символ.
Левое порождение. Порождение, при котором на каждом этапе заменяется крайний левый нетерминал сентенциальной формы.
Леволииейиая грамматика. Грамматика, все продукции которой имеют
одну из следующих двух форм. А-* Вс
Здесь А и В — нетерминалы, а с и d — терминалы грамматики.
Лексический анализ. Фаза процесса компиляции, основной целью которой
является формирование символов языка из строк знаков.
240
Глоссарий
Лексический анализатор. Часть компилятора, выполняющая лексический
анализ.
Линейно ограниченный автомат. Машина Тьюринга с конечной длиной ленты.
Машина Тьюринга. Автомат, включающий состояния, переходы и память,
состоящую из бесконечной ленты.
Машинно-независимый код. Кодовый выход компилятора, независимый
от конкретной машины.
Множественное наследование. Наследование, включающее более одного
родительского класса.
Множество первых порождаемых символов. Множество терминалов, совместимых с применением конкретной продукции в процессе нисходящего синтаксического анализа.
Наследование (в объектно-ориентированных языках). Механизм, при котором у класса предполагается наличие свойств (например, методов)
суперкласса.
Наследуемые атрибуты. Атрибуты (символов атрибутной грамматики),
значения которых передаются от левых частей соответствующим
правым частям контекстно-свободных продукций.
Неоднозначная грамматика. Грамматика, в которой имеется более одного
левого порождения (или синтаксического дерева, или правого порождения), по меньшей мере, для одного генерируемого предложения.
Неоднозначный язык. Язык, который невозможно сгенерировать ни одной
однозначной грамматикой.
Непрямая рекурсия. Рекурсия, затрагивающая более одной продукции.
Нетерминальный символ (или нетерминал). Символ, используемый грамматикой для генерации предложений языка.
Нечистая грамматика. Грамматика, все продукции которой используются.
Нисходящий синтаксический анализ. Метод синтаксического анализа, заключающийся в создании предложений языка из символа, предложения грамматики, генерирующей данный язык.
Однозначная грамматика. Грамматика, в которой невозможно создать ни
одного предложения, содержащего более одного левого порождения
(или синтаксического дерева, или правого порождения).
Операционная семантика. Разновидность определения семантики, в котором операции языка описываются через действия абстрактной машины, выполняющей программу.
Оптимизация кода. Улучшение кода с точки зрения его размера или времени его выполнения.
Оптимизация машинного кода. Улучшение машинно-независимого кода
относительно использования памяти или времени выполнения.
Глоссарий
241
Оптимизация машинно-независимого кода. Улучшение машиннонезависимого кода относительно использования памяти или времени выполнения.
Полиморфизм. Использование одного имени оператора или функции с
различными значениями в зависимости от типов его/ее параметров/операндов.
Порождение. Последовательность шагов, когда предложение языка порождается из грамматики, генерирующей данный язык.
Постпроцессор (компилятора). Части компилятора, наиболее близкие к
машине.
Правая рекурсия. Разновидность рекурсии, при которой символ из левой
части продукции грамматики может генерировать (посредством применения одной или нескольких продукций) строку символов, крайним правым символом которой будет тот же символ.
Прашяла разрешения неоднозначности. Правила, разрешающие неоднозначности грамматики.
Правое иороледение. Порождение, при котором на каждом этапе заменяется крайний правый нетерминал сентенциальной формы.
Праволинейная грамматика. Грамматика, все продукции которой имеют
одну из двух следующих форм.
А-^ЬС
Здесь А и С — нетерминалы, a h и d — терминалы грамматики.
Предложение языка. Строка, принадлежащая языку.
Препроцессор (компилятора). Часть компилятора, ближайшая к исходному коду.
Продукция грамматики. Правило, которое составляет часть грамматики и
определяет, как подстрока сентенциальной формы может замещаться
другой подстрокой в ходе порождения предложения.
Промежуточный код. Код, создаваемый компилятором в качестве промежуточного этапа перед производством целевого кода.
Проход. Компонент компилятора, включающий однократное чтение исходного кода или его представления.
Процесс компиляции. Преобразование исходного кода, обычно написанное на языке высокого уровня, в семантически эквивалентный машинный код или другое представление, близкое к машинному коду.
Прямая рекурсия. Разновидность рекурсии, при которой нетерминал левой части продукции также входит в правую часть.
Пустая строка. Строка длины нуль.
Распределение памяти. Распределение области памяти для значений переменных и т.д. для использования во время выполнения.
242
Глоссарий
распределение регистров. Выделение регистров машины переменным и
промежуточным данным в ходе генерации кода.
Реализация. Действие, заключающееся в создании компилятора, или собственно компилятор.
Регулярная грамматика. Праволинейная или леволинейная грамматика.
регулярное выражение. Выражение, представляющее множество строк,
состоящих только из символов алфавита и операторов дизъюнкции,
сопоставления и звездочки Клини.
Регулярный язык. Язык, который может генерироваться регулярной грамматикой.
Рекурсивно перечислимая грамматика. Наиболее общий тип грамматики,
разрешенной определением грамматики.
Рекурсивно перечислимый язык. Язык, который можно сгенерировать рекурсивно перечислимой грамматикой.
Рекурсивный спуск. Метод нисходящего синтаксического анализа, основанный на написании функции или процедуры на языке реализа ции, проверяющей все нетерминалы грамматики.
Рекурсия (в контекстно-свободной грамматике). Свойство, заключающееся
в том, что нетерминал может генерировать строку символов, содержащую данный нетерминал.
Самовложение. Средняя рекурсия в грамматическом правиле или группе
грамматических правил.
Сборка мусора. Процесс, в ходе которого очищается область кучи, уже
недоступная программе.
Семантический анализ. Анализ исходного текста для определения его значения/предполагаемого эффекта.
Сентенциальная форма. Любая последовательность символов, которую,
используя продукции грамматики, можно породить из символа
предложения.
Символ-иоследователь. Символ, который в сентенциальной форме может
следовать за данным символом.
Символ предложения (или аксиома) грамматики. Нетерминал, используемый в начале каждого порождения предложения.
Символ предпросмотра. Символ, который следующим считает программа
синтаксического анализа на определенном этапе анализе.
Синтаксис (языка). Множество строк языка.
Синтаксический анализ. Этап процесса компиляции, основная задача которого — определить структуру программы.
Синтаксический анализатор. Часть компилятора, выполняющая синтаксический анализ.
Глоссарий
243
Синтаксическое дерево. Древоподобная структура, используемая для пред.
ставления структуры программы. Синтезатор. Часть компилятора,
создающая целевой код. Синтезируемые атрибуты. Атрибуты (символов
атрибутной грамматики),
значения которых передаются от правых частей соответствующим
левым частям контекстно-свободных продукций.
Средняя рекурсия. Рекурсия в продукции, не являющаяся ни левой, ни
правой.
Стартовый символ (в нисходящем синтаксическом анализе). Терминальный
символ, появляющийся в начале строки символов, или (для строки,
которая начинается с нетерминала) терминал, который может появиться в начале строки, генерируемой нетерминалом.
Статическая память. Область памяти, которую нужно распределить на
время выполнения программы и требования к которой известны во
время компиляции.
Статическая семантика. Аспекты семантики, которые можно определить
статически.
Статические указатели. Указатели (в стек времени выполнения), отражающие статическую структуру вызовов исходного кода.
Статический анализ (программного обеспечения). Анализ программного
обеспечения без его выполнения.
Стек символов. Стек, в котором во время восходящего синтаксического
анализа хранятся последовательности символов.
Стек состояния. Стек, в котором хранятся состояния в ходе восходящего
синтаксического анализа.
Стековый фрейм. Раздел стека времени выполнения, соотнесенный с одной функцией или процедурой.
Счетчик ссылок. Счетчик, используемый для отслеживания числа указателей, указывающих на определенную ячейку динамической памяти.
Таблица меток. Таблица времени компиляции, которая содержит информацию, касающуюся меток программы.
Таблица символов. Таблица, используемая во время компиляции для хранения информации об области видимости, типе переменных и другой информации, касающейся переменных.
Таблица синтаксического анализатора (или таблица синтаксического анализа).
Зависимая от языка таблица, которая используется для управления процессом принятия решений программы синтаксического анализа.
Таблица типов. Таблица, используемая во время компиляции для информации, касающейся типов.
Таблица функций. Таблица времени выполнения, содержащая информацию, касающуюся функций программы.
244
Глоссарий
Т-диаграмма. Разновидность диаграммы, используемая для отображения
трех языков (исходного, целевого и реализации), задействованных в
компиляторе.
Терминальный символ (или терминал). Символ, который формирует часть
грамматики и может появляться в предложениях, генерируемых этой
грамматикой.
Трехадресиый код. Разновидность промежуточного кода, описанная в разделе 8.2.
Универсальный промежуточный язык (Universal Intermediate Language —
UIL). Промежуточный язык, подходящий для реализации большого
числа языков на большом числе машин.
Фаза. Логический компонент компилятора.
Фаза маркировки (процесса сборки мусора). Фаза процесса сборки мусора,
в которой определяются и некоторым образом маркируются ячейки
памяти кучи, значения которых еще требуется поддерживать.
Фаза сжатия (процесса сборки мусора). Процесс перемещения всей требуемой памяти кучи в одну область доступного пространства.
Характеристический конченый автомат. Представление таблицы нисходящего синтаксического анализа в виде конечного автомата.
Целевой текст или целевой код. Конечный выход компилятора, обычно —
код для реальной машины.
Чистая грамматика. Грамматика, не содержащая продукций, которые являются избыточными или не могут использоваться в любом порождении строк терминалов.
Эквивалентные грамматики. Грамматики, генерирующие один и тот же язык.
Этап. Основной логический компонент компилятора.
Этап анализа. Этап процесса компиляции, в основном посвященный
анализу исходного кода.
Этап синтеза. Этап процесса компиляции, в основном связанный с созданием целевого кода.
Язык. Множество строк из символов некоторого алфавита.
Язык 0-го типа. См. рекурсивно счетный язык.
Язык 1-го типа. См. контекстно-зависимый язык.
Язык 2-го типа. См. контекстно-свободный язык.
Языж 3-го типа. См. регулярный язык.
Язык реализации. Язык, на котором пишется компилятор.
Глоссарий
245
Knuth, D. E., On (he translation of languages from left to right, Information and
Control, vol. 8, pp. 607-639. (Кнут Д. О переводе (трансляции) языков слева
направо// Сб. "Языки и автоматы" — М.: Мир, 1975. — С. 9-42.)
Knuth, D. E., 1968a. Semantics of Context-free languages, Mathematical Systems Theory. 2:2, pp. 127-145.
Knuth, D. E., 19686. The Art of Computer Programming, volume 1, Fundamental
Algorithms, Addison Wesley. (Кнут Д. Искусство программирования. Т.
I. Основные алгоритмы. — 3-е изд. — М.: Издательский дом
"Вильяме", 2000)
Knuth, D. Е., 1971. Topdown Syntax Analysis, Acta Informatica, vol. 1, pp. 79-110.
Lesk, M. E., 1975. Lex — a lexical analyser generator, Computing Science
Technical Report 39, Bell Laboratories, NJ.
Levine, J. R., Mason, T. and Brown, D., 1992. Lex and YACC (2 nd edn),
O'Reilly and Associates.
Lindholm, T. and Yellin, F., 1996. The Java Virtual Machine Specification, Addison Wesley.
Louden, K. C, 1997. Compiler Construction; Principles and Practice, PWS Publishing Ltd.
Lucas, P., 1961. The structure of formula translators, Electronische Rechenanlagen, vol. 3, pp. 159-166.
Meyer, J. and Downing, Т., 1997. Java Virtual Machine, O'Reilly and Associates.
Naur, P., 1964. The Design of the GIER Algol Compiler, Annual Review in
Automatic Programming, vol. 4, pp. 49-85.
Nori, K. V. et al., 1981. Pascal P implementation note, in Barren D. W., Pascal and
its Implementation, J, Wiley.
Randell, B. and Russel, L. J., 1964. Algol 60 Implementation, Academic Press.
(Ревделл Б., Рассел Л. Реализация АЛТОЛа 60. — М.: Мир, 1967.)
Schreiner, А. Т. and Friedman, H. G., 1985. Introduction to Compiler
Construction with Unix, Prentice Hall.
Stallman, ft, 1994. Using and Porting GNU CC, Gnu ftp distribution
(prep.ai.mit.edu), Cambridge, MA, Free Software Foundation.
Terry, P. D., 1997. Compilers and Compiler Generators: an Introduction with C++,
International Thomson Computer Press.
UUmann, J., 1994". Compiling in Modula-2, Prentice Hall.
Watt, 13. A., 1997. An extended attribute grammar for Pascal, Report number
II, Department of Computing, University of Glasgow.
Watt, D. A., 1993. Programming Language Processors, Prentice Hall.
Welsh, J. and Hay. A., 1986. A Model Implementation of Standard Pascal, Prentice
Hall International.
Welsh, J., Sneeringer, W. J. and Hoare, C. A. R., 1977. Ambiguities and insecurities
in Pascal, Software — Practice and Experience, vol. 7, pp. 685-696.
Wilhelm, R. and Maurer, D., 1995. Compiler Design, Addison-Wesley.
Wirth, N., 1971. The design of Pascal compiler, Software Practice and Experience, vol. 1, pp. 309-333.
Wirth, N.. 1996. Compiler Construction, Addison-Wesley.
\Щ
248
Литература
Предметный указатель
Lex, 24; 62-76; 133; 138; 139, 140 LL(l)rpaMMaiHKa, 84; 86 LL(1)-H3HK, 87 LRанализ, 134-132
лексический, 16; 57; 71
нисходящий синтаксический,
52, 81-108
псевдонимов, 218
семантический, 157
P
синтаксический, 17; 27; 51
статический, 18
Р-код, 201
статический семантический,
165
Аннотации, 145
Архитектура
UIL, 19
CISC, 206
YACC, 23; 75; 76; 135-153
RISC, 206
Адрес, 21; 173
Атрибуты
времени компиляции, 185 Адрес
наследуемые, 50
времени компиляции, 176 Аксиома, 32
синтезированные, 50
Алгоритм
LALR(l), 131
Б
LR(0), 132
LR(1), 132
БайтSLR(l), 129; 131; 149 Алфавит, 30
код,
Анализ, 12
203
В
LL(1), 101
восходящий синтаксический, 52; 109- Временное имя, 198
155
Выбор инструкций, 208
живучести, 211
и
Предметный указатель
Генератор
лексических анализаторов, 23
синтаксических анализаторов,
23
Генераторы генераторов
кода, 219
Генерация кода, 197
249
Грамматика, 31
0-го типа, 34
1-го типа, 35
2-го типа, 35
3-го типа, 35
LL(1), 84; 86
LR(0), 129 LR
( ) , /2; 72Р
LR(k), /52
LR(k)., 112
SLR(l), /2P
аннотированная, 7.ЗД 121
атрибутная, 49
контекстно-свободная, 35; 47
леволинейная, 36
неоднозначная, 43
нечистая, 88
однозначная, 43
праволинейная, 35
преобразования, 96
регулярная, 36
Грамматики
эквивалентные, 34 Граф
взаимодействия, 213
д
Действия, 102; 104
Дисплей, 182 Доступность,
217 Дублирование констант,
216
Звездочка Клини, 29
И
Иерархия Хомского, 34
Интегрированная среда
разработки, 22
Исключение ненужных
инструкций, 216
Исключение общих
подвыражений, 217
Исходный код, 12
К
Компилятор, 11-22; 40; 51; 97;
135; 215-219
Компилятор компиляторов, 21
Компиляция, //; 12; 15; 19; 23
Конечный автомат, 60
Контроль типов, 159 •
Конфигурация, 124
Конфликт
перенос/свертка, 111; 219
свертка/свертка, 111; 219
Куча, 175; 189
Л Лексический
анализатор, 16; 57
м
Маркировка, 191
Машина Тьюринга, 35
Машинный код, 206
Метрики, 140
Множество первых порождаемых
символов, 84—86
Множество стартовых символов,
85
н
Нетерминальный символ, 32
О
Оптимизация кода, 215
п
Память, 173 глобальная, 21;
174 динамическая, 21; 174176 освобождение, 175
распределение, 21; 173—196
статическая, 21; 174—176
Перенос, 110; 114; 122; 150
Порождение, 31; 41; 81
левое, 42
правое, 42
Постфиксная запись, 102
Правило, 32; 50 Предложение,
33 Продукция, 31
Промежуточный код, 197
Проход, 15; 21
Р
Распределение регистров, 208
Реализация, 12 Регулярное
выражение, 29 Рекурсивный
спуск, 89 Рекурсия, 39; 88; 93
левая, 39
непрямая, 39
правая, 39
прямая, 39
средняя, 39
удаление, 96
Сборка мусора, 190—193
Свертка, 110; 114; 122; 150
Семантика, 28
аксиоматическая, 52
денотационная, 52
операционная, 53
определение, 52
статическая, 19
Сентенциальная форма, 33
Сжатие, 191 Символ, 57
Символ предложения, 32; 82
Символ-последователь, 84
Символ предпросмотра, 83
Синтаксис, 27; 28
Синтаксический анализатор, 17
Синтаксическое дерево, 17; 42
абстрактное, 17 Синтез,
12, 19 Снижение стоимости,
216 Стартовый символ, 84
Стек, ПО
времени выполнения, 176—177
символов, 115
состояний, 115 Стековый
фрейм, 177-180 Счетчик
ссылок, 190-193
Таблица
SLR(l), 129
меток, 168
символов, 162
типов, 167
функций, 168
синтаксического анализа, 113;
120; 139
Т-диаграмма, 14 Терминал,
32 Терминальный символ,
32 Тип, 48 Трехадресный
код, 198
Удаление бесполезного кода, 217
Фаза, 15
Факторизация, 90; 98 Форма
Бэкуса-Наура, 93
расширенная, 93
X
Характеристический конечный
автомат, 122
250
251
Ц
Целевой код, 12
э
Этап, 15
) 112
LR(k), 112
контекстно-свободный, 39
неоднозначный, 43
определение, 27
реализации, 12
регулярный, 37; 39
Я
Язык
, 87
Научно-популярное издание
Робин Хантер
Основные комцепщии компиляторов
Литературный редактор
Верстка
Художественный редактор
Обложка
Корректор
Т.Т. Шматко
О. В. Линник
А.А, Минъко
Е.П. Дынник
Л.А. Гордиенко
Издательский дом "Вильяме".
101509, Москва, ул. Лесная, д. 43, стр. 1.
Изд. лиц. ЛР Не 090230 от 23.06.99
Госкомитета РФ по печати.
Подписано в печать 08.10.2002. Формат 60x88/16.
Гарнитура Times. Печать офсетная.
Усл. печ. л. 16,0. Уч.-изд. л. 11,0.
Тираж 3000 экз. Заказ № 1564.
Отпечатано с диапозитивов в ФГУП "Печатный двор"
Министерства РФ по делам печати,
телерадиовещания и средств массовых коммуникаций.
197110, Санкт-Петербург, Чкаловский пр., 15.
252
Download