Text
                    Основы
вычислительных
систем
Основные концепции
компиляторов

Робин Хантер

THE ESSENCE OF Compilers Robin Hunter ftvnlicc flail An imprint of Pearson Education LONDON • NEW YORK • TORONTO • SYDNEY • TOKYO • SINGAPORE MADRID • MEXICO CITY • MUNICH
Основные концепции ^компиляторов Робин Хантер Издательский дом “Вильямс" Москва ♦ Санкт-Петербург ♦ Киев 2002
ББК 32.973.26-01S.2.75 Х19 УДК 6S 1.3.07 Издательский дом “Вильямс” Зав. редакцией А.В. Слепцов Перевод с английского В.Н. Гаркавенко. А.В. Назаренко Под редакцией А.В. Назаренко По обшнм вопросам обращайтесь в Издательский дом “Вильямс” по адресу: info@wiUiamspublishing.com. hup://ww\\.williamspublishing.com Хантер, Робин. Х19 Основные концепции компиляторов.: Пер. с англ. — М.: Издательский дом “Вильямс", 2002. — 256 с.: ил. — Парад, тит. англ. ISBN 5-8459-0360-2 (рус.) Эта небольшая, но емкая книга является введением в теорию создания компиля- торов, а также кратким описанием принципов их работы. Материал изложен в рас- чете на читателя. не знакомого с данным предметом. В тексте предлагаются реко- мендации по дополнительной литературе и даны подсказки по средствам инструмен- тальной поддержки. Для закрепления материала предлагаются упражнения (с решениями). В завершение книги приводится словарь терминов, используемых в данной области. Книга может быть полезна как студентам, так и преподавателям, читающим соответствующий курс лекций. ББК 32.973.26-018.2.75 Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами, будь то электронные или механические, включая фотокопирование и запись на магнитный носитель, если на это нет письменного разреше- ния издательства Pearson Educations Europe. Authorized translation from the English language edition published by Prentice Hall Europe. Copy- right © 1999 All rights reserved. No pan of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage re- trieval system, without permission from the Publisher. Russian language edition published by Williams Publishing House according to the Agreement with R&.I Enterprises International. Copyright © 2002 ISBN 5-8459-0360-2 (рус.) © Издательский дом “Вильямс’*. 2002 ISBN 0-1372-7835-7 (англ.) © Prentice Hall Europe. 1999
Оглавление Предисловие 9 Глава 1. Процесс компиляции Ц Глава 2. Определение языка 27 Глава 3. Лексический анализ 57 Глава 4. Нисходящий синтаксический анализ 81 Глава 5. Восходящий синтаксический анализ 109 Глава 6. Семантический анализ 157 Глава 7. Распределение памяти 173 Глава 8. Генерация кода 197 Приложение А. Решения упражнений 223 Глоссарий 237 Литература 247 Предметный указатель 249
Содержание Предисловие Благодарности 9 10 Глава 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. Lex 3.5. Другие применения Lex 3.6. Взаимодействие с YACC 3.7. Лексические затруднения 3.8. Резюме 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
Дополнительная литература Упражнения Глава 4. Нисходящий синтаксический анализ 4.1. Вступление 4.2. Критерии принятия решений 4.3. LL( 1)-грамматики 4.4. . Рекурсивный спуск 4.5. Преобразования грамматик 4.5.1. Удаление левой рекурсии 4.5.2. Факторизация 4.6. Достоинства и недостатки Щ1)-анализа 4.7. Введение действий в грамматику 4.8. Резюме Дополнительная литература Упражнения Глава 5. Восходящий синтаксический анализ 5.1. Вступление 5.2. Основные понятия 5.3. Использование таблицы синтаксического анализа 5.4. Создание таблицы синтаксического анализа 5.5. Особенности LR-анализа 5.6. Введение в YACC 5.7. Вычисление метрик 5.8. Использование YACC 5.9. Резюме Дополнительная литература Упражнения 79 79 81 81 81 84 S9 96 96 98 101 102 105 106 106 109 109 109 113 120 132 135 140 143 153 154 154 Глава 6. Семантический анализ 6.1. Вступление 6.2. Не-контекстно-свободные характеристики языка 6.3. Таблицы компилятора 6.3.1. Таблицы символов 6.3.2. Таблицы типов 6.3.3. Другие таблицы 6.4. Реализация наследования в С++ 6.5. Резюме Дополнительная литература Упражнения Глава 7. Распределение памяти 7.1. Вступление 7.2. Память 7.3. Статическая и динамическая память 157 157 157 162 162 167 168 169 170 171 171 173 173 173 176 Содержание 7
'.4. Адреса времени компиляции 7.5. Куча 7.6. Резюме Допол н и тел ьная л итература Упражнения Глава 8. Генерация кода 8.1. Вступление S.2. Создание промежуточного кода 8.2.1. Трехадресный код 8.2.2. Р-код 8.2.3. Байт-код 8.3. Создание машинного кода 8.3.1. Выбор инструкций 8.3.2. Распределение регистров 185 189 194 194 195 197 197 197 198 201 203 206 208 8.3.3. Распределение регистров путем раскрашивания графа 213 8.4. Оптимизация кода 215 S.5. Генераторы генераторов кода 219 8.6. Резюме 220 Дополнительная литература 220 Упражнения 220 Приложение А. Решения упражнений 223 Глава 1 223 Глава 2 224 Глава 3 226 Глава 4 230 Глава 5 232 Глава 6 233 Глава 7 234 Глава 8 234 Глоссарий 237 Литература 247 Предметный указатель 249
Предисловие Изучение компиляторов является центральным и одним из наиболее вос- требованных аспектов компьютерных наук. Написание компилятора тре- бует знания исходного языка и целевой машины и обеспечения их взаи- мосвязи. Наличие современного инструментального обеспечения осво- бождает программиста от многих утомительных, подверженных ошибкам моментов при создании компилятора. Мое первое знакомство с компиляторами произошло в 1967 году на лекциях создателя ранних моделей компиляторов для языка ALGOL 60 Питера Наура (Peter Naur) в Университете Ньюкасла. Позднее, в 1974 го- ду, мне посчастливилось прослушать расширенный курс по конструиро- ванию компиляторов в Техническом университете Мюнхена (отдел Ф. Л. Бауэра (F. L. Bauer)). С тех пор я, с некоторыми перерывами, работаю с компиляторами и инструментами для их разработки. Большинство теоретических разработок и методов для конструирования компиляторов возникло в 1970-х, а некоторые из них даже раньше. В то же время развитие языков программирования постоянно ставило перед создате- лями компиляторов новые задачи: от определения языка ALGOL 60 (ранние работы Наура в Дании, а также Ренделла (Randell) и Рассела (Russell) в Вели- кобритании) до удовлетворения потребностей возникших позже языка Ada и языков объектно-ориентированного программирования. Современные требо- вания Java к сетевой безопасности, эффективности и переносимости вновь ставят перед создателями компиляторов новые проблемы. В то же время по- явление Java Virtual Machine дает великолепный пример промежуточного языка, позволяющего проиллюстрировать различные аспекты генерации кода и распределения памяти. Процесс создания компилятора соединяет в себе как творческую, так и рутинную работу. Он требует хорошей инструментальной поддержки, что от- четливо видно при изучении истории развития компиляторов. В наше время доступно множество инструментальных средств для создания компиляторов. Некоторые из них можно получить бесплатно через Internet, но на данный момент наиболее используемыми и распространенными инструментальными средствами являются Unix Lex и УАСС (сейчас они могут применяться и на других платформах). В Internet доступны также бесплатные версии Lex и УАСС, известные как Hex и Bisson. Также можно получить существующие версии turbo Pascal (изначально основанные на С). Во многих книгах по компиляторам описываются средства Lex и УАСС с примерами их использования, но лишь в немногих из них со- держится достаточно информации для того, чтобы читатель сам смог воспользоваться этими инструментами для решения своих задач. Именно поэтому одной из задач, поставленных при написании данной книги, яв-
ляетсч всестороннее (хотя и не полное, все-таки киша небо папам) рас- смотрение Lex и YACC. При этом их использование иллюсrpitpycicq не только на примерах, связанных с созданием компиляторов, но и на при- мерах. затрагивающих*другие синтаксически управляемые средства, на- пример. инструменты по вычислению метрик исходного кола. Основным языком при написании различных алгоритмов на базе Lex и YACC является С. Данная книга прежде всего посвящена компиляции императивных языков, поэтому язык С также будет применяться в каче- стве исходного языка во многих примерах, описывающих различные ас- пекты компиляции. В то же время многие свойства языка, компиляцию которого мы желаем рассмотреть, не связаны с С, поэтом} в таких случа- ях будут использоваться другие, более подходящие языки — FORTRAN. Pascal, Ada, ALGOL 68, C++, Java. Первая часть книги посвящена этапу анализа процесса компиляции. После введения в процесс компиляции (глава 1) следуют главы по опре- делению языка, лексическому анализу, нисходящему и восходящему син- таксическому анализу и семантическому анализу (главы 2-6). Более ко- роткая вторая часть книги посвящена этапу синтеза процесса компиля- ции. Она содержит две главы, в которых рассмотрено распределение памяти и генерация кода (главы 7—8). Каждая глава завершается серией упражнений, ответы на которые приведены в конце книги. Кроме того, в конце каждой главы помешен список литературы, рекомендуемой для дальнейшего изучения. Значение большинства технических терминов, используемых в книге, можно найти в глоссарии. Данная книга рассчитана на односеместровый курс по компиляторам и их инструментальным средствам, подобный читаемому в Университете Стратк- лайда (University of Stratchclyde). Названный университетский курс также включает практические занятия, на которых студенты закрепляют навыки по использованию Lex и YACC. Как и другие книги серии ‘‘Основы вычисли- тельных систем”, эта книга стремится подать все необходимые аспекты про- цесса компиляции, не пытаясь охватить всю область. Благодарности С особым удовольствием я выражаю свою признательность издательству Prentice Hadi за помощь и поддержку в создании настоящей книги. Необ- ходимо также поблагодарить Бориса Когана (Boris Cogan) и Тамару Мат- вееву (Tamara Matveeva) за их полезные комментарии к текст}'. Благода- рю свою жену Кейт (Kate) за ее поддержку и терпение, а также за про- верку всей рукописи, которую она охотно выполнила. Благодарю рецензентов издательства и редактора серии Рея Велланда (Rey Welland) за их конструктивные замечания на разных этапах создания книги. Робин Хантер (Robin Hunter) Глазго Май 1998
Глава 1 Процесс компиляции 1.1. Вступление В этой главе будут определены основные понятия компилятора языка, его структура, функции и реализация. В частности, будут рассмотрены следующие вопросы. • Взаимосвязь между различными языками, задействованными в ти- пичном компиляторе. • Типичная структура компиляторов. • Функции основных фаз компиляции. • Цели, которые ставятся при разработке компиляторов. • Роль инструментальных средств при разработке компилятора. • Возможная степень автоматизации разработки компилятора. 1.2. Основные понятия Программное обеспечение можно создавать с помощью множества язы- ков программирования. Ими могут быть традиционные императивные языки (COBOL, FORTRAN, Pascal, С), объектно-ориентированные язы- ки (C++, Smalltalk, Java), функциональные или логические языки (LISP, Prolog), языки четвертого поколения или визуальные языки (Visual C++, Visual Basic, Delphi). Задача компилятора состоит в преобразовании ори- ентированного на пользователя представления программного обеспече- ния в машинно-ориентированное (в котором происходит непосредствен- ное выполнение программы компьютером). Компиляторы — это изна- чально изощренные системы обработки текста, которые имеют много общего с другими инструментальными средствами обработки текста, на- писанными на языке программирования либо на естественном языке. Обрабатываемый ими текст может быть написан как вручную на обыч- ном языке, так и полуавтоматическим способом при использовании ви- зуальных языков или языков четвертого поколения. Задача компилятора обычно рассматривается на двух этапах.
1. Этап анализа, на котором анализируется исходный текст. 2. Этап синтеза, на котором генерируется машинно-ориентированное предстаазение. В дальнейшем вход этапа анализа будем называть исходным текстом или исходным кодом, а выход этапа синтеза — целевым текстом или целе- вым кодам. Преобразование исходного кода в целевой обычно называется процессом компиляции. Процесс компиляции осуществляет компилятор. Компилятор языка можно также назвать реализацией языка. Произведен- ный компилятором целевой код может иметь вид машинного кода для некоторой машины (компьютера) или ассемблерного кода, или некото- рого промежуточного кода, который в дальнейшем будет преобразован (уже с помошью других инструментальных средств) или в ассемблерный код, или в машинный код для некоторой машины. Возможен также ва- риант, когда промежуточный код непосредственно используется посред- ством интерпретатора. Несмотря на то, что предлагаемая книга посвящена процессу компи- ляции, при случае будут рассматриваться и другие возможности приме- нения текстуального анализа. Следует отметить, что при рассмотрении компиляции большее внимание будет уделяться вопросам анализа, чем синтеза. Это связано с тем, что понятия стадии анализа обладают боль- шей степенью общности и применимости по сравнению с частными, за- висимыми от машины понятиями стадии синтеза. Технологии компиляторов значительно развились со времени появле- ния компьютеров, и теперь стала возможной автоматизация многих ас- пектов процесса создания компилятора. Существующие инструменталь- ные средства позволяют автоматизировать создание анализаторов, хотя успехи по автоматизации создания генераторов кода намного скромнее. Вопросы возможной степени автоматизации будут рассматриваться в по- следующих главах, где с этой целью будут широко использованы средства генерации анализаторов Lex и УАСС. В основном языком создания компиляторов с помошью Lex и УАСС является С. Поэтому в дальнейшем в качестве языка реализации (языка написания компилятора) будет подразумеваться именно С, и требуемые алгоритмы будут описываться на нем. Чтобы избежать частого перехода от одного языка к другому, реализуемым языком также будет считаться С. Впрочем, для описания отдельных моментов будут использоваться и другие языки, более удобные в этих конкретных случаях. 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
Pascal М-код М-код Рис. 1.3. C++ Т-код Н-код Рис. 1.4. Часто эту проблему решают следующим образом: вместо того, чтобы пи- сать программу на языке низкого уровня, ее получают путем компиляции программы, написанной на языке высокого уровня. Поэтому сами по се- бе компиляторы изначально пишутся на языке высокого уровня, а затем с помошью уже существующих компиляторов преобразуются к виду, ко- торый воспринимает машина. Данную ситуацию можно изобразить с по- мошью трех расположенных рядом Т-диаграмм (рис. 1.5). В левом верхнем углу рисунка расположен компилятор, изначально написанный на C++, а в правом верхнем углу — исполняемый компиля- тор, полученный после компиляции компилятором, помешенным внизу рисунка. Эти три Т-диаграммы можно объединить, что позволит обнару- жить некоторые закономерности получения одного компилятора из дру- гого. Например, два языка в соответствующих двух верхних частях верх- 14 Глава 1. Процесс компиляции
них диаграмм должны быть одинаковыми, а два соседствующих появле- ния языка C++ и машинного кода должны относиться к одному языку. Изображенный внизу М-код мог, по сути, быть любым языком, опреде- ляемым машиной, на которой компилируется компилятор. С помошью Т-диаграммы легко показать, как перенести компилятор, написанный для одной машины, на другую. Пусть имеется компилятор, созданный для машины А, тогда при его переносе на машину В нужно, по меньшей мере, изменить язык реализации. Чем пытаться преобразо- вать код одной машины в код другой, намного проше вернуться к перво- начальной версии компилятора, написанной на языке высокого уровня, и откомпилировать ее в код машины В. Во многих случаях необходимо преобразовать выход кода с помощью компилятора (например, в код машины В). Это уже совсем другая задача, и ее сложность будет зависеть от иных факторов, например, от того, на- сколько автономно производство кода. 1.4. Этапы, фазы и проходы Хорошо написанный компилятор имеет модульную структуру и должен представлять пример хорошо сконструированной программы. Логически процесс компиляции разделяется на этапы, которые, в свою очередь, разделяются на фазы. Физически компилятор разделяется на проходы. Остановимся на этих вопросах более детально. Как уже говорилось, основными (и часто единственными) этапами компиляции являются анализ (определение структуры и значения исход- ного кода) и синтез (построение целевого кода). Кроме того, может быть этап предварительной обработки, в которой происходит присоединение исходных файлов, развертывание макросов и т.п. Этот этап достаточно прост и в основном связан с языками С и C++. Подробно данный этап рассматриваться не будет. Типичные фазы процесса компиляции показаны на рис 1.6. Этап анализа принято разделять на три отдельных фазы. 1. Лексический анализ. 2. Синтаксический анализ. 3. Семантический анализ. Этап синтеза состоит из следующих (всех или нескольких) фаз. • Генерация машинно-независимого кода. • Оптимизация машинно-независимого кода. • Распределение памяти. • Генерация машинного кода. • Оптимизация машинного кода. /.4. Эталь/, фазы и проходы 15
Рис. 1.6. Лексический анализ — это относительно простая фаза, в которой формируются символы (или токены) языка. Слова языка, например, for do while или пользовательские идентификаторы, например, name salary или последовательности знаков, например, удобно воспринимать как один символ, как это делается на этапе анали- за. Задачей фазы лексического анализа или лексического анализатора яв- ляется переход от последовательности знаков к символам языка, с кото- рыми в дальнейшем будут работать синтаксическая и семантическая фа- зы. Такой подход копирует поведение человека при чтении программы. В •конечном счете, мы ведь воспринимаем текст программы не как простой набор знаков, а как набор символов, состоящих из этих знаков. Наряду с преобразованием последовательности знаков в символы, лексический анализатор также обрабатывает пробелы и удаляет коммен- тарии и любые другие символы, не имеющие смысловой нагрузки для последующих этапов анализа. Важно отметить, что лексический анализа- тор всего лишь формирует символы — их последовательность не имеет для него никакого значения, т.е., если бы программа, допустим написан- ная на С, начиналась с ; number int return do == ++ то лексический анализатор просто бы передал этот текст в символьном виде синтаксическому анализатору. Другими словами, лексический ана- 16 Глава 1. Процесс компиляции
лизатор обычно нс работает с контекстом. Если он обрабатывает один символ, для него совершенно не важно, что предшествовало этому сим- волу или что последует за ним. Сравнительная простота и четкая формулировка лексического анализа позволяют легко автоматизировать создание лексических анализаторов, и в настоящее время существует множество инструментальных средств, по- зволяющих генерировать лексические анализаторы для языка, исходя только из его лексической (т.е. локальной) структуры. О том, как исполь- зовать эти инструменты, будет рассказано в главе 3. Несмотря на сравнительную простоту лексического анализатора, его выполнение может занимать значительное время в процессе компиля- ции. Это не должно удивлять, если учесть, что лексический анализ — единственная фаза компиляции, которая имеет дело со знаками, которых значительно больше языковых символов. Если же работа лексического анализатора не занимает значительную часть времени компиляции, то стоит задуматься об эффективности остальных фаз процесса. В процессе синтаксического анализа определяется общая структура программы, что включает понимание порядка следования символов в программе. Это означает, что синтаксический анализатор должен обла- дать информацией о контексте, в котором он работает, т.е. учитывать уже прочитанные символы. Результатом работы синтаксического анализатора является представление программы в древовидной форме, которую назы- вают синтаксическим деревом. К примеру, выражение (а + Ь) ♦ (с+ d) может быть представлено в виде, показанном на рис. 1.7. Рис. 1.7. Это представление называется абстрактным синтаксическим деревам. Следует отметить, что в данном представлении нет необходимости показы- вать скобки, поскольку структура, задаваемая ими в первоначальном выра- жении, представляется структурой дерева. Таким же образом вся программа может быть представлена с помощью абстрактных синтаксических деревьев. 1.4. Этапы, фазы и проходы 17
Синтаксический анализатор считывает символы в программе слева направо (привычным способом). В процессе считывания он должен уметь определять, является ли последовательность уже считанных симво- лов началом программы. Например, первые десять входных символов могут быть началом некоторой программы, а первые одиннадцать — нет. В этом случае последующие действия компилятора будут зависеть от принятого способа восстановления после ошибок. По крайней мере, ком- пилятор должен указать на одиннадцатый символ и сообщить, что в этой точке вход стал некорректным. Многие компиляторы имеют более широ- кие возможности, например, могут выводить (предположительно полез- ные) сообщения следующего характера: semi-colon missing? Стоит отметить, что иногда подобные сообщения не помогают, а наобо- рот, еще больше запутывают. Возникновение ошибки при считывании одиннадцатого символа мо- жет быть связано как с некорректностью настоящего символа, так и с ошибкой в предыдущих символах программы, и это — одна из причин того, что сообщения об ошибках могут причинять неудобства. Это не оз- начает, что компилятор может выдать сообщение об отсутствующей ошибке, он просто может указать неверное место ее нахождения и из-за этого неправильно ее охарактеризовать. Вопросы правильной диагности- ки ошибок и стратегии их устранения чрезвычайно важны, но в данной книге они не рассматриваются, дабы не отклоняться от главной темы и общих принципов книг серии “Основы вычислительных систем". Синтаксические анализаторы для проверки синтаксиса могут быть по- строены автоматически (с использованием соответствующих инструмен- тальных средств) из определения языка, хотя при этом требуется писать код для формирования абстрактного синтаксического дерева. Об исполь- зовании таких инструментов рассказывается в главах 4 и 5. Фаза синтаксического анализа является ключевой на этапе анализа. Она непосредственно взаимодействует с лексической фазой, а результаты ее работы в дальнейшем будут использоваться семантической фазой. Кроме того, фаза синтаксического анализа создает основу для работы компилятора в целом, коды этапа синтеза образуются именно благодаря взаимодействию со структурой, которую образует фаза синтаксического анализа. К тому же, синтаксический анализ создает основу для работы большого количества инструментальных средств анализа исходного кода, например, рахличных инструментов измерений, перекрестных ссылок, компоновки и т.д. Все упомянутые типы анализа являются частными случаями статического анализа, т.е. анализа, который осуществляется над исходным кодом без его выполнения. Анализ кода, связанный с вы- полнением команд исходного кода, называется динамическим. Методы синтаксического анализа требуют интенсивного изучения. Некоторые из них обладают большей общностью, чем другие (в смысле применимости к более широкому классу языков), некоторые легче авто- 18 Глава 1. Процесс компиляции
матизировать, а некоторые более эффективны. Далее в главах 4 и 5 будут подробно рассмотрены два всдуших метода — метод рекурсивного спуска, являющийся наглядным и простым при кодировании, и синтаксический анализ SLR(l), который легко автоматизируется и пригоден для большого диапазона языков. Некоторые свойства языков программирования не могут быть прове- рены простым сканированием слева направо без создания таблиц произ- вольного размера для обеспечения доступа к информации на некотором расстоянии (с точки зрения символов исходного кода). В эту категорию, к примеру, попадает информация о типах переменных и области их ви- димости. Проверка этих типов свойств языков программирования (называющихся статической семантикой) выполняется не в синтаксиче- ской, а в семантической фазе анализа. Это означает, что синтаксический анализатор не заметит несовместимости используемых типов. Проблема такого рода будет исследоваться в семантической фазе анализа. Семантический анализ обычно инициируется синтаксическим анали- затором для создания и получения доступа к соответствующим таблицам. То, что семантический анализ может не производиться автоматически синтаксическим анализатором, имеет свои положительные стороны: ошибки статической семантики легко обнаруживаются, и после них лег- ко производится восстановление. Как уже говорилось, этап синтеза процесса компиляции состоит из следующих основных фаз. • Генерация машинно-независимого кода. • Оптимизация машинно-независимого кода. • Распределение памяти. • Генерация машинного кода. • Оптимизация машинного кода. В частных случаях некоторые из этих фаз могут отсутствовать. Например, если компилятор непосредственно компилирует в машинный код, пер- вые две фазы могут пропускаться. Оптимизация кода может происходить на уровне машинно-независимого кода, на уровне машинного кода, на обоих уровнях или ни на одном. Распределение памяти является ключе- вой фазой, которая управляется одной из фаз генерации кода. Существуют причины, по которым вначале необходимо создавать ма- шинно-независимый код; это способствует переносимости компилятора и служит для обособления зависимости от языка и зависимости от ма- шины. Многие компиляторы также производят некоторые промежуточ- ные коды, которые могут быть независимы от исходного языка, машин- ного языка или от обоих. В свое время были приложены значительные усилия для создания так на- зываемого Универсального промежуточного языка (Universal Intermediate Language — UIL), удобного для использования в качестве промежуточного языка для компиляции всех, или почти всех, языков на любую машину. К 1.4. Этапы, фазы и проходы 19
сожалению, эта великолепная идея оказалась неосуществимой. Однако, на данный момент существуют хорошо разработанные промежуточные языки для компиляции исходных языков, например. P-код для Pascal, Diana для Ada. байт-код для Java. Также существуют промежуточные языки для компи- ляции на определенные машины, например, CTL для машины Manchester MU5. Если бы существовал удачный язык UIL, то проблема использования т языков на п машинах решалась бы созданием т препроцессоров (front end) (каждый из них состоял бы из соответствующего анализатора одного из язы- ков и генератора UIL) и п постпроцессоров (back end) (каждый из них состоял бы из соответствующего транслятора с UIL в один из машинных кодов). Сказанное выше иллюстрируется на рис. 1.8. С другой стороны, независимая реализация каждого компилятора потребует создания т * п программных блоков, отображающих каждый язык на каждую машину. п машин Рис. 1.8. Одной из проблем при создании UIL является проблема выбора уровня языка: U1L может оказаться либо слишком высокоуровневым для некоторых языков, либо слишком низкоуровневым для некоторых машин. Несмотря на это, существует множество примеров компиляторов с одним исходным язы- ком. который преобразуется в коды нескольких машин, и компиляторов, ко- торые преобразуют различные исходные языки в один и тот же машинный. Рассмотрим вопрос оптимизации кода. Потребность в ней может быть различной. Если требуется очень эффективный код, то компилятор обязан обеспечить значительную оптимизацию. В то же время во многих средах скорость работы программного обеспечения не является критическим пара- метром. следовательно, необходима всего лишь незначительная оптимизация. Некоторые типы оптимизации реализовать просто, и поэтому их часто вклю- чают в компиляторы, тогда как другие формы оптимизации, в особенности глобазьные (в отличие от локальных), трудоемки и требуют значительных за- трат времени при компиляции, а посему употребляются редко. Многие ком- пиляторы позволяют пользователю самому определить, требуется ли исчер- пывающая (а следовательно, дорогая) оптимизация. 20 Глава 1. Процесс компиляции
В фазе распределения памяти каждая константа и переменная, фигу- рирующие в программе, получают зарезервированное место в памяти для хранения своего значения. Данная область памяти может иметь один из следующих типов. • Статическая память, если время жизни переменной равно време- ни жизни программы. Не может быть освобождена до завершения выполнения программы. • Динамическая память, если время жизни переменной равно времени жизни определенного блока, функции или процедуры. Может быть освобождена после выполнения данного фрагмента программы. • Глобаи>ная память, если на момент компиляции время жизни неиз- вестно, а память должна выделяться и освобождаться в процессе вы- полнения. Эффективный контроль подобной памяти обычно подразу- мевает определенные служебные издержки времени выполнения. Результатом работы фазы распределения памяти является создание адреса, в котором содержится полная информация о локализации памяти. В дальнейшем адрес передается генератору кода. Этап синтеза процесса компиляции (в отличие от этапа анализа) не очень поддается автоматизации, поэтому нельзя сказать, что средства его инструментальной поддержки весьма широко распространены. Ранняя идея создания компилятора компиляторов (compiler-compiler), програм- мы, вход которой являлся бы спецификацией языка и машины, а вы- ход — реализацией языка на машине, была в значительной степени осу- ществлена для этапа анализа и в меньшей степени — для этапа синтеза. Если в логических терминах компилятор рассматривается как состоя- щий из этапов и фаз, физически он составлен из проходов (pass). Компи- лятор осуществляет проход каждый раз при считывании исходного кода или его представления. Многие компиляторы являются однопроходными, т.е. полный процесс компиляции полностью выполняется при однократ- ном чтении кода. В этом случае рахтичные описанные фазы будут вы- полняться параллельно (что, как правило, явпяется наиболее удобным), что устраняет необходимость сложной связи между различными прохо- дами. Ранние компиляторы были многопроходными (обычно 7-8 прохо- дов) по причине недостаточного объема памяти машин того времени. Для современных компиляторов проблем с объемом памяти уже (как правило) не существует, поэтому большинство из них явзяются однопро- ходными. В то же время некоторые языки, такие как /\LGOL 68, невоз- можно откомпилировать ха один проход. Это связано с тем, что инфор- мация, необходимая какой-то конкретной фазе, недоступна в той части исходного кода, в которой она используется. Требуемые многопроходные компиляторы можно легко описать как компиляторы с несколькими предварительными проходами, в течение которых информация собирает- ся и записывается в таблицы с последующим использованием на этапах анализа и синтеза. 1.4. Этапы, фазы и проходы 21
1.5. Интегрированные среды разработки Современные компиляторы часто являются не отдельными, автономны- ми инструментальными средствами, а представляют собой часть интегри- рованных сред разработки (Integrated Development Environment — IDE), которые иногда называют средами программирования. Помимо предос- тавления средства компиляции, современная среда IDE предлагает сред- ства языково-ориентированного редактирования, отладки, определения рабочих профилей программы, управления конфигурацией и т.д. Хоро- ший пример такой среды — Borland IDE для C/C++, которая в среде Windows предпагает средства для выполнения множества различных опе- раций, часть из которых выделена в перечисленные ниже группы. • Редактирование со средствами вырезания, вставки, отмены опера- ции и т.п. • Поиск со средствами поиска текста, замены текста и локализации функций в процессе отладки. • Обзор различных окон, содержащих средства диагностики и другую информацию, связанную с текущим проектом, включая информа- цию по иерархии вызовов, расположению точек прерывания про- граммы, содержимого регистров, расположению переменных, ис- пользованию классов и т.д. • Управление проектом, включая запуск новых проектов, компиля- цию и связывание различных компонентов проекта при раздель- ном контроле средств компиляции и сборочных файлов. • Отладку для возможности запуска программы в обычном режиме или режиме отладки со средствами пошагового выполнения, уста- новки точек прерывания, отслеживания значений выражений, просмотра таблиц символов и т.д. • Средства выполнения, связанные с IDE. Borland Pascal IDE для Windows предлагает подобный интерфейс, что по- зволяет легко переходить с одного языка на другой. Хотя подробный об- зор сред IDE выходит за круг рассматриваемых в книге вопросов, в даль- нейшем, время от времени, мы будем обращаться к инструментальным средствам IDE. 1.6. Проектирование компилятора Общая структура компилятора во многом зависит от его фазовой структуры и структуры синтаксического анализатора, создающего основу большинства фаз, а структура синтаксического анализатора отражает свойства исходного языка. Обычно при проектировании компилятора необходимо руководство- ваться следующими нефункциональными требованиями. 22 Глава 1. Процесс компиляции
• Эффективная компиляция. • Минимальный размер компилятора. • Минимальная длина полевого кода. • Создание эффективного целевого кода. • Легкость переносимости. • Простота использования. • Практичность, что включает хорошие средства диагностики оши- бок и восстановления после ошибок. Безусловно, одновременно удовлетворить всем вышеперечисленным требованиям практически невозможно, поэтому некоторым из них при- ходится отдавать предпочтение. К примеру, легкость переносимости и простота использования может не согласовываться с требованием мини- мального размера компилятора, а создание эффективного целевого ко- да — с требованием эффективной компиляции. В обучающих средах, например, эффективность компиляции и хоро- шие средства диагностики могут быть важнее создания эффективного це- левого кода, тогда как для встроенных систем первоочередное значение имеет размер и эффективность целевого кода. Многие компиляторы раз- решают пользователю самому определять режим работы компилятора — степень оптимизации, выполнение проверок времени выполнения и т.д. 1.7. Использование инструментальных средств При создании компиляторов используются два основных типа инстру- ментальных средств. • Генераторы лексических анализаторов (lexical analyser generator). • Генераторы синтаксических анализаторов (syntax analyser generator, или parser generator). На вход инструментального средства, которое создает лексический анализатор, поступает информация о лексической структуре языка (как из знаков составляются токены языка), а результатом его работы являет- ся лексический анализатор (например, программа на С) этого языка. Графически данный процесс представлен на рис. 1.9. На вход инструментального средства, создающего синтаксический анализатор, поступает синтаксическое определение языка, а результатом его работы является синтаксический анализатор (например, программа на С) этого языка. Графически этот процесс представлен на рис. 1.10. В настоящее время разработаны генераторы синтаксических анализа- торов, поддерживающие наиболее распространенные методы грамматиче- ского разбора. Самым известным из генераторов синтаксических анали- заторов разбора является УАСС на основе Unix, который обычно исполь- зуется совместно с генератором лексических анализаторов Unix, 1.7. Использование инструментальных средств 23
именуемым Lex. YACC (Yet Another Compiler-Compiler — “еше один компилятор компиляторов”) поддерживает достаточно общий метод вос- ходящего синтаксического анализа и допускает введение программных вставок на С, а также производство выхода на С. Рис. 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], [Loudon, 1997], [Appel, 1997] и [Тепу, 1997]. Кроме того, рекомендуется монография [Wirth, 1996], где рас- сматриваются более общие вопросы в контексте компиляции языка Oberon, и работа [Diller, 1988], в которой рассматривается компиляция функциональ- ных языков. Работа [Wan, 1993] предоставляет всесторонний обзор области, а в [Randell and Russel, 1964] и [Cries, 1971] представлены ранние тексты, по- священные компиляторам. Работа Наура по созданию ранних компиляторов ALGOL 60 описана в книге (Naur, 1964]. Работа [Levine, Mason and Brown, 1992] — это, пожалуй, единственная книга, полностью посвященная Lex и YACC. Общедоступные версии Lex и YACC (Flex и Bison, соответственно) можно получить через Internet: http://www.gnu.ai.mit.edu/home.html 1.8. Резюме 25
itiH (для высших учебных заведений Великобритании) http: ’ micros .hensa .ас.uk/ Упражнения 1.1. Перечислите правила совместимости для объединения Т-диаграмм. 1.2. Величина х используется для представления двух различных пере- менных в программе на С. Стоит ли ожидать, что лексический анализатор сможет рахличить эти две переменные? Обоснуйте свой ответ. 1.3. Нарисуйте абстрактное синтаксическое дерево для оператора при- сваивания. 1.4. Предложите три способа определения размера программы, напи- санной на языке высокого уровня. 1.5. Во время какой фазы процесса компиляции происходит распозна- вание типа литерала? 1.6. Во время какой фазы процесса компиляции может обнаружиться несовместимость типов? 1.7. Какой тип памяти для хранения переменных не очень удобен при использовании стеков? 1.8. Предложите цели проектирования компиляторов, отличающиеся от рассмотренных в главе. 1.9. Предложите количественное определение надежности компиляторов. 1.10. Приведите аргументы за и против использования С в качестве языка реализации.
Глава 2 Определение языка 2.1. Вступление В этой главе будет рассказано о существующих методах определения как языков в общем, так и языков программирования в частности. Будут оп- ределены строки, составляющие язык, а также значение этих строк, что позволит нам подойти к рассмотрению фундаментальной задачи этапа анализа процесса компиляции — задачи синтаксического анализа. Итак, в этой главе будут рассмотрены следующие вопросы. • Методы определения бесконечного набора строк. • Понятие грамматики для определения языков программирования. • Иерархия грамматик. • Выведение предложений языка из грамматики. • Неоднозначные грамматики. • Задача синтаксического разбора — как найти порождение предло- жения, исходя из грамматики. • Методы определения семантики языков программирования. 2.2. Определяя синтаксис Компилятор должен создавать правильный целевой код при любом вхо- де, принадлежащем исходному языку', для которого разработан компиля- тор, или же одно или несколько сообщений об ошибках, если это невоз- можно (вход является искаженным, ошибочным, неправильным). Про- верка правильности входа требует определения языка, на котором написан исходный код. Это определение должно быть: • точным (или формальным); • лаконичным (чтобы компилятор не был слишком большого размера); • машинно-читаемым. Учебники по языкам обычно не являются достаточно точными и ла- коничными для использования в наших целях, поэтому более удобным оказывается языковой Стандарт (если он есть). Использование Стандарта
должно также обеспечить совместимость компиляторов, предназначен- ных для одного языка. Определение языка должно определять все строки символов, сущест- вующих в данном языке (синтаксис языка), а также их значения или планируемый эффект (семантика языка). Если язык состоит из конеч- ного числа строк, то его определение не представляет принципиальной сложности и заключается в (возможно утомительном) перечислении всех элементов. В то же время, поскольку все интересующие нас языки со- держат бесконечное число строк, требуется найти средство представления бесконечного числа строк конечным образом. Рассмотрим несколько очень простых языков и продемонстрируем метод представления бесконечного числа строк символов, содержащихся в языке. Например, язык, состоящий из всех строк, которые содержат произвольное целое число символов х, можно описать следующим образом. {хл| л>0) Здесь знак “|" можно прочесть как “где", п — целое, а умножение следует понимать как конкатенацию. Ниже проводится другой пример языка. {//|п>0} Этот язык состоит из всех строк, которые состоят из одного или боль- шего числа символов х, после чего следует такое же количество символов у. Например, этому языку могут принадлежать следующие строки. ху хххууу ххххххххуууууууу С другой стороны, определение {xV |т, л>0} представляет язык, состоящий из строк, в которых вначале располагается хотя бы один символ х, за которым следует хотя бы один у, причем число элементов х не обязательно должно совпадать с числом элементов у. Язык можно определить и следующим образом {x'Vl'n.n^O} то тогда строки ххх (нуль элементов у) и ууууууу (нуль элементов х) также представляют возможные строки языка. При таком определении языка ему также принадлежит элемент е 28 Гпава 2. Определение языка
Этот элемент — пустая строка, не содержащая ни знаков х, ни знаков у. Как будет показано далее, пустые строки играют важную роль в опреде- лении языков программирования. Рассмотренные выше языки можно также определить с помошью ре- гулярных выражений, подобных приведенному ниже. ху Здесь символ (другое название — звездочка Клини (Kleene star)) обо- значает, что предшествующий ему элемент, употребляется нуль или большее число раз. Если в каждой строке языка должно находиться ми- нимум по одному элементу х и у, то язык можно определить следующим образом. хх‘уу‘ Возможна альтернативная запись. X*/ Здесь плюс означает “одно или большее число вхождений предшествующего элемента”. Кроме того, в регулярных выражениях можно использовать сим- ват “|” (в данном контексте читается как “или”). Таким образом, выражение представляет запись языка, стоки которого состоят из нуля или большего количества элементов х или из нуля или большего количества элементов у. Выражение (а| ЬГ представляет язык, строки которого состоят из нуля или большего числа элементов а или b (другими словами, строка состоит из нуля или боль- шего числа знаков, каждый из которых может быть а или Ь). В послед- нем выражении скобки употребляются для того, чтобы показать более высокий приоритет знака | над знаком ♦. Принято, что знак * имеет бо- лее высокий приоритет, чем |, поэтому язык а | Ь’ будет содержать только строки, состоящие из одного элемента а или же нуля или большего числа элементов Ь. Приведем другой пример регуляр- ного выражения (aab | abf Этот язык будет включать в себя следующие строки. е aababaab ababab aabaabaabab Регулярное выражение (aaa | aby 2.2, Определяя синтаксис 29
иллюстрирует три оператора, используемых в регулярных выражениях, т.е. *, конкатенацию (представленную последовательными символами) и |, перечисленные в порядке убывания их приоритета. Чтобы более формально определить понятие регулярного выражения, введем вначале понятие алфавита. Алфавит представляет собой конеч- ный набор символов, подобный приведенным ниже. {0,1} {«•со) {0..9} Если а — алфавит, то к числу регулярных выражений относятся: • нулевая строка (обозначается е); • любой элемента А. Кроме того, если Р и Q являются регулярными выражениями, то регу- лярными являются также следующие выражения. PQ (Q следует после Р) P\Q (РилиО) Г (нуль или более вхождений Р) Следует отметить, что введенный ранее оператор + (одно или более вхож- дений элемента), строго говоря, не является оператором регулярного вы- ражения, поскольку при описании любого регулярного выражения мож- но обойтись и без него. Это связано с тем, что любое выражение, содер- жащее +, можно заменить эквивалентным выражением, содержащим оператор конкатенации и оператор *. Например, выражение (аЬс)* эквивалентно записывается следующим образом (abc)(abcY Таким образом, поскольку включение оператора + никоим образом не расширяет возможности формы записи, его часто используют так, как будто он является оператором регулярного выражения. Как будет показано далее, использование регулярных выражений не является самым подходящим способом описания языков программирова- ния в целом, но они часто употребляются для определения символов языков программирования через составляющие их знаки. Например, во многих языках программирования идентификатор можно представить следующим регулярным выражением. /(/|d)‘ Здесь / обозначает букву, ad— цифру. Число с фиксированной запятой можно представить следующим выражением. (tfd.<f)\((f.d(f) Здесь d также обозначает любую цифру. 30 Глава 2. Определение языи
Один из языков, ранее рассмотренных в данном разделе, {^/|n>0} невозможно определить посредством регулярного выражения, поскольку в регулярных выражениях не существует возможности указать, что количество элементов х должно равняться количеству элементов у. Следовательно, в этом случае нам необходим более мощный механизм, который позволит описать такой очевидно простой язык. Один из методов заключается в ис- пользовании продукции (production), подобной приведенным ниже. S-> xSy S-+ ху Здесь символ можно читать как “может иметь вид”. Продукции могут использоваться для генерации строк языка с использованием сле- дующих правил. 1. Начать с символа S и заменить его строкой, расположенной справа от знака продукции. 2. Если полученная строка не содержит больше символов S, она являет- ся строкой языка. В противном случае следует снова заменить S стро- кой после знака продукции, а затем снова вернуться к п. 2. Приведем пример последовательности строк. S xSy xxSyy хххууу Обычно подобную последовательность записывают следующим образом. S => xSy => xxSyy => хххууу Здесь знак “=>” читается “порождает”. Последовательность шагов, ис- пользованная для генерации строки с применением продукций грамма- тики, называется порождением (derivation) строки. Очевидно, что описанным выше способом могут быть получены все строки языка {✓/1 п > 0} Более того (что весьма важно), при этом не будет порождена ни одна строка, не принадлежащая указанному языку. Итак, теперь мы готовы к определению понятия грамматики, основы- ваясь на введенном выше понятии продукции. 2.3. Грамматики Грамматика определяется как следующая четверка чисел. (VT, Vn, Р, S) 2.3. Грамматики 31
Здесь VT — алфавит, символы которого называют терминальными символами, или терминалами. — алфавит с нетерминальными символами, или нетерминалами. VT и V\ не имеют общих символов (т.е. Vv= 0) (Vопределяется как VT^j V\) Р — множество продукций (или правил), каждый элемент которого со- стоит из пары (а, р), где а — левая часть продукции, р — правая часть продукции, а сама продукция записывается следующим образом. а-> р Здесь а принадлежит V* (строки, состоящие из одного или более симво- лов V), а Р принадлежит И (строки, состоящие из нуля или более симво- лов V). S принадлежит Vv, называется символом предложения, или аксио- мой грамматики, и с него начинается генерация любой строки языка. Грамматика используется для генерации последовательностей символов, составляющих строки языка, начиная с аксиомы и последовательно заменяя ее (или нетерминалы, которые появятся позднее) с помощью одного из по- рождений грамматики. На каждом этапе к нетерминалу из левой части при- меняется продукция, заменяющая этот нетерминал последовательностью символов своей правой части. Процесс прекращается после получения стро- ки, состоящей только из терминальных символов (т.е. не содержащей нетер- миналов). Языку принадлежат те, и только те строки символов, которые можно получить с помощью заданной грамматики. Например, грамматикой для языка {хУ|п>0} будет грамматика G,. Gi=({x,y), (S}.P,S) Здесь P={S-»xSy, S-»xy} Грамматикой для языка {x^/l m, naO) является G3. Gz = ({x, у), {S, В), P, S) Здесь набор продукций P имеет следующий вид. S-* xS S->yB S->x S^y B^yB в-*у 32 Глава 2. Определение языч
Поскольку пустая строка также принадлежит языку, то в набор Р входит следующая продукция. S —> Е Строка ххууу генерируется следующим образом. S=> xS=> xxS => ххуВ => ххууВ => ххууу Каждая из строк, фигурирующих в порождении, называется сентенциаль- ной формой (sentential form), а последняя строка (состоящая только из терминалов) называется предложением($ъпш\съ) языка. Использование символа “=>” между двумя сентенциальными формами обозначает, что строка справа от этого символа получена из строки слева от него посред- ством одного порождения. В то же время можно записать следующее. ♦ S => хууу Это означает, что хууу порождается из S за нуль или более шагов. По- добным образом выражение + S => ххууу означает, что хууу порождается из S за один или более шагов. Условимся, что порождения S —> у В В-> у можно записать в более сжатой форме В -+ у В | у Здесь, как и ранее, символ читается как “или”. Как и в приведенных выше примерах, будем, как правило, использо- вать строчные буквы (иногда слова, состоящие только из строчных букв) для обозначения терминалов грамматики, а заглавные буквы (иногда слова из заглавных букв) для обозначения нетерминалов. Символы пред- ложений часто будут обозначаться буквой S, хотя это обозначение не яв- ляется обязательным. Греческие буквы будут использоваться для обозна- чения строк терминалов и/или нетерминалов. Во всех случаях, когда бу- дут использоваться другие обозначения, это будет оговариваться. Необходимо отмстить, что для генерации конкретного языка обычно не существует единственной грамматики. На тривиальном уровне любой нетерминал можно заменить еще неиспользованным символом. Можно произвести более значительные изменения: изменить форму и количест- во продукций. Рассмотрим следующий язык. {Z1/1 т, п > 0} Данный язык можно сгенерировать, используя такой набор продукций. S-+XY 2.3. Грамматши 33
Х-*хХ Х->е Y->yY Г-Н Этот набор отличается от приведенного ранее для того же языка. Две грамма- тики, генерирующие один и тот же язык, называются эквивалентными. Как будет показано далее, для компилятора часто оказывается полезным или даже необходимым заменить данную грамматику' эквивалентной. Данное выше определение грамматики допускает грамматики более обших типов чем те, что были приведены в качестве примеров. Напри- мер, левая часть продукции не обязательно должна состоять из одного символа. Рассмотрим следующую грамматику. вз = ({a), {S, N, Q, Я), Я, S) Здесь множеству Р принадлежат такие элементы. S-*QNQ QN-+QR RN-+NNR RQ-+NNQ N-> a □ -4Е В этом случае согласно второй продукции N в порождении можно заме- нить R только в том случае, если N следует после О; а согласно четвертой продукции R можно заменить NN, только если после R следует Q. Про- дукции являются контекстно-зависимыми (строго данный термин будет определен несколько позднее). Типичными порождениями при исполь- зовании данной грамматики будут иметь подобный вид. S=> QNQ^> QRQ=> QNNQ X aa S => QNQ => QRQ => QNNQ => QRNQ => QNNRQ => QNNNNQ X aaaa Из этого примера видно, что число элементов а всегда будет степенью двойки, точное значение зависит от того, на каком этапе последователь- ность символов N (длина которой является степенью двойки) будет заме- нена символами а. Таким образом, язык, который генерируется данной грамматикой, имеет следующий вид. {а'7’ | т является положительной степенью двойки} Теперь мы готовы ввести важное понятие иерархии Хомского (Chomsky hierarchy) грамматик/языков. Хомский определил четыре класса грамма- тик, которые назвал их грамматиками 0-го ... 3-го типов. Грамматики го типа, или рекурсивно перечислимые (recursively enumerable) грамматики, определяются как все грамматики, которые соответствуют данному выше определению без ограничений на типы продукций. Данные граммати- ки — это наиболее общий класс, и, как будет показано далее, граммати- 34 Гпава 2. Определение язва
ки других типов могут быть получены путем наложения ограничений на возможные формы продукций грамматик 0-го типа. Грамматики 0-го типа эквивалентны машинам Тьюринга в том смыс- ле, что для любой данной грамматики 0-го типа сушествует машина Тьюринга, которая допускает, и только допускает, все предложения, сге- нерированные данной грамматикой. И наоборот, для данной машины Тьюринга существует грамматика 0-го типа, которая генерирует точно все предложения, допускаемые машиной Тьюринга. Первое ограничение на форму продукции, которое может возникнуть в грамматике, — задать, чтобы для всех продукций вида а-> Р длина строки а (исчисляемая в количестве символов, которое она может содержать) была не больше длины строки р. I«l - IPI Грамматики, все продукции которых подчиняются данному ограниче- нию, называются грамматиками 1-го типа, или контекстно-зависимыми (context sensitive). Если обратиться к теории автоматов, грамматики 1-го типа эквивалентны линейно ограниченным автоматам в том же смысле, как грамматики 0-го типа эквивалентны машинам Тьюринга. Следует отметить, что рассмотренная выше грамматика для языка {ат | т является неотрицательной степенью двойки) является грамматикой 0-го, а не 1-го типа, поскольку в продукции 0->е левая часть длиннее правой. Если (помимо уже названного ограничения) в левой части продукции должен находиться только один нетерминал, грамматика называется грамматикой 2-го типа, или контекстно-свободной (context free) грамма- тикой. За исключением G?, все рассмотренные в этой главе грамматики принадлежат ко 2-му типу. Поскольку теория компиляторов практически полностью основывается на грамматиках 2-го и 3-го типов, грамматики 0-го и 1-го типов в дальнейшем почти не будут рассматриваться. В контекстно-свободных грамматиках удобно разрешить продукцию S —> е (хотя строго эта продукция не разрешена даже в контекстно-зависимых грамматиках.) Это позволит включить в язык пустую строку. В некоторых грамматиках также будут встречаться продукции, в которых пустые строки генерируют другие нетерминалы. То, что мы сделали, не увеличивает силу контекстно-свободных грамматик, но иногда оказывается полезным. Грамма- тики 2-го типа эквивалентны магазинным автоматам (push-down automata). Последним классом грамматик в иерархии Хомского являются грам- матики 3-го типа, или регулярные грамматики. Однако, вначале опреде- лим праволинейную грамматику (right linear grammar) как грамматику, ка- ждая продукция которой имеет одну из двух форм. 2.3. Грамматики 35
A -> a ШЛИ A->bC Здесь использована принятая выше договоренность об обозначении тер. миналов и нетерминалов. Например, грамматика с продукциями S->xS S —> уВ S —х S-^y В->уВ В-+у является праволинейной. Если мы хотим включить в язык, генерируемый этой грамматикой, пустую строку, следует ввести в грамматику такую продукцию. Здесь S — символ предложения. Рассмотрим, впрочем, следующую экви- валентную грамматику. S-+XY хХ Х->Е Y->yY У->Е Данная грамматика не является праволинейной, поскольку продукции I 3 и 5 не имеют требуемой формы. В праволинейных грамматиках нетер- миналы, отличные от символов предложения, могут не генерировать п\с- тые строки. Грамматика, являющаяся праволинейной, называется регулярно'* (regular). Кроме того, леволинейная грамматика (left linear grammar), ко- торая определяется по аналогии с праволинейной, также по определение относится к регулярным. Например, грамматика со следующими продук- циями является леволинейной, следовательно, регулярной: S-* Sy S-+Bx S->x S-+y В-+ Вх В->х Как и ранее, можно добавить следующую продукцию. S —> € Хотя грамматики, все продукции которых являются праволинейными принадлежат к регулярным и то же можно сказать о грамматиках, имек*- 36 Глава 2. Определенней
тих только леволинейные продукции, грамматики, часть продукций ко- торых являются праволинейными, например, А -> аВ а часть продукций — леволинейными, например, Р-+ Qr регулярными не являются. Отметим, что рассмотренная выше леволинейная грамматика генери- рует тот же язык {✓У \т,п> 0), что и праволинейная грамматика. В общем случае любой язык, который можно сгенерировать с помошью праволинейной грамматики, можно также образовать посредством леволинейной грамматики. Язык, который можно сгенерировать с помощью регулярной грамма- тики, называется регулярным. Регулярный язык {УУ | т, п £ 0} также можно определить следующим регулярным выражением. Сказанное справедливо для всех регулярных языков. И наоборот, любой язык, определенный любым регулярным выражением, можно сгенериро- вать регулярной грамматикой (отсюда и название регулярный). Регуляр- ные языки и регулярные выражения эквивалентны конечным автоматам. Таким образом, имеем трехстороннюю эквивалентность между регуляр- ными выражениями, регулярными языками и конечными автоматами, как показано на рис. 2.1. Для тех, кто не знаком с понятием конечных автоматов, оно будет рассмотрено в следующей главе. Конечные * автоматы языки ► выражения Рис. 2.1. Иерархия Хомского является включающей, так что все грамматики 3- го типа являются грамматиками 2-го, все грамматики 2-го типа являются грамматиками 1-го, а все грамматики 1-го типа — грамматиками 0-го ти- па. Схематически это показано на рис. 2.2. 2.3. Грамматики 37
Определим язык 3-го типа как язык с грамматикой 3-го типа, язык 2- го типа — как язык с грамматикой 2-го типа и т.д. Таким образом, суще- ствует включающая иерархия языков, которая соответствует иерархии грамматик. В то же время не стоит поспешно судить о типе языка — из того, что грамматика языка не относится к третьему типу, еще не следует отсутствие эквивалентной грамматики 3-го типа. Приведем пример языка 3-го типа, который можно определить грамматикой 2-го типа (этот язык уже рассматривался выше). {х^/|/77, л>0} Данный язык можно сгенерировать следующими продукциями, не отно- сящимися к 3-му типу. S-+XY X—>хХ X —> Е У->уУ У->Е В то же время язык можно определить следующими продукциями 3-го типа. S-> xS S-> у В S -4 х S-* у В-+ у В В^у S->£ В дальнейшем о грамматиках и языках 0-го и 1-го типов будет сказа- но немного. В то же время грамматики и языки 2-го и 3-го типов играют важную роль в написании компилятора, посему в оставшейся части гла- вы нас будут интересовать именно их отличительные особенности и ог- раничения с точки зрения выразительной силы языков и грамматик. 38 Глава 2. Определение языка
2.4. Отличия регулярных и контекстно-свободных языков При написании компиляторов широко используются как контекстно- свободные (2-го типа), так и регулярные (3-го типа) языки. Регулярные грамматики и языки являются подмножествами контекстно-свободных язы- ков и грамматик, имеющими преимущество над последними с точки зрения простоты. Таким образом, где это возможно, на этапе анализа процесса ком- пиляции имеет смысл использовать регулярные грамматики и языки. Следо- вательно, мы должны распознавать ситуации, когда регулярные грамматики и языки подходят к имеющимся задачам. К счастью, существует простое свойство грамматики (будет рассмотрено несколько позднее), которое позво- ляет определить, является ли генерируемый язык регулярным. Прежде, прав- да, требуется ввести понятие рекурсии в грамматике. Продукции А-^АЬ В~>сВ C-*dCf содержат прямую рекурсию, поскольку нетерминал из левой части продукции входит и в правую часть. В первом случае имеем левую рекурсию, поскольку нетерминал из левой части продукции находится в крайней левой позиции правой части продукции. Во втором случае имеем правую рекурсию, а в треть- ем — среднюю, поскольку нетерминал из левой части продукции входит в правую часть, но не находится ни на левой крайней, ни на правой крайней позиции. Практически все грамматики содержат определенную рекурсию, поскольку в противном случае было бы невозможно создавать произвольные большие предложения. Стоит отметить, что тип рекурсии может быть весьма важным, и в данном разделе нас особо будет интересовать средняя рекурсия. Отметим также, что помимо прямой рекурсии существует непрямая, показан- ная в следующих примерах. 1. А-*Вс В-ь Cd С-*Ае 2. P-*xQz Q-^ wPy В первом случае имеем непрямую левую рекурсию, а во втором — непрямую среднюю рекурсию. В непрямую рекурсию может быть вовлечено произвольно большое число продукций. Создается впечатление, что непрямая рекурсия сложнее прямой. На самом деле отличие между ними не такое значительное, как кажется на первый взгляд. Дело в том, что существует простой алгоритм (который в данной книге не рассматривается) преобразования непрямой ре- курсии в прямую. Таким образом, в последующем рассмотрении будут фигу- рировать только грамматики с прямой рекурсией. 2.4. Отличия регулярных и контекстно-свободных языков 39
Чтобы определить, генерирует ли данная грамматика регулярный язык, в первую очередь необходимо посмотреть, содержит ли она рекур- сию. В тех немногих случаях, когда грамматика не содержит рекурсии, язык является конечным, следовательно, регулярным, поскольку можно просто перечислить все предложения языка. Любой конечный набор строк можно представить регулярной грамматикой. Чрезвычайно полез- ным является утверждение (которое мы примем без доказательства): "Если грамматика не содержит средней рекурсии (такую грамматику также называют само&юженной (self-embeded)), то генерируемый ею язык является регулярным”. Таким образом, язык с продукциями S-+XY Х-+хХ Х-* х Y-tyY Y-*y S-> х S-*y S-> e является регулярным, поскольку продукции не содержат средней рекур- сии. а имеющаяся правая рекурсия не является проблемой. Рассмотрим простой нерегулярный язык {хл/|п>0) с продукциями S-*xSy S-+xy В первой продукции этого языка имеется средняя рекурсия, следователь- но, язык не является регулярным. Итак, нерегулярными являются уже достаточно простые языки! Рассмотрим связь введенных понятий с созданием компиляторов. Лексические аспекты большинства языков, такие как имена переменных, числа, константы и многознаковые символы (например, -н-), практически всегда можно определить посредством регулярных выражений, следова- тельно, сгенерировать регулярными грамматиками. В то же время в арифметических выражениях или составных операторах почти всегда присутствует сопоставление с элементом, помещенным в скобки, которое невозможно выразить посредством регулярных выражений. Например, продукции для грамматики, генерирующей язык, строки которого состо- ят из скобок сопоставления, будут иметь следующий вид $-*($) s-> ss S-> e Данная грамматика содержит самовложение, которое нельзя удалить. 40 Глава 2. Определение языка
Подведем итоги: регулярные грамматики (3-го типа) почти всегда можно использовать в качестве основы лексического анализа, а контек- стно-свободные грамматики (2-го типа), в основном, необходимы для синтаксического анализа. Строго говоря, программы синтаксического анализа, основанные только на контекстно-свободных грамматиках, не могут полностью охватить все аспекты синтаксического анализа. В даль- нейшем будет показано, какое расширение требуется, чтобы данные программы получили тс сравнительно немногие функции, требуемые для полного представления синтаксиса. 2.5. Порождения Выше было показано, как порождения используются для генерации предложений языка из грамматики языка. Например, язык {*V|n>0} генерируется грамматикой с такими продукциями. S-> xSy ху Порождение S => xSy => xxSyy => xxxSyyy => xxxxyyyy генерирует следующее предложение. xxxxyyyy Данное порождение является единственным, генерирующим данное кон- кретное предложение. Впрочем, в общем случае порождения не являются уникальными. Рассмотрим следующий язык. {/’/|П71 л>0} Данный язык генерируется такими продукциями. S->XY Х-*хХ Х-+ х Y^yY Y-+y Предложение хххуу может быть сгенерировано порождением S XY => xXY => xxXY xxxY => xxxyY хххуу или порождением S XY => XyY Хуу хХуу ххХуу хххуу Можно привести множество других порождений, также генерирующих дан- ное предложение, которые будут отличаться порядком использования про- 2.5. Порождения 41
дукций для X и Y. Если на каждом шаге порождения заменяется крайний ле- вый нетерминал сентенциальной формы (как в первом приведенном приме- ре). такое порождение называется левым (leftmost). Подобным образом второе из рассмотренных порождений называется правым (rightmost). Существуют порождения, которые не являются ни левыми, ни правыми. S=> XY=$ xXY => xXyY => xxXyY => xxXyy => xxxyy Отметим, что в каждом из порождений предложения xxxyy каждая продукция используется одинаковое число раз, но используются они в разной последовательности. Стоит обратить внимание, что в регулярных грамматиках (по крайней мере, выраженных в простейшей форме) для каждой строки существует единственное порождение. Прежде всего это связано с тем, что в сентен- циальной форме имеется не более одного нетерминала. Рассмотрим, на- пример, грамматику со следующими продукциями. S-> хА А-^хА А-+уВ А-*у В-*уВ В-> у Данная грамматика генерирует такой язык. (Zy | п?, л > 0} Предложение xxxyy можно получить единственным образом. S => хА ххА => хххА => хххуВ => xxxyy Порождение можно описать и другим способом — с помощью син- таксического дерева (или дерева синтаксического разбора). Например, по- рождение S => XY xXY => xXyY => xxXyY => ххХуу => xxxyy (а также другие возможные порождения, дающие данное предложение) соответствует синтаксическому дереву, изображенному на рис. 2.3. Раз- личные порождения отличаются только порядком соединения отдельных частей дерева. 2.6. Неоднозначные грамматики Для многих грамматик любому предложению, которое можно сгенерировать, соответствует единственное синтаксическое дерево, а также единственное правое или левое порождение. Фактически, эти три условия эквивалентны: 42 Глава 2. Определение языка
из любого из них следуют два других. Иными словами, если одно из сле- дующих утверждений справедливо, то справедливы и остальные. • Каждое сгенерированное грамматикой предложение имеет единст- венное левое порождение. • Каждое сгенерированное грамматикой предложение имеет единст- венное правое порождение. • Каждое сгенерированное грамматикой предложение имеет единст- венное синтаксическое дерево. Это утверждение представляет собой хорошо известный результат из тео- рии грамматик, который здесь не доказывается. Грамматики, для кото- рых справедливы вышеуказанные утверждения, называются однозначными (unambiguous). В противном случае грамматика является неоднозначной (ambiguous). Если все грамматики, генерирующие язык, являются неод- нозначными, язык также называют неоднозначным. Исследуем понятие неоднозначных грамматик, для чего рассмотрим грамматику с такими продукциями. S-»S+S S->x Очевидно, что все предложения этой грамматики имеют следующий вид. х х + х х + х+х 2.6. Неоднозначные грамматики 43
Возьмем, к примеру, следующую строку. х + х + х Она имеет два левых порождения. 5=>3+3=>х+$=^х+3+$=>х + х+$=>х + х+х и S=*S+S=*S+S+S=>x+S+S=>x + x+S=>x + x + x Кроме того, данная строка имеет два правых порождения и два синтак- сических дерева (рис. 2.4). Рис. 2.4. Эта грамматика явно неоднозначна, поэтому непригодна для некото- рых желаемых методов синтаксического анализа. Если же синтаксическое дерево все-таки необходимо как-то построить, то потребуются правила, устраняющие неоднозначность, т.е. единственным образом определяю- щие синтаксическое дерево. По этой причине компиляторы часто созда- ются на основе однозначной грамматики, хотя (как будет показано далее) это не является обязательным. Возникает два вопроса. 1. Известна неоднозначная грамматика языка. Существует ли однознач- ная грамматика для генерации того же языка? 2. Существуют ли алгоритмы, используя которые можно определить, яв- ляется ли данная грамматика или язык неоднозначными? Ответ на первый вопрос — да, на второй (к сожалению) — нет. Про- иллюстрируем положительный ответ на первый вопрос. Рассмотрим опи- санный выше язык с неоднозначной грамматикой. Этот язык можно оп- ределить посредством грамматики со следующими продукциями. S-> S + x S-> х В этом случае предложение х + х + х 44 Глава 2. Определение языка
имеет единственное синтаксическое дерево, только одно правое порож- дение и только одно левое порождение, которое приводится ниже. S^S+x=>S + x + x=>$=>x+x+x То же справедливо для любого предложения, генерируемого грамматикой, следовательно, язык является однозначным, поскольку его можно задать од- нозначной грамматикой (выше был найден явный вид такой грамматики). Поскольку (ответ на второй вопрос) не существует алгоритма, позволяю- щего в общем случае определить, является ли грамматика однозначной, не существует также и алгоритма, посредством которого можно было бы в об- щем случае создать однозначную грамматику, эквивалентную неоднознач- ной. Это утверждение справедливо, поскольку в противном случае по выходу алгоритма можно было бы определить, является ли язык однозначным. Итак, проблема однозначности языка еще не разрешена, т.е. из теорети- ческих рассуждений известно, что не существует общего решения данной за- дачи. Для читателя, знакомого с соответствующей теорией, определение, яв- ляется язык однозначным или нет, эквивалентно определению, остановится ли машина Тьюринга после прочтения определенного входа. В обоих случаях можно найти решения для конечного набора входных условий, но в общем случае задача не решается. Теория языка изобилует подобными нерешенны- ми (или неразрешимыми) задачами, которые могут либо заинтересовать, либо разочаровать (в зависимости от точки зрения). Все же, по меньшей мере, эти задачи позволяют осознать, что даже у компьютера возможности ограничены! Итак, существуют две родственные неразрешимые задачи, касающие- ся неоднозначности в языках, а именно: • задача определения, является ли грамматика неоднозначной; • задача определения, является ли неоднозначным язык. Хотя в общем случае задача не решена, существует один класс грам- матик, для которых известно, что элементы являются неоднозначными, т.е. существуют продукции, характеризующиеся одновременно правой и левой рекурсией. Таким образом, рассмотренная выше грамматика с продукцией S-»S+S является неоднозначной. Следует отметить, что обратное утверждение неверно, т.е. грамматики, не содержащие правой и левой рекурсии, не обязательно являются однозначными (иначе у нас был бы простой кри- терий однозначности). Наиболее известной неоднозначной грамматикой является используе- мая во многих языках программирования для определения оператора if с необязательной частью else. Эта грамматика часто определяется следую- щим образом. statement -> if expr then statement else statement | if expr then statement | other 2.6. Неоднозначные грамматики 45
Здесь выделенные слова являются терминалами грамматики, a other ("другое”) генерирует операторы, отличные от операторов if. Кроме того, в приведенном выше примере мы отклонились от ранее принятой дого- воренности обозначать строчными буквами терминалы, а прописными — нетерминалы, поскольку statement (“оператор") — это нетерминал. В данной грамматике отсутствуют продукции с правой и левой рекур- сией, хотя одна из продукций является дважды рекурсивной! Тем не ме- нее, эти продукции являются неоднозначными. Чтобы показать это, дос- таточно привести строку, для которой существует более одной правой inn левой продукции или более одного синтаксического дерева. Наибо- лее простая из подобных строк (вообще, их существует бесконечное чис- ло) имеет следующий вид. if expr then if expr then other else other В данной строке неоднозначность проявляется в том, что оператор else может относиться к любому из двух операторов then. Некоторые языки (например, COBOL) разрешают эту неоднозначность следующим образом: при считывании кода слева направо каждый оператор else соотносится с ближайшим предшествующим незанятым оператором then. Два синтаксиче- ских дерева для приведенного предложения изображены на рис. 2.5 и 2.6. Для COBOL и многих других языков с подобной структурой правильным будет первое из приведенных деревьев, а не второе. Впрочем, это следует не из грамматики, которой обычно задается язык, а устанавливается формально (по крайней мере, так сделано в большинстве текстов и руководств по CO- BOL). Данное обстоятельство может навести на мысль, что с помошью одной лишь грамматики невозможно разрешить неоднозначность. Это не так, по- скольку существуют следующие альтернативные продукции. statement -> matched | unmatched matched -» if expr then matched else matched | 46 Глава 2. Определение языка
other unmatched -> if expr then statement | if expr then matched eise unmatched Рис. 2.6. Данные продукции генерируют те же предложения, но уже однознач- но. В то же время эти продукции стараются не использовать, поскольку они менее наглядны. В дальнейшем, при рассмотрении генератора син- таксических анализаторов YACC, будет показано, что существуют более естественные и элегантные способы разрешения неоднозначности. Таким образом, при разработке компиляторов иногда возникают не- однозначные грамматики (наиболее частый случай рассмотрен выше), но на практике их применение не вызывает особых проблем, так как обыч- но существуют простые методы разрешения неоднозначности. Неодно- значные языки (языки, для которых не существует однозначной грамма- тики) часто оказываются сложными для человеческого понимания и обычно не используются в языках программирования. 2.7. Ограничения контекстно-свободных грамматик Пришло время задаться вопросом: “Насколько контекстно-свободные грам- матики подходят для генерации языков программирования?” Отметим для начала, что существует ряд достаточно простых языков, которые нельзя полу- чить с помощью контекстно-свободных грамматик. Например, в разделе 2.3 была рассмотрена грамматика G3, которая не является контекстно-свободной, поскольку левые части некоторых ее продукций не состоят из одного нетер- минала. Данная фамматика генерирует следующий язык. {ат | т — положительная степень двойки) Разумеется, тот факт, что язык можно образовать с помощью не кон- текстно-свободной грамматики, еще не означает, что этот язык нельзя 2.7. Ограничения контекстно-свободных грамматик 47
образовать посредством контекстно-свободной грамматики. Впрочем, в нашем конкретном случае можно показать, что данный язык нельзя об- разовать с помощью контекстно-свободной грамматики. Можно также показать, что следующие простые на вид языки не являются контекстно- свободными. 1. {а"Ь’с’| л > 0}. 2. {а" | п — простое число). 3. {ww| iv принадлежит промежутку {0,1 Можно также привести примеры весьма похожих контекстно-свободных языков. 1. {зГЬГ\п*О}. 2. {ww* | и^—обратное к w, wпринадлежит промежутку {0,1)‘). Нас будет интересовать следующий практический вопрос: “Можно ли с помощью контекстно-свободных грамматик выразить основные харак- теристики языка программирования?” Оказывается, это возможно в зна- чительной степени. Рассмотрим роль типов в языках программирования. Pascal и Ada служат примером сильно типизированных языков, требующих значи- тельной совместимости типов в различных контекстах. Например, фраг- мент программы на языке Pascal. var х : integer; begin x : = является некорректным, поскольку х может принимать только целые значения. Подобным образом, если переменная р объявлена как procedure р (i, j: integer); ее нельзя вызвать следующим образом р(3,4,5) (поскольку в вызове используется три параметра вместо двух). Подобным образом, если массив А объявлен как var А[1..1О] 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. <ехро ::= <term> 3. <term> ::= <terrrr> <factor> 2.7. Ограничения контекстно-свободных грамматик 49
4. <term> ::= <factor> 5. <factor> ::= constant 6. <factor> ::= X<expr>'T Здесь терминалы (такие как операторы) взяты в кавычки, чтобы пока- зать, что они являются действительным представлением терминалов, и отличить от имени терминала (такого как "constant"), которое представля- ет набор (не обязательно конечный) действительных представлений. С некоторыми терминалами и нетерминалами грамматики могут свя- зываться атрибуты. Поскольку все атрибуты соответствуют значениям (“value") подвыражений или полных выражений, то естественно обозна- чить их VAL (там, где значения нельзя спутать). Чтобы не спутать значе- ния различных символов в продукции, будем (где это необходимо) ис- пользовать обозначения VAL1, VAL2 и т.д. Атрибуты называются синтези- рованными, поскольку они используются для передачи значений вверх по синтаксическому дереву или (с другой стороны) для вычисления атри- бутных значений, соответствующих левой части продукции, из значений, соответствующих правой части. Атрибуты, которые переносят информа- цию в противоположном направлении, т.е. вниз по дереву или от левой к правой части продукции, называются наследуемыми . Ниже приводится пример контекстно-свободной грамматики, которая дополнена атрибутными правилами для определения значений выражений через значения их компонентов. Вертикальная стрелка перед именем атрибу- та обозначает, что он является синтезированным, а не наследуемым. 1. <ехрг> ? VAL ::= <ехрг> ( VAL1 u+° <term> ? VAL2 [правило: ? VAL = 1 VAL1 + ? VAL2] 2. <expr> Т VAL ::= <term> ? VAL 3. <expr> Т VAL ::= <term> ? VAL1 <factor> ? VAL2 [правило: ? VAL = ? VAL1 x ? VAL2\ 4. <term> ? VAL ::= <factor> ? VAL 5. <factor> ? VAL ::= constant T VAL 6. <factor> ? VAL ::= “("<expz> T VAL “)" Считалось, что если атрибут с одним и тем же именем используется в раз- личных местах продукции, оба экземпляра имеют одинаковое значение. Атрибутная 1рамматика для задания правил использования типов в языке, подобном Pascal, является сложной, так как требует задания эквивалентности наборов информации из таблиц символов, используемой на синтаксическом дереве в качестве атрибутов. Впрочем, ограничения типов в языке Pascal, в принципе, можно описать через атрибуты подобно тому, как задается значе- ние выражения, определенного рассмотренной выше грамматикой. Поскольку атрибутная грамматика задает правила соответствия, то ее несложно преобразовать в действия по проверке выполнения правил со- 50 Глава 2. Определение языка
ответствия. В принципе, несложно создать атрибутную грамматику на основе контекстно-свободной программы синтаксического анализа, до- полненной определенными действиями. В то же время наивная реализа- ция атрибутной грамматики для типичного языка программирования бу- дет крайне неэффективной, хотя бы потому, что потребуется копирова- ние (возможно больших) таблиц. Как будет показано далее, атрибутные грамматики также можно ис- пользовать для определения метрик исходного кода таким способом, что будет легко создать инструментальные средства для измерения значений метрик. Выразительная сила атрибутных грамматик равна силе грамма- тик 0-го типа, но при этом первые интуитивно значительно понятнее. Впервые атрибутные грамматики были определены Кнутом и использо- вались в качестве основы создания сред программирования, для опреде- ления аномалий в программах и как основа парадигмы разработки про- граммного обеспечения. 2.8. Задача синтаксического анализа До настоящего момента рассматривалось, как можно использовать граммати- ку для генерации предложений и для определения синтаксиса языка. В про- цессе компиляции задача синтаксического анализа (или разбора) состоит из нахождения порождения (если оно существует) конкретного предложения, используя данную грамматику. Таким образом, требуется не столько найти предложения, генерируемые грамматикой, сколько, используя грамматику, найти порождение данного предложения или указать, что его не существует. В большинстве случаев искомыми являются левые или правые порождения, которые обычно являются единственными. Если порождение не является единственным (например, для неоднозначных грамматик), должны сущест- вовать правила (устранения неоднозначности), которые бы указывали, какое именно из порождений следует использовать. Благодаря этому, результат синтаксического анализа всегда вполне определен. Конечно, пользователя не удовлетворит компилятор, который просто сообщает, что строка не принадлежит к исходному языку, и прекращает свою работу без дальнейшего анализа. На практике компилятор должен также указывать на последний символ, на основании которого был сде- лан соответствующий вывод, после чего продолжать анализ, приняв не- которое допущение, наиболее подходящее в данном случае. Простейший способ представить задачу синтаксического анализа — вообразить большой лист бумаги, в верхней части которого располагается символ предложения, а в его основании — предложения, которые требу- ется проанализировать. Задача синтаксического анализа далее состоит в создании синтаксического дерева, которое бы соединяло символ предло- жения с предложением, используя при этом поддеревья, соответствую- щие продукциям грамматики. Существует несколько подходов к реше- нию этой задачи. Можно начать с вершины (символа предложения) и 2.8. Задача синтаксического анализа 51
двигаться к предложению внизу страницы (нисходящий синтаксический анализ (top-dawn parsing)), а можно начать с предложения в основании страницы и двигаться к символу предложения на вершине (восходящий синтаксический анализ (bottom-up parsing)). Можно применять смешанный подход (mixed approach), а также горизонтальные и диагональные подхо- ды. например, слева направо или справа налево, или даже с правой верх- ней части к левой нижней. Решения задачи могут быть детерминирован- ными (если какая-то часть дерева нарисована, уничтожать ее запрешено) или недетерминированными (при необходимости можно стереть уже на- рисованные части дерева). В истории реализации языков программирования было множество разра- ботанных методов синтаксического анализа, включающих названные выше характеристики. Ранние методы нисходящего синтаксического анализа часто были недетерминированными и достаточно неэффективными, но в наши дни синтаксический анализ высокоэффективен, а время его выполнения пропорционально длине анализируемого предложения. Практически всегда программы анализируются слева направо, хотя иногда привлекательной ка- жется идея обратного анализа (справа налево). Нисходящий синтаксический анализ обладает большей степенью наглядности, чем восходящий, однако, последний обладает большей общностью и белее мошной инструментальной поддержкой. Каждый из методов имеет своих сторонников и будет рассмот- рен в главах 4 и 5 соответственно. 2.9. Определение семантики Помимо знакомства с понятием семантики в начале этой главы, было очень мало сказано о семантике языка и ее определении. Статические семантики (проверка типов и т.д.), рассмотренные в разделе 2.7, тракто- вались (в большей или меньшей степени) как расширение синтаксиса. Способность определять значение части программы (результат ее выпол- нения) так же важна, как и способность определять, из чего состоит кор- ректная программа. Таким образом, изучение семантики находит свое применение как в проверке правильности программы, так и в технологии компиляции. На данный момент не существует универсального, обще- принятого метода описания семантики. Перечислим существующие рас- пространенные методы. • Денотационная семантика. Основывается на функциональных вычис- лениях. в которых операции в языке отображаются в однозначные ма- тематические понятия, которые затем применяются для описания ре- зультата программы через входные и выходные сигналы. • Аксиоматическая семантика. Базируется на исчислении предика- тов. где результат вычислений описывается через взаимоотноше- ние между значениями переменных до и после применения опре- деленных операций. 52 Глава 2. Определение языка
• Операционная семантика. Здесь операции в языке описываются через деятельность некой абстрактно»! машины, выполняющей программу. Все рассмотренные выше методы использовались для определения неко- торых или всех языков программирования. В то же время многие определе- ния языков по-прежнему используют неформальные (и возможно неодно- значные) методы определения семантики. Несмотря на очевидные опасности такого подхода, он будет использован и в этой книге, поскольку в настоящее время отсутствует полностью удовлетворительный и простой в реализации метод описания семантики языка программирования. 2.10. Резюме Данная глава посвящена языкам программирования и их определению. В частности, было сделано следующее. • Введено понятие грамматики и показано, как с ее помощью мож- но генерировать язык. • Определено и рассмотрено значение иерархии Хомского. • Проиллюстрированы понятия левого и правого порождений, а также синтаксического дерева. • Обсуждено значение неоднозначности в грамматиках. • Рассмотрены ограничения регулярных и контекстно-свободных грамматик. ' • Определено, что программа синтаксического анализа должна вы- полнять проверку неконтекстно-свободных аспектов языка. • Введено понятие атрибутной грамматики. • Сформулирована задача синтаксического анализа и общие подходы к ее решению. • Выделены три возможных метода описания семантики языка. Дополнительная литература Практически во всех книгах по компиляторам для определения языков используются грамматики. Контекстно-свободные грамматики были вве- дены в работе [Chomsky, 1956] по отношению к естественным языкам. Регулярные выражения были описаны в книге [KJeene, 1956]. Однознач- ная грамматика для оператора if представлена в [Aho, Sethi and Ullman, 1985]. Атрибутные грамматики были разработаны в работе [Knuth, 1968а], а применены к описанию языка Pascal в [Watt, 1977]. Сравнение различ- ных методов описания семантики для языков программирования было выполнено в [Terry, 1997]. 2.10. Резюме 53
Упражнения 2.1. Опишите на русском языке следующие строки. а) {а* | л £ 0} б) {аЧГ| л?, л£1) в) {xyZ|n£0} Г) {х'"/’/" | Л7> 0, Л > 1) д) {У/г’ | т, п, р > 0} 2.2. Опишите на русском языке следующие выражения. а) х’у б) хху/ в) (х|уГ г) (а | Ь)а’Ь* Д) (а\ЬУ 2.3. Укажите, какие из данных грамматик являются регулярными. Обос- нуйте свой ответ (S — символ предложения; используется принятая договоренность об обозначении терминалов и нетерминалов). a) S-+aX S-tbY Х->х Х-4ХХ У-4 у Y-+yY б) S-taX S-*bY Х-> х Х-+хХ Y^y Y-tyY в) S-+AB А-> а Д-4 аА В->Ь В-*ЬВ г) S -4 xSy S-4 ху 2.4. Укажите, какие из грамматик в упражнении 2.3 генерируют регулярные языки. Если сгенерированный язык является регулярным, а граммати- ка — нет, постройте регулярную грамматику для этого языка. 2.5. Для грамматики S-» S+x I* 54 Глава 2. Определение языка
напишите правое порождение следующего выражения. х+х+х + х Объясните, почему это порождение будет единственным. 2.6. Для грамматики statement -> if expr then statement else 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 -> if expr then statement | if expr then matched else unmatched 2.7. Запишите грамматику, которая для алфавита {0,1} генерирует все строки, включая пустую. 2.8. Рассмотрите грамматику со следующими продукциями (PROGRAM — символ предложения). PROGRAM-* begin DECS; STATS end DECS-* d\ DECS \d STATS-*s; STATS Is Создайте правое и левое порождение для следующего выражения. begin d; (f, s; s end 2.9. В языке FORTRAN идентификатор состоит из последовательности (до шести знаков) букв и цифр, причем первым знаком является бу- ква. Постройте: а) регулярное выражение для идентификатора; б) грамматику 3-го типа, которая будет генерировать все иденти- фикаторы языка FORTRAN. 2.10. Большинство языков имеют условные операторы, подобные опи- санным в упражнении 2.6. Укажите, как избегают или разрешают проблему неоднозначности Ъ известных вам языках.

Глава 3 Лексический анализ 3.1. Вступление В этой главе на концептуальном уровне рассматривается, что представ- ляет собой первая фаза компиляции — лексический анализ — основные функции которой состоят в группировке последовательностей знаков ис- ходного кода в символы языка. В частности, будут рассмотрены следую- щие вопросы. • Основные черты типичного лексического анализатора. • Построение простого лексического анализатора с помощью регу- лярных выражений и связанных с ними автоматов. • Использование генератора лексических анализаторов Lex для соз- дания лексических анализаторов. • Некоторые лексические “проблемы”, возникающие в хорошо из- вестных языках программирования. 3.2. Основные понятия Как уже говорилось, на этапе лексического анализа происходит форми- рование языковых символов из последовательностей знаков. Например, в языке С содержится шесть типов символов. 1. Ключевые слова, например, const, char, if, else, typedef. 2. Идентификаторы, например, sum, main, printf. 3. Константы, например, 28, 3.141529, 017 (восьмеричная система). 4. Строковые литералы. например, “Katherine”, “bannockburn”. 5. Операторы, например, +, -, ++, >>, /=, &&. 6. Знаки пунктуации, например, {, ),..., ;. Каждый из этих типов символов формируется лексическим анализатором в процессе лексического анализа. Лексический анализ является достаточно прямолинейным процессом, ко- торый подобен автоматически производимому человеком при чтении. Благо-
даря сравнительно простой природе символов, их всегда можно представить с помощью регулярных выражений или, эквивалентно, грамматик 3-го типа. Как будет показано далее, на основе соопк'тствующих регулярных грамматик не составляет особого труда построить необходимые устройства распознава- ния. Действительно, процесс создания лексического анализатора легко авто- матизируется, а инструментальные средства для его создания на основе [кгу- лярных грамматик (или регулярных выражений) всегда доступны. Изучение лексического анализа в будущем поможет глубже понять более сложную за- дачу синтаксического анализа. Помимо распознавания символов языка, лексический анализатор также выполняет некоторые другие задачи. • Удаление комментариев. • Введение номеров строк. • Вычисление констант. Впрочем, существуют аргументы за то, чтобы последнюю задачу выпол- нял машинно-зависимый постпроцессор компилятора. 3.3. Распознавание символов Важно понять, что лексический анализатор всего лишь распознает символы языка для передачи их синтаксическому анализатору. Порядок следования символов для него абсолютно не важен. Например, лексический анализатор не обнаружит никакой ошибки в следующей последовательности символов. 64 const char typedef >> + Причина — каждый отдельный символ полностью корректен. То, что данная последовательность не составляет начала (или хотя бы фрагмента) какой-либо программы, будет обнаружено синтаксическим анализатором. Лексический анализатор не придает значения области видимости пере- менных. т.е. он не различает использование идентификатора sum для представления двух различных переменных в различных функциях. Где бы ни появлялся идентификатор, для лексического анализатора он явля- ется одним и тем же. В фазе лексического анализа он (в отличие, напри- мер, от имени функции) даже не будет определен как переменная. Для лексического анализа регулярные выражения представляют собой удобный метод представления символов, таких как идентификаторы и константы. Например, идентификатор может быть представлен следую- щим образом. letter (letter] digit)* Подобным образом можно (в некоторых языках) представить и действи- тельное число. (+| - | )digit*.digit digit* В любом случае можно достаточно легко написать программу распозна- вания символов. Для идентификатора она будет иметь следующий вид. 58 Глава 3. Лексический анализ
ttinclude <stdio.h> flinclude <ctype.h> main() (char in; in = getcharO; if (isalpha(in)) in = getchar(); else error(); while (isalpha(in) || isdigit(in)) in = getchar(); } Здесь in — значение только что считанного знака; функции isalphaO и isdigitО осуществляют проверку аргумента на предмет принадлеж- ности к буквам и цифрам, соответственно; error () выполняет некото- рые операции при возникновении ошибки. Написать код довольно про- сто: проверять поступающие символы и использовать цикл while для реализации оператора *. Подобным образом можно написать программу для распознавания действительных чисел. #include <stdio.h> #include <ctype.h> main О {char in; in = getchar(); if (in=='+'| | in=='-') in = getchar(); while (isdigit(in)) in = getchar(); if (in=='.O in = getcharO; else errorO; if (isdigit(in)) in = getchar(); else errorO ; while (isdigit(in)) in = getchar(); printf("ok\n"); } Обратите внимание, что возможны три ситуации и три способа их пред- ставления в программе. 1. Необязательные знаки Если их нет — это не ошибка, про- сто переходим к считыванию следующего символа. 3,3. Распознавание символов 59
2. Обязательные знаки (десятичная точка и одна цифра после нее). Если их нет — вызывается функция error. 3. Знаки, которые могут появиться нуль или большее число раз (цифра пе- ред точкой или после первой цифры за точкой) — инициируется цикл while для проверки каждого знака без обращения к функции error. Написать код очень просто — он пишется с такой же скоростью, с какой вы можете набирать! Более того, его создание можно легко автоматизировать. Вместо того, чтобы писать программу с помощью регулярных выражений, можно использовать соответствующий конечный автомат. Конечный автомат состоит из конечного множества состояний и переходов между ними, кото- рые определяются считываемыми знаками из входной строки. При этом одно состояние определяется как начальное, а одно или более состояний — как ко- нечные. Считается, что конечный автомат принял входную строку, если, начав рабов- с начального состояния и выполнив соответствующие переходы при считывании каждого знака исходной строки, автомат переходит в конечное состояние, когда строка полностью считана. Более формально конченый ав- томат определяется как следующая пятерка элементов. М=(К,£,8, S. F), где К — множество состояний; 1 — алфавит, на основе которого формируются входные строки; 8 — множество переходов; S (S е К) — начальное состояние; F(Fq К) — множество конечных состояний. Переходы 8 можно определить как таблицу (или графически), и они для каждого состояния будут указывать следующее состояние и все возможные входные знаки. Например, конечный автомат для распознавания идентифи- катора можно определить, как показано на рис. 3.1, где изображено два со- стояния: состояние 1 (начальное) и состояние 2 (конечное). Считывание бук- вы в состоянии 1 приводит к переходу в состояние 2, после чего считывание любого числа цифр или букв снова приводит в состояние 2. Рис. 3.1. При создании распознавателей удобно использовать представление конечных автоматов. Например, следующая программа осуществляет распознавание идентификаторов. ^include <stdio.h> Sinclude <ctype.h> 60 Глава 3. Лексический анализ
int main() {int state; char in; state = 1; in = getchar(); while (isalpha (in) || isdigit (in)) {switch (state) {case 1: if (isalpha(in)) state = 2; else error(); break; case 2: state = 2; break; ) in = getcharO; ) return (state == 2) ; ) Цикл while обеспечивает прекращение работы программы при счи- тывании любого знака, который не является буквой или цифрой. Опера- тор switch имеет по элементу для каждого состояния автомата, причем в каждом элементе представлены все возможные переходы из данного со- стояния. Во втором элементе оператора switch уже не нужно проверять вход, поскольку (из-за условия цикла while) переход к нему невозмо- жен, если последний считанный знак не является буквой или цифрой. Присвоение этому состоянию значения 2 не является обязательным — оно просто делает переход явным. Действительное число, определенное в этом разделе как регулярное выражение, можно представить с помошью конечного автомата (рис. 3.2) и запрограммировать способом, подобным приведенному выше. 3.3. Распознавание символов 61
#include <stdio.h> *include <ctype.h> int issign (char sign) {return (sign == || sign == } int mainO {int state; char in; state = 1; in = getchar(); while (isdigit(in)||issign(in)||in == '.') {switch (state) { case 1: if (isdigit(in)||issign(in)) state = 2; else if (in == '.') state = 3; break; case 2: if (isdigit(in)) state = 2; else if (in == '.•) state = 3; else errorO; break; case 3: if (isdigit(in)) state = 4; else errorO; break; case 4: if (isdigit(in)) state = 4; else errorO; break; } in = getchar(); } return(state == 4); Здесь error () имеет то же значение, что и ранее; sign () принимает значе- ние true, если его параметр равен + или -, и false — в остальных случаях. 3.4. Lex Конструирование устройств распознавания символов из регулярных выраже- ний или конечных автоматов, принимающих эти выражения, является до т°го простым, что может быть легко автоматизировано с помощью соответст- 62 глава 3. Лексический анализ
вуюших инструментальных средств. Наиболее известным и широко исполь- зуемым для этих целей инструментом является Lex, изначально разработан- ный для использования совместно с генератором программ синтаксического анализа 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
{printf("идентификатор опознан\п*);} Полным входом для создания анализатора, распознающего идентифика- торы и при этом каждый раз выполняющего приведенную выше коман- ду, будет следующий код Lex. letter [a-z] digit (0-9) identifier {letter}({letter}|{digit})* %% {identifier} {printf("идентификатор опознан \n");} %% Пусть этот код содержится в файле firstlex.l, тогда анализатор созда- ется с помощью следующей команды. lex firstlex.l Результатом данной команды будет написанный на С анализатор, поме- шенный в файл lex.yy.c. Далее его можно откомпилировать сс -о firstlex lex.yy.c -11 и поместить целевой код в файл firstlex (параметр, следующий за мет- кой -о). Отметим, что присутствие параметра библиотеки (-11) является обязательным. Код firstlex можно выполнить с данными из файла, со- держащего программу на С, например, cprog. firstlex <cprog Здесь вход перенаправлен с клавиатуры (стандартного канала ввода) на файл cprog (отсюда — знак “<”)• Выход будет отображаться на экране, хотя его также можно перенаправить в файл (например, idents). firstlex <cprog >idents Более интересный анализатор можно создать с помошью следующего кода Lex. letter [a-z] digit [0-9] identifier {letter}({letter}| {digit})* %% {identifier} {printf("идентификатор %s в строке %d\n\ yytext, yylineno);} %% Данный анализатор использует две переменные Lex: yytext, значение которой является текстовым представлением последнего распознанного символа, и yylineno, которая содержит текущий счетчик концов строк и значение которой представляет номер текущей строки. В Lex и УАСС используется достаточно много переменных такого рода. Наиболее по- лезные из них приведены в табл. 3.1 (раздел 3.6). Выходом анализатора, созданного из рассмотренного выше кода, бу- дет последовательность строк следующего вида. идентификатор х в строке 1 64 Глава 3. Лексический анализ
Общий вид входа, ожидаемого Lex. определения %% правила %% пользовательские функции Здесь вторая часть является обязательной, а остальные используются по мере необходимости. Рахпичные части должны отделяться посредством строки, которая в крайнем левом положении содержит символы %% Вход Lex для создания анализатора по распознанию и распечатке дей- ствительных чисел, ранее определенных регулярным выражением (+ | - \)digi t'.digit digit* будет иметь следующий вид. digit [0-9] realno [+\-]?{digit)*\.{digit)+ %% {realno) {printf("действительное число%э в строке %d\n", yytext,yylineno);) В этом примере иллюстрируются два момента. 1. Перед знаками ввода, которые являются частью системы обозначений и “.”), необходимо употреблять знак “\” (или брать в двойные кавычки). Следует отметить, что при первом появлении нет нужды выделять подобным образом знак “+”, поскольку здесь неоднозначно- сти не имеется. 2. Знак “+” (как часть системы обозначения) используется для указания, что предшествующий знак употребляется один или более раз. Это не увеличивает выразительную силу обозначения по сравнению с регу- лярными выражениями, но делает запись немного компактнее и легче для понимания. На данном этапе будет полезным показать на примерах основные свойства обозначений, используемых на входе Lex: а представляет отдельный знак; \а представляет а, если а — знак, используемый в системе обозначений (для устранения неоднозначности); "а* также представляет а, если а — знак, используемый в системе обозначений; а | Ь представляет а или Ь; а? представляет нуль или одно вхождение а; а* представляет нуль или более вхождений а; а+ представляет одно или более вхождений а; а{т, п) представляет от m до п вхождений а; 3.4 Lex 65
[a-z] [a-zA-Z] [Aa-z) {name} предстаапяет набор знаков (алфавит); также представляет набор знаков (больший); представляет дополнение первого набора знаков; представляет регулярное выражение, определенное Aa a$ ab\xy Некоторые идентификатором пате; представляет а в начале строки; представляет а в конце строки; представляет ab, следующее перед ху. из приведенных выше обозначений иллюстрируются в следующем примере, а именно: создание (несколько упрощенного) ана- лизатора для распознавания констант, идентификаторов, строк и некото- рых слов в программе Pascal (другие слова языка будут распознаваться как идентификаторы). Ниже приводится вход Lex. digit (0-9] intconst (+\-]?{digit}+ realconst [+\-]?{digit}+\.{digit}+(e(+\-J?{digit}+)? letter [A-Za-z] identifier {letter}({letter}|{digit})* whitespace ( \t\n] stringch Г'] string '{stringch}+' otherch [A0-9a-zA-Z+\-' \t\n] othersymb %% {otherch}+ program printf("опознано слово program\n") ; var printf("опознано слово var\n"); begin printf("опознано слово begin\n"); for printf("опознано слово for\n"); to printf("опознано слово to\n"); do printf("опознано слово do\n"); end printf("опознано слово end\nw); {intconst} printf("целое число %s в строке %d\n\ yytext yylineno); ’{realconst} printf("действительное число %s в строке %d\n*, yytext, yylineno); {string} printf("строка%8 в строке %d\nw, yytext, yylineno); {identifier} printf("идентификатор %s в строке %d\n", yytext, yylineno); {whitespace} ; /*нет действий*/ {othersymb} ; /‘нет действий*/ Отметим, что whitespace может представлять собой любое количество пробелов, новых строк и символов табуляции, а \п и \t применяются для указания новой строки и знака табуляции соответственно (подобно тому, как они используются в операторах printf в С). Отметим также, что символ перехода \ применяется в смысле, противоположном исполь- 66 Глава 3. Лексический анализ
зованному ранее — указывает, что п и t являются не обычными знаками, а знаками системы обозначений. Следует отметить, что подобное (непоследовательное) использование символа перехода не приводит к пу- танице. string определяется как любая последовательность знаков в ка- вычках, исключая кавычки, a othersymb — как любая последователь- ность ранее нс упомянутых знаков. Обратите внимание, что действие “нет действия” связано как с whitespace, так и с othersymb. Это объ- ясняется тем, что если эти символы не были распознаны во второй части входа Lex, то Lex выводит их в стандартный канал вывода вместе с дру- гими выходными данными, представляя неоформленный результат. Предположим, что на вход анализатора, созданного с помошью Lex, поступает следующая программа Pascal. program double (input, output); var i: 1..10; begin writein('число':10, 'удвоенное число':10); for i:= 1 to 10 do writein (i:10, i*i:10); writein end. В этом случае выход анализатора будет иметь следующий вид: опознано слово program идентификатор double в строке 1 идентификатор input в строке 1 идентификатор output в строке 1 опознано слово var идентификатор i в строке 2 целое число 1 в строке 2 целое число 10 в строке 2 опознано слово begin идентификатор writein в строке 4 строка 'число' в строке 4 целое число 10 в строке 4 и т.д. Отметим, что слова языка распознаются как таковые, а не как иден- тификаторы. Это объясняется тем, что для поиска соответствия Lex пре- жде всего обращается к разделу правил, а поэтому важно, чтобы именно там были определены слова языка. Отметим также, что double правиль- но распознается как идентификатор, а первые две его буквы не распо- знаются как слово языка do. Это объясняется тем, что для сопоставления Lex всегда подыскивает более длинное слово, и только в случае, если два слова имеют одинаковую длину, анализатор выбирает первое. С помощью функции yylexO Lex может вызываться из программы на С. Следующий пример входа Lex показывает, как код С можно интег- рировать в анализатор, созданный Lex. 34. Lex 67
int chars = 0, lines = 0; %) %% \n ♦♦lines; ♦♦chars; %% main() {yylexO ; printf("число знаков = %d, число строк = %d\n", chars, lines) ; } Функции, находящиеся в третьей части программы, а также объ- явления или другие элементы программы могут появляться и в ее первой части при условии, что будут выделены отступом или окруже- ны парой Любые такие элементы будут скопированы в созданную Lex программу на С. При этом более предпочтительно пользоваться парой а не отступом, поскольку цель по- следнего не всегда бывает ясна. В любом случае, если символы ис- пользуются, они должны находиться на своем месте в начале строки. Следует также помнить, что все строки с отступом игнорируются Lex и копируются в программу на С без изменений. Незнание этого мо- мента часто является причиной ошибок. Необходимо отметить, что обозначенное точкой “соответствующее всему” регулярное выражение в данном случае будет сопоставляться с любым зна- ком, кроме знака новой строки. В общем случае оно будет сопоставляться с любым предопределенным, но еще не сопоставленным символом. С помощью следующего входа Lex можно получить максимальное и среднее значения длин слов для некоторой части программы. %{ int letters = 0, words = 0, len = 0, length; %} word [a-zA-Z]+ space [ \n) ws {space}+ %% {word} {++words; length = yyleng; letters = letters+length; i if (length > len) len = length;} ws ;/*ничего не делать*/ ;/*ничего не делать*/ %% main() {yylex(); printf("максимальная длина слова = %d, средняя длина слова = %f\n*, len, letters/words); } 68 Глава 3. Лексический анализ
Отметим использование yyleng для получения длины последнего счи- танного символа. Если необходимо, чтобы анализатор окончил работу в кон- це первого предложения, то вход Lex можно записать следующим образом. %{ int letters = 0, words = 0, len = 0, length; %) word [a-zA-Z]+ space [ \n] ws {space}+ eos [ ’ ? . ] %% {word} {++words; length = yyleng; letters = letters+length; if (length > len) len = length;} {eos} yywrapO; ws ;/*ничего не делать*/ ;/*ничего не делать*/ %% main() {yylexO ; printf("максимальная длина слова = %d, средняя длина слова = %f\n", len, letters/words); } Здесь вызов yywrap () означает прекращение анализа. Еще одним простым примером является использования Lex для соз- дания инструмента добавления номеров строк в исходный код. Рассмот- рим следующий вход Lex. %{ int lineno = 1; %} line[A\n)*\n %% {line} {printf("%d %s", lineno++, yytext);} %% main() {yylexO ; } Выходом будет исходный код, в котором каждая строка (включая пустые) начинается с номера строки, считая от начала исходного кода. Может показаться удивительным, но распознать комментарии в языке обычно нелегко. Проблема заключается в знаках, используемых для обособления комментария, которые, следовательно, не должны появляться внутри комментария. Разумеется, для комментария можно определить регулярное выражение (например, в С), но, как будет по- казано, сделать это тяжело, и данный процесс подвержен ошибкам. Более простое решение — написать код, который бы распознавал на- ЗЛ. Lex 69
чхю комментария, а затем пропускал все знаки вплоть до замыкаю- щего знака (в конце концов, содержание комментария не важно для компилятора). В следующем ниже фрагменте Lex показан один из ме- тодов работы с комментариями в С. %% "/*" {char in; for (;;) while ((in = getcharO) ’ = '*'); /* ничего не делать */ while ((in = getcharO) = '*'); /* пропустить *’S */ if (in == ' /') break; /♦ окончание комментария*/ } } Приведенный выше код должен игнорировать последовательность знаков ♦s, за которой не следует а/, и последовательность /s, перед которой нет а*. Этот код может также быть доработан для нахождения внутри комментария последовательности EOF (end of file — конец файла). Комментарий можно определить как регулярное выражение для входа Lex. coiment || Сложность данного комментария требует некоторых пояснений. После- довательности м/** в начале и "*/Л в конце просто указывают пары знаков, что должны находиться в начале и в конце комментария. Далее остается то, что описывает содержимое комментария. "/’*(['*/)||"**(Л/1)*"*'* Последовательность */"* слева указывает, что в начале комментария может быть любое (включая нулевое) число вхождений знаков «/*, а по- следовательности справа — что в конце может находиться любое (включая нулевое) число вхождений знаков *** (два применения могут сбивать с толку'). Средняя часть представляет последовательность из нуля или большего числа сегментов, каждый из которых может: • не содержать " / • или " *"; • содержать только " / *, перед которым нет " *"; • содержать только ***, за которым не следует "/*. Так что не все так страшно, как кажется на первый взгляд! Непонят- но. правда, является ли сложность структуры комментария следствием его определения или ограничением формы записи Lex. Как было показа- но выше, язык комментария является регулярным, но регулярность — это еше не простота. Во многих языках сложными для описания являют- 70 Глава 3. Лексический анализ
ся также строчные литералы — вспомните рассмотренный выше пример из Pascal (а в С это еще сложнее!). 3.5. Другие применения Lex Существует множество способов применения Lex для анализа исходного кода. В то же время следует помнить, что многие типы анализа кода лучше проводить с помощью инструментальных средств, созданных на основе генераторов синтаксического, а нс лексического анализа. Тс типы анализа, что связаны скорее с синтаксической, а не лексической структу- рой программы, проще осуществлять с помощью инструментов с синтак- сической, а не лексической основой. Это вовсе не означает, что синтак- сическая структура программы не может быть эффективно проанализи- рована с помощью инструментов лексического анализа, однако, многие типы анализа проще и естественнее осуществляются посредством син- таксических инструментов. Например, анализ степени использования операторов, вложенных структур, перекрестных ссылок и т.д. более отно- сится к синтаксической, чем лексической структуре языка. Как правило, если анализ на основе Lex оказывается сложным, дальнейшее рассмотре- ние производится с использованием YACC. При этом практически не возникает потребности в использовании стеков, флажков и других средств, что часто применяются при попытке перейти от синтаксической задачи к лексической. Приведем типы анализа, связанные с лексической структурой про- граммы. • Идентификация слов языка. • Идентификация и вычисление констант. • Определение всех отдельных идентификаторов программы (не пе- ременных!). • Определение числа строк комментариев в программе. • Определение числа и средней длины литералов в программе. Допустим, мы хотим подсчитать количество строк в программе, кото- рые не содержат комментариев (non-comment lines of code — NCLOC). Для этого нужно подсчитать полное число строк программы и вычесть из него число пустых строк и строк, содержащих только комментарии. Код Lex для этой цели может иметь следующий вид. %{ int neloe =0, count =0; %) comment «/*""/"* ( Г*/] | Г*) | Г/)) *"*',*"*/'t space [ \t) newline \n %% {comment) ;/*ничего не делать*/ И. Другие применения Lex 7f
{space} ;/*ничего не делать*/ {newline} {if (count > 0) ncloc = ncloc+1; count = 0;} count = count * 1; main () {yylex(); printf("число строк программы, не содержащих комментаоиев, = %d*, ncloc); } Здесь count увеличивает свое значение при каждом знаке, отличном от пробела, символа табуляции и знака комментария. Если он имеет нену- левое значение в конце строки, то увеличивается значение ncloc. В качестве полезной метрики программы можно использовать среднее чисто знаков в строке. Эго чисто и параметр NCLOC позволяют получить более латное представление о размере программы, чем NCLOC сам по себе. Чтобы быть последовательными, подсчитаем среднее число знаков для строк без комментариев (исключая комментарии), также не учитывая пустые стро- ки. Код Lex дтя этой цели может иметь следующий вид. %{ int nochars = 0, ncloc = 0, count =0; %} comment space newline "/♦*"/'* (Г*/) ] Г*) "/*|"**Г/] )*"*"*"*/" ( \t] \n %% {comment} {space} {newline} ;/*ничего не делать*/ ;/‘ничего не делать*/ {if (count > 0) ncloc = ncloc+1; count = 0;} {count = count+1; nochars = nochars+1);} main() {yylexO ; printf("число знаков на строки NCLOC = %f", ncloc/nochars); } В процессе лексического анализа можно вычислять и другие метрики, такие как общее число строк, число строк программы с комментариями, а также общее число знаков в программе. Такие метрики, как количество функций, число операторов, количество операторов на строку и т.д.; лучше вычислять в процессе синтаксического анализа, причем сделать это удобно с помощью YACC. Как будет показано далее, более сложные метрики также удобнее вычислять в процессе синтаксического анализа. В процессе лексического анализа помимо проведения определенных вычислений можно осуществлять поиск нежелательных свойств про- граммы (иногда называемых дефектами). Считается, например, что слишком длинные или слишком короткие идентификаторы вредят чита- бельности программы. Читабельность — это важная качественная харак- теристика кода, который, возможно, придется читать многократно. Как 72 Глава 3. Лексический анализ
будет показано, опознать короткий или длинный идентификатор совсем не сложно. Разумеется, желательно, чтобы идентификатор был еще и значимым, но проверить это автоматически намного сложнее! Ниже приводится код 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\Da | С*)(ХС\XL | LX* | X*)(IX\IV\ VF \ Г) Этот набор легко получить, рассмотрев, как выражаются тысячи, сотни, десятки и единицы. Приведенное выражение создает все римские цифры, а также строки, которые ими не являются, например, 3.5: Другие применения Lex 73
VIIHII (В данном выражении содержится слишком много /.) Таких строк можно избежать, если заменить последнюю часть регулярного выражения сле- дующим выражением. IX\ V| Vl\ Vll\ VIII\IV\l\ll\lll\s К данному выражению можно, по желанию, добавить ешс //// (что, пожа- луй, встречается только на циферблатах часов). Подобная трактовка пер- вых трех частей регулярного выражения также позволит избежать появ- ления некорректных строк. Таким образом, можно записать (достаточно сложное) регулярное выражение, представляющее точно все римские цифры. Строго говоря, написанное выше выражение следует сше не- сколько модернизировать, чтобы пустая строка е не воспринималась как римская цифра. Поскольку мы работаем с Lex, преобразования не явля- ются обязательными, т.к. проверки, необходимые для устранения некор- ректных строк, можно запрограммировать в действия. Ниже приводится код Lex для работы с римскими цифрами. %{ int value = 0; %) thousands hundreds tens units %% {thousands) {hundreds) M* CM|CD|DC*|C* XC|XL|LX*|X* IX|IV|VI*|I* {value = value + yyleng * 1000;) {if(’strcmp(yytext, "CM")) value = value + 900; else if(Istrcmp(yytext, "CD")) value = value+400; else if (yytext [0] == 'D') value = value+500+(yyleng -1)* 100; {tens) else value = value + yyleng * 100;) {if(Istrcmp(yytext, "XC")) value = value+90; else if (!stromp(yytext, "XL")) value = value* 40; else if (yytext [0] == 'L') value = value+50+(yyleng-1) *10; {units) else value = value + yyleng*10;) {if(istremp(yytext, "IX")) value = value + 9; else if ('stremp(yytext, "IV*)) value = value + 4; else if(yytext (0) == 'V') value = value+5+(yyleng-1) ; else value = value + yyleng;) 74 Глава 3. Лексический анализ
%% main() {yylexO; printf("значение нумерала равно%б\п”, value); ) Для включения проверок слишком большого числа цифр С, X ИЛИ L можно добавить что-то, подобное приведенному ниже. {hundreds) {if ( ’stremp(yytext, "CM") ) value = value + 900; else if (’stremp(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("слишком много C\n"); else value = value + yyleng*100;) Использование stremp понятно интуитивно, поскольку он должен давать значение 0, если две строки, являющиеся его параметрами, иден- тичны. В С это эквивалентно значению false, поэтому значение нужно инвертировать с помощью оператора !. 3.6. Взаимодействие с YACC yylexO можно использовать для возвращения значения любой вызы- вающей его функции. В приведенных выше примерах это обычно функ- ция main(), хотя часто вызывающей функцией является программа син- таксического анализа, созданная 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). Таблица 31.__________________________________________________________ Имя Lex Использование ________ yytext Текст последнего распознанного символа yyleng Число знаков в последнем распознанном символе yylval Значение, соотнесенное с последним распознанным символом 1 ех. уу. с файл С, создаваемый Lex 3.7. Лексические затруднения Несмотря на общую простоту’ процесса лексического анализа, существует небольшое количество языковых характеристик, что усложняют создание лексических анализаторов. Большинство из таких характеристик принад- лежит к одному из следующих классов. • Слова языка доступны для использования в качестве идентифика- торов. • Интерпретация некоторых последовательностей знаков является контекстно-зависимой. Языки FORTRAN и PL/1 допускают использование слов языка в ка- честве идентификаторов, определяемых пользователем. Преимущество такого подхода заключается в том, что пользователю языка не нужно знать все слова языка перед написанием программы. С другой стороны, язык COBOL содержит более 100 слов (точное количество зависит от ис- пользуемой версии), при этом ни одно из них не может быть переопре- делено пользователем. Возможность использования слов языка в качестве идентификаторов приводит к возникновению трудностей в лексическом анализаторе и, возможно, затрудняет чтение программы человеком. Рас- смотрим, к примеру, фрагмент кода на языке FORTRAN. 76 Глава 3. Лексический анализ
IF(I) = 1 Данный фрагмент можно интерпретировать только как присвоение зна- чения массиву с именем if, но точно этого сказать нельзя, пока не будет достигнут конец строки — подобным же образом может начинаться, на- пример, оператор if. IF (I) 1,2,3 Помимо того, что FORTRAN допускает использование слов языка в качестве идентификаторов, определяемых пользователем, он также не придает значе- ния пробелам внутри идентификаторов и (в отличие от большинства языков) не использует пробелы для разделения символов. Выражение DO 7 I = 1,5 является началом оператора do. В то же время, пока не считан знак начало строки может представлять идентификатор DO 7 I Чтобы избежать подобной неоднозначности, лексический анализатор для языка FORTRAN должен уметь выполнять некоторый предпросмотр, и, по-видимому, именно поэтому Lex предлагает средства сопоставления строк. Предпросмотр в FORTIbKN обычно ограничен и никогда не про- стирается далее текущего оператора. По меньшей мере, в ранних версиях FORTRAN в качестве нижних индексов массива можно было использо- вать только ограниченные (и короткие) формы выражений, что помогало ограничивать требуемый объем предпросмотра. В языке PL/1 слова языка также (в основном) не резервируются. Вследст- вие более структурированной природы языка (по сравнению с FORTRAN) для устранения локальных неоднозначностей может требоваться произволь- ный объем предпросмотра. Рассмотрим следующее выражение. IF (I) = THEN + THEN; Оно представляет оператор присваивания. В то же время, выражение IF (I) = THEN + THEN + THEN; является оператором if. Подобным образом DO WHILE (Р=0); является оператором (do while), а DO WHILE(P) = 0... является оператором do с управляющей переменной while(Р). В ALGOL 68 (языке, о котором практически ничего не слышно в на- ши дни) существуют проблемы, связанные с контекстной зависимостью фуппировки некоторых последовательностей знаков в символы. Напри- мер, последовательность <= может означать операторный символ “€=” или же может возникнуть в объявлении оператора 3,7. Лексические затруднения 77
op <=... В последнем случае “<” и “=” будут двумя отдельными символами (с точки зрения синтаксического анализа). В показанном примере, чтобы различить две интерпретации последовательности знаков, необходимо знание контекста, а именно: появления оператора ор непосредственно перед двумя знаками. Впрочем, различить две интерпретации совсем не просто, если последовательность знаков появляется позже в списке объ- явлений операторов, как, например, в следующей ситуации. ор Еще одной интересной (с лексической точки зрения) особенностью ALGOL 68 является вопрос форматов. Форматы, контролирующие вход и выход, выделяются значками $. Внутри форматов лексическая структура языка достаточно отличается от структуры любой другой его части. Более того, сами по себе форматы могут содержать выражения с лексической структурой, эквивалентной структуре выражений вне форматов, а выра- жения внутри форматов также могут содержать форматы и т.д. до произ- вольной степени вложенности. Приведем пример формата. $n(x-l)x,dd$ Здесь второй х означает пробел (это его обычное значение внутри фор- мата), а первый х является частью выражения, на что указывает предше- ствующее п. Лексический анализатор для ALGOL 68 должен уметь работать в двух режимах: один для “нормального” текста, а второй — для текста внутри форматов. Кроме того, два режима обязаны уметь рекурсивно вызывать друг друга. Это может показаться очень сложным, но реализовать это на практике не так уж и тяжело. 3.8. Резюме Данная глава была посвящена лексическому анализу, как части процесса компиляции, а также его использованию в других областях. В частности, было сделано следующее. • Показано, как можно создать лексический анализатор, используя регулярные выражения и конечные автоматы. • Продемонстрировано, как используется Lex для создания лексиче- ского анализатора. • Показано, как можно использовать Lex для создания инструментов вычисления метрик текстов. • Рассмотрено взаимодействие Lex и YACC. • Приведены примеры языковых характеристик, которые усложняют процесс лексического анализа. 78 Глава 3. Лексический анализ
Дополнительная литература Вопросы лексического анализа хорошо освещаются во всех вступитель- ных трудах по компиляторам, которые были приведены в конце главы 1. Традиционным справочником по Lex является [Lesk, 1975]. Кроме того. Lex подробно описывается в документации no Unix, однако, собственно учебников по этому средству не так уж много. Основной из них уже упоминался ранее: [Levine, Mason and Braun, 1992]. Многие работы по компиляторам содержат общие сведения об инструментальных средствах, но лишь немногие из них могут предоставить достаточно информации, которую можно использовать в серьезной работе. Наиболее полную ин- формацию по метрикам исходного кода (подобным NCLOC) можно най- ти в работе [Fenton and Pfleeger, 1996]. Упражнения 3.1. Объясните, почему лексический анализ обычно является относи- тельно медленной фазой процесса компиляции. 3.2. Необходимы ли значения констант в процессе лексического анализа? Ответ аргументируйте. 3.3. Предложите конечный автомат для распознавания идентификаторов FORTRAN не длиннее шести знаков (букв и цифр), которые начи- наются с буквы. 3.4. Для каждого из следующих регулярных выражений определите ко- нечный автомат: а) /(М* б) cf.dcf в) (а|Ь|с)хх*(а|Ь|с) 3.5. Укажите грамматику 3-го типа для каждого из регулярных выраже- ний в упражнении 3.4. 3.6. Из регулярного выражения для определения действительных чисел (+ I - digit digif(e(+ | - \)digit.digif) получите: а) конечный автомат, принимающий действительные числа; б) код С для распознавания действительных чисел на основе ко- нечного автомата. 3.7. Из регулярного выражения для определения действительных чисел в упражнении 3.6 создайте грамматику 3-го типа для генерации дей- ствительных чисел. Дополнительная литература 79
3.8. Рассмотрите двоичные строки, которые состоят из четного числа единиц. Получите конечный автомат, который принимает такие строки, а затем образует: а) регулярные выражения для таких строк; б) код Lex для определения таких строк. 3.9. Предложите методы восстановления после лексических ошибок. 3.10. Предложите альтернативу взаимно рекурсивным процедурам работы с двумя лексическими режимами ALGOL 68. Обсудите преимущест- ва и недостатки такого подхода по сравнению с подходом рекурсив- ных процедур.
Глава 4 Нисходящий синтаксический анализ 4.1. Вступление В данной главе будут рассмотрены принципы нисходящего синтаксиче- ского анализа, а также их применение на практике. Методы нисходящего синтаксического анализа являются более интуитивными, чем методы восходящего анализа. Поэтому вначале будет рассмотрен первый из ука- занных случаев. Впрочем, для восходящего синтаксического анализа раз- работано больше инструментальных средств, и он может применяться более широко, чем нисходящий анализ. Подробно методы восходящего синтаксического анализа будут описаны в главе 5. В настоящей главе будут рассмотрены следующие вопросы. • Критерии принятия решений, применяемые при нисходящем син- таксическом анализе. • Контекстно-свободные грамматики, на которых может основы- ваться нисходящий синтаксический анализ. • Простые методы создания нисходящих синтаксических анализато- ров с использованием соответствующих грамматик. • Преобразование грамматики в форму', подходящую для нисходя- щего анализа. • Преимущества и недостатки нисходящего синтаксического анализа. • Использование контекстно-свободных грамматик как основы про- цессов времени компиляции. 4.2. Критерии принятия решений Напомним, что задача синтаксического анализа состоит в нахождении порождения (если таковое существует) конкретного выражения с исполь- зованием данной грамматики. При нисходящем анализе в большинстве случаев требуется найти левое порождение. При обратном порядке разбора чаще всего искомым является правое порождение. Следует помнить, что
при нисходящем синтаксическом анализе мы начинаем с символа пред- ложения и генерируем предложение, тогда как при восходящем анализе имеется предложение, которое сворачивается в символ предложения. Да- лее будем предполагать, что предложения, которые предстоит сгенериро- вать или свернуть, читаются слева направо (хотя теоретически возможны и обратные проходы, когда предложения читаются справа налево). В разделе 2.5 был рассмотрен язык {/У | т, п > 0), сгенерированный следующими продукциями. S^XY Х->хХ Х-»х Y^yY Y->y Было показано, что предложение хххуу можно породить (или сгенерировать) с помощью левого порождения, а именно: S => XY => xXY => xxXY => xxxY => xxxyY => хххуу Первый шаг порождения очевиден, поскольку символ предложения S на- ходится с левой стороны только одной продукции. S^XY Следующий шаг несколько сложнее, поскольку крайний левый нетерми- нал в сентенциальной форме (X) входит в левую часть более одной про- дукции (в данном случае — в две продукции). В то же время следует помнить, что при синтаксическом анализе конечный результат (сгенерированное предложение) всегда известен. В данном случае резуль- тат будет следующим. хххуу Поскольку в указанном выражении более одного х, следующей исполь- зуемой продукцией должна быть такая. Х->хХ Подобным образом на третьем шаге порождения должна использоваться та же продукция. В результате получим следующее. xxXY Четвертый шаг имеет вид xxXY=> xxxY Видим, что, поскольку требуется сгенерировать последний х, в первый раз используется продукция X —> х 82 Глава 4. Нисходящий синтаксический анализ
На пятом шаге xxxY => xxxyY используется следующая продукция. Использование именно этой продукции объясняется тем, что далее также требуется сгенерировать у. Поскольку в дальнейшем уже не требуется генерировать у, то последний шаг порождения xxxyY xxxyy подразумевает использование следующей продукции. У-»у В приведенном выше примере найти порождение было нетрудно, так как было известно выражение, которое следовало сгенерировать, хотя на большинстве этапов было необходимо знать два символа помимо уже сгенерированных. При использовании некоторых грамматик для нахож- дения правильного порождения требуется более двух символов (в некото- рых случаях это число может быть произвольным). В дальнейшем нас интересуют такие грамматики, которым для определения правильного порождения требуется не более одного символа предпросмотра на каждом этапе порождения. Еще один наглядный пример, демонстрирующий этапы нисходящего порождения, приводится в табл. 4.1. Знаки предложения рассматривают- ся по одному и используются для управления процессом синтаксического анализа. После генерации знак предложения зачеркивается (в столбце “входная строка”)- Каждому этапу синтаксического анализа соответству- ют три позиции таблицы: входная строка с вычеркнутыми символами, текущая продукция, а также текущее состояние сентенциальной формы. В конце синтаксического анализа все знаки входной строки зачеркнуты, а сентенциальная форма соответствует исходной заданной строке. Таблица 4.1 Входная строка Продукция Сентенциальная форма xxxyy S->XY ХУ xxxyy X->xX xXY «хуу X->xX xxXY xxxyy X->x xxxY xxxyy Y-*yY xxxyY Y-^y xxxyy На каждом этапе первый незачеркнутый символ входной строки оп- ределяется как входной символ и используется для разбора. Если в сен- тенциальной форме генерируется терминал, появляется еще один за- черкнутый символ. По определению, символ предпросмотра (lookahead 4.Я, Критерии принятия решений 83
symbol) — это либо текущий входной символ, либо маркер конца (специальный символ, стоящий в конце строки; обычно обозначается как 1). Принятие решений при нисходящем синтаксическом разборе, как правило, основывается на символе (или последовательности символов) предпросмотра. Кроме того, существуют более общие методы, в которых учитывается история синтаксического анализа. 4.3. LL(1)-грамматики В данном разделе рассматриваются свойства грамматик, поддерживаю- щих методы нисходящего синтаксического анализа с одним символом предпросмотра. Будем считать, что грамматики являются однозначными, так что каждому предложению языка соответствует единственное левое порождение. В данном случае для каждого нетерминала, который нахо- дится в левой части нескольких продукций, необходимо найти такие не- пересекаюшиеся множества символов предпросмотра, чтобы каждое множество содержало символы, соответствующие точно одной возмож- ной правой части. Выбор конкретной продукции для замены данного не- терминала будет определяться символом предпросмотра и множеством, к которому принадлежит данный символ. Объединение различных непере- секаюшихся множеств для заданного нетерминала не обязательно должно составлять алфавит, на котором определен язык. Если символ предпро- с.мотра не принадлежит ни одному из непересекающихся множеств, можно сделать вывод о наличии синтаксической ошибки. Множество символов предпросмотра, соотнесенных с применением определенной продукции, называется се множеством первых порождае- мых символов (director symbol set). Перед определением данного понятия, определим вначале следующие два. 1. Стартовый символ данного нетерминала определяется как любой сим- вол (например, терминал), который может появиться в начале строки, генерируемой нетерминалом. 2. Символ-последователь данного нетерминала определяется как любой символ (терминал или нетерминал), который может следовать за не- терминалом в любой сентенциальной форме. Вычисление множества стартовых символов может быть достаточно тру- доемким и вычислительно сложным процессом, и оно всегда выполняется в процессе генерации программы синтаксического анализа, а не при каждом запуске этой программы. Впрочем, возможны ситуации, когда упомянутые вычисления относительно просты. Пусть, например, (используется принятая ранее договоренность об обозначении терминалов и нетерминалов) для не- терминала Т грамматика содержит только две продукции. Т-4 aG T^bG 84 Глава 4. Нисходящий синтаксический анализ
В этом случае имеем следующие множества стартовых символов. Продукция Множество стартовых символов Т-> aG {а} Т-> bG {b} В общем случае, если продукция начинается с терминала, ее множество стартовых символов просто состоит из этого терминала. В то же время, если продукция нс начинается с терминала, для нее все равно нужно вы- числить множество стартовых символов. Пусть в рассматриваемой грам- матике имеются следующие продукции для нетерминала R. R-+BG R-+CH Тогда множество стартовых символов для этих продукций нельзя опреде- лить “с ходу”. В то же время, пусть имеются только следующие продук- ции для нетерминала В. B-tcD В-+ TV Тогда можно заключить, что множеством стартовых символов для про- дукции R-+BG будет набор {а, Ь, с), состоящий из всех стартовых символов для В. Введение символа с в множество стартовых является очевидным (см. первую продукцию для 8), а введение набора {а, Ь) объясняется тем, что эти символы являются стартовыми для Т. В общем случае ситуация мо- жет быть значительно сложнее. Пусть имеется следующая последователь- ность продукций. А ВО, В-+ DE; D-+ FG; F-> HI; H-+xY Из данных продукций следует, в частности, что х является стартовым символом для продукции Д-4ВС Еще одним источником сложностей можно назвать нетерминалы, ко- торые могут генерировать пустые строки. Допустим, имеются следующие продукции. Д-4ВС 8-46 В этом случае множество стартовых символов для продукции А ВС бу- дет включать стартовые символы С, а также стартовые символы В (определяемые не приведенными здесь продукциями). Если оба нетер- минала В и С могут генерировать пустые строки, то на использование 4.3. СЦ1)-грамматнки 85
данной продукции будут указывать символы предпросмотра, являющиеся последователями А и стартовыми символами ВС. Множество первых по- рождаемых символов продукции выбирается как множество всех термина- лов, которые, выступая как символы предпросмотра, указывают на ис- пользование данной продукции. Таким образом, множество первых по- рождаемых символов для продукции А-ьВС будет включать все символы-последователи Д, а также стартовые символы ВС. Рассмотрим грамматик со следующими продукциями. S-> Ту Т-4 АВ ТsT Д —> аА А е В-*ЬВ В Е Ниже приводятся множества первых порождаемых символов для различ- ных продукций данной грамматики. Продукция Множество первых порождаемых символов ТАВ {а. Ь. Я Т sT {Я А->аА {а} Д-4 Е {Ь,И В->ЬВ {Ь} В —4 Е W Множество первых порождаемых символов для продукции в->Е равно {у}, поскольку у может следовать за В; подобным образом, множество первых порождаемых символов для продукции А -> е равно {Ь, у}, по- скольку Ь и у могут следовать за Д, если В генерирует пустую строку. Существует алгоритм (см. раздел дополнительной литературы в конце главы) поиска множеств первых порождаемых символов для всех продук- ций грамматики. Сложность этого алгоритма, в основном, связана с тем, что символы могут генерировать пустые строки; в данной книге этот ал- горитм приводиться не будет. После вычисления всех множеств первых порождаемых символов их можно проверить на предмет пересечения. LL( 1)-грамматику можно определить как грамматику, в которой для каж- дого нетерминала, появляющегося в левой части нескольких продукции, множества первых порождаемых символов всех продукций, в которых появляется этот нетерминал, являются непересекаюшимися. Термин LL(1) имеет следующее происхождение: первое L означает чтение слева (Left) направо, второе L означает использование левых (Leftmost) порож- дений, а 1 — один символ предпросмотра. 86 Глава 4. Нисходящий синтаксический анализ
Описанная выше грамматика, очевидно, является Щ1)-грамматикой, поскольку множества символов предпросмотра для Г, А и В не пересека- ются. Щ!)-грамматики формируют основу методов нисходящего анали- за, описываемых в данной главе. Если вычислены все множества первых порождаемых символов для всех возможных правых частей продукций, то языки, которые описываются Ы(1)-грамматикой, всегда анализируются детерминировано, т.с. без необходимости отменять продукцию после се применения. Существуют более распространенные классы грамматик, которые могут использоваться для детерминированного нисходящего анализа, но обычно используются именно 1±(1)-грамматики. Недетер- минированный нисходящий анализ, основанный на откате (backtracking), уже не считается эффективной процедурой, хотя в начале эры компиля- торов он широко использовался в языках, подобных FORTRAN. Грамма- тики LL(k), требующие к символов предпросмотра для различения аль- тернативных правых частей, также уже не считаются практичными с точ- ки зрения синтаксического анализа. ЬЦ1)~язык — это язык, который можно сгенерировать посредством Ы(1)-грамматики. Отсюда следует, что для любого Ы-(1)-языка возмо- жен нисходящий синтаксический анализ с одним символом предпро- смотра. Рассмотрим некоторые “теоретические” результаты, связанные с Щ1)-грамматиками и языками, после чего перейдем к более практиче- ским вопросам реализации. Во-первых, как было сказано выше, существует алгоритм определе- ния, относится ли данная грамматика к классу LL(1), поэтому грамма- тику можно проверить на “ЬЦ1)-ность” прежде, чем создавать на ее основе программу синтаксического анализа. В то же время, что может несколько удивить на первый взгляд, не существует алгоритма опреде- ления, ртносится ли данный язык к классу LL(1), т.е. имеет он LL(1)- грамматику или нет. Это означает, что не-1Л(1)-грамматика может иметь или не иметь эквивалентную LL( 1), генерирующую тот же язык, и не существует алгоритма, который для данной произвольной грамма- тики определит, является ли генерируемый ею язык LL(1) или нет. Ра- зумеется, существуют алгоритмы, которые могут использоваться для ча- стных случаев, например, если грамматика является LL( 1) — язык так- же является LL(1); также можно выделить определенные классы грамматик, которые никогда не будут генерировать 1±(1)-языки. В то Же время, в общем случае задача является неразрешимой в том же смысле, как неразрешимы задача определения однозначности языка и проблема остановки для машин Тьюринга. Приведенный выше результат является важным, поскольку далее бу- дет показано, что имеются грамматики, не являющиеся LL(1), которые, тем не менее, генерируют ЕЬ(1)-языки, т.е. грамматики имеют эквива- лентные Щ1)-грамматики. Это означает, что грамматики часто нужно преобразовывать, прежде чем использовать с методами нисходящего син- таксического анализа. Фактически, грамматики, которые обычно исполь- зуются в определениях языков или в учебниках, редко являются LL( 1) и, 4:3. Щ1/-грамматики 87
следовательно, не могут непосредственно использоваться для эффектив- ного нисходящего анализа. Тем больше оснований сожалеть, что нс су- ществует алгоритма определения, имеет ли грамматика эквивалентную LL( 1). а это означает, что в любом случае не существует алгоритма поиска эквивалентной LL( 1)-грамматики, если даже такая грамматика и сущест- вует. Впрочем, в этом несовершенном мире иногда приходится иметь де- ло с несовершенными алгоритмами, так что, если нет алгоритма выпол- нения преобразования в обшем случае (т.е. для всех случаев), имеются алгоритмы, работающие для большого числа групп частных случаев, ко- торые никогда не дают неверного результата. В то же время эти алгорит- мы иногда могут зацикливаться. Не стоит пугаться, что существует одно свойство грамматики, которое (если оно присутствует) препятствует тому, чтобы грамматика была LL( 1), и это — левая рекурсия. Рассмотрим следующие продукции. Ох D^y Обозначив через DS множество первых порождаемых символов (director symbol set), можем записать следующее: DS(D->Dx) = {y) OS(D->y) = {y} Здесь второе множество первых порождаемых символов следует непо- средственно из продукции, а первое следует из того, что D является стар- товым символом правой части. Очевидно, что никакая грамматика, имеющая подобную левую рекурсию, не может быть LL(1). Предполо- жим, впрочем, что для D имеется единственная продукция. D-> Dx В этом случае, разумеется, в алгоритме по определению принадлежности к классу LL( 1) вообще не будут найдены первые порождаемые символы для D. Использование данной продукции никогда не даст ни одной строки терми- налов, поскольку' не существует способа избавиться от нетерминала D, если таковой появится в сентенциальной форме. Грамматика с продукциями, ко- торые не могут использоваться или (по каким-то причинам) не являются не- обходимыми, часто называется нечистой (unclean); далее предполагается, что все рассматриваемые грамматики являются “чистыми”. Левая рекурсия может быть непрямой, включающей две или более продукции. Рассмотрим, например, следующий набор продукций. А->ВС B-+DE D-+FG F-+AH Здесь имеет место непрямая левая рекурсия, в которой задействованы нетер- миналы Д, В, D и F. Разумеется, должны существовать нерекурсивные прави- ла, по крайней мере, для некоторых нетерминалов, гарантирующие чистоту 88 Глава 4. Нисходящий синтаксический анализ
грамматики. Как и для прямой левой рекурсии, любая грамматика, имеющая непрямую левую рекурсию, будет характеризоваться пересекающимися мно- жествами первых порождаемых символов для некоторых нетерминалов и, следовательно, такая грамматика не может быть LL( 1). Итак, ни одна леворе- курсивная грамматика нс является LL( 1). Это нс представляет такой уж серь- езной проблемы, как может показаться на первый взгляд, поскольку можно показать (см. раздел 4.5), что все левые рекурсии грамматики можно заме- нить правыми, не затронув генерируемый язык. Преобразования грамматик, выполняемые автоматически или вруч- ную, являются неотъемлемой частью Ы(1)-анализа и, как говорят мно- гие, одним из его ограничений. Впрочем, при наличии соответствующей грамматики написание программы синтаксического анализа является простой задачей. Кто-то может даже сказать, что написать эту программу можно с той же скоростью, с которой вы набираете на клавиатуре, ис- пользуя при этом известный метод рекурсивного спуска, который будет описан в следующем ниже разделе. 4.4. Рекурсивный спуск Синтаксический анализ методом рекурсивного спуска (recursive descent) включает использование рекурсивных процедур и работает нисходящим образом — отсюда и название! Предположим, например, что в граммати- ке языка программирования имеется символ предложения program и единственное правило с символом предложения с левой стороны. PROGRAM-* begin DECLIST comma STATELIST end Как обычно, слова из прописных букв представляют нетерминальные символы, а слова из строчных букв — терминальные символы. Использо- вание синтаксического анализа методом рекурсивного спуска состоит из последовательного определения всех символов правой части. Появление слова begin определяется посредством вызова лексического анализатора, DECLIST определяется вызовом функции (для удобства названной de- clist), comma определяется непосредственно, с помощью лексического анализатора, STATELIST определяется посредством вызова функции, именуемой statelist, наконец, end определяется непосредственно, снова с помощью лексического анализатора. Пусть другие продукции грамматики имеют следующий вид. DECLIST-* d semi DECLIST d STATELIST-* s semi STATELIST s Здесь d можно рассматривать как объявление (declaration), as— как опе- ратор (statement), но на данный момент оба символа считаются просто терминалами. Метод рекурсивного спуска может применяться только к LL(1)- грамматикам, но данная грамматика, очевидно, такой не является, поскольку 4.4. Рекурсивный спуск 89
DS(DECUST^ dsemi DECLIST) = {d} DS(DECLIST-* d) = {d) Данные множества не являются непересекаюшимися; то же можно сказать к для продукций с нетерминалом STATEUST. Итак, данную грамматику требу- ется преобразовать. Для выполнения необходимого преобразования продук- ций для нетерминала DECLIST вначале следует отметить, что данные две продукции генерируют последовательности следующего вида: d d semi d d semi d semi d и т.д., причем последний терминал d генерируется вторым правилом для нетерминала DECLIST, а остальные — первым. Полное множество по- добных последовательностей можно записать как регулярное выражение. d(semi d) Каждое предложение можно рассматривать как начинающееся с d, за ко- торым следует либо пустая строка, либо символ semi, за которым идет что угодно, составляющее DECLIST. Следовательно, две продукции мо- жем переписать в следующем виде. DECLIST-* dX X -* semi DECLIST г Итак, в грамматике появился новый нетерминал X. Подобным образом можно переписать продукции для STATELIST. STATELIST-* sY Y-* semi DECLIST e Получили еще один новый нетерминал — Y. Преобразования грамматики не являются совсем очевидными. Про- стейшим путем их определения является рассмотрение языка, генерируе- мого продукциями, подлежащими преобразованию, как это было сделано выше. В то же время данный тип преобразования является настолько общим, что его легко определить и выполнить вручную или автоматиче- ски. Данный процесс часто называется факторизацией, по аналогии с со- ответствующим алгебраическим процессом. Фактически, грамматику* по- лезно рассматривать как некоторую алгебру с присущими ей правилами преобразования, не затрагивающими язык в целом. Перечислим продукции преобразованной грамматики. PROGRAM-* begin DECLIST comma STATELIST end DECLIST-* dX X-* semi DECLIST e STATEUST-* sY 90 Глава 4. Нисходящий синтаксический анализ
Y-* semi STATELIST e Чтобы показать, что данная грамматика относится к классу LL( 1), доста- точно рассмотреть множества первых порождаемых символов (DS) для двух продукций X и двух продукций Y. Для продукций, имеющих в левой части нетерминал X, имеем следующее. DS(X -> semi DECLIST) = {semi} DS{X -* e) = {comma} Последнее множество определено путем рассмотрения последователей X. Терминал comma является последователем DECLIST что видно из сле- дующего выражения. PROGRAM-* begin DECLIST comma STATELIST end В то же время любой последователь DECLIST является последователем X, что следует из продукции DECLIST-* dX Таким образом, comma является последователем X, а значит, первым по- рождаемым символом продукции Х->€ Подобным образом DS(Y-* semi STATELIST) = {semi} DS{Y-* e) = {end} поскольку end является последователем STATELIST что видно из продукции PROGRAM-* begin DECLIST comma STATELIST end а любой последователь STATELIST является последователем Y, что следу- ет из продукции STATELIST-* sY Таким образом, в каждом случае оба множества первых порождаемых симво- лов являются непересекающимися, и грамматика относится к классу LL( 1). Ниже приводятся функции для каждого нетерминала грамматики: PROGRAM, DECLIST, X, STATELIST, Y. Код написан на языке С, но можно использовать любой другой язык, разрешающий применять ре- курсивные функции. 1 void PROGRAMO /*соответствует PROGRAM*/ { if (token! = begin) error(); token = lexical(); DECLIST(); if (token! = comma) error(); token = lexical(); STATELIST(); if (token! = end) 4.4. Рекурсивный спуск 91
error(); } void DECLIST() { if (token! = d) error(); token = lexical()• XO ; } void X() { if (token == semi) { token = lexical (H DECLIST(); ) . else if (token == comma) ; /*ничего не делать*/ else errorO; } void STATELIST() { if (token! = s) error(); token = lexical(); YO; ) void Y() { if (token == semi) { token = lexical(); STATELIST(); ) else if (token == end) ; /*ничего не делать*/ else errorO; } main () { token = lexical О; PROGRAM(); Вызов lexical() вынуждает лексический анализатор передать еле- дующий символ синтаксическому анализатору, a error () тся при появлении синтаксической ошибки. Предполагается, что при вызове функции errorO инициируются определенные процедуры восстановле- ния после ошибок (здесь не конкретизируются), semi, comma, egin и end являются предварительно заданными константами, значениями ко- торых являются представления данных символов этапа, следующего за лексическим анализом. „ Порядок, в котором записаны функции, соответствует порядку про- дукций грамматики. Для компиляции посредством компилятора Borland 92 Глава 4. Нисходящий синтаксический анализ
С им должны предшествовать следующие прототипы функций (это по- зволит применять функции до их объявления). void DECLISTО; void STATELISTO; void X() ; void Y () ; В продукциях грамматики присутствует (хотя и неявно) рекурсия, следовательно, она имеется и в программе синтаксического анализа. Ра- зумеется, если ни одна из продукций не содержит рекурсии, генерируе- мый язык будет крайне ограниченным и будет состоять только из конеч- ного числа предложений. В то же время рекурсия в программе синтакси- ческого анализа может быть дорогой, и ее можно избежать следующим образом. Продукции грамматики следует переписать с использованием расширенной формы записи, которая включает знак * с его обычным значением (нуль или более вхождений предшествующего элемента). За- тем продукции могут записываться следующим образом. PROGRAM -» begin DECLIST comma STATELIST end DECLIST -» d (semi d) * STATELIST —> s (semi s)* Данное представление грамматики компактнее и, пожалуй, читабельнее, чем приводившееся ранее. Использованная форма записи иногда называ- ется расширенной формой Бэкуса-Наура, исходная форма записи экви- валентна форме Бэкуса-Наура, изначально использованной для опреде- ления языка ALGOL 60. Программа синтаксического анализа, созданная на основе приведен- ных продукций, будет использовать уже не рекурсию, а итерации. Функ- ции main () и PROGRAM О аналогичны приведенным выше, а функции declist () и statelisto можно переписать следующим образом. Функции х и Y не нужны. void DECLIST() { if (token! = d) error() ; token = lexical(); while (token == semi) {token = lexical(); if (token! = d) error (); token = lexical(); } } void STATELISTO { if (token! = s) error(); token = lexical О ; while (token == semi) 44. Рекурсивный спуск 93
{token = lexical(); if (token! = s) error (); token = lexical(); } } Здесь lexical(), errorO, semi, comma, begin н end имеют то же значение, что и ранее. С помощью описанного способа правую рекурсию всегда можно превратить в итерацию; кроме того, данный процесс можно автомати- зировать. Левая рекурсия не может появляться в Ы(1)-грамматике и, как было показано выше, ее можно преобразовать в правую рекур- сию. Таким образом, правую и левую рекурсии можно рассматривать как итеративные, а не рекурсивные процедуры, поэтому программу синтаксического анализа создать возможно всегда. В то же время грамматики для языка 2-го (но не 3-го) типа будут со- держать среднюю рекурсию (например, для сопоставления с шаблоном), и это нельзя (точнее, нельзя легким способом) заменить итерацией. Рас- смотрим, например, грамматику' для выражений со следующими продук- циями, в которых терминалы заключены в кавычки, чтобы не возникало путаницы между терминалами “(” и “)” и теми же знаками, исполь- зованными как метасимволы. E->EVT Е-4 Т Т-+ T*nF T-+F F^T^r F-t'X' Данную грамматику можно преобразовать к виду LL( 1). Е-> ТХ VTX Х->е T->FY Y^^FY Y-*t F->“Z Заменяя, где это возможно, рекурсию итерацией, получаем следующее. Т-> F(**nF)* F-> F-*“)C 94 Глава 4. Нисходящий синтаксический анализ
Впрочем, остается (непрямая) средняя рекурсия, в которой фигури- руют Е, Т и F, устранить которую нельзя. Функции рекурсивного спуска соответствуют (что неудивительно) также продукциям, содержащим не только итерацию, но и рекурсию (см. продукции для Е и 7). Следующая ниже реализация основана на приведенных выше продукциях, дополнен- ных продукцией, которая вводит символ предложения, не появляющийся в правой части ни одной продукции. функции имеют следующий вид. void Е() { ТО; while (token == plus) {token = lexicalO; TO; } } void Т () { F () ; while (token == times) {token = lexicalO; F() ; } } void F () { if (token == ©bracket) {token=lexical(); EO ; if (token == ©bracket) token=lexical(); else error(); } else if (token == x) token = lexical О; else error(); ) main () { token = lexicalO; E(); } 4.4. Рекурсивный спуск 95
Здесь plus, times, obracket, cbracket их— представления на этапе, следующем за анализом, символов +, *, (, ) и х соответственно. Как и ра- нее, для компиляции функции в начале кода должны находиться прото- типы ДЛЯ Е, F, Т. Одним способом реализации эффекта средней рекурсии является вве- дение в программу синтаксического анализа явного стека, который мож- но использовать для хранения адресов возврата для входа и выхода функ- ции. Данный подход, похоже, эффективнее, чем более общий механизм обработки рекурсии, предлагаемый высокоуровневыми языками про- граммирования. 4.5. Преобразования грамматик Одним из основных ограничений анализа методом рекурсивного спуска, как и других методов Щ1)-анализа, является необходимость преобразо- вания грамматики. При этом применяются два типа преобразования. 1. Удаление левой рекурсии. 2. Факторизация. 4. 5.1. Удаление левой рекурсии Левую рекурсию, как показывалось ранее, всегда можно удалить из кон- текстно-свободной грамматики. В то же время этот процесс следует про- водить аккуратно, поскольку при этом изменяются значения строк, гене- рируемых изменяемыми продукциями. Например, требуется преобразо- вать левые рекурсии следующих продукций в правые. Е-+Е+Т Е-+ Т Может показаться, что данные продукции следует изменить таким образом. Е-> Т+Е Е-> Т При этом генерируемые строки затронуты не будут. Действительно, это мо- жет был» все, что требуется. В то же время, если значение строк важно (например, при создании компилятора или нахождении значения выраже- ния), то приведенная выше леворекурсивная форма генерирует предложение Т+ 74 Т + Т следующим образом. Е=> Е + Е + 7 + 7=> Е+ 7+ 7+ 7=> 7+ 7 + 74 Т В данном случае подразумевается вычисление выражения слева направо, что ниже представлено посредством скобок. ((((7+7)+7) +7)+7) 96 Глава 4. Нисходящий синтаксический анализ
В то же время праворекурсивная форма генерирует выражение следую- щим образом. Е=> Т+Е=> Т+ Т + Е=> Т+ Т+Т+Е=> Т + Т+ Т+ Т Здесь имеет место вычисление выражения справа налево. (Т+(Т+ (Т+ (Т+ Т)))) Влияет ли порядок вычисления выражения на его значение, зависит от значения оператора +; для арифметического оператора + (по крайней мере, для целых чисел) порядок вычисления значения не имеет. Впро- чем, компилятор обычно определяет конкретный порядок вычисления арифметических выражений, который, скорее всего, является простей- шим и легчайшим в реализации. Считается, что в большинстве случаев проще реализовать вычисление слева направо, так что из приведенных выше продукций предпочтительнее леворекурсивные (по крайней мере, с указанной точки зрения). Пожалуй, стоит сказать, что в тех случаях, когда невозможно подвести вычисление выражения слева направо под приведенные выше рекурсив- ные правила, реализовывать это будет неудобно и неестественно, поэто- му следует что-либо изменить, дабы избежать такой ситуации. Что действительно требуется — так это праворекурсивная грамматика, подразумевающая вычисление слева направо, и вот здесь находит приме- нение использованное ранее преобразование, поскольку правила Е-> ТХ Х-++ТХ X —4 6 являются праворекурсивными и предполагают вычисление слева направо. Пусть генерируется следующее выражение. Г+Т+ Т+ Т Тогда порождение Е=> ТХ=> Т+ ТХ=> Т+ 74 ТХ=> Т+ 74 74 ТХ=> Т+ 74 74 Т предполагает следующую расстановку скобок. ((((Т+ Л + 7) + 7) + 7) К сожалению, преобразование продукций Е-> Е + Т Е-> Т в продукции Е-> ТХ Х^+ТХ X —> 8 может показаться не слишком естественным. Очевидно, что оно не на- столько просто, как обращение порядка символов в первой продукции. В 4.5. Преобразования грамматик 97
то же время уже отмечалось, что данное преобразование не будет слож- ным, если рассматривать его с точки зрения языка, генерируемого прави- лами грамматики. В общем случае правила Р^Ра Р^Ь генерируют язык Ьа* Данные правила также могут генерироваться следующими продукциями. Р->ЬХ Х->аХ € Полученный результат легко обобщить. Пусть имеется множество лево- рекурсивных продукций для нетерминала Р и множество продукций дня Р, которые не являются леворекурсивными. Р—> Poti, Р—> Atta, Р—> Р&3, Р“♦ PCLn Р —> pi. Р—> 02» Р“> Рз» ...» РPm Здесь символы Р не содержат Р. Данные продукции генерируют следующее. (01 I 02 | Рз I ... | Рт)(О1 I «2 | Оз I ... | On)* Данный язык можно также сгенерировать следующими группами про- дукций. Р—> Ръ Р—> 02» Р—> 03, ..ч Р—> pm Р-» 0,Z, Р-> p2Z, Р-4 P3Z,Р-> (W Z-> Оъ Z—>СС2, Z-ХХз, ...» Z-^On Z->aiZ, Z-KX2Z, Z-KX3Z, ....Z-xinZ Здесь Z — новый нетерминал, а продукции стали праворекурсивными. Сле- дует отметить, что более общим, чем рассмотренный, случаем является не- прямая рекурсия. Алгоритм устранения непрямой левой рекурсии заключает- ся в первоначальной замене непрямой левой рекурсии прямой левой ретор- сией (это можно сделать всегда, но соответствующий алгоритм мы приводить не будем), после чего задача сводится к рассмотренной выше. Основным в приведенном выше обсуждении является наличие алгоритма удаления левой рекурсии из грамматики и замены ее правой рекурсией и то, что данный процесс можно автоматизировать, т.е. поручить программе. Ис- пользование программы делает процесс более надежным, менее подвержен- ным человеческим ошибкам. Разумеется, не следует забывать о возможности совершить ошибку в самой программе преобразования, но частое использо- вание программы должно подтвердить ее правильность. 4. 5.2. Факторизация Перейдем ко второму типу преобразований, которому должны подвергаться продукции грамматики для превращения ее в Щ1). Как говорилось выше. 98 Глава 4. Нисходящий синтаксический анализ
это преобразование называется факторизацией, а его иллюстрация приводит- ся ниже. Рассмотрим грамматику со следующими продукциями. Р->аРЬ Р-^ аРс P-*d Очевидно, что данная грамматика не является LL( 1), поскольку две пер- вых продукции в качестве первого порождаемого символа имеют а. Про- блема устраняется путем преобразования данных продукций в такие. Р-^аРХ Х-+Ь Х-4С P->d Здесь было факторизовано аР, и появился новый нетерминал X. В каче- стве другого примера рассмотрим продукции P^abQ Р-> acR Их можно преобразовать в следующие продукции. Р-^аХ X->bQ X-+CR Процесс выглядит достаточно простым, так что может показаться, что произвести его можно всегда и что существует алгоритм преобразования пэамматики, требующей факторизации, в ЬЁ(1)-грамматику. Впрочем, из следующего примера видно, что это не так. Рассмотрим продукции Р-4 Qx P-^Ry O-^sQm 0-4 q Рч sRn R-+ r Данная грамматика не является LL( 1), поскольку первые две продук- ции в качестве первого порождаемого символа имеют s. Перед тем, как станет возможной факторизация, нетерминалы О и Я в первых двух продукциях нужно заменить, используя последние четыре про- дукции. Таким образом, вместо первых двух продукций получаем сле- дующее. Р-> sQmx •Р-ь qx P-^sRny Р-> гу Полученные продукции можно факторизовать, что дает такой результат. 4.5. Преобразования грамматик 99
Р -> sPy Р^> qx Р-^ гу Здесь Ру -> Qmx Ру -> Rny Перечислим все полученные продукции. P-*sPy P->qx P^ry Ру -*Qmx Ру -> Rny Q-tsQm Q-*q R-> sRn R-> r Данные продукции по-прежнему не дают Щ1)-грамматики, посколь- ку обе продукции для Р, в качестве первого порождаемого символа име- ют s. Проблема идентична первоначальной, так что можем попытаться продолжить аналогичным образом. Для замены нетерминалов Q и R бу- дут использованы четыре последние продукции, результат будет фактори- зован введением новой переменной Р2 — и все это только для того, что- бы обнаружить в точности ту же проблему с Р2, которая ранее была свя- зана с Р и Р|. Процесс не прекратится никогда, но грамматика будет становиться все больше и больше. В разделе 4.3 уже отмечалось, что не существует алгоритма преобразования любой грамматики для LL(1)- языка в форму LL( 1). Таким образом, неудивительно, что факторизация возможна не всегда, даже если язык является LL( 1). Кстати, то, что алго- ритм зацикливается и не дает Щ1)-грамматики, еще ничего не говорит о языке — является он LL( 1) или нет. Язык, генерируемый приведенной выше грамматикой, не является LL( 1) и его можно выразить в следующем виде. {s'qrrix | s'rrly\ Данный язык нельзя проанализировать нисходящим образом, слева на- право, поскольку при прочтении символа s невозможно определить, поя- вится далее такое же число символов т или символов п, не используя неопределенное число символов предпросмотра, чтобы обнаружить сле- дующий за s символ q или г. Приведенный выше пример был искусственным, подобные свойства маловероятны в реальных языках программирования. Например, в язы- ках программирования при наличии открывающей скобки обычно име- ется единственный символ, представляющий соответствующую закры- вающую скобку1. Большинство языков программирования (точнее, кон- /00 Глава 4. Нисходящий синтаксический анам
текстно-свободные их аспекты) относятся к классу LL( 1), следовательно, поддаются анализу методом рекурсивного спуска. Проблема заключается в том, что грамматики, используемые для представления языков про- граммирования, нс относятся к классу LL( I), так что перед разработкой программ синтаксического анализа методом рекурсивного спуска обычно необходимы преобразования грамматики. В следующем разделе этот во- прос рассматривается полнее, также обсуждаются другие достоинства и недостатки синтаксического анализа LL( 1). 4.6. Достоинства и недостатки LL(1 )-анализа Причина привлекательности синтаксического анализа LL( 1) заключается в его естественности, данный метод является крайне наглядным и удоб- ным для создания основы для последующей компиляции языка програм- мирования. Кроме того, его легко реализовать и убедиться в корректно- сти его работы. Помимо выполнения собственно синтаксического анали- за написанный код может содержать функции по выполнению проверки соответствия типов и других проверок, а также действия этапа синтеза, такие как распределения памяти и генерация кода. В то же время можно определить и некоторые недостатки синтаксиче- ского анализа методом рекурсивного спуска, такие как неэффективность вызовов функций и необходимость преобразования грамматики, даже не зная, существует ли подходящее преобразование. Проблема заключается не только в нахождении преобразования, но и в проверке корректности его применения. Таким образом, имеются веские причины использовать при преобразовании надежные инструментальные средства, а не зависеть от ручного подхода. Среди других недостатков синтаксического анализа методом рекурсивного спуска можно выделить следующие. • Часто создаются очень большие программы синтаксического анализа. • Существует тенденция к появлению в теле одной функции опера- ций, относящихся к разным фазам процесса компиляции. К сожалению, последняя особенность не сильно улучшает общую струк- туру компилятора. Для эффективного использования рекурсивного спуска требуется сле- дующее. • Хороший преобразователь грамматики, который в большинстве слу- чаев сможет трансформировать грамматику в форму LL( 1) — ранее показывалось, что, по теоретическим причинам, преобразователь не сможет выполнить свою работ}' для всех возможных входов. • Возможность представить эквивалент программы синтаксического анализа методом рекурсивного спуска в табличной форме. Это оз- начает, что при проверке входного текста программа будет не вхо- дить в функции и покидать их, а просто перемешаться по таблич- 1-6- Достоинства и недостатки Щ1)-анализа 101
ному эквиваленту грамматики, при необходимости занося в стек адреса возврата. Хорошие преобразователи существуют и временами объединяются с инструментальными средствами и дают “таблицы” LL(1). Кроме того, те же инструменты могут позволять пользователям определять (причем в исходной грамматике) операции, которые следует выпол- нять на определенных этапах синтаксического анализа. Существен- ным преимуществом является задание операций именно относитель- но исходной, а не преобразованной, грамматики, поскольку пользо- вателю удобнее мыслить понятиями исходной, более естественной грамматики, чем понятиями менее естественной Щ1)-грамматики. порожденной преобразователем. В частности, если в преобразованной грамматике будет отсутствовать левая рекурсия, она может быть в ис- ходной грамматике, обеспечивая, таким образом, более естественную основу для определения операций времени компиляции, таких как генерация кода для вычисления выражений слева направо. На прак- тике неестественная природа преобразованной грамматики никоим образом не должна существенно мешать создателю компилятора. В следующем разделе показывается, как в грамматике можно опреде- лить операции по выполнению действий во время компиляции. 4.7. Введение действий в грамматику Классический способ анализа арифметических (и других) выражений пе- ред генерацией машинного кода заключается в формировании постфикс- ной записи. В качестве примера постфиксной (иногда называемой об- ратной польской (reverse Polish)) формы записи рассмотрим следующее (инфиксное) выражение. (а + Ь) * (с + d) В постфиксной форме данное выражение имеет следующий вид. ab + cd + * Отметим, что при такой форме записи отсутствуют скобки и понятие приоритета оператора. Кроме того, если постфиксное выражение вычис- ляется слева направо, операнды каждого оператора известны до появле- ния оператора. Эта особенность постфиксной формы записи делает ее относительно простой для создания выходного кода. Рассмотрим грамматику, имеющую следующие продукции. S-*EXP ЕХР-* TERM ЕХР-* ЕХР + TERM ЕХР-* EXP- TERM TERM-* FACT TERM-* TERM*FACT 102 Глава 4. Нисходящий синтаксический анализ
TERM-) TERM/FACT FACT-) -FACT FACT-) VAR FACT-) (EXP) VAR -) a | b | c | d | e В число выражений, генерируемых данной грамматикой, входят следующие. (а + Ь)*с а*Ь+ с a*b+c*d*e Далее будем предполагать существование среды, в которой действия, введенные в грамматику, выполняются каждый раз, когда соответствую- щей частью грамматики генерируется код анализа. Для генерации пост- фиксных выражений в грамматику необходимо ввести три действия, ко- торые обозначим через At А2 п АЗ. S-)EXP ЕХР-) TERM EXP -4 EXP + <A1> TERM<A2> EXP -4 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; Кроме того, определяется переменная, значение которой равно послед- нему символу. char in; В результате получаем такие три действия. <А1> { stack[++ptr] = in; } <А2> { printf("%с", stack [ptr—)); } 47. Введение действий в грамматику 103
<АЗ> { printf("%с", in); J Действия кажутся удивительно простыми, и, если быть честными, си- туация была несколько упрощена, поскольку по использованному опре- делению переменные состоят только из одного символа. На первый взгляд, удивляет еще одно — действия не учитывают различные уровни приоритетов возможных операторов. Впрочем, понятие приоритета счи- тается внедренным в исходную грамматик}’, так что нет необходимости что-либо знать о приоритете операторов или об использовании скобок Видим, что действия, связанные с продукциями, содержащими скобки, отсутствуют. Рассмотрим в качестве примера, как действия преобразуют следующее выражение грамматики. (-a + b)* (c+d) Чтобы показать использование различных действий, полезно продемон- стрировать эффект генерации приведенного выше выражения граммати- кой, содержащей действия. (-<А1>а<АЗхА2> + <A1>b<A3xA2>)* <А1>(с<АЗ> + <A1>d<A3><A2>)<A2> Здесь показано, как при чтении строки в предложение вводятся дей- ствия. Результат действий можно представить в виде таблицы (табл. 4.2). Таблица 4,2. Считываемый символ Действие Содержимое стека Выход ( - (минус) А1 - (минус) а АЗ — а А2 - - (минус) т А1 + ь АЗ + b А2 + + ) А2 • ( • с АЗ « с -j- А1 •+ d АЗ •+ d А2 • + ) А2 • • Полный выход представляет прочтение последнего столбца сверху вниз, а верх стека предполагается находящимся справа. Достаточный размер стека — три элемента, поскольку' существует всего три различны* уровня приоритета операторов (унарный, аддитивный и мультипликатив- 104 Глава 4. Нисходящий синтаксический анализ
ный). Большее число уровней приоритета операторов потребует большего стека. Кроме того, при стеке произвольного размера может потребоваться правая рекурсия! Разумеется, алгоритм зависит от доступности средств генерации синтаксических анализаторов, позволяющих создать код для чтения входа и соответствующего выполнения действий. Впрочем, такие сред- ства имеются, и они предлагают мощное средство написания программ синтаксического анализа для чтения и выполнения действий над лю- бым входом, который можно представить посредством контекстно- свободной грамматики. Типичным примером такого входа является ис- ходный код; операции, которые можно произвести над этим кодом, разнообразны и их насчитывается множество — генерация выходного кода, использование перекрестных ссылок, проведение различных из- мерений и т.д. Мы пытались показать, что многие операции, обычно производимые над исходным кодом, простым и естественным образом выражаются как действия в контекстно-свободной грамматике. Выра- зив их именно таким образом и используя подходящие инструменталь- ные средства, можно значительно упростить создание компиляторов и связанных с ними инструментальных средств. Становится не только просто писать компиляторы, облегчается их понимание, а значит, ком- пиляторы прошс модифицировать, а также проще отследить, насколько корректно они работают. В следующих главах подробнее рассматриваются методы восходящего синтаксического анализа, а также связанные с этим процессом инстру- ментальные средства. В процессе рассмотрения станут понятнее выгоды использования грамматики как основы действий времени компиляции. 4.8. Резюме Данная глава посвящена нисходящему синтаксическому анализу. В част- ности, в ней было сделано следующее. • Определены Щ1)-грамматики и языки. • Показано, как на основе Щ1)-грамматик могут создаваться про- граммы синтаксического анализа методом рекурсивного спуска. • Показано, как определенные грамматики можно привести к виду LL( 1), используя удаление левой рекурсии и факторизацию. • Определены принципиальные преимущества и недостатки анализа методом рекурсивного спуска. • Показано, как в программу синтаксического анализа можно ввести действия времени компиляции. W Резюме 105
Дополнительная литература Терминология грамматик LL(1) вводится в книге [Knuth, 1971], а свойст- ва таких грамматик — в книге (Foster, 196SJ. Идея компиляция методом рекурсивного спуска уходит корнями в 1960-е, и ее авторство приписы- вается Лукасу [Lucas, 1961]. Она широко использовалась в 1970-х для создания переносимых компиляторов Pascal [Wirth, 1971]; пример ком- пилятора для языка Pascal приводится в работе [Welsh and Hay, 1986]. Существует множество работ по компиляторам, созданных на основе метода рекурсивного спуска, и среди них можно выделить [Ullmann. 1994]. Инструментальные средства синтаксического анализа методом ре- курсивного спуска описаны в работе [Тепу, 1997]. Алгоритм, даюший от- вет на вопрос принадлежности грамматики к классу LL( 1), а также соз- дающий для грамматики таблицу синтаксического анализа, описывается в [Aho, Sethi and Ullman, 1985]. Упражнения 4.1. Приведите пример грамматики, не относящейся к классу LL( 1), но генерирующей ЬЬ(1)-язык. 4.2. Объясните следующие факты. а) Неоднозначная грамматика не может быть LL( 1). б) Грамматика, содержащая левую рекурсию, не может быть LL(I). 4.3. Покажите, что следующие языки относятся к классу LL( 1), опреде- лив для каждого случая ЕЬ(1)-1рамматику. a) {Za/1 л > 0} б) {Za/vZaZ | л >= 0} в) {Za/uZa/1 л >= 0} (о — оператор объединения множеств) 4.4. Обсудите относительные преимущества и недостатки левой рекур- сии в грамматиках с точки зрения синтаксического анализа. 4.5. Рассмотрим два набора продукций. a) DECLIST -> d semi DECLIST DECLIST-* d 6) DECLIST-* DECLISTsemi d DECLIST-* d и предложение d semi d semi d Для каждого набора продукций а) и б) определите, какой терминал d порождается второй продукцией. DECLIST-* d 106 Глава 4. Нисходящий синтаксический анализ
4.6. Приведите LL( !)-грамматики для следующих языков, а) {О'та12п| л>0) б) {а | а принадлежит {0,1}* и не содержит двух последовательно идущих единиц} в) {« | а состоит из равного числа нулей и единиц) 4.7. Определите, относится ли грамматика со следующими продукциями к классу LL( 1). Ответ аргументируйте. S-> АВ S~*PQx А-> ху Д —> т В->ЬС С->ЬС С —> е Р-*рР Р-ь г Q-+qQ О—> е Здесь S — символ предложения. 4.8. Опишите язык, генерируемый грамматикой из упражнения 4.7. 4.9. Преобразуйте грамматику со следующими ниже продукциями в форму LL( 1). S-+EXP ЕХР-* TERM EXP-t ЕХР + TERM ЕХР-ь EXP- TERM TERM-* FACT TERM-* TERM* FACT TERM-* TERM/FACT FACT - FACT FACT(EXP) FACT -4 VAR VAR-* a\b\ c\ d\ e 4.10. Покажите, где в преобразованной грамматике упражнения 4.9 поя- вятся действия, определенные в разделе 4.7 для производства пост- фиксной записи.

Глава 5 Восходящий синтаксический анализ 5.1. Вступление В данной главе описывается восходящий синтаксический анализ и его реализация. В частности, будут рассмотрены следующие вопросы. • Этапы восходящего синтаксического анализа и критерии принятия решений. • Использование таблицы синтаксического анализа для направления процесса синтаксического анализа. • Ключевые свойства восходящего синтаксического анализа. • Генератор синтаксических анализаторов, именуемый YACC, и ис- пользование данного средства для создания различных простых синтаксических инструментов. • Некоторые практические вопросы, связанные с использованием YACC. 5.2. Основные понятия Задача синтаксического анализа заключается в нахождении порождения (если таковое существует) конкретного предложения, используя данную грамматику. При восходящем синтаксическом анализе искомым обычно является правое порождение, и далее будет показано, как этот анализ может выполняться с помощью грамматики, представленной в разде- ле 4.2, для иллюстрации левых порождений. Напомним, что язык {/У1 т, п > 0} генерируется следующими порождениями. S-+XY Х-*хХ Х->х Y-+yY Y-*y
Как и ранее, рассматривается, как можно найти порождение (в данном случае — правое) следующего предложения. хххуу Искомое порождение выглядит так. S => XY => XyY => Хуу => хХуу => ххХуу => хххуу Отметим, что порождение проходит в столько же этапов, что и при левом порождении, и каждая продукция используется такое же число раз, хотя порядок использования продукций несколько отличается. Впрочем, при восходящем синтаксическом анализе этапы порождения определяются нс в показанном, а в противоположном порядке, а правый синтаксический анализ. который соответствует приведенному выше порождению, можно записать в следующем виде. хххуу => ххХуу => хХуу => Хуу => XyY XY^> S На каждом этапе применяется продукция грамматики; правая часть про- дукции заменяется ее левой частью, которая состоит из одного символа. Пусть предложение хххуу читается слева направо, тогда совсем не очевидно, почему первый х не заме- няется X на первом этапе синтаксического анализа, используя соответствую- щую продукцию грамматики, или почему первый у подобным образом на четвертом этапе не заменяется Y. Очевидно, что для выполнения синтаксиче- ского анализа требуется некоторая дополнительная информация, отличная от предоставляемой продукциями грамматики. Далее процесс восходящего син- таксического анализа рассматривается более подробно. При восходящем синтаксическом анализе правые части продукций не распознаются, пока не будут полностью считаны, следовательно, существует необходимость хранения частично распознанных правых частей продукций, пока они не будут заменены соответствующими левыми частями. Для запо- минания частично распознанных строк подходящей структурой является стек. Таким образом, подробное описание процесса восходящего синтаксиче- ского анализа включает отображение содержимого этого стека, а также ин- формации, указанной в связи с нисходящим синтаксическим анализом, представленным в разделе 4.2. Этапы процесса синтаксического анализа мо- гут рассматриваться как состоящие из двух типов действий. 1. Перемещение последнего считанного символа в стек — действие пере- носа (shift action). 2. Замена строки наверху стека посредством применения продукции грамматики — действие свертки (reduce action). Теперь можно перейти к более подробному рассмотрению представ- ленного выше синтаксического анализа. Различные этапы показаны в табл. 5.1. Вершина стека расположена справа, а в последнем столбце по- казано, когда применяется действие переноса (S) или свертки (Я). Дей- 110 Глава 5. Восходящий синтаксический анализ
ствие переноса включает удаление символа из исходной строки и занесе- ние его в стек, а действие свертки — изменения на вершине стека и в сентенциальной форме. В начале синтаксического анализа сентенциаль- ная форма — это в точности читаемое предложение, а стек пуст; в конце синтаксического анализа сентенциальная форма — это символ предложе- ния, строка прочитана, а стек содержит только символ предложения. Таблица 5.1, Входная строка Стек Продукция Сентенциальная форма Перенос(8)/свертка(П) xxxyy xxxyy «хуу X xxxyy (S) xxxyy XX xxxyy (S) *«УУ XXX xxxyy (S) xxxyy xxX xxXyy (R) W xX X-+xX xXyy (Я) xxxyy X X-^xX Xyy (fl) xxxyy xy Xyy (S) MAff Xyy Xyy (S) ХуУ Y-)y XyY (fl) IQQQAi MATT XY Y->yY XY (fl) IQQQAi MATT S S-+XY S (R) Хотя в приведенном выше представлении явно показано каждое действие синтаксического анализатора, в нем указывается, но не объясняется, когда должны применяться действия переноса и свертки и как производится вы- бор, если возможными являются несколько операций свертки. Необходимым условием действия свертки является наличие правой части некоторой про- дукции на вершине стека, в противном случае производится перенос, и на вершине стека появится следующий символ. В то же время, появление пра- вой части продукции на вершине стека не является достаточным условием для применения свертки. Проиллюстрируем данный момент если на первых этапах синтаксического анализа на вершине стека появляется х, он не свора- чивается в X по неясным, на первый взгляд, причинам. Кроме того, в строке символов на вершине стека возможно будут оп- ределены правые части более одной продукции, так что на определенном этапе синтаксического анализа могут существовать две шли более воз- можных свертки. Итак, если в определенный момент кажутся возмож- ными действия свертки и переноса, говорят, что имеет место конфликт перенос/свертка (shift-reduce conflict). Если возможными кажутся не- сколько операций свертки, говорят, что имеет место конфликт сверт- ка/свертка (reduce-reduce conflict). С целью выработки детерминирован- ного метода синтаксического анализа могут применяться различные стратегии разрешения названных конфликтов. На практике данные кон- фликты разрешаются с использованием следующей информации (один пункт или оба). 5.2. Основные понятия 111
• Предшествующая история синтаксического анализа. • Информация, полученная путем предпросмотра. Как и при нисходящем синтаксическом анализе, для разрешения кон- фликтов обычно используется один символ предпросмотра. Кроме того, дня разрешения конфликтов может использоваться информация, касающаяся ис- тории синтаксического анализа. В приведенном выше примере символом предпросмотра, определяющим применение продукции Х-*х был у, подобным образом, символ предпросмотра 1 (маркер конца) оп- ределяет применение такой продукции: Y-* у Грамматика, все конфликты которой, возникающие при восходящем синтаксическом анализе слева направо, могут быть разрешены с исполь- зованием фиксированного объема информации, касающейся уже прове- денного анализа, и конечного числа символов предпросмотра, называется LR(k)-epa\wamuKOu. Здесь L означает чтение слева (Left) направо, R - правые порождения (/ftghtmost), а к обозначает количество символов предпросмотра. Языком LR(k) называется язык, который можно сгенери- ровать посредством ЬК(А)-п>амматики. Если (довольно частая ситуация) требуется только один символ пред- просмотра, грамматика и язык относятся к классу LR(1). Проиллюстрируем синтаксический анализ на еще одном примере. Пусть имеется грамматика со следующими продукциями. 1. Е-»Е+Т 2. Е-*Т 3. T-+TF 4. 5. F->(E) 6. F-> х Здесь Е — символ предложения. Грамматика (как она есть) может использо- ваться в качестве основы для восходящего синтаксического анализа, и в табл. 5.2 показан синтаксический анализ следующего предложения. х + х+ х Первый х, помещаемый в стек, сворачивается в F, затем в Г, затем в Е, тогда как второй и третий х сворачиваются в F, потом в Г, а четвертый х — только в Я Первый и второй символы х имеют одинаковые символы предпросмотра, и различная их трактовка основана на предшествующей истории синтаксического анализа. Для третьего и четвертого символов х символы предпросмотра отличаются (* и 1, соответственно), и снова имеем различную трактовку — и снова, основанную на истории синтак- сического анализа. Критерий принятия решения относительно предпри- 112 Глава 5. Восходящий синтаксический анализ
нимаемого действия — переноса или свертки (или выбора из нескольких возможных сверток) — может содержаться в таблице, называемой табли- цей синтаксического анализа. Отложим пока вопрос формирования дан- ной таблицы и рассмотрим, как она может использоваться для определе- ния действий синтаксического анализатора. Таблица синтаксического анализа формируется единожды, при создании компилятора, и с этого момента используется для управления каждым синтаксическим анализом. Поскольку таблица синтаксического анализа создается инструменталь- ным средством, наподобие YACC, пользователю (и даже разработчику компилятора) нет нужды понимать принципы ее формирования. Впро- чем, некоторые вопросы се использования все же стоит рассмотреть, что и будет сделано ниже. Таблица 5.2. Входная строка Сгех Продукция Сентенциальная форма Перенос (3)/свертка(Я) х + х + х* X X + X + X* X Х + Х + Х* X X X + X + X* X (S) X + X + X* X F F-+ X Е + х + х* х (Я) X + X + X* X т Т-> F Г + х + х* х (Я) X + X + X* X Е Е-+ Т Е + х + х* х (Я) х±х + X* X Е + Е + х + х* х (S) х-±-х + х* X Е + х Е + х + х* х (S) x-44f + x‘x Е + Е F-+ X Е + Е + х* х (Я) х-М + х* х Е+Г Т-+ F Е + Г + х* х (Я) *-М + х*х Е Е-+ ЕтТ Е + х* х (Я) X + X + X* X Е + Е+х* х (S) X Е + х Е + х* х ($) У ± V Л, V* V Л ' Я ’ Я А Е+Е F-+ X Е+Рх (Я) у X у 4. у* у Я Л Е+Г Т-> F Е+Гх (Я) М-Х + х* х Е+Г Е+Гх (S) *-+•*-+-** X Е+Гх Е+Гх (S) Х-+-Х + X* X Е+Г F F-* X Е+ГЕ (Я) Х-4--Х + X* X Е+Т Т-+ ГР Е+Г (Я) Х-+-Х-+Х*Х Е Е-+ Е+Т Е (Я) 5.3. Использование таблицы синтаксического анализа Таблица синтаксического анализа, которая используется в восходящем синтаксическом анализе, является прямоугольной, каждому состоянию анализатора (всегда конечное число) соответствует одна строка, а каждо- му терминалу и нетерминалу грамматики соответствует один столбец. Простой пример таблицы синтаксического анализа приведен в табл. 5.3. 5.3. Использование таблицы синтаксического анализа 113
Таблица 53. Е Г F ( ) X 1 1 S2 S5 S8 S9 S12 2 S3 3 S4 S8 S9 S12 4 R1 S6 R1 R1 5 R2 S6 R2 R2 6 S7 S9 S12 7 R3 R3 R3 R3 8 R4 R4 R4 R4 9 S10 S5 S8 S9 S12 10 S3 S11 11 R5 R5 R5 R5 12 R6 R6 R6 R6 В начале процесса синтаксического анализа анализатор находится в состоянии 1, а входным символом является первый введенный символ. Каждый шаг анализа определяется позицией таблицы, соответствующей текущему состоянию, и входным символом. Позиция таблицы может при- надлежать к одному из двух типов. 1. Позиция переноса вида Sm, вынуждающая анализатор выполнить дей- ствие переноса и изменить текущее состояние на состояние т. 2. Позиция свертки вида Ял, вынуждающая анализатор выполнить дей- ствие свертки, используя продукцию л. Пустые позиции таблицы соответствуют синтаксическим ошибкам во вводе, и, при желании, с каждой такой позицией можно соотнести инди- видуальное сообщение об ошибке. На практике таблицы синтаксического анализа могут быть очень большими, но они часто сжимаются, в основ- ном. за счет удаления различных пустых позиций и увеличения времени обращения к элементам таблицы. В любом случае точные сообщения об ошибках, предоставляемые анализатором, часто немногого стоят, по- скольку анализатор не предполагает, что будет делать пользователь при обнаружении ошибки. Таблица синтаксического анализа представляет зависимую от языка часть синтаксического анализатора, остальная часть анализатора является полно- стью или преимущественно независимой от языка и состоит из драйверной программы, которая интерпретирует данные в таблице синтаксического ана- лиза и выполняет подходящие действия. Если зависимая от языка часть ана- лизатора (таблица синтаксического анализа) может быть довольно большой, независимая от языка часть, скорее всего, невелика и переносится с одного компьютера на другой. Ниже описываются действия драйвера. На каждом этапе синтаксического анализа анализатор находится в одном из конечного числа состояний, и это состояние плюс входной сим- вол (либо символ предпросмотра, либо только что свернутый нетерминал) 114 Глава 5. Восходящий синтаксический анализ
определяют элемент в таблице синтаксического анализа. Предполагая от- сутствие синтаксических ошибок, этот элемент — действие переноса или свертки. В начале синтаксического анализа анализатор находится в со- стоянии 1, а входной символ — это первый символ анализируемого предложения. Если позиция таблицы определяет действие переноса, име- ют место следующие операции. • Символ, соответствующий столбцу, в котором находится данная позиция таблицы, заносится в стек символов. • Анализатор переходит в стек, который определяется позицией пе- реноса, и это состояние заносится в стек состояний. • Если входной символ является терминалом, он принимается, и входным символом становится следующий терминал предложения (или маркер конца). Если позиция таблицы определяет действие свертки, имеют место следующие операции. • Из стека символов удаляются п символов и из стека состояний удаляются п состояний, где п — число символов в правой части продукции, фигурирующей в свертке. • Анализатор переходит в состояние на вершине стека состояний. • Входной символ становится символом в левой части продукции, определенной в позиции свертки. Проследим анализ следующего предложения. х+х + х* х Как и ранее, все шаги анализатора определяются табл. 5.3. На каждом шаге будет приводиться содержимое стеков символов и состояний. Итак, изначально имеем. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х*х 1 х + х + х*х Состояние — 1, входной символ — х; из таблицы синтаксического анали- за находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х* х 1, 12 х х + х+х* х Состояние — 12, входной символ — +; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 6, удаление одного состояния из стека состояний (поскольку в правой части продукции 6 находится только один символ) и одного символа из стека символов (по той же причине); входным символом становится F. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х*х 1 х+х+х*х 5.3. Использование таблицы синтаксического анализа 115
Состояние — 1 (снова), входной символ — F; из таблицы синтаксиче- ского анализа находим: перенос в состояние 8, стек 8 в стеке состояний и стек F в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма х + х+х’х Л 8 F F+x+x*x Состояние — 8. входной символ — +; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 4, удаление одного состояния из стека состояний и одного символа из стека символов; входным симво- лом становится Т. Входная строка Стек состояний Стек символов Сентенциальная форма х + х + х* х 1 F+ х+ х* х Состояние — 1 (снова), входной символ — Г; из таблицы синтаксиче- ского анализа находим: перенос в состояние 5, стек 5 в стеке состояний и стек Т в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х*х 1,5 Т Т+х+х*х Состояние — 5, входной символ — +; из таблицы синтаксического анали- за находим: свертка согласно продукции 2, удаление одного состояния из стека состояний и одного символа из стека символов; входным символом становится Е. Входная строка Стек состояний Стек символов Сентенциальная форма X + X + X* X 1 Т+х+х*х Состояние — 1 (снова), входной символ — Е; из таблицы синтаксиче- ского анализа находим: перенос в состояние 2, стек 2 в стеке состояний и стек Е в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х*х 1,2 Е Е+х+^х Состояние — 2, входной символ — +; из таблицы синтаксического ана- лиза находим: перенос в состояние 3, стек 3 в стеке состояний и стек + в стеке символов; принимается +. Входная строка Стек состояний Стек символов Сентенциальная форма х-*-х + х* х 1,2,3 Е+ Е+х+х*х Состояние — 3, входной символ — х; из таблицы синтаксического анали- за находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма х-4-х + х* х 1,2,3, 12 Е + х Е + х + х* х 116 Глава 5. Восходящий синтаксический анализ
Состояние — 12, входной символ — +; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 6, удаление одного состояния из стека состояний и одного символа из стека символов; входным симво- лом становится F. Входная строка Стек состояний Стек символов Сентенциальная форма хч-х+х*х 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, входной символ — 7; из таблицы синтаксического ана- лиза находим: перенос в состояние 4, стек 4 в стеке состояний и стек Т в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма хч-*+х*х 1,2,3,4 Е+Т Е+Т+х”х Состояние — 4, входной символ — +; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 1, удаление трех состояний из стека состояний (поскольку в правой части продукции 1 находятся 3 символа) и трех символов из стека символов (по той же причине); вход- ным символом становится Е. Входная строка Стек состояний Стек символов Сентенциальная форма х-+-х+х*х 1 Е+Т+х”х Состояние — 1, входной символ — Е; из таблицы синтаксического ана- лиза находим: перенос в состояние 2, стек 2 в стеке состояний и стек Е в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма xh-x + Zx 1,2 Е Е+х*х Состояние — 2, входной символ — +; из таблицы синтаксического ана- лиза находим: перенос в состояние 3, стек 3 в стеке состояний и стек + в стеке символов; принимается +. Входная строка Стек состояний Стек символов Сентенциальная форма х+х+х*х 1,2,3 Е + Е+хГх 5.3. Использование таблицы синтаксического анализа 117
Состояние — 3, входной символ — х; из таблицы синтаксического анали- за находим: перенос в состояние 12, стек 12 в стеке состояний и стек хв стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма х + х + Z х 1, 2,3, 12 Е+х Е+ х” х Состояние — 12, входной символ — *; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 6, удаление одного состояния из стека состояний и одного символа из стека символов; входным симво- лом становится F Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2,3 Е+ Е + Z х Состояние — 3. входной символ — F; из таблицы синтаксического ана- лиза находим: перенос в состояние 8, стек 8 в стеке состояний и стек Fb стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2, 3,8 E+F Е+Рх Состояние — 8, входной символ — ♦; из таблицы синтаксического анали- за находим: свертка согласно продукции 4, удаление одного состояния из стека состояний и одного символа из стека символов; входным символом становится Т. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2,3 Е+ Е+Рх Состояние — 3, входной символ — Т; из таблицы синтаксического ана- лиза находим: перенос в состояние 4, стек 4 в стеке состояний и стек Т в стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма x+x + Zx 1,2,3,4 Е+Т Е+Гх Состояние — 4, входной символ — ♦; из таблицы синтаксического анали- за находим: перенос в состояние 6, стек 6 в стеке состояний и стек * в стеке символов; принимается *. Входная строка Стек состояний Стек символов Сентенциальная форма х + х + х*х 1,2, 3,4, 6 „Е+Т* Е+Гх Состояние — 6, входной символ — х; из таблицы синтаксического анали- за находим: перенос в состояние 12, стек 12 в стеке состояний и стек х в стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1, 2, 3, 4, 6, 12 Е+Гх Е+Гх Состояние — 12, входной символ — 1; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 6, удаление одного состояния 118 Глава 5. Восходящий синтаксический анализ
кз стека состояний и одного символа из стека символов; входным симво- лом становится F. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2, 3,4, 6 Е+Т* Е+Гх Состояние — 6, входной символ — F; из таблицы синтаксического ана- лиза находим: перенос в состояние 7, стек 7 в стеке состояний и стек Ев стеке символов. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2, 3,4, 6,7 E+TF E+TF Состояние — 7, входной символ — 1; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 3, удаление трех состояний из стека состояний и трех символов из стека символов; входным символом становится Т. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2,3 Е+ E+TF Состояние — 3, входной символ — 7; из таблицы синтаксического ана- лиза находим: перенос в состояние 4, стек 4 в стеке состояний и стек 7 в стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма x+x+Zx 1,2, 3,4 Е+Т Е+Т Состояние — 4, входной символ — 1; из таблицы синтаксического ана- лиза находим: свертка согласно продукции 1, удаление трех состояний из стека состояний и трех символов из стека символов; входным символом становится Е. Входная строка Стек состояний Стек символов Сентенциальная форма x+x + Zx 7 Е+ 7 Состояние — 1, входной символ — Е; из таблицы синтаксического ана- лиза находим: перенос в состояние 2, стек 2 в стеке состояний и стек Е в стеке символов; принимается х. Входная строка Стек состояний Стек символов Сентенциальная форма x+x + Zx 1,2 Е Е Когда в стеке символов находится символ предложения Е и считано все предложение, анализ успешно завершается. Хотя стек символов позволяет удобно проиллюстрировать ход анали- за, его содержимое не влияет на решения, принимаемые синтаксическим анализатором, — на прогресс анализа влияет содержимое стека состоя- ний. На любом этапе анализа состояния стека соответствуют частично распознанным правым частям продукций, которые через некоторое вре- мя будут свернуты в соответствующие левые части. 5.3. Использование таблицы синтаксического анализа 119
5.4. Создание таблицы синтаксического анализа Итак, рассмотрев. как синтаксический анализатор использует одноименную таблицу, пришло время определить принципы ее формирования из контек- стно-свободной грамматики. Вначале проиллюстрируем роль состояний, рас- смотрев создание таблицы синтаксического анализа для простой грамматики, генерирующей конечный язык. Далее в этом разделе приведен пример фор- мирования таблицы для более реалистичной грамматики. Рассмотрим грамматику со следующими продукциями (Р — символ предложения). 1. P^bD-,Se 2. D^dtd 3. S->s;s Единственное генерируемое предложение выглядит так. bdtd,stse Как обычно, предполагается, что синтаксический анализатор изначально находится в состоянии 1. Для отображения этого факта следует несколь- ко изменить форму записи грамматики (в данном случае символ предло- жения обозначается не S, а Я). I. P-^bD-'Se 2. D->d;d 3. После считывания символа b грамматика будет находиться в состоянии (скажем) 2. I. Р^{b2O,Se 2. D-^d,d 3. Анализатор находится в состоянии 2 до распознавания символа D, так что соответствующее обозначение помешаем и в начало продукции для нетерминала D. 1. P-^b&Se 2. D-4,d;d 3. S-»s;s Состояния 3 и 4 также соответствуют считыванию символов продукции 1. Поскольку анализатор находится в состоянии 4 до распознавания сим- вола S, оно также соответствует началу продукции для S. 120 Глава 5. Восходящий синтаксический анализ
I. P-*ib2D^Se 2. D-^ct'd 3. S->4s;s Подобным образом вводим еше восемь состояний, в которых может на- ходиться синтаксический анализатор. I. Р —> 2. D->2d7;sd9 Л. S-> 4$ЮИ|$|2 Синтаксический анализ предложения b&,dtstse будет проходить, как показано в табл. 5.4. Таблица 5.4, Входная строка Стек состояний Стек символов Сентенциальная форма bd;d;s;se 1 bd;d;s;se bd;d;s;se 1,2 b bd;d;s;se bd;d;s;se 1.2.7 bd bd;d;s;se Mjd;s;se 1,2. 7,8 bd; bd;d;s;se bd;4;s;se 1,2, 7. 8.9 bd;d bd;d;s;se M-d;s;se 1.2 b bd;d;s;se M#;s;se 1.2,3 bD bD;s;se bdrdvs;se 1.2. 3,4 bD; bD;s;se vv 1.2, 3. 4,10 bD;s bD;s;se bd^se 1.2, 3. 4.10.11 bD;s; bD;s;se 1.2. 3, 4,10,11.12 bD;s;s bD;s;se 1.2, 3.4 bD; bD;s;se 1.2. 3.4, 5 bD;S bD;Se 1.2. 3. 4. 5,6 bD;Se bD;Se W|\qV|VV 1 bD;Se 1 P P Вся информация, необходимая для направления процесса анализа, содержится в представленной выше аннотированной грамматике, но удобнее ее представить в табличной форме (табл. 5.5). Позиции переноса заносятся в таблицу легко. Например, из нача- ла правой части продукции 1, состояние 1, при входном символе b очевидным является перенос в состояние 2, другие позиции переноса также очевидны. Рассмотрим теперь позиции свертки. Состояние в конце продукции — это состояние, в котором должна происходить свертка, так что из аннотированной версии продукции 1 видим, что в состоянии 6 должна иметь место свертка согласно продукции 1. Дей- ствия свертки вносятся в каждый столбец состояния свертки, если в 5.4. Создание таблицы синтаксического анализа 121
рассматриваемой строке нет действий переноса; по этой причине действия переноса всегда заносятся в таблицу до действий свертки. Позднее будет рассмотрен вопрос о том, что происходит при возник- новении конфликта между действиями переноса и свертки (конфликт перенос/свертка) или между двумя действиями свертки (конфликт свсртка/свертка). В приведенном выше примере конфликты отсутст- вуют, и грамматика обозначается LR(0) (L — чтение слева направо; R— используя правое порождение; 0 — при отсутствии предпросмот- ра для разрешения конфликтов). Таблица 55 Р D S b е d $ 1 1 S2 2 S3 S7 3 S4 4 S5 S10 5 S6 6 R1 R1 R1 R1 R1 R1 R1 R1 R1 7 S8 8 S9 9 R2 R2 R2 R2 R2 R2 R2 R2 R2 10 S11 11 S12 12 R3 R3 R3 R3 R3 R3 R3 R3 R3 Другим способом представления грамматики приведенного выше примера является ориентированный граф (рис. 5.1). Это представле- ние называется характеристическим конечным автоматом (“characteristic finite state machine'’) грамматики, далее — ХКА. Если передать управление синтаксическим анализом данному конечному автомату' и соотнести с каждым состоянием его номер состояния, то при действии переноса этот номер будет заноситься в стек состояний. В то же время, при действии свертки поведение анализатора будет несколько иным. В этом случае из стека будет извлекаться требуемое число состояний и проходиться такое же число шагов обратно по ко- нечному автомату. Как и ранее, следующий входной символ — это символ в правой части продукции, только что использованной при свертке. ХКА формируется аналогично тому, как в грамматику добав- лялись аннотации. Некоторые считают, что создать ХКА проще, по- скольку он имеет более наглядную природу. Из автомата также мож- но получить таблицу синтаксического анализа, интерпретируя каждое перемещение автомата как перенос таблицы и вводя действия свертки согласно описанным ранее принципам. 122 Глава 5. Восходящий синтаксический анализ
1 R1 Рис. 5.1. В большинстве случаев действия свертки поместить в таблицу не так просто, как может показаться из приведенного примера. Для иллюстра- ции этого вернемся к рассмотренной выше грамматике. 1. Е->Е + Т 2. Е-+Т 3. T-+TF 4. T-+F 5. F-+(E) 6. F->x Аннотирование грамматики происходит следующим образом. В первую очередь устанавливается состояние 1. 5.4. Создание таблицы синтаксического анализа 123
Каждая позиция, в которую было помешено состояние 1, — это при- мер конфигурации грамматики. По определению, конфигурация — это по- зиция в правой части продукции перед первым символом, после послед- него символа или между двумя любыми символами. Конфигурации, соответствующие одному состоянию, неразличимы с точки зрения синтаксического анализатора. Далее вводим в грамматику состояние 2, которое соответствует единственной конфигурации. I. Е-^+Т 2. Е-^Т 3. T-^TF 4. T-^F 5. F-* ДЕ) 6. F-» |Х Состояние 3, поскольку оно появляется перед нетерминалом, соответ- ствует нескольким конфигурациям. 1. Е-»,Е: + 5Г 2. Е->,Г 3. Г->1(3ГЕ 4. T-^yF 5. F—>, ?(Е) 6. F->, На первый взгляд может показаться странным наличие двух состоя- ний, соответствующих одной конфигурации грамматики (например, оба состояния 1 и 3 появляются в начале правой части продукции 3). В то же время, если учитывать историю синтаксического анализа, два состояния различимы, поскольку состояние 3 всегда появляется после символа +, а состояние 1 — нет. Вводим далее состояние 4. 1- E-*iE2 + yT< 2. E-+J 3. Т-+..ЛТ 4. T^F 5. ,.,(£) 6. F-»!.pc Состояние 4 появляется в двух местах: в конце продукции 1, где оно соответствует действию свертки, и (поскольку оно определено как со- стояние, в которое переходит состояние 3 при считывании символа 7) после Т в продукции 3. Уже видно, что состояние 4 приведет к некото- рому конфликту перенос/свертка! 124 Глава 5. Восходящий синтаксический анализ
По подобной причине состояние 5 также появляется в двух местах: в конце продукции 2 (действие свертки) и в продукции 3 (перенос) (ешс одна потенциальная проблема?). Отмстим, что в продукции 3 состояния, идущие перед Т в правой части, появляются в порядке, соответствующем состояниям, тушим после Т, т.е. после 1 идет состояние 5, а после 3 — состояние 4. I. |Е2 + УТ4 2- 3. T-^T^F 4. i F-* ,.,(£) 6. Состояние 6 появляется в нескольких местах, поскольку оно предше- ствует нетерминалу. I- Н->,Е, + }Т4 2. E->tT} 5- ।_3Т5 4’6F 4 . Г-4,.,Г 5 F-> и,.6(£) 6 F-» 1.3.6* Далее просто вводятся состояния 7 и 8. I. Б-9.Б + .Л 2. Е->,Т5 3- 7-> । }Г, 4’6F7 4. T->I}FS 5- F-> 6- F->,.3 6x Состояние 9 несколько сложнее, так как оно появляется перед нетер- миналом, поэтому должно находиться в начале правил для этого нетер- минала и т.д. рекурсивно. I- f-»i.9H2 + ,T4 2. Е->1ЛТ} 3- F-» । 4\F, Т"-* I. 3. 9^6 I. 3. 6. 9(9^) F-* I. 3. 6. 9* 5.4. Создание таблицы синтаксического анализа 125
Состояние 10 следует вводить аккуратно. Состояние 10 следует после Е в состоянии 9, таким образом, появляется после Е в продукциях 1 и 5. В то же время, в продукциях 2 и 3 уже не нужно вводить новые состоя- ния, которые бы "помнили", появилось это состояние из продукции в состоянии 1 или в состоянии 9. По этой кажущейся несколько условной причине любое новое состояние, введенное после Т в данных продукци- ях, будет соответствовать набору конфигураций, идентичному сущест- вуюшему в уже введенном состоянии. Хотя если не ввести такие состоя- ния, может возникнуть определенный конфликт, но на практике это бы- вает редко, поэтому пока такую возможность будем игнорировать. В конце концов, зачем делать таблицу синтаксического анализа большей, чем это необходимо! I* ^“* 1. 9^2. 10 + 3^4 2. Е^^Т, 7"~* I, 3.9^3. 4 6^1 4- ^“*1.3. Л FI. з.б. 9(9^10) 6- ^“* 1.3.6.9х В продукциях 4-6 уже не требуются новые состояния, следующие за 9 (по причинам, сходным с приведенными выше для продукций 2 и 3). Ос- тавшиеся состояния грамматики вводятся без каких-либо проблем. 1.9^2. ю + 3^4 2. Е->1<975 3- Т-* I. 3.9^5. 4* 6^7 < Т“* 1.3.9^ 5- ^“*1.3.6.9(9^10)11 6- f “*1.3.6. 9X12 ХКА для полученной аннотированной грамматики приведен на рис. 5.2. Переносы (табл. 5.6) легко получаются из грамматики или из ХКА. Кроме того, в таблицу просто (не порождая конфликтов) вводятся некоторые свертки (табл. 5.7) Замечаем, что свертки в состояниях 4 и 5 не являются очевидными, поскольку в строках, которые соответствуют данным состояниям, уже имеются несколько переносов. Итак, данная грамматика не относится к классу LR(0), и при определении нужного действия (перенос или сверт- ка) следует принимать во внимание символы предпросмотра. Рассмотрим первую ситуацию, возникающую в состоянии 4, где могут иметь место как перенос, так и свертка. Из табл. 5.7 или ХКА видим, что перенос воз- можен, только если символ предпросмотра — *. Таким образом, делаем вывод, какие символы предпросмотра соответствуют свертке. 126 Глава 5. Восходящий синтаксический анализ
R1 Рис. 5.2. 127 Создание таблицы синтаксического анализа
Таблица 55 Е Т F 4. • ( ) X 1 1 S2 S5 S8 S9 S12 2 S3 3 S4 S8 S9 S12 4 S6 5 S6 6 57 S9 S12 7 8 9 S10 S5 S3 S9 S12 10 S3 S11 11 12 Таблица 57. Е Т F Т ( ) X 1 1 S2 S5 58 S9 S12 2 S3 3 S4 S8 S9 S12 4 S6 5 S6 6 57 S9 S12 7 R3 R3 R3 R3 R3 R3 R3 R3 R3 8 R4 R4 R4 R4 R4 R4 R4 R4 R4 9 S10 S5 S8 S9 S12 10 S3 S11 11 R5 R5 R5 R5 R5 R5 R5 R5 R5 12 R6 R6 R6 R6 R6 R6 R6 R6 R6 Свертка, если она имеет место, будет происходить в символ Е, так что мы рассматриваем символы, которые могут следовать за Е (будущие воз- можные символы предпросмотра при свертке согласно продукции 1). Из продукции 1 получаем, что за Е может следовать символ +, а из продук- ции 5 находим символ). Кроме того, за Е также может следовать символ 1, поскольку Е — это символ предложения; таким образом, единствен- ные символы, которые могут идти за Е, — это [+, 1, )]. Итак, действия свертки в состоянии 4 помешаются только в те столбцы таблицы синтак- сического анализа, что соответствуют трем указанным символам. Рассмотрим теперь состояние 5. Снова символом предпросмотра для действия переноса является *, а символы предпросмотра, соотнесенные с действием свертки, — это [+, 1, )] (последователи символа Е). Теперь можно поместить действия свертки для продукций 1 и 2 в соответствую- щие столбцы для состояний 4 и 5, в результате чего получим табл. 5.8. 128 Глава 5. Восходящий синтаксический анализ
Поскольку в данных продукциях отсутствуют действия переноса, все конфликты перенос/свертка разрешены. Таблица 5.8. Е Т F + { ) X 1 1 S2 S5 S8 S9 S12 2 S3 3 S4 S8 S9 S12 4 R1 S6 R1 R1 5 R2 S6 R2 R2 6 S7 S9 S12 7 R3 R3 R3 R3 R3 R3 R3 R3 R3 8 R4 R4 R4 R4 R4 R4 R4 R4 R4 9 S10 S5 S8 S9 S12 10 S3 S11 11 R5 R5 R5 R5 R5 R5 R5 R5 R5 12 R6 R6 R6 R6 R6 R6 R6 R6 R6 Хотя строго это и не обязательно, позиции других действий свертки также можно вычислить, приняв во внимание символы предпросмотра. Например, свертка согласно продукции 3 будет уместной, только если символ предпро- смотра — это символ, который может следовать за Г. Поскольку (из продук- ции 3) за Г может следовать символ *, а (из продукции 2) любой последова- тель Е может быть последователем Т, полное множество символов- последователей Т имеет вид [♦, +, 1, )]. Таким образом, действие ЯЗ в со- стоянии 7 уместно только в этих столбцах. Подобным образом, для состоя- ния 8 (где свертка также выполняется в Т) действие Я4 появляется только в столбцах, которые соответствуют множеству [*, +, 1,)]. В состояниях 11 и 12 свертка производится в символ F, и снова рассмат- ривая символы, которые могут следовать за F и Т (согласно продукции 4), получаем множество [*, +, 1, )]. Таким образом, действия Я5 и Я6 должны появляться только в этих столбцах. Окончательно получаем табл. 5.3, которая приводилась ранее без объяснения принципов формирования. Если для внесения в таблицу синтаксического анализа действий свертки учитываются все возможные символы-последователи нетермина- лов левой части продукции, таблица называется простой (simple) LR(l)- табмщей или SLR(1)-таблицей, а использованный алгоритм именуется алгоритмом SLR(l). Если созданная таким образом таблица не содержит конфликтов, то грамматика, на основе которой получена таблица, назы- вается SLR(l)-epoMMamuKou. Очевидно, все 1Ж(0)-грамматики относятся к классу SLR(l), хотя, как можно видеть из рассматриваемого примера, не все §1Ж(1)-грамматики относятся к классу LR(0). Отметим также, что даже если грамматика не является SLR(l), оставшиеся конфликты, воз- можно, удастся разрешить каким-то иным способом, и грамматику' мож- но будет назвать LR( 1). 5.4. Создание таблицы синтаксического анализа 129
В качестве синтаксической таблицы для рассматриваемой грамматики может использоваться как табл. 5.S, так и табл. 5.3. Первую лете полу- чить, но вторая обеспечивает более эффективную поддержку процесса синтаксического анализа, но только в объясненном ниже смысле. Может случиться так, что использование табл. 5.8 приведет к приме- нению действия свертки, тогда как использование табл. 5.3 приведет к выдаче сообщения о синтаксической ошибке. В таких случаях фактиче- ски имеет место синтаксическая ошибка, а свертка, на которую указыва- ет табл. 5.3, некорректна. В то же время использование табл. 5.8 приведет к выдаче сообщений о синтаксической ошибке чуть-чуть позже — в том смысле, что синтаксический анализатор успеет произвести на несколько действий больше, чем это было бы при использовании табл. 5.3, но ошибка будет выявлена до считывания следующего входного символа. С точки зрения пользователя в обоих случаях синтаксическая ошибка будет выявлена на одном этапе анализе, т.е. при одинаковом числе считанных символов. В обоих случаях синтаксическая ошибка будет обнаружена при первом неприемлемом символе; вообще, такое свойство является жела- тельным для анализаторов LR(1) (и LL( 1)). Описанные выше идеи иллю- стрируются ниже, на примере из уже использованной грамматики. Рассмотрим следующую синтаксически некорректную строку ввода. хх Изначально. Входная строка Стек состояний Стек символов Сентенциальная форма XX 1 XX Состояние — 1, входной символ — х; используя либо табл. 5.3, либо табл. 5.8, получаем: перенос в состояние 12, стек 12 в стеке состояний и стек х в стеке символов; принимается х. Предложение Стек состояний Стек символов Сентенциальная форма хх 1,12 х хх Состояние — 12, входной символ — х. Согласно табл. 5.3 имеем синтак- сическую ошибку; в то же время, исходя из табл. 5.8, следует выполнить свертку' согласно продукции 6. Чтобы увидеть результат, сделаем, как прехтагает табл. 5.8, — удалим одно состояние из стека состояний и один символ из стека символов. Следующим входным символом является F. Предложение Стек состояний Стек символов Сентенциальная форма XX 1 XX Состояние — 1, входной символ — F; используя табл. 5.8, получаем: пе- ренос в состояние 8, стек 8 в стеке состояний и стек F в стеке символов. Предложение Стек состояний Стек символов Сентенциальная форма хх 1,8 F Fx 130 Глава 5. Восходящий синтаксический аналю
Состояние — 8, входной символ — х; снова используя табл. 5.8, имеем указание на свертку, на этот раз согласно продукции 4. Удаляем одно со- стояние из стека состояний и один символ из стека символов; входным символом становится Т. Предложение Стек состояний Стек символов Сентенциальная форма хх 1 Fx Состояние — 1, входной символ — Г; согласно табл. 5.8 следует выпол- нить перенос в состояние 5, стек 5 в стеке состояний и стек Т в стеке символов. Предложение Стек состояний Стек символов Сентенциальная форма хх 1,5 Т Тх Состояние — 5, входной символ — х; только теперь в табл. 5.8 имеем указание на синтаксическую ошибку. Хотя синтаксическая ошибка может обнаруживаться позже (через не- сколько действий анализатора), использование таблицы с дополнитель- ными свертками (подобной табл. 5.5) не увеличивает время анализа программы, не имеющей синтаксических ошибок. Следовательно, обыч- ный подход при формировании таблицы — помешать операции свертки в каждый столбец состояния свертки, если это не приводит к конфликтам; рассматривать символы-последователи, если конфликты возникают. Как было показано ранее, алгоритм LR(0) успешен, если вообще нет нужды рассматривать символы-последователи, и все действия свертки можно поместить во все столбцы. Алгоритм SLR(l) успешен, если кон- фликты, выявленные алгоритмом LR(0), разрешаются с использованием символов-последователей описанным выше способом. В то же время иногда даже этого подхода недостаточно для разрешения всех конфлик- тов, и требуется более обший подход. В этом случае оставшиеся кон- фликты пытаются разрешить посредством алгоритма LALR(l) (LALR(l) — lookahead LR(1), LR(1) с предпросмотром). Алгоритм lALR(i) ограничивает число символов-последователей, которые нужно рассматривать при конкретной свертке, используя контекстную (т.е. ка- сающуюся состояний) информацию для определения только тех последо- вателей, которые корректны в рассматриваемом состоянии. Все конфликты может не разрешить даже алгоритм LALR(l). Это, ра- зумеется, объясняться тем, что грамматика не относится к классу LR( 1), так что нельзя создать бесконфликтную синтаксическую таблицу опи- санного выше типа. Например, к классу LR( 1) не относятся неоднознач- ные грамматики. В то же время существуют грамматики, являющиеся LR(1), но не LALR( 1). Для этих грамматик таблица синтаксического ана- лиза формируется путем введения в аннотированную грамматику допол- нительных состояний в тех местах, где мы этого не делали в приведен- ном выше примере, т.е. когда одному набору конфигураций грамматики соответствует более одного состояния. Такие состояния бывают иногда нужны, возможно, их даже окажется много, что значительно увеличит 5.4. Создание таблицы синтаксического анализа 131
таблицу синтаксического анализа. К счастью, описанной трактовки тре- буют лишь несколько грамматик, представляющих скорее академиче- ский. чем практический (языки программирования) интерес, поэтому со- ответствующие примеры приводиться не будут. Рассмотрим общий алгоритм формирования таблицы ЕИ(1)-анализа. Он имеет несколько этапов. I. Для создания таблицы пытаемся использовать алгоритм LR(0). Если он успешен (т.е. конфликты отсутствуют), алгоритм прекращается. 2. При неудаче пытаемся применить алгоритм SLR(l). Если он увенчал- ся успехом, алгоритм прекращается. 3. При неудаче пробуем алгоритм LALR(l). Если он достиг цели, алго- ритм прекращается. 4. При неудаче испытываем алгоритм LR( 1). Основная идея описанного подхода: первым применяется простейший алгоритм, далее сложность алгоритмов идет по возрастающей. Реально к классу LR(0) принадлежат немногие языки программирования, но мно- гие языки принадлежат к классу SLR(l), оставшиеся практически навер- няка будут LALR(l). Состояния LR(0)-. SLR(l)- и LALR( 1)-грамматик будут почти одинаковыми, но в ЕЯ(1)-грамматике их будет значительно больше. Последнее замечание: не имеет значения, какой алгоритм при- меняется для создания таблицы синтаксического анализа, синтаксиче- ский анализатор все таблицы использует одинаково. 5.5. Особенности LR-анализа На данном этапе стоит упомянуть некоторые теоретические результаты. • Существует алгоритм определения, относится ли грамматика к классу LR(k) при данном к. • Не существует алгоритма определения, существует ли к, при кото- ром данная грамматика относится к классу LR(k). В общем случае данная задача не решается. • Любой язык, относящийся к классу LR(k) при данном к, также от- носится к классу LR(1). Впрочем, ни один из приведенных результатов не является особенно важным с практической точки зрения. Третий, например, означает, что с точки зрения языка нет смысла рассматривать более одного символа пред- просмотра, поскольку если при некотором к для языка существует граммати- ка LR(k), также имеется и грамматика LR( I). Менее очевидно (и не доказы- вается), что на практике грамматика обычно является LR( 1), хотя ниже будут приведены несколько примеров 1Ж(2)-грамматик. Впрочем, что также будет показано, эти грамматики легко преобразовываются в LR( 1). Принимая во внимание существование ЕЯ(1)-грамматики для всех ЕВ(£)-языков, первые два результата также не являются особо сущест- 132 Глава 5. Восходящий синтаксический анализ
венными с практической точки зрения. На первый взгляд они даже ка- жутся противоречивыми. В то же время, из того, что для фиксированного к существует алгоритм (подобный описанному для к = I) определения, является ли грамматика LR(k), не следует, что существует конечный ал- горитм, позволяющий определить наименьшее к (если оно существует), при котором грамматика является LR(fc). Другими словами, существуют алгоритмы, позволяющие определить, относится ли грамматика к классу LR(1), LR(2) и т.д.; но хотя мы можем доказать, что для некоторого большого к грамматика не является LR(fc), всегда может оказаться, что ня какого-то большего к грамматика будет относиться к классу LR(k). Таким образом, алгоритм не будет конечным. Несколько типичных языков программирования имеют явные не- LR(1) свойства. Рассмотрим, например, фрагмент грамматики со сле- дующими продукциями. 1. S^aL, S 2. \aL 3. L-*x,L 4. |х Генерируемые предложения — это списки списков следующего вида. ах, х, х, х, ах, х Важный момент: на обоих уровнях списка используется один разделитель. Данная грамматика не относится к классу LR( 1), поскольку при считы- вании фрагмента ах и предпросмотре символа неизвестно, следует применять перенос (как в продукции 3) или свертку (согласно продукции 4).'В то же время, два символа предпросмотра разрешают конфликт, поскольку из предпро- смотра последовательности “,х” следует перенос, соответствующий про- дукции 3, а из предпросмотра пары “,а” следует свертка, соответствую- щая концу продукции 4. Это объясняется тем, что “,S” является последо- вателем L (из продукции 1), а “а” — стартовым символом S (снова из продукции 1). Кроме того, “х” — стартовый символ для L. Согласно представленной выше теории LR(2)-rpaMMaTHKy можно пре- образовать в ЬЯ(1)-грамматику. В этом случае “простое” преобразование дает грамматику, генерирующую один список из элементов “х” и “ах” (после первого элемента, которым всегда является ах). Приведем продук- ции новой грамматики. 1. S^axF I F->,JF : |е J-»a* 5. |х И. Особенности LR-аналнза 133
Доказательство того, что данная грамматика действительно является LR(I), предлагается в качестве одного из упражнений в конце данной главы (решение этого и других упражнений приводится в конце книги). Особенность исходной грамматики, “благодаря" которой она не относит- ся к классу LR(I), — это использование правой рекурсии плюс двойное использование запятой. В то же время, хотя грамматики с левой рекурси- ей не могут быть LL( 1), утверждение, что ЬК(1)-грамматики не могут со- держать правой рекурсии, в обшем случае неверно. Следует сказать, что только в относительно редких случаях правая рекурсия порождает про- блемы в LR(l)-rpaMMaTHKax, а если и порождает, то, в основном, из-за наличия в грамматике помимо правой рекурсии каких-либо иных осо- бенностей. В то же время в большинстве программ синтаксического ана- лиза левая рекурсия предпочтительнее правой, так как позволяет исполь- зовать свертку входа по мере чтения, не занося его в стек. Неоднозначные грамматики, разумеется, не moot быть LR(1). Часто неоднозначность в грамматике — это наличие нескольких порождений для пустой строки, е. Проиллюстрируем это на довольно тривиальном примере. Пусть в грамматике присутствует следующий фрагмент. S-M) В\ £ Д-» аА £ Таким образом, в данной грамматике есть два порождения для е. S=2£ Одно порождение можно легко удалить из грамматики, заменив А -> е следующей продукцией: Д-» а Вообще проблемы подобного рода обычны в грамматиках, но, как прави- ло, с ними удается легко справиться. Подведем итоги: LR-анализ имеет следующие желательные особенности. • Его можно применить к широкому классу грамматик и языков. • Необходимые преобразования грамматик обычно минимальны. • Время, требуемое для анализа, прямо пропорционально длине входа. • Синтаксические ошибки выявляются на первом недопустимом символе. • LR-анализ имеет хорошую инструментальную поддержку. Более того, инструментальная поддержка обычно означает, что разработ- чик компилятора не обязан точно знать, как формируется таблица син- 134 Глава 5. Восходящий синтаксический анализ
глкеического анализа. В следующем разделе будет подробно рассмотрено, как используется распространенное средство генерации синтаксических анализаторов. 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 по многим пунктам, хотя, конечно, поддерживает более общие языковые конструк- ции. Анализируемый язык выражается как контекстно-свободная трам- матика, при этом используется форма записи, подобная применяющейся з контекстно-свободных грамматиках, хотя и с некоторыми расширения- ми, которые будут объяснены позже, на примерах. Эти расширения не увеличивают выразительной силы формы записи в том смысле, что она э.б. Введение в YACC 135
может использоваться для описания языков, которые нельзя описать по- средством контекстно-свободной грамматики, но они упрощают описа- ние некоторых языков, а также иногда сокращают описание языка. В качестве примера покажем, как в виде входа YACC можно предста- вить язык арифметических выражений. %left ' + ’ %left %% expr : expr '+' expr | expr '-' expr | expr '*' expr I expr '/' expr j ' (' expr ')’ | num; Отметим, что терминалы берутся в одинарные кавычки, а для разделения правой и левой частей продукций используется знак : вместо более привыч- ного ->. Для разделения альтернативных правых частей продукции, как и ра- нее, используется знак |. Расширение формы записи, использованной для контекстно-свободных грамматик, связано с отображением уровней приори- тетов операторов. В первой строке представленного выше кода определено, что операторы + и - имеют одинаковый уровень приоритета, поскольку на- ходятся в одной строке, а во второй строке то же делается для операторов ♦ и /. Кроме того, поскольку * и / находятся в строке, расположенной ниже строки с + и -, их приоритет определен более высоким. Появление left пе- ред каждой парой операторов обозначает, что они являются левоассоциатив- ными. Значит, выражение 3 + 4 + 5 нужно вычислять следующим образом: ((3 + 4)+ 5) Данный метод вычисления является наиболее привычным и соответству- ет использованию в грамматике левой рекурсии. Более высокий приори- тет операторов умножения перед операторами сложения можно выразить альтернативно (будет показано ниже), определив, что выражение должно быть суммой термов, каждый из них является произведением множите- лей. В то же время, во входе YACC этого не требуется, так что граммати- ка YACC короче и, возможно, читабельнее. Сами по себе грамматиче- ские правила являются неоднозначными. Например, существует более одного порождения для приведенного выше выражения. 3 + 4 + 5 В то же время правила ассоциативности и приоритетов полностью раз- решают эту неоднозначность. Из сказанного видно, что грамматики YACC могут быть неоднозначными (несмотря на то, что не существует неоднозначных ЬК(1)-грамматик), если имеются правила, которые по- зволяют разрешить неоднозначность. Такие правила называются прави- лами разрешения неоднозначности (disambiguating rules). 136 Глава 5. Восходящий синтаксический анализ
Приведенный выше пример можно расширить, включив унарные операторы, приоритет которых может отличаться от приоритетов бинар- ных операторов, представленных теми же символами. Если нужно вклю- чить унарный минус с приоритетом выше, чем у операторов умножения, вход YACC будет иметь следующий вид. %left %left '/' %left UMINUS %% expr : expr ' + • expr expr '- ' expr expr '* ' expr expr '/' expr '- * expr %prec UMINUS '(' expr ')* num; Теперь мы почти готовы написать вход YACC для создания програм- мы, вычисляющей математические выражения. Перед этим, правда, нуж- но кое-что сказать о введении действий во вход YACC. Как и для Lex, действия пишутся на С и обычно (хотя и нс всегда) располагаются в конце синтаксических правил, таким образом соответствуя приведению выражений. Например, к синтаксическому правилу expr : expr ' + * expr; можно добавить действие, в результате чего получится следующее. expr : expr ' + ' expr {$$=$1+$3;}; Здесь код С замкнут в фигурные скобки. Переменные со знаком долла- ра — это отличительная особенность YACC, которая является крайне по- лезной. $п — это численное значение (атрибут), соотнесенное с л-м сим- волом правой части продукции, а $$ — численное значение, которое бу- дет соотнесено с символом в левой части. Таким образом, в приведенном примере значение переменной со знаком доллара, соотнесенное с выра- жением в левой части правила, будет равно сумме значений переменных со знаком доллара, соотнесенных с первым и третьим символами правой части правила. Подобным образом значения могут передаваться от пра- вых частей продукций левым частям или (с другой точки зрения) от ос- нования синтаксического дерева к вершине. Нахождение значений тер- минальных узлов синтаксического дерева является задачей лексического анализа, и, в этом случае, значение на вершине синтаксического дерева ятяется значением всего выражения, которое можно напечатать. Вход YACC, включающий комментарии, которые выделены знаками /* и */, имеет следующий вид. %token NUMBER %left '+' Ueft '/' Heft UMINUS /♦приоритет унарного минуса выше приоритетов других 5.6. Введение в YACC 137
операторов*/ % % /’раздел правил*/ s : expr {printf("%d\n*, $1) expr : expr ' + ' expr {$$=Sl+$3;} | expr '-' expr {$$=$l-$3;} | expr '*• expr {$$=$1*$3;} | expr '/’ expr {if ($3 == 0) yyerror ("divide by 0") • else $$=$l/$3;) [ expr %prec UMINUS {$$=-$2;} | ' (' expr ')' {$$=$2;} | NUMBER; %% #include "lex.yy.c" yyerror(s) char *s; {printf("%s\n*, s); } main() {return yyparseO; ) Для связи с лексическим анализатором, созданным Lex, перед пользо- вательскими функциями “включается” lex.yy.c. Отметим, что в случае деления вначале выполняется проверка, не равен ли нулю делитель; если равен — вызывается yyerror, и программа прекращается. Пользователь должен сам поддерживать свою версию функций yyerror и main (которая должна возвращать значение, полученное при вызове yyparseO). Эти функции могут быть простыми (подобно приведенным) или сложными, по желанию. Отметим также, что терминалы в грамматике, которые не соот- ветствуют одному знаку, “объявляются” как %token. Это обеспечивает связь с лексическим анализатором. Терминалы, состоящие из одного зна- ка. могут передаваться Lex автоматически, без необходимости явного “распознавания”. Вход Lex для распознавателя выражений будет иметь следующий вид. %% [0-9]+ {yylval = atoi (yytext); return NUMBER;} [ \t) ; /’игнорировать пробелы*/ return yytext[0]; Здесь yytext - массив, содержащий знаки символа, с которым только что было найдено соответствие. Для конвертирования этой строки в ие- 138 Глава 5. Восходящий синтаксический анализ
лое число используется функция С, именуемая atoi, а для обеспечения связи с синтаксическим анализатором возвращается символ number. Зна- чение числа присваивается целому yylval, посредством которого это значение и передается синтаксическому анализатору. Отметим, что пере- менные, имеющие специальное значение, в YACC обычно начинаются с уу. Это помогает избежать путаницы с пользовательскими переменными. Предполагая, что входами Lex и YACC являются, соответственно, r.ums.l и nums. у, для генерации синтаксического анализатора потребу- ется следующий ввод. lex nums. 1 уасс muns. у сс -о nums у. tab.с -11 -1у Опция -о указывает, что синтаксический анализатор должен быть создан в файле nums, а опции -11 и -1у гарантируют включение библиотек Lex и YACC. Для выполнения программы синтаксического анализа требуется ввести следующее. nums <dat Здесь dat содержит данные (выражение), значение которых требуется вычислить. Хотя обычной практикой является введение действий в конец правил YACC, можно также ввести действия в середину правил, если это не сде- лает грамматику неоднозначной. Например, можно представить, что при введении действия внутрь правила грамматика подвергается простому преобразованию — вводится новый нетерминал, который генерирует только пустую строку непосредственно перед позицией действия. Если такая преобразованная грамматика остается LALR(l), таблицу синтакси- ческого анализа можно создавать, как обычно. Рассмотрим, например, продукцию (из 1-АЬК(1)-грамматики) X->aLMb с действием, введенным между L и М. Х-> aL{action(Y,}Mb Если замена данной продукции продукциями Х-» aLNMb N-> t{action(Y) не мешает грамматике оставаться LALR(l), действие можно вводить так, как сделано выше. Теперь можно вернуться к примеру из раздела 4.7, где действия вво- зились в ЬЬ(1)-грамматику с целью создания из обычной (инфиксной) формы записи постфиксной. Грамматику (в виде входа YACC) можно те- перь записать следующим образом. S : ЕХР; EXP : TERM | ЕХР+{Al();} TERM {А2();) 5.6. Введение в YACC 139
I EXP-(Al();} TERM (A2();} TERM : FACT | TERM • (A1();}FACT (A2();} I TERM. (All);}FACT (A2() ; ) FACT : -(All);)FACT (A2();) | VAR {A3<);> I ( EXP ); VAR: a|b|c|d|e; Здесь Al () и т.п. — вызовы функций, соответствующие действиям, опре- деленным ранее, в разделе 4.7. Пользователю не нужно заботиться о пре- образованиях, задействованных при введении нового нетерминала, кото- рый генерирует пустую строку для каждого действия, не помешенного в конец правша. Со всем этим автоматически справится YACC. 5.7. Вычисление метрик В разделе 3.5 было рассмотрено вычисление метрик исходного кода, ос- нованное на инструментах компилятора. Также отмечалось, что некото- рые метрики по своей природе являются лексическими и их вычисление легко выполнять средствами на основе Lex. Указывалось, что природа других метрик является синтаксической, и для вычисления таких метрик удобно использовать средства УАСС. В качестве примера простого сред- ства вычисления метрик, которое можно создать с использованием УАСС, допустим, что сложность арифметического выражения определя- ется (условно) следующей формулой. Д + 2В+5С Здесь А — число бинарных операторов сложения и вычитания, В — чис- ло унарных операторов сложения и вычитания, а С — число операторов умножения и деления. Ниже приводятся входы Lex и УАСС для создания требуемого инст- румента. var [a-z] space ( \n\t) morespace {space}+ %% {more space} {var} {return VAR;} {return yytext[0];} И %token VAR %left ' + ' %left '8' '/' %% s : expr {printf("fcdXn", $1);}; 140 Гпава 5. Восходящий синтаксический анализ
expr : expr '+• expr {$$ = $i+$3+l;) I expr '-' expr {$$ = $i+$3+i;} I expr '* * expr {$$ = $l+$3+5;) | expr '/' expr ($$ = $l+$3+5;} | '-• expr {$$ = $2+2;} | '+• expr {$$ = $2+2;} | '(' expr ') ' ($$ = $2;} | VAR ($$ = 0;}; %% linclude "lex.yy.c" yyerror(s) char *s; (printf("%s\n", s) ; } main() {return yyparse () ; ) Для простоты выше предполагалось, что выражения составлены из идентификаторов, скобок и операторов, каждый из которых включает один знак. Отметим также, что в действительности не требуется подсчи- тывать количество операторов каждого типа. Существует еще одна синтаксическая метрика, именуемая метрикой Мак-Кейба (McCabe's metric). Эта метрика основана на использовании теории графов и эквивалентна никломатической сложности ориентиро- ванного графа. Если управляющая структура программы (или ее части) представлена как ориентированный граф, значение метрики Мак- Кейба — это число линейно независимых частей графа управления что, в свою очередь, равно числу решений в программном модуле плюс один. По- скольку данное понятие достаточно просто для понимания, ниже пока- зывается, как создать средство вычисления числа решений в программ- ном блоке. Для иллюстрации будет использован следующий модуль Pas- cal, определенный грамматикой в записи YACC. proc : Procheading block; block : constdec vardec prodecs stmpart; stmpart : compoundstat; compoundstat : BEGIN stmtseq END; 57. Вычисление метрик 141
stmtseq : statement | stmtseq statement; statement : compoundstat structstat /*пустей оператор*/ assignstat procstat; structstat : condstat |whilestat; condstat : IF condition THEN statement ELSE statement |IF condition THEN statement; whilestat : WHILE condition DO statement; Некоторые нетерминалы приведенной выше грамматики, такие как 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 ';' statement {$$ = $l+$3;}; statement : compoundstat {$$ = $1;} 1structstat (SS = si;} |/*пустой оператор / {$$ = 0;} |assignstat 142 Глава 5. Восходящий синтаксический анализ
{$$ = 0;) |procstat {$$ = 0;}; structstat : condstat {$$ = $1;) |whilestat {$$ = $1;); condstat : IF condition THEN statement ELSE statement {$$ = $4+$6+i;} |IF condition THEN statement {$$ = $4+1;}; whilestat : WHILE condition DO statement {$$ = $4+1;}; Отметим, что все переменные со знаком доллара соответствуют "количеству решений”, связанных с нетерминалом. 5.8. Использование YACC В данном разделе рассматриваются некоторые ситуации, которые могут возникнуть при использовании YACC. Больше узнать о том, как YACC формирует таблицу синтаксического анализа, можно, запустив YACC в “подробном режиме” (verbose mode), после чего получить достаточно подробное описание в следующем файле. у. output Вереде Unix для получения этого файла нужно вызвать YACC с опцией -v. уасс -V nums . у Файл у.output содержит подробности относительно состояний синтак- сического анализатора. Например, вход %token NUM %left '+' Ueft %% expr : expr '+' expr expr expr ' (* expr ')• NUM; даст следующий выход. state 0 $accept: _expr $end i8. Использование YACC 143
NUM shift 3 ( shift 2 .error expr goto 1 state 1 Saccept: expr_$end expr: expr_+ expr expr: expr_* expr Send accept ♦ shift 4 * shift 5 .error state 2 expr : (_expr ) NUM shift 3 ( SHIFT 2 . error expr goto 6 state 3 expr : NUMu (4) . reduce 4 state 4 expr : expr +_expr NUM 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 144 Глава 5. Восходящий синтаксический аналю
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 state 9 expr : ( expr )_ (3) .reduce 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 Optimizer space used: input 37/40000, output 218/70000 218 table entries, 206 zero maximum spread: 257, maximum offset: 42 Этот выход согласуется с грамматикой, дополненной одним правилом (ruleO) и снабженной аннотациями — состояниями 0-9. accept :оехрг\ &фГ : 0. 2, 4. 5eXP6. 6. 7, 8**+" 4вХрГ7 | 0.2. 4. Ь^хргу, 6, 7. 8и*И5в-*Р^8 | 0. 2. 4. 5"(“20*Ргб”)”9 | 0.2.4.5NL//lfc Вместе с информацией относительно ассоциативности и приоритетов (которая представлена во входе YACC) данная грамматика соответствует приведенной таблице синтаксического анализа (табл. 5.9). 5А Использование YACC 145
Таблица 5.9. expr ( ) NUM 1 0 1 SI S4 S5 S2 S3 RO 2 S6 S2 S3 3 R4 R4 R4 R4 R4 R4 R4 4 S7 82 S3 5 88 S2 S3 6 84 85 89 7 R1 R1 R1 R1 R1 R1 R1 8 R2 R2 R2 R2 R2 R2 R2 9_ R3 R3 R3 R3 R3 R3 R3 Отметим, что в состоянии 1 свертка выполняется только при символе предпросмотра 1. У чет ассоциативности и приоритетов операторов приводит к тому, что в состоянии 8 всегда выполняется свертка. Отметим, впрочем, что в состоянии 7 свертка производится не всегда (при предпросмотре символа *)• Отметим также, что в файле у.output различается goto, за которым следует нетерминал. и shift, за которым следует тер1\шнал, хотя ранее в главе и в приведенной выше таблице оба случая трактовались одинаково. Должно быть очевидно, что в файле у.output подчеркнутый символ указывает конфигу- рации грамматики, к которым принадлежит состояние, а символ . (точка) указывает действие при всех неуказанных входных символах, во многих слу- чаях — синтаксическую ошибку. Числа в скобках справа указывают соответ- ствующие номера продукций. Последняя часть выхода у.output содержит статистику, связанную с грамматикой и созданной таблицей синтаксического анализа. Например: 5/600 grammar rules, 10/1000 states Это означает, что из 600 возможных грамматических правил в грамма- тике присутствует 5, а из 1000 возможных состояний синтаксического анализатора требуются 10. Размеры таблицы, используемой YACC, мож- но увеличить, применив флаг -N, где за N следует целое число (> 40 000 в некоторых версиях). Если выход порождает конфликты перенос/свертка или сверт- ка/свертка, содержимое у.output является просто неоценимым для их разрешения. Например, если в приведенный пример не включать опре- деление приоритетов операторов, у.output будет идентичен полученно- му выше до состояния 6 включительно, но выход для состояний 7 и 8 бу- дет иметь следующий вид. 7: shift/reduce conflict (shift 4 red'n 3) on + 7: shift/reduce conflict (shift 4 red'n 1) on * state7 expr : expr_+expr expr : expr +expr_ 146 Глава 5. Восходящий синтаксический анализ
expr : expr_* expr ♦shift 4 * shift 5 .reduce 1 8: 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 Отметим, что состояния в обоих случаях совпадают, единственное отли- чие — появление конфликтов перенос/свертка в состояниях 7 и 8, при- водящих к возникновению неоднозначности в грамматике, которая (в данном случае) не разрешена правилами приоритетов. Как упоминалось выше, интересная и широко известная неоднознач- ность появляется при использовании оператора if, следующим образом определенного в С. if-statement :if '('expression')' statement | if '('expression')' statement else statement; При таком определении может возникнуть следующее неоднозначное выражение. if (х==3) if (у==5) z=6; else w = 7; Неоднозначность заключается в том, что неясно, к какому if относится else — к первому или второму. Легко показать, что приведенное выше предложение имеет два левых и два правых порождения и два синтакси- ческих дерева (см. упражнение 2.6). Язык С — не единственный, в кото- ром присутствует подобная неоднозначность; она имеется в ряде других языков, включая COBOL и C++. Для иллюстрации того, как YACC справляется с такой неоднозначностью, рассмотрим следующих вход. Stoken IF, ELSE, EXP %start statement %% statement: if_statement I; if-statement: IF '('EXP')' statement | IF '('EXP')' statement EXP statement; Здесь, для простоты, exp предполагается терминалом, а как единственная альтернатива оператора if включается пустой оператор (частный случай 5.8. Использование YACC 147
выражения-оператора в С). При обработке подобного входа YACC со- держимое у.output выглядит следующим образом. state О $accept: „statement Send statement IF shift 3 .reduce 2 statement gotol if-Statement goto 2 state 1 Saccept: statement_$end Send accept .error state 2 statement: if_statement_ (1) .reduce 1 state 3 if-Statement: if_statement: ( shift 4 .error IF-(EXP) IF_(EXP) statement statement ELSE statement state 4 if.statement: if-Statement: IF(-EXP) IF(_EXP) statement statement ELSE statement EXP shift 5 .error state 5 if-Statement: if_statement: IF(EXP—) IF(EXP-) statement statement ELSE statement ) shift 6 .error state 6 if_statement: if-Statement: IF(EXP)_ IF(EXP)- statement statement ELSE statement statement : _ (2) IF shift 3 .reduce 2 148 Глава 5. Восходящий синтаксический анаМ
statement goto 7 if-statement goto 2 7: shift/reduce conflict (shift 8, red'n 3) on ELSE state 7 if_statement: IF(EXP) statement. if_statement: IF(EXP) statement.ELSE statement ELSE shift 8 .reduce 3 state 8 if—statement: IF (EXP) statement ELSE. statement statement: _(2) IF shift 3 .reduce 2 statement goto 9 if-statement 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), которая, следовательно, аннотируется следующим образом. accept: ostatementi statement: о, 6, sif_statement: о, 6. 81 ; if.statement: о. 6. sIF3' (' 4ЕХР5') • е statement? | о. 6. 8IF3' ('4ЕХР5') 4 statement? ELSEs statement? Получаем табл. 5.10, где указаны действия переноса и все действия свертки, кроме свертки согласно продукции 3 (в состоянии 7). Алгоритма SLR(l) достаточно для разрешения любого конфликта в табл. 5.10. В то же время внесение действия R3 в состояние 7 несколько сложнее. Если рассмотреть последователей if .statement, то в их число войдут else и 1, и если с помещением свертки в столбец 1 никаких проблем не возникает, в столбце else обнаруживаем действие переноса. Более того, использование более общих алгоритмов LALR(l) и LR(1) в данном случае конфликта не разрешает. Наличие неразрешимого конфликта в таблице син- 5.8. Использование YACC 149
токсического анализа нс должно удимять, поскольку, как отмечалось ранее, исходная пх1мматика была неоднозначной. Напротив, если неразрешимых конфликтов не было обнаружено, то ни для одного предложения грамматики невозможно будет найти альтернативное порождение, что противоречит ут- верждению о неоднозначности грамматики. Таблица 5.10. State st if-st IF EXP ELSE ( ) 1 0 SI S2 S3 R2 R2 1 RO 2 R1 Rt R1 R1 R1 R1 R1 R1 3 S4 4 S5 5 S6 6 S7 S2 S3 R2 R2 7 S8 8 S9 S2 S3 R2 R2 9 R4 R4 R4 R4 R4 R4 R4 R4 Хотя представленная выше грамматика является неоднозначной, язык таковым не является; т.е. существует однозначная грамматика, которая генерирует тот же язык, и ее можно использовать в качестве основы ТИ(1)-анализатора. Такая грамматика была представлена в разделе 2.6, но для многих людей подобный способ представления языка является не слишком естественным. Таким образом, вместо создания анализатора на основе ухищренной и неестественной грамматики лучше воспользоваться свойством YACC, разрешающим создавать анализаторы из неоднознач- ных грамматик. Анализатор для неоднозначной грамматики, синтезиро- ванный YACC, будет использовать следующее соглашение: если позиция таблицы содержит действия переноса и свертки, во всех случаях перенос имеет преимущество перед сверткой. Таким образом, получаем эффек- тивную табл. 5.11, созданную с помошью YACC. Таблица 5.11. State st rf-st IF EXP ELSE ( ) 1 0 S1 S2 S3 R2 R2 1 RO 2 R1 R1 R1 R1 R1 R1 R1 R1 3 S4 4 S5 5 S6 6 S7 S2 S3 R2 R2 7 S8 R3 8 S9 S2 S3 R2 R2 9 R4 R4 R4 R4 R4 R4 R4 R4 150 Гпава 5. Восходящий синтаксический анализ
Перенос в состоянии 7 (столбец else) в состояние 8 означает, что else соотносится с ближайшим предшествующим if. Поскольку именно такое соглашение принято в С, да и практически во всех языках с подоб- ном неоднозначностью, анализатор YACC, к счастью, поступает пра- вильно. Несмотря на возникновение конфликта в файле у.output, этот конфликт можно просто игнорировать, если оператор if трактуется так, как было указано. В то же время, этой практике не стоит следовать по- всеместно, все остальные конфликты нужно тщательно изучать и, по возможности, устранять. То, что YACC имеет предопределенное дейст- вие, которое он предпринимает при возникновении конфликта, — и хо- рошо, и плохо, поскольку появляется соблазн проигнорировать кон- фликт, но при этом существует вероятность неверной трактовки входа. Большинство конфликтов следует устранять, если только предопределен- ное действие YACC не является заведомо правильным. На практике, единственным обычно игнорируемым конфликтом является описанный выше конфликт, связанный с оператором if. Стоит отметить, что, как видно из приведенных примеров, неодно- значные грамматики иногда предлагают наиболее простой и естествен- ный способ представления особенностей языка. Таким образом, неодно- значные грамматики — это не так уж плохо, а иногда даже хорошо, если имеются правила разрешения неоднозначности. Помимо сообщений о конфликтах перенос/свертка YACC иногда вы- дает сообщения о конфликтах свертка/свертка. Эти конфликты встреча- ются намного реже, и их причина обычно кроется в каких-либо необыч- ных свойствах языка, так что простой пример конфликта свертка/свертка привести затруднительно. Они могут проистекать из неоднозначности грамматики или появляться в однозначной грамматике, генерирующей предложения, левая часть которых имеет более одного порождения. Та- ким образом, не всегда возможно различить альтернативные порождения левой части, зная левую часть плюс один символ предпросмотра (или даже некоторое фиксированное число символов предпросмотра). Пред- положим, например, что на вход YACC подан следующий код. %% part :ints |reals; ints : listl ':• int; reals : List2 real; listl : listl ell | ell; list2 : list2 ', • el2 | el2; ell : 'a' | 'b' | 'p' ; el2 : 'x' j 'y' | 'p' ; %% 5.S, Использование КАСС 757
С введенными состояниями, определенными YACC, получаем следующее. Saccept: cpartil (part .-cints: lorealsj; ints : clistl< uinti?; reals : olist2s isreali?; listl : distil nellie I cell6; list2 : clist2s ', ' i6el2:o | oel2.; ell : c'a's|o'b'q|o'P'io el2 : o'x'n|o'y'i:|o'P'ic Конфликт свертка/свертка, разумеется, имеет место в состоянии 10, где р можно свернуть либо согласно ell, либо согласно el2, и для раз- решения конфликта требуется произвольное число символов предпро- смотра (до определения 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!2 : p_ (14) .reduce 11 Как и для конфликтов перенос/свертка, YACC содержит предопреде- ленные действия, которые предпримет анализатор, если конфликт раз- решить не удастся. В данном случае это свертка согласно первой из двух (или большего числа) продукций. В приведенном примере такие продук- ции — это 11 и 14, так что анализатор всегда будет выполнять свертку согласно продукции 11, что может иметь смысл только при отсутствии ситуаций, когда требуется альтернативная свертка. Рассмотрим следую- щее предложение, которое генерируется исследуемой грамматикой. р,х,у : real Согласно правилу р сворачивается в ell, которое, в свою очередь, сворачивается в listl. Затем считывается запятая, и ожидается, что сле- дующий символ также будет ell. В то же время х не является экземпля- ром ell, и получаем указание на синтаксическую ошибку, хотя во вве- денном предложении ее нет! Мало того, трудно сказать, как может по- 152 Глава 5. Восходящий синтаксический анамз
ступить YACC для улучшения ситуации. Понятно, что постоянная сверт- ка согласно продукции 14 приведет к аналогичной кажущейся синтакси- ческой ошибке. Похоже, что единственный подход к решению пробле- мы — использовать произвольное число символов предпросмотра, хотя, возможно, корни проблемы лежат в языке, предъявляющем неразумные требования к синтаксическому анализатору! Конфликты свертка/свертка ьчтгдя должны исследоваться; предопределенные действия YACC, скорее всего, окажутся неудовлетворительными. Если созданный YACC синтаксический анализатор работает, как ожи- далось, подробную информацию по его работе можно получить, исполь- зуя YACC в режиме отладки с установкой флага -с при выполнении. Полученная информация объемна, и ее не стоит использовать постоян- но. Впрочем, иногда она является единственной возможностью разо- браться в проблемах, возникающих при использовании YACC. Действия, введенные в грамматику' YACC, могут использоваться для обнаружения не-контекстно-свободных ошибок. Хотя, как отмечалось ранее, восстановление после подобных ошибок обычно легче восстанов- ления после контекстно-свободных, можно сделать так, что при появле- нии не-контекстно-свободных ошибок YACC будет действовать так же, как и при появлении контекстно-свободных, вызвав Макрос yyerror. Восстановление после ошибок — это сложный вопрос, который далее рассматриваться не будет. Отметим, впрочем, что YACC предоставляет средства реализации относительно изощренных стратегий восстановле- ния после ошибок, а также достаточно простой и редко удовлетворитель- ной стратегии прерывания анализа при выявлении первой контекстно- свободной ошибки. Итак, представлены основные особенности YACC. Вообще, свойства YACC несколько меняются в зависимости от реализации, так что не все, сказанное здесь, может быть применимо к вашей версии YACC. Кроме того, могут иметься свойства, вообще не описанные здесь. В целом вход YACC достаточно переносим, если избегать некоторых архаичных функ- ций, использованных в ранних версиях YACC. 5.9. Резюме В данной главе было сделано следующее. • Введена концепция восходящего синтаксического анализа. • Показано, как процесс анализа может направляться таблицей син- таксического анализа. • Введены понятия конфликтов перенос/свертка и свертка/свертка. • Показано, как состояния синтаксического анализатора могут опре- деляться через конфигурации грамматики. 5.9. Резюме 153
• Продемонстрированы алгоритмы LR(0) и SLR(l) формирования таблицы синтаксического анализа. • Представлено средство генерации синтаксических анализаторов YACC. • Показано, как в грамматику YACC можно ввести действия време- ни компиляции. • Показано, как можно разрешить проблемы, возникающие при ис- пользовании YACC. Дополнительная литература Понятие LR-анализа было введено Кнутом в работе [Knuth, 1965], а практически было развито в книге [De Reemer, 1971]. Использование LR-анализа с неоднозначными грамматиками было рассмотрено в работе [Aho. Johnson and Ullman, 1975]. YACC — это лишь один (хотя, пожалуй, наиболее известный) из множества генераторов синтаксических анализаторов, доступных в наши дни через Internet. Он был разработан Джонсоном [Johnson, 1975] и опи- сан (хотя и не всегда достаточно подробно) во множестве работ, посвя- щенных компиляторам, например, [Aho, Sethi and Ullman, 1985] и [Benett, 1990]. Нельзя сказать, что работа [Levine, Mason and Brown, 1992] целиком посвяшена компиляторам, но в ней присутствует достаточно подробное описание YACC (и Lex). Создание компилятора с использова- нием YACC освещается в работе [Schreiner and Freidman, 1985]. Родственное YACC средство Bison было представлено в работе [Stallman, 1994], a Gnu Bison доступно (благодаря Free Software Foundation) на мнопгх Internet-сайтах (см. раздел дополнительной литературы в главе 1). Упражнения 5.1. Объясните, почему восходящий синтаксический анализ применим более широко, чем нисходящий. 5.2. Что подразумевается под конфликтами перенос/свертка и сверт- ка/свертка в восходящем синтаксическом анализе? 5.3. Объясните, почему неоднозначная грамматика не может относиться к классу LR( 1). 5.4. Объясните, почему действительно необходимым для управления процессом синтаксического анализа является один из двух стеков (стек состояний и стек символов). 5.5. Создав таблицу синтаксического анализа, докажите, что грамматика с указанными ниже продукциями относится к классу LR(1) (см раздел. 5.5). 154 Глава 5. Восходящий синтаксический анализ
1. S—>axF 2. F-+.JF 3. |e 4. J-ьах 5. |x 5.6. Покажите, что грамматика co следующими ниже продукциями нс относится к классу LR( 1). S-> 1S0 S~>0S1 S-> 10 S-+01 5.7. Покажите, что грамматика со следующими ниже продукциями не относится к классу LR( 1), но относится к классу LR(2). S-> И=Е S-tLS L-+h У->/ Каждый знак считается отдельным символом. Предложите, как можно проводить лексический анализ для грамматики, не являю- щейся LR(1). 5.8. Изучите, как ваша версия YACC работает с грамматиками, пред- ставленными в упражнениях 5.6 и 5.7. В каждом случае исследуйте содержимое файла у.output. 5.9. Приведите аргументы за и против использования макроса yyerror. 5.10. Приведите пример однозначной грамматики, которую не сможет обработать YACC.

Глава 6 Семантический анализ 6.1. Вступление Как уже упоминалось в главе 1, некоторые характеристики языков про- граммирования не являются контекстно-свободными, следовательно, их нельзя определить с помощью контекстно-свободных грамматик. В этой главе рассматриваются аспекты семантического анализа, в частности, анализируются не-контекстно-свободные аспекты языков программиро- вания. В этой главе будут рассмотрены следующие вопросы. • Характеристики языка, не являющиеся контекстно-свободными. • Усовершенствование контекстно-свободного синтаксического анали- затора путем введения протабулированных действий для проверки не- контекстно-свободных характеристик языков программирования. • Практические методы реализации таблиц символов и типов. • Некоторые моменты, относящиеся к объектно-ориентированным языкам. 6.2. Не-контекстно-свободные характеристики языка Каждая программа данного языка будет иметь, по меньшей мере, одно синтаксическое дерево (и по меньшей мере, одно левостороннее и право- стороннее порождение), которое может быть использовано для отобра- жения ее порождения. В то же время не каждое синтаксическое дерево, которое можно сгенерировать грамматикой языка, соответствует кор- ректной программе. В подтверждение этого существуют синтаксические деревья, основанные на созданной ANSI контекстно-свободной грамма- тике для С, которые соответствуют обеим следующим программам. ainclude <stdio.h> main () { int first, second; first = 4;
second = 5; printf ("%d", first * second); } 11 ♦include <stdio.h> main () ( first = 4; second = 5; printf ("%d", first + second); ) При этом первая программа компилируется и выполняется, а вторая приводит к появлению сообщений об ошибках (в Borland Turbo С). Error 622.С 5: Undefined symbol 'first* in function main() Error 622.C 6: Undefined symbol 'second* in function main() Первая программа корректна в С, а вторая — нет (переменные first и second не были объявлены). Другими словами, появление в программе пе- ременных подразумевает, что в каком-то месте программы должны быть их объявления. Следовательно, существуют ограничения на способ производства порождения. Контекстно-свободные грамматики не имеют механизма опре- деления таких ограничений (в действительности, этого еще никто не дока- зал’), следовательно, не могут применяться для точного определения, что со- ставляет программу на С. В то же время контекстно-свободные грамматики можно использовать для определения расширенного множества всех про- грамм на С, расширенного множества всех корректных программ, а также расширенного множества всех программ, некорректных вследствие не- контекстно-свободных сбоев, подобных отсутствию объявления переменных Проиллюстрируем другую категорию не-контекстно-свободных дефектов на следующем примере. ♦include <stdio.h> main О { int first; int second [5] = {6,8,4,5,21; first = second; printf ("%d**, first); } Очевидно, что проблема этой программы заключается в несовместимости типов по обе стороны оператора присваивания: first = second; В целом, язык С достаточно терпим к так называемым ошибкам ти- пов; ни один из следующих операторов присваивания не породит сооб- щения об ошибке. 158 Глава 6. Семантический анам
int p = 4.3; real x = 2; int x = 'a' ; int x = NULL; В приведенных выше примерах при необходимости осуществляются пре- образования целого числа в действительное и действительного в целое. Также выполнялось преобразование знака в целое — замена знаков их ASCII- зквивалентом (который, в любом случае, используется для внутреннего пред- ставления знаков). При этом философия С заключается в выполнении всех преобразований типов, которые требуются по контексту, так что при появле- нии величины с несоответствующим контексту типом ее тип будет преобра- зован в более подходящий. Значение null в приведенном выше примере преобразовывается в целое 0. Если “разумную” замену произвести невоз- можно, имеем ошибки компиляции, как в приведенном примере присвоения целому числу целочисленного массива. В других языках, таких как Ada и ALGOL 68, типы трактуются стро- же, поэтому неявные преобразования либо вообще отсутствуют, либо их очень мало. Это связано с тем, что при таком подходе в процессе компи- ляции будет обнаружено большинство программных ошибок. Такие язы- ки называются языками со строгим контролем типов (strong typing). Другие языки имеют не статические, а динамические типы, когда тип ве- личины неизвестен в процессе компиляции и должен определяться уже во время выполнения программы. Это означает, что во время выполне- ния программы также должны осуществляться преобразования типов, для чего следует сгенерировать код еще в процессе компиляции. Позже, при рассмотрении генерации кода, будут приводиться примеры действий, что могут выполняться во время компиляции, а также действий, для которых в процессе компиляции нужно сгенерировать код, который позволит произвести действия во время выполнения программы. На следующем примере иллюстрируется еще один тип не-контекстно- свободных дефектов, которые могут возникнуть в программе. ^include <stdio.h> int bigger(int nol, int no2) { if (nol > no2) return nol; else return no2; } main() { int first, second; first = 4; second = 5; second = bigger (first); printf ( • second) ; ) W. Не-контекстно-свободные характеристики языка 159
Функция bigger определена с двумя параметрами, но из main она вы- зывается только с одним параметром, что приводит к выдаче следующей ошибки в процессе компиляции. Error 624.С 14: Too few parameters in call to 'bigger* in function main Типы параметров в вызове функции также должны соответствовать типам в объявлении. В языке С, разумеется, изменение типов между int, float и char осуществляется автоматически. В то же время несоответст- вие между параметром типа int в описании функции и массивом чисел типа int, используемым в качестве данного параметра функции, будет ошибочным. Подобного рода ошибки могут возникнуть в индексах массивов, как это показано на следующем примере. ^include <stdio.h> main О { int number; int matrix (3) [2] = {{4,5}, {8,9}, {11,12}}; number=matrix (1,1,1); printf ("%d", number); } Здесь к массиву matrix, определенному как двумерный, обращаются как к трехмерному массиву. Несколько иная разновидность контекстно-свободных дефектов воз- никает в связи с определенными в языке правилам области видимости. Проиллюстрируем это на следующем примере. ^include <stdio.h> int р = 7; void funi() {int p = 4; printf ("локальное p = %d\n*,p); {int p = 11; printf ("более локальное p = %d\n*,p); } } void fun2() {printf ("глобальное p = %d\n", p); } main() 160 Глава 6. Семантический анализ
{funl () ; fun2 () ; ) Выход данного кода будет иметь такой вид. локальное р = 4 более локальное р = 11 глобальное р =7 В то же время следующая ниже программа на С некорректна, поскольку было удалено объявление глобального р, и ни одно из других объявлений р не находится в области видимости f un2 (): ^include <stdio.h> void funl() {int p = 4; printf ("локальное p = %d\n", p); {int p = 11; printf ("более локальное p = %d\n", p) ; } } void fun2 () {printf ("глобальное p = %d\n", p); } main() {funl () ; fun2() ; } Эта программа даст при компиляции следующую ошибку. Error 628.С 14: Undefined symbol 'р' in function fun2 Ошибки времени компиляции, связанные с рассмотренными выше “программами”, не могут выявляться программой синтаксического ана- лиза, основанной исключительно на контекстно-свободной грамматике. •Иначе говоря, синтаксический анализатор, построенный с помощью YACC, не сможет обнаружить ни одной из подобных ошибок, обратив- шись к “пустой” позиции (соответствующей синтаксической ошибке) в созданной YACC таблице 1АЕК(1)-анализа. Таким образом, для обнару- жения этих ошибок потребуются дополнительные проверки. Тот факт, что данные типы ошибок не приводят к появлению ошибочной записи в таблице синтаксического анализа, значительно упрощает восстановление после них, поскольку для продолжения анализа не нужно делать каких- то предположений об ошибках программиста. Операция, выявляющая ошибки, может просто сообщать о проблеме и продолжить синтаксиче- ский анализ. Обычно программисту проще произвести подробную диаг- 6:2. Не-конгекстно-свободные характеристики языка 161
ностику на предмет наличия не-контскстно-свободных ошибок, чем най- ти контекстно-свободные ошибки. В следующем разделе рассмотрим природу действий, которые нужно добавить к программе синтаксического анализа для обнаружения ошибок типов и области видимости. В принципе, эти типы дефектов легко обна- ружить, поскольку вся необходимая информация считывается анализато- ром до места появления ошибки. Если же информация, требуемая дня обнаружения дефекта, находится после места его проявления, потребует- ся дополнительный проход или проходы. Впрочем, чтобы требуемая ин- формация была доступна, следует использовать таблицы с информацией о типах и области видимости. Создание и структура таких таблиц рас- сматриваются в двух последующих разделах. 6.3. Таблицы компилятора В процессе компиляции анализатору требуются две основные таблицы. • Таблица символов. • Таблица типов. Подробно данные таблицы рассматриваются ниже. Кроме того, необхо- димыми являются следующие таблицы. • Таблица функций. • Таблица меток. Эти таблицы рассматриваются в разделе 6.3.3. 6.3.1. Таблицы символов Основная задача таблицы символов — установить соответствие между пе- ременной и ее типом. С таблицей символов связаны следующие две ос- новные операции. • Соответств\тошая определяющему вхождению переменной, например, int х Имя переменной и ее тип помещаются в таблицу символов. • Соответствующая применимому вхождению переменной, например, х = 5; Исследуется таблица символов для нахождения типа переменной. Сложность таблицы символов и процедур работы с таблицей зависят ог. • языка реализации; • важности эффективной компиляции. Необходимо отметить, что неверным будет предположение о том, что только одна переменная в программе может быть представлена иденти- 162 Глава 6. Семантический анализ
фнкатором х, поскольку, в обшсм случае, в программе может находиться произвольное количество переменных с именем х. Таким образом, для каждого применимого вхождения переменной х определяется позиция таблицы символов, соответствующая подходящему определяющему вхож- дению переменной х. Форма таблицы символов, требуемой для анализа одной функции языка С, является простой. Рассмотрим схему функции С. void scopes() {int a,b,c; /* уровень 0 */ {int a,b; /* уровень la */ }’ {float c,d; /* уровень lb */ {int m; /* уровень 2 */ Таблицу символов можно представить с помощью растущих вверх стеков, если поиск в ней определяющего вхождения идентификатора осуществляется сверху вниз, и позиции удаляются из стека после выхода переменной из области видимости. Используя в качестве иллюстрации приведенную выше схему функции, покажем состояния стека на рахзич- ных этапах анализа. Изначально таблица символов пуста: После обработки первых трех объявлений таблица имеет следующий вид. с int b int a int После обработки объявлений уровня 1а таблица имеет такой вид. Поскольку поиск в таблице символов осуществляется сверху вниз, бу- дут идентифицированы позиции, соответствующие наиболее недавним (или наиболее глубоким) определяющим вхождениям а или Ь. После прохождения области видимости, соответствующей объявлениям 1а, по- зиции, соответствующие этим объявлениям, должны удаляться из табли- цы символов, таким образом, таблица вернется к прежнему виду. 6.3. Таблицы компилятора 163
В это же время значение указателя стека уменьшается до значения, имевшегося перед обработкой объявлений уровня 1а. Чтобы сделать это, требуется поддерживать массив указателей стека. После обработки объяв- лений уровня 1b стек принимает следующий вид. d float с float с int b int а int После обработки объявлений уровня 2 таблица имеет такой вид. m int d float c float c int b int a int После прохождения области видимости объявлений уровня 2 стек воз вращается к прежнему значению. d float c float c int b int a int После прохождения области видимости объявлений уровня 1b он вновь имеет следующий вид. с int b int a int После выхода из функции таблица символов снова становится пустой Существует несколько моментов, на которые стоит обратить внимание. Во-первых, для глобально объявченных переменных (между определениями функции) необходим нижний (или внешний) уровень стека, существующий во время всего процесса компиляции. Отметим также, что при рассмотрении операций таблицы символов внешняя переменная (для которой память выде- ляется в другом исходном файле) трактуется так же, как и глобальная. С точки зрения таблицы символов статическая переменная трактует- ся так же, как и автопеременная (подобная рассмотренным в примере), хотя при распределении памяти работа с этими переменными достаточно отличается. 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, например, use stack 6.3. Таблицы компилятора 165
Здесь stack — имя пакета, содержащего процедуры добавления и удале- ния элементов из стека, имена которых оператор use делает видимыми, не показывая при этом подробностей реализации стека. Из правил языка Ada следует: • оператор use может сделать идентификатор непосредственно ви- димым, только если тот не является непосредственно видимым при отсутствии этого оператора; • идентификатор, ставший непосредственно видимым при использо- вании оператора use, должен объявляться в одном и только одном пакете, указанном в операторе use. Учитывая эти правила, необходимо вставить корректную реализацию оператора use в видимую часть сегмента таблицы символов, указанную в операторе use “вверху" таблицы символов (таблицу символов представ- ляем как стек, а “верх" — это текущее значение стека). После того, как завершится область видимости оператора use, данную часть таблицы символов можно будет удалить. Типы соотносятся не только с переменными и константами; каждый эле- мент выражения имеет соответствующий ему тип, в том числе это относится: • к литералам, таким как 3,23.4, true; • к выражениям, таким как 3 + 4. Типы литералов обычно определяются при лексическом анализе (это не всегда справедливо для языка Ada, где для определения типа литерала может потребоваться достаточно сложный анализ). Например (предполагается использование языка С), 3, очевидно, имеет тип int, а 23.4 — float. Типы выражений определяются по типам их компонен- тов. Поскольку 23.4 и 34.2 имеют тип float, то выражение 23.4 + 34.2 также будет иметь тип float. Подобным образом выражение 23.4 + 5 будет иметь тип float. Это можно объяснить двумя способами, в зави- симости от рассматриваемого языка. • Переменная типа float плюс переменная типа int дает перемен- ную типа float. • Сложение определено только для переменных одного типа, по- этому перед сложением целое 5 должно преобразовываться в тип float. В большинстве языков программирования имеет место неявное изменение типов (иногда называемое приведением типов (coercion)). Реже встречаются языки, подобные Ada, в которых большинство из- менений типов должно быть явным. В языке С явно заданные изме- 166 Глава 6. Семантический анализ
нения называются приведением типов (cast)1, примером подобного может служить следующее выражение. х = float (m) Здесь значение m (предполагаемое целым) преобразовывается в тип float перед присвоением его значения переменной х. В языках со статическими типами, например С, все типы известны во время компиляции, и это относится к типам выражений, идентификато- рам и литералам. При этом не важно, насколько сложным является вы- ражение: его тип может определяться во время компиляции за опреде- ленное количество шагов, исходя из типов его составляющих. Фактиче- ски, это позволяет производить контроль типов во время компиляции и находить заранее (в процессе компиляции, а нс во время выполнения программы!) многие программные ошибки. 6.3.2. Таблицы типов В компиляторе должен существовать способ уникального представления каждого типа конкретной программы. Если исходный язык содержит только конечное число типов, для представления разрешенных типов можно использовать различные целые числа. Некоторые ранние языки, такие как FORTRAN, подобное позволяли, однако, более поздние языки в общем случае уже нельзя рассматривать так просто. При рассмотрении подходящего представления типов в программе необходимо принять во внимание следующие факторы. • Высокая структурированность и рекурсивная природа многих типов. • Общие операции, которые компилятор должен будет производить над типами. Общими операциями над типами в С являются следующие. • Нахождение типа поля элементов struct или union. • Нахождение типа элемента массива. • Нахождение типа результата функции. Основные типы, такие как int, float и char, могут представляться в С посредством целых чисел, а составные типы, например, array и un- ion, могут представляться как структуры. Например, тип typedef typedef struct ( int day; int mth; int year; }dob; 1 На русский язык слова “cast” и “coercion” переводятся одинаково — “приведение”, хотя происхождение этих терминов разнос: “cast” используется при описании языка С, a “coercion” — при описании языка ALGOL 68. — Прим. ред. 63. Таблицы компилятора 167
можно представить с помощью структуры. изображенной на рис. 6.1, а тип typedef Рис. 6.1. typedef long int [9] (19) matrix; можно представить с помощью структуры, показанной на рис. 6.2, и т.д. агг 9 агг 19 Рис. 6.2. Этот способ представления позволяет сравнительно легко выполнять обычные операции над типами. Остается всего лишь определить массив (таблиц}’ типов), который отображает имена типов в указатели на струк- туры типов. Поскольку используемые в программе имена типов, как и другие имена, имеют области видимости, в таблице также должна суще- ствовать возможность отображения областей видимости подобно тому, как это сделано в таблице символов. В простых случаях достаточной яв- ляется стековая структура таблицы. 6.3.3. Другие таблицы Число других таблиц, необходимых в процессе компиляции, определен- ным образом зависит от компилируемого языка. Обычно используются следующие таблицы. • Таблица функций. • Таблица меток. 168 Глава 6. Семантический анализ
Некоторым образом функция подобна типу: с ней соотнесены подтипы, т.е. типы параметров и результатов функции. В процессе генерации кода ей также выделяется адрес, а вся информация об этом хранится в табли- це времени компиляции, обзор которой производится согласно соответ- ствующим правилам языка. Как будет показано в главе 8, управляющие структуры в языках высокого уровня должны быть представлены в целевом коде посредством переходов (условных или иных) и меток. Таким образом, целевые версии исходных программ будут содержать как метки, определенные пользователем, так и метки, определенные компилятором. Структура многих языков позволяет использовать таблицу стекового типа для связывания определяющего вхожде- ния метки и применимого вхождения метки. Из этого следует, что, не- видимому, в процессе компиляции (и во время выполнения) придется ис- пользовать несколько стеков. Управление стеками, в смысле выделения каж- дому из них достаточной памяти, становится сложным, если число стеков больше двух, так что значительные преимущества дает возможность объеди- нения множества стеков в единую стековую структуру. К счастью, правила обзора многих языков такие возможности предоставляют. 6.4. Реализация наследования в C++ На данном этапе будет уместно немного рассказать о реализации объект- но-ориентированных языков программирования на примере C++. Основ- ными характеристиками объектно-ориентированных языков программи- рования являются: • абстракция данных и инкапсуляция; • полиморфизм; • наследование. Абстракцию данных можно проиллюстрировать с помощью классов C++. Рассмотрим следующий пример. class complex { float real, imag; public: complex (float = 0.0, float = 0.0); complex (const complex&); float get_real() const; float get_imag() const; }; Класс complex определяется вместе с некоторыми операциями для соз- дания числа типа complex и для нахождения его действительной и мни- мой частей. Полиморфизм можно проиллюстрировать с помощью конст- рукторов (constructor function), которые ведут себя по-разному, в зависи- мости от того, имеют ли они два параметра типа float или один типа complex. Полиморфизм позволяет различать функции или процедуры с 6.Д р&тюння наследования в C++ 169
одинаковыми именами, поскольку типы и число их параметров не совпа- дают. Это означает, что в данном примере конкретную функцию можно определить посредством типа ее параметров. Понятие полиморфизма уже применяется во многих языках для операторов, которые могут иметь раз- личные значения в зависимости от типов их операндов. Простым приме- ром может служить оператор + в С, который имеет разные значения в за- висимости от того, имеют ли его операнды тип float или int. Поли- морфические функции — это просто расширение понятия функции, реализация которого является более сложной. Понятие наследования проиллюстрируем на следующем примере: class addcom: public complex {public: adds (const complex&, const complex^) } Здесь определяется новый класс addcom, который наследует все свойства класса complex, а также имеет дополнительный оператор. Ест новый класс является наследником только одного порождающего масса, как в языке Java, имеем дело с единичным наследованием. при этом но- вый класс имеет все поля и методы своего порождающего класса плюс поля и методы, которые относятся только к нему самому. В языке Java класс мо- жет содержать описание метода с таким же названием, что и метод порож- дающего класса. В этом случае метод порождающего класса не будет наследо- ваться, а будет переопределяться как метод класса с таким же названием (предполагается, что метод порождающего класса не является абстрактным). Реализация наследования скорее основывается на содержании объекта, чем на его типе, следовательно, имеет динамическую (проявляется во время выполнения), а не статическую (проявляется во время компиля- ции) сущность. При единичном наследовании это достаточно просто. С другой стороны, множественное наследование, при котором класс может иметь несколько порождающих классов, более сложно в реализации. Примером множественного наследования может служить реализация сте- ка, который наследует методы абстрактного класса stack и конкретного класса array. Читателю, интересующемуся данными вопросами, стоит обратиться к соответствующей литературе, которая приводится ниже. 6.5. Резюме Настоящая глава посвящена тому, как можно анализировать и реализо- вать характеристики языка, которые нельзя описать с помошью контек- стно-свободной грамматики. В частности, было сделано следующее. • Показано, что не-контекстно-свободные характеристики языка обычно связаны с типами и правилам обзора языка. • Показано, как можно использовать таблицу символов для хране- ния информации, необходимой для нахождения не-контекстно- свободных дефектов программы. 170 Глава 6. Семантический анализ
• Продемонстрировано, каким образом структура таблицы символов зависит от компилируемого языка, а также приведен ее вид для компилятора С. • Предложена оптимизация таблицы символов. • Рассмотрена роль, которую таблица символов играет в компилято- ре, и предложена форма, удобная для выполнения этой роли. • Обсуждена необходимость других таблиц в процессе компиляции. • Указано, как можно реализовать типичные характеристики объ- ектно-ориентированных языков, таких как С и Java. Дополнительная литература Использование таблиц символов описано во всех вводных книгах по компиляторам, указанных ранее. Структуры данных для таблиц симво- лов, а также алгоритмы их поиска обсуждаются в [Aho, Hopcroft and Ull- man, 1974]. Эквивалентность типов (очень важный и непростой вопрос при рассмотрении таблицы типов) и ее проявления для языка Pascal рас- смотрены в [Welsh, Sneeringer and Ноаге, 1977]. Реализация единичного и множественного наследования изложена в [Wilhelm and Maurer, 1995]. Упражнения 6.1. Напишите список не-контекстно-свободных характеристик С или любого другого известного вам языка программирования. 6.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.1. При рассмотрении адресов переменных и т.д. следует отметить, что иногда (например, при использовании статической памяти) адреса вре- мени выполнения известны во время компиляции. В то же время чаше имеем обратную ситуацию, когда адреса времени выполнения должны вычисляться, исходя из множества факторов, часть которых известна в процессе компиляции, а часть неизвестна до начала выполнения про- 7.2. Память 175
граммы. В этих случаях аспекты адреса, известные при компиляции, на- зываются адресом времени компиляции. В языке С для переменных имеется четыре возможных класса памяти. static, auto, extern и register. Для статических переменных память выделяется на все время программы. Дтя переменных класса auto (класс по умолчанию) память выделена до момента завершения работы составного оператора (термин С соответствует блоку в некоторых других языках), в котором были объявлены данные переменные. Таким образом, более удобной для данных переменных является память в стеке. Для переменных класса extern память выделяется в другом файле. Значения переменных класса register хранятся в регистре, если компилятор способен организовать это удобным образом, в противном случае такие переменные эквивалентны переменным auto. Помимо памяти, необходимой переменным программы на С, с по- мощью malice можно выделить память для значений, к которым обра- щаются посредством указателей, например: Р = malloc(sizeof(int)); В данном выражении выделяется достаточно памяти для целого значения, а р является указателем на это значение. Эта память может освобождаться по- сле того, как ни одна переменная программы (в том числе р) не будет указы- вать на данную область памяти. В то же время, поскольку это невозможно определить в процессе компиляции, область, выделяемая посредством mal- ice. обязательно должна располагаться в куче. 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 m; /*уровень 2*/ } ) ) В контексте таблицы символов нас интересовала информация о типах и хранении типов переменных, т.е. вопросы, относящиеся к периоду компиляции. Во время выполнения программы важнее значения, а не типы, и это отражается на структуре стека времени выполнения, который запоминает значения так, как таблица символов запоминает типы. По ме- ре выполнения представленного фрагмента программы проследим, как может выделяться память в стеке времени выполнения во многом этот процесс подобен изменению содержимого таблицы символов в период компиляции программы. Изначально стек времени выполнения пуст. После объявления а b с (уровень 0) стек выглядит следующим образом. 1 = 1 ь | а | Здесь а представляет область памяти для хранения значения переменной а и т.д. После объявления уровня I стек может выглядеть так. I b I а с b I а I После прохождения уровня 1а во время выполнения программы стек возвращается к предыдущему состоянию. 7.3. Статическая и динамическая память 177
с b а В начале уровня 1b он может преобразоваться к такому виду, d с с Ь а Здесь для значений end типа float выделено вдвое больше памяти, чем для значений а, Ь и с типа int. В начале уровня 2 стек принимает вид ТП d с с Ь а После выхода с уровня 2 стек становится таким, d с с Ь а После прохождения уровня 1b стек возвращается в состояние 178 Глава 7. Распределение памяти
с b а и вновь становится пустым после завершения функции scopes. Согласно данному выше описанию после завершения выполнения со- ставного оператора область выделенной ему памяти стека просто освобо- ждается. Для этого может использоваться массив указателей, каждый элемент которого указывает на основание сегмента стека, который соот- ветствует выполняемому в данный момент составному оператору. Для определения адреса переменной по отношению к основанию сте- кового фрейма необходимо всего лишь знать объем памяти, занимаемый значениями каждой из переменных, расположенных в стеке ниже рас- сматриваемой переменной; эта информация (по меньшей мере, для про- стых переменных) известна в процессе компиляции. На практике стековый фрейм может не расширяться и сжиматься при входе и выходе из каждого составного оператора или блока, как это было выше. Вместо этого при вызове каждой функции может выделяться мак- симально необходимое пространство памяти для фрейма. Описанная модель достаточна для удовлетворения требований к памя- ти одной простой функции, но не программ, содержащих множество функций, которые могут вызывать друг друга. Таким образом, требуется более общая модель. Поскольку рассматривается динамическая память, то на любом этапе выполнения программы память необходима лишь тем функциям, что используются в данный момент. Кроме того, выход из функций будет происходить в порядке, противоположном порядку их вы- зова, так что модель не будет сильно отличаться от уже рассмотренной. Основное отличие заключается в том, что последовательность вызовов функций, в общем случае, во время компиляции неизвестна. Рассмотрим следующий фрагмент программы на С. main() ( first (); second (); } first () { secondO ; } Как видно, функцию second () можно вызвать любым из двух способов. I. Непосредственно из main(). 2. Из first (), которая вызывается из main (). На рис. 7.2 и 7.3 изображены соответствующие стеки времени выпол- нения. Через secondO помечена область стека, соответствующая функ- ции second (), и т.д. 7.3. Статическая и динамическая память 179
Рис. 7.2. second () second() main () first () main () Рис. 7.3. Как говорилось ранее, адрес переменной по отношению к основанию стекового фрейма, в котором она хранится, известен в процессе компиляции. В то же время расположение стекового фрейма по отношению к основанию стека, в общем случае, во время компиляции неизвестно и должно опреде- ляться уже во время выполнения программы. Программа на языке С имеет одно характерное свойство: во время выполнения доступ к переменным (это не относится к переменным класса extern и глобальным переменным) воз- можен только из простой функции (функции, которая активна в данный мо- мент). Следовательно, если во время выполнения программы имеется указа- тель на начало текущего стекового фрейма, то информации о значении ука- зателя и адреса переменной внутри секции стека (известен во время компиляции) достаточно для нахождения адреса переменной по отношению к основанию стека. Указатели на начало каждого стекового фрейма, которые соответствуют активным в данный момент функциям, называются множеством динамиче- ских указателей стека. После завершения выполнения функции, соответст- вующей верхнему стековому фрейму, управление возвращается функции, чей фрейм располагается ниже, при этом становятся доступными любые се пе- ременные. Следовательно, для каждого стекового фрейма, находящегося в данный момент в стеке, необходимо запоминать значения динамических ука- зателей. указывающих на этот фрейм. Как показано на рис. 7.4, это можно осуществить с помощью массива указателей. Для поддержания массива динамических указателей необходимы сле- дующие действия времени выполнения программы. • Включение начального адреса нового стекового фрейма в массив указателей при вызове каждой новой функции. • Удаление верхнего значения массива указателей каждый раз при окончании работы с функцией, соответствующей покидаемому стековому фрейму. /80 Глава 7. Распределение памяти
Массив динамических указателей Стек времени выполнения В качестве альтернативы вместо использования массива указателей мож- но запоминать динамические указатели в самом стеке. В языке С указатели на основания фреймов в стеке времени выпол- нения требуются только для того, чтобы после прекрашения работы с вызванной функцией среда вызова могла быть создана заново. В 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: integer; begin end; begin second; third end; 7 3 Статическая и динамическая память 181
begin first end. В момент вызова процедуры second стек времени выполнения может выглядеть подобно изображенному на рис. 7.5. Стек времени выполнения q Р d с У х Рис. 7.5. Чтобы облегчить доступ к переменным, объявленным во внешних об- ластях видимости, модель памяти Pascal, возможно, должна будет иметь указатели (называемые статическими указателями) к каждому из доступ- ных в данный момент внешних блоков. Массив таких указателей обычно называют дисплеем, и его вид подобен изображенному на рис. 7.6. В то же время он не обязательно соответствует массиву указателей ко всем процедурам, которые сейчас выполняются. Предположим, например, что в процедуре third также имеется обращение к процедуре second. program demo (output); var x, у: real; procedure first; var c, d: integer; procedure second; var p, q: char; begin end; procedure third; var m, n: integer; 182 Глава 7. Распределение памяти
begin second; end; begin second; third end; begin first end. Дисплей Стек времени выполнения Ситуация непосредственно перед вызовом second из third изо- бражена на рис. 7.7, а сразу же после вызова — на рис. 7.8. Из иллю- страций видно, что в дисплее содержатся только указатели на блоки с переменными, доступными в данный момент, следовательно, по od- но.чу указателю к каждому статическому уровню, к переменным ко- торого возможен доступ. Данная ситуация показана на рис. 7.8, где отсутствует возможность доступа к переменным third после вызова second из third. Это связано с тем, что second и third располага- ются на одном статическом уровне! 7.3. Статическая и динамическая память 183
Дисплей Стек времени выполнения Дисплей Стек времени выполнения 184
В модели распределения памяти с использованием дисплея, если функция (или процедура) объявлена на том же статическом уровне, что и вызываемая в данный момент функция, происходит обновление значе- ния указателя на вершине дисплея; если же функция статически объяв- лена внутри вызываемой в данный момент функции, то на дисплей по- ступает новое значение. Подобное имеет место и при завершении работы функции. После завершения функции возможно возвращение или к функции того же статического уровня, или к функции вмещающего уровня. В первом случае происходит обновление верхнего значения дис- плея, а во втором — верхний элемент дисплея удаляется. Возможен еще один случай: функция (или процедура) вызывает саму себя — рекурсивный вызов, возможный во многих языках, включая Pas- cal и языки, родственные С. В этом случае вызывающая среда и вызы- ваемая среда статически находятся на одном уровне, и верхний элемент дисплея должен обновляться при начале и завершении каждого вызова. Чтобы восстановить элементы дисплея, можно хранить значения дина- мических указателей в основании каждого стекового фрейма. 7.4. Адреса времени компиляции В общем случае, в процессе компиляции адреса переменных неизвестны. Укажем некоторые причины, почему это происходит. • Во время выполнения программы расположение стекового фрей- ма, который соответствует конкретной функции или процедуре, зависит от порядка вызова функций (процедур). • В процессе компиляции значение индексов массива обычно неиз- вестно и будет вычисляться при выполнении программы. • Доступ к некоторым переменным осуществляется посредством указателей, значения которых в процессе компиляции неизвестны. Хотя в процессе компиляции адреса неизвестны, часть информации о них обычно имеется. Например, известны следующие параметры. • Смещение простого значения относительно основания стекового фрейма. • Смещение начала массива относительно основания стекового фрейма. • Статическая глубина функции, в которой объявлена переменная. Статическая глубина (пункт 3) относится к языкам Pascal и Ada (в С та- кого понятия нет). В языке С адрес простой переменной в процессе компиляции представ- ляет собой смешение по отношению к основанию стекового фрейма. Это же относится и к полю записи, так как поля записи всегда запоминаются после- довательно, и предполагается, что объем требуемой памяти для каждого из 7.4. Адреса времени компиляции 185
палей известен. Для языка Pascal или Ada адрес времени компиляции про- стой переменной или пазя записи будет состоять из пары: (номер уровня, офсет) Здесь номер уровня — номер статического уровня функции или процедуры, в котором была объявлена переменная или запись, а термин “офсет" употреб- ляется с тем же значением, что и в языке С (смешение от начала фрейма). Для массивов со статическими границами (значение границ известно в процессе компиляции) адрес элемента массива, в зависимости от при- меняемого языка, можно также выразить через номер уровня и офсет или просто через офсет. Смешение элемента массива по отношению к основанию стекового фрейма состоит из двух частей. • Смешение начала массива по отношению к основанию стекового фрейма. • Смешение элемента массива по отношению к началу массива. Для массивов со статическими границами значение первой части из- вестно в процессе компиляции, а второй, в общем случае, — нет, по- скольку, повторимся, в процессе компиляции обычно неизвестно зна- чение индексов массива. При нахождении адресов элементов массива часть вычислений осуще- ствляется во время выполнения программы с использованием информа- ции, известной при компиляции. Как будет показано далее, объем вы- числений зависит от размерности массива. Проиллюстрируем сказанное с помощью следующего примера на языке Pascal. Рассмотрим объявление массива. var table: array [1. .10,1..20] of integer Элементы массива обычно записывают построчно или, точнее, согласно лексикографическому порядку индексов. Например, значения элементов приведенной таблицы будут занесены в память в следующем порядке. table [1,1], table [1,2],..., table [1,20], table [2,1], table [2,2],..., table [2,20], table [10,1], table [10,2].......... table [10,20] Адрес конкретного элемента массива вычисляется как смешение от адре- са первого элемента массива. adpec(table [/, /]) = адрес (table [A, У) + (иг - fe + 1) • (/ — Л) + (/ — fe) Здесь /| и U| — нижняя и верхняя границы первого измерения и т.д., а каждый элемент массива предполагается размером в одну ячейку памяти. В приведенном выше примере нижние границы в каждом случае равны 1. а верхние — 10 и 20 соответственно. Для трехмерного массива аггЗ, объявленного как var аггЗ: array [1Х. .щ, I2..U2, I3..U3) of integer 186 Глава 7. Распределение памяти
общая формула для адреса элемента массива arr3[i,j,k) имеет сле- дующий вид. adpec(arr3[i, j, к]) = адрес(аггЗ [А, fe, У) + (Мг - h + 1Г (из - h + 1) * (iА) + (u3-/3 + 1)*G-fe) + (k-y Выражение (иг-/г+1) представляет число различных значений, которые мо- жет принимать г-й индекс, т.е. (иу~ А+ 1) — это число значений, что может принимать третий индекс, а также расстояние между элементами массива, которые отличаются на единицу во втором индексе. Подобным образом (иг - k + 1) * (из - /з + 1) представляет число различных пар значений, которые могут образовать второй и третий индексы, а также расстояние между элементами масси- ва, которые отличаются на единицу в первом индексе. Расстояние между элементами массива, которые отличаются на единицу в z-ом индексе, на- зывают шагом по i-ому индексу (rth stride). Таким образом, в приведенном выше примере шаг по первому индексу равен (иг - h + 1) * (из - /3 + 1), по второму и третьему — (иу - /, + 1) и 1 соответственно. Из приведенной выше формулы для нахождения смешения элемента массива относительно адреса первого элемента массива понятно, что вы- числения становятся достаточно простыми, если известны шаги по ин- дексам. Например, для аггЗ адрес элемента arr3(i,j,k) выражается следующим образом. adpec(arr3[i. j, /ф = адрес(агтЗ[к. k, У) + Л * ('-А) + «г * (/- fe) + «з * (к- А) Здесь sb s, — шаги по соответствующим индексам, равные следующему. (Иг - fe + 1) * (и3-(з + 1) («з-Ь+ 1) 1 На рис. 7.9 показано применение шагов по индексам для нахождения адреса элемента массива из следующего массива, объявленного таким образом (язык Pascal). N(1,1,1)N(1,1,2) .N (1,1,10),N(l, 2,1) .. . N (2,1,1) . .N[10,10,10] Рис. 7.9. 7.4. Адреса времени компиляции 187
var N: array [1..10, 1..10, 1..10] of integer; Для языков, в которых границы массива известны во время компиля- ции, значения шагов по индексам могут вычисляться сразу же (во время компиляции), что сокращает количество вычислений времени выполне- ния программы при каждом обращении к массиву. В то же время, даль- ше упростить приведенную формулу уже невозможно, поскольку раз- ность (/ - /,), в общем случае, во время компиляции неизвестна. Для язы- ков с динамическими границами (до выполнения программы они неизвестны) шаги по индексам можно найти после объявления массива и занесения его в стек, что опять же уменьшит количество вычислений, выполняемых при каждом обращении к массиву. Хотя значения шагов по индексам в процессе компиляции могут быть неизвестны, практиче- ски всегда будет известен объем памяти, которую будут занимать шаги по индексам, и память для них может быть выделена в процессе компиля- ции. В то же время память для самих элементов массива может выде- ляться только при выполнении программы, поскольку при компиляции значения границ могут быть неизвестны. Для рассмотрения динамических массивов (массивов с динамически- ми границами) требуется более общая модель стека времени выполнения, чем рассмотренная ранее. В общем случае неизвестно расположение на- чала массива в стековом фрейме. Поэтому каждый стековый фрейм удобно разбить на две части: статическую часть, в которой содержатся значения, известные во время компиляции, и динамическую часть, со- держащую значения, неизвестные в процессе компиляции. Все значения динамической части можно будет получить (с помошью указателей) из значений статической части. Следовательно, в статической части фрейма будут содержаться следующие значения. • Все простые значения (типы integer, float и т.д.). • Статические части массивов (границы, шаги по индексам, указате- ли на элементы массива). • Статические части записей (поля, размеры которых известны во время компиляции). • Указатели на глобальные значения — хотя глобальные значения будут храниться не в стеке, а в куче. С другой стороны, в динамической части фрейма будут находиться эле- менты массива. При использовании этой модели на практике даже эле- менты массива со статическими границами будут храниться в динамиче- ской части фрейма. Описанная более общая модель стекового фрейма изображена на рис. 7.10. В этой модели для доступа к элементам массива (по сравнению с дос- тупом к элементам, не входящим в массив) необходимы дополнительный указатель и офсет. Значение номера уровня фрейма дает первый указа- тель с дисплея. К этому добавляется офсет указателя (в статической части массива) относительно начала массива, кроме того, во время выполнения 188 Глава 7. Распределение памяти
. ипрличивастся, чтобы представлять адрес программь| данный указатель увели iiwav » конкретного элемента массива. Дисплей Стек времени выполнения Динамическая часть фрейма Статическая часть фрейма В процессе компиляции адрес массива в целом — это просто уровень и офсет, соответствующий началу статической части массива. Для нахождения адреса во время выполнения требуются вычисления, общий вид которых приведен выше. Очевидно, что доступ к элементам массива занимает много времени, в особенности для многомерных массивов. Эго время можно уменьшить, если производить вычисление шагов по индексам только один раз. Для массивов с динамическими границами (в отличие от массивов со статическими границами) при выполнении программы время также тратится на каждое обращение к дополнительному указателю. 7.5. Куча Как уже говорилось, куча используется для хранения значений, к которым может потребоваться доступ от момента выделения для них памяти и до за- вершения программы. Не существует механизма языка, подобного выходу' из блока или функции, который сделает область памяти недоступной. На пер- вый взгляд схема распределения для такой памяти должна выделять память от одного конца линейного пространства до другого, пока свободная память не будет распределена полностью. Может показаться, что при этом никаких проблем с перераспределением или чрезмерным использованием памяти возникнуть не должно. В то же время данный подход имеет существенный недостаток, а именно: после первого полного распределения памяти приме- нение следующего оператора, например, string = malloc(4); 7.5. Куча 189
который попытается выделить четыре бита памяти и вернуть указатель на эту область, вызовет ошибку в программе. Впрочем, прежде чем сми- риться с этим, следует вспомнить, что область памяти может стать недос- тупной вследствие таких операций программы, как переназначение ука- зателей и т.д. Например, выделенное ранее пространство может стать не- доступным для переменной string после следующего присваивания. string = newstring; В то же время данная операция позволяет получить доступ к рассматри- ваемому пространству некоторой другой переменной. Рассмотрим резуль- тат выполнения присваивания stringl = string; между двумя указанными выше операторами. Считая, что другие опера- торы, связанные с данными, отсутствуют, подобные действия приведут к тому, что переменной stringl будет доступ но пространство, выделенное оператором mall ос. Поскольку, в общем случае, в процессе компиляции неизвестно, как будет выполняться программа, то при компиляции невозможно узнать, когда станет недоступной память, выделенная оператором malloc. Это означает, что код для восстановления области кучи не может быть сгене- рирован в процессе компиляции, хотя недоступными могут стать боль- шие области, выделенные в куче. Один из способов преодоления такой трудности заключается в том, чтобы программисты (исходя из своих зна- ний о том, как будет выполняться программа) предугадывали момент, когда память кучи становится недоступной, и вводили в исходный код явные инструкции для перераспределения памяти. Например, в С дня освобождения области памяти, отведенной переменной string, можно записать следующее. free (string); В то же время данный подход требует от программиста большого про- фессионализма и ответственности. Итак, какие-либо автоматизирован- ные методы освобождения памяти применять нежелательно. В языке Java предполагается, что ответственность за освобождение недоступной памя- ти должна возлагаться на реализацию Java, а не на программиста; таким образом, любая реализация Java должна иметь соответствующие меха- низмы. Примечательно, что в отличие от ранее описанных моделей па- мяти, Java сохраняет в куче массивы. Все объекты также хранятся в куче. В связи с восстановлением недоступных областей памяти существуют два возможных метода управления кучей. • сборка мусора (garbage collection); • использование счетчиков ссылок (use of reference counters). Первый метод, пожалуй, является более популярным, но и более необхо- димым. Преимущество этого подхода заключается в том, что до полного распределения всего доступного пространства памяти не возникает по- 190 Глава 7. Распределение памяти
трсбности в восстановлении любой его части. Вследствие этого во многих случаях для сборки мусора времени вообще не требуется. Если (и когда) сборка мусора все-таки требуется, этот процесс происходит в две фазы. • Фаза маркировки, в которой (посредством введения значений в би- товую карту) помечается память кучи, доступная для переменных программы. • Фаза сжатия, в которой все доступное пространство сдвигается в один конец кучи, а память, подлежащая повторному использоза- нию, образует непрерывный блок в другом конце кучи. При этом, разумеется, следует аккуратно проверить, чтобы соответствующим образом изменились все значения указателей. Из этих двух фаз фаза маркировки наиболее интересна и допускает меньше альтернативных способов реализации. Требуются некоторые средства "маркировки” ячеек памяти, к которым при необходимости могут обращать- ся переменные программы. Для этого может использоваться битовая карта с достаточным числом битов для сопоставления с каждой ячейкой кучи. Бито- вая карта не является частью кучи и располагается отдельно от нее. Каждый бит в битовой карте может принимать одно из двух значений. 1. О — соответствующая ячейка памяти не доступна для переменных программы. 2. 1 — соответствующая ячейка памяти доступна для переменных про- граммы. В начале процесса сборки мусора все элементы битовой карты имеют значение 0, а при выполнении алгоритма различным элементам карты присваивается значение 1. В завершение сборки мусора значение "1” по- лучат все элементы битовой карты, которые соответствуют ячейкам па- мяти, доступным для переменных программы. Простой алгоритм сборки мусора использует стек (называемый сте- ком сборки мусора) и заключается в следующем. Сборка мусора 1 1. Стек времени выполнения линейно просматривается, пока не будет обнаружена переменная, указывающая на непомеченную ячейку кучи. Это может быть или собственно переменная, которая является указа- телем (в кучу), или компонент записи, который является указателем. В дальнейшем, все ячейки кучи, на которые указывают подобные пе- ременные, маркируются посредством включения соответствующих бит в битовую карту. 2. Некоторые ячейки, в свою очередь, могут быть указателями на непо- меченные ячейки кучи. В этом случае их адреса помешаются в стек сборки мусора. j Далее следуют адреса с верха стека сборки мусора или (если стек сборки мусора пуст) адреса, содержащиеся в следующем указателе на 7.5. KV*1 191
стек времени выполнения. Затем маркируются все непомеченные ячейки кучи, на которые указывает куча, и их адреса помешаются в стек сборки мусора. 4. Третий шаг повторяется до тех пор, пока освободится стек сборки му- сора, и все указатели в стеке времени выполнения будут обработаны описанным образом. Поскольку на третьем шаге всегда маркируются непомеченные ячейки, то, в конце концов, выполнение алгоритма прекратится. Описанный выше алгоритм является наглядным, простым для понимания и эффективным. Однако, у него имеется один существенный недостаток — он нереальный, поскольку требует использования стека произвольного разме- ра в момент наибольшей загруженности памяти. Другими словами, сборка мусора просто не будет инициирована! Безусловно, никто не ожидает, что чистка памяти будет выполняться при отсутствии пространства для работы. В то же время, поскольку для сборки мусора требуется небольшой (и извест- ный) объем памяти, то при нехватке памяти ее можно инициировать в пер- вую очередь. Фактически, существует алгоритм сборки мусора с предельно малыми запросами рабочего пространства. Сборка мусора 2 1. Пометить все ячейки кучи, на которые прямо указывают значения из стека времени выполнения. 2. Просмотреть кучу, начиная с низших адресов, чтобы найти первый помеченный указатель, указывающий на непомеченную ячейку. По- метить эту ячейку. 3. Продолжить просмотр кучи, помечая непомеченные ячейки, на которые указывают помеченные ячейки. Выделить адрес ячейки с наименьшим адресом, помеченным таким способом. Назвать этот адрес низшим. 4. Повторять шаги 2 и 3, уже начиная с низшего адреса, пока при про- смотре будет помечаться хотя бы одна ячейка. Поскольку число ячеек, которые необходимо пометить, конечно, то, в конце концов, выпол- нение алгоритма прекратится. Помимо пространства, необходимого для битовой карты, алгоритму также требуются три переменные, представляющие: • текущую позицию при просмотре; • ячейку, к которой идет обращение; • низший адрес, к которому должно идти обращение при текущем просмотре. В то же время, с точки зрения затрачиваемого времени этот алгоритм может быть крайне неэффективным. В частности, это может быть в том случае, когда в куче содержится много обратных указателей, и это явля- ется ценой за неиспользование стека. 192 Глава 7. Распределение памяти
Компромиссом между двумя описанными алгоритмами будет алго- ритм, придерживаюшийся стратегии I при достаточно свободной памяти и стратегии 2 — в противоположном случае. Например, если стек доста- точно большой, то алгоритм может использовать стек фиксированного размера и придерживаться первой стратегии. Как только при увеличении стека станет реальной угроза его переполнения, из стека (из нижней час- ти) может удаляться одно значение. Удаленное таким образом нижнее значение стека запоминается и используется для начала второй фазы ал- горитма, которая во многом будет подобна сборке мусора 2. В другом хорошо известном алгоритме (см. раздел дополнительной литературы в конце главы) куча рассматривается как древовидная струк- тура с указателями от вершины к основанию. Сборка мусора начинается с вершины дерева и идет по направлению вниз. Вместо использования стеков для запоминания указателей, требующих последующей обработки, алгоритм использует указатели самого дерева, временно обращая их для обеспечения пути возврата вверх по дереву. Этот алгоритм эффективнее w с точки зрения времени, и с точки зрения требуемой памяти. Другие схемы очистки памяти включают различные схемы сборки му- сора с учетом поколений (generational garbage collection), в которых произ- водится разделение: • между глобальными объектами, которые существуют относительно долго еще до инициации процесса сборки мусора, и память для которых очищать не обязательно; • локальными объектами, которые существуют меньшее время, и память которых постоянно требуется возвращать в доступную область. Очевидно, что такая схема уменьшает время сборки мусора и может быть достаточно эффективной. В других схемах для уменьшения времени сжатия кучи используются две глобальные области. Ссылки на некоторые приводятся в разделе дополнительной литературы в конце главы. Какой бы метод сборки мусора не использовался, может случиться так, что программа просто исчерпает доступную память и будет вынуж- дена завершить работу, если только система не разрешит эту проблему каким-то иным способом. Память программы может также ограничивать- ся за счет сборки мусора, если при используемом алгоритме большая часть времени уходит именно на чистку памяти — вскоре после заверше- ния сборки мусора, когда программа уже кажется готовой к продолже- нию работы, куча снова переполняется, что вновь требует проведения очистки памяти. В такой ситуации служебные издержки на проведение сборки мусора могут быть очень значительными, и именно здесь будет уместным альтернативный подход — использование счетчиков ссылок. Этот метод позволяет (достаточно часто) заменить непредсказуемые из- держки на сборку мусора издержками постоянными и предсказуемыми. При использовании счетчиков ссылок предпринимается попытка очи- стить каждый элемент памяти кучи сразу же после прекращения обраще- ний к нему. Каждая ячейка памяти в куче имеет счетчик ссылок, в кото- 7.5. Куча 193
ром фиксируется число значений, обращающихся к данной ячейке. По- явление каждой новой переменной, обращающейся к данной ячейке, увеличивает значение счетчика, а исчезновение ссылки уменьшает его. Когда значение счетчика становится нулевым, ячейка может быть воз- вращена в область свободной памяти для дальнейшего распределения. Этот метод удачен, но имеет некоторые ограничения. • Не может очищаться память, которая связана со структурами дан- ных, подобными кольцевым спискам. • Постоянные издержки, связанные с использованием счетчиков ссылок, могут сильно уменьшать эффективность программ с пре- дельно малыми запросами относительно памяти. В заключение отметим, что второй пункт противоречит принципу Бауэра, который утверждает, что “простые программы" не должны платить за неиспользование существующих дорогих характеристик языка. 7.6. Резюме В этой главе рассмотрен процесс распределения памяти в типичных язы- ках программирования. В частности, было сделано следующее. • Определено различие между статической, динамической и глобаль- ной памятью. • Описана модель стека времени выполнения для динамической па- мяти, включая использование стековых фреймов и дисплея. • Введено понятие адреса времени компиляции. • Описаны механизмы хранения массивов и доступа к массивам. • Обсуждено использование кучи для хранения глобальных значений. • Описаны альтернативные методы сборки мусора для очищения требуемой памяти кучи. • Рассмотрено использование счетчиков ссылок для контроля памя- ти кучи, а также рассмотрены преимущества и недостатки исполь- зования счетчиков ссылок в качестве альтернативы сборке мусора В следующей главе будут рассмотрены методы и принципы генерации кода. Дополнительная литература В большинстве книг по компиляторам вопросы распределения памяти рассматриваются достаточно хорошо, например, можно порекомендовать книги [Loudon, 1997] и [Terry, 1997]. Понятие стека времени выполнения впервые было использовано в ранних компиляторах ALGOL 60 и описывается в [Naur, 1964] и [ Randell 194 Глава 7. Распределение памяти
and Russell, 1964]. Понятие кучи появилось несколько позже и впервые потребовалось в таких языках, как SNOBOL 4, LISP 1.5 и ALGOL 68. В (Knuth, 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. Обсудите, что является более предпочтительным в среде реального вре- мени — управление посредством счетчика ссылок или сборки мусора?

Глава 8 Генерация кода 8.1. Вступление В этой главе будет изучена фаза генерации кода при компиляции. В ча- стности, будут рассмотрены следующие вопросы. • Различные типы промежуточного кода, создаваемого компилято- ром, и то, как они генерируются. • Основные типы современных машинных архитектур и создание кода для них. • Оптимизации кода и ее осуществление в различных фазах процес- са компиляции. • Некоторые моменты, связанные с генераторами генераторов кода. В то же время вместо всестороннего рассмотрения всех аспектов гене- рации кода основное внимание будет уделено вопросу создания кода. Де- тальное рассмотрение того, как может выполняться код, созданный для какой-то конкретной машины, скорее запутает, чем прояснит ситуацию. Как обычно, в конце данной главы можно будет найти ссылки на лите- ратуру, которая поможет при рассмотрении более сложных моментов. 8.2. Создание промежуточного кода Как уже говорилось в разделе 1.4, существует ряд причин для создания компиляторами промежуточного кода как первого шага к созданию кода Для реальных машин. Перечислим эти причины. • Обеспечение четкого разделения меду машинно-независимой и машинно-зависимой частями компилятора. • Минимизация усилий для переноса компилятора в новую среду. • Минимизация усилий для реализации т языков на п машинах. • Простота оптимизации. Промежуточный код может выглядеть по-разному. Он может связы- ваться с реализуемым языком, например P-код для языка Pascal, Diana Для языка Ada, байт-код для языка Java. В качестве альтернативы он мо-
жет также связываться с машинами, на которых осуществляется реализа- ция. Пример: язык CTL (Compiler Target Language), который применялся в 70-х годах в Манчестерском университете в качестве промежуточного языка для машины MU5. Промежуточный язык может быть близким к реализуемым языкам или к машинам, на которых осуществляется реали- зация. В любом случае он представляет собой линеаризацию синтаксиче- ского дерева, созданного в процессе синтаксического и семантического анализа, и формируется посредством разбиения древовидной структуры на последовательность инструкций, каждая из которых эквивалентна од- ной или нескольким (небольшому числу) машинным командам. Машин- ный код может генерироваться, исходя только из промежуточного кода, хотя при этом может потребоваться доступ к таблице символов или дру- гая информация, относящаяся к процессу компиляции. В качестве примеров промежуточных кодов рассмотрим три хорошо известных кода. 1. Трехадресный код. 2. Р-код — ориентированный на конкретный язык промежуточный код, на котором основано большинство реализаций Pascal. 3. Байт-код, используемый Java Virtual Machine. 8.2.1. Трехадресный код Примером трехадресного кода (three-address code) является строка а = Ь ор с Здесь ор — арифметический (или другой) оператор, ь и с — его операн- ды (или их адреса), а а — адрес результата применения оператора к опе- рандам. Арифметическое выражение (a + b)*(c+d) можно представить в виде последовательности таких инструкций трехад- ресного кода ti = а + Ь t2 = с + d t3 = ti * t2 Здесь t — создаваемые компилятором временные имена. Можно также применять унарные операторы (monadic operator). Например, оператор -л? создаст инструкцию C1 = -ш (хотя, безусловно, в этом случае трехадресный код будет содержать всего лишь два адреса!). Преобразование выражений в последовательность инструкций трехадресного кода легко осуществляется с помощью анализатора на основе YACC, подобного использованному при создании постфикс- 198 Гпава 8. Гоперация кода
НОЙ записи, которая описана в разделе 5.6 (на основе примера из раз- дела 4.7). Как и ранее, грамматика YACC с действиями выглядит сле- дующим образом. S : ЕХР; ЕХР : TERM; | ЕХР + {А1();) TERM <А2(>;} | ЕХР - {А1();} TERM (А2();}; TERM : FACT I TERM*{A1();} FACT (A2() I TERM/{Al();} FACT {A2();}; FACT : - {Al();} FACT {A4();} | VAR {A3 ( ) ;} | ( EXP ); VAR : a|b|c|d|e; Здесь, однако, действия отличаются от приведенных ранее. Необходимо, чтобы многоцелевой стек мог хранить операторы и операнды (в том чис- ле временные имена). Действиями являются: А1 — занести оператор в стек; А2 — следующим образом напечатать инструкции трехадресного кода: напечатать имя следующей распределяемой временной величины напечатать напечатать три верхних элемента стека снизу вверх занести в стек только что распределенное имя временной величины; АЗ — занести в стек операнд; А4 — следующим образом напечатать инструкции трехадресного кода: напечатать имя следующей распределяемой временной величины напечатать “=” напечатать два верхних элемента стека снизу вверх занести в стек только что распределенное имя временной величины. Трехадресный код может также применяться для представления дру- гих аспектов типичных языков программирования, например, присваи- ваний, обращений к массивам, условных и безусловных переходов. Все следующие выражения являются примерами трехадресного кода. а : = ti ti = c(i] goto L if ti goto L Каждый оператор трехадресного кода имеет максимум три адреса, также существуют формы инструкций для присваивания, включающего адреса и указатели, вызовы процедур, вычисление параметров и т.д. Вы- сокоуровневые управляющие структуры, такие как циклы, условные опе- раторы и операторы выбора для создания трехадресного кода, сводятся к проверкам условий и переходам. Приведем примеры того, как управляющие структуры компилируются в трехадресный код. 8.2. Создание промежуточного кода 199
1. if (выражение) onepamop[ else оператор2 Приведенный выше оператор if можно реализовать путем добавления к грамматике следующих действий. if (выражение) <11 > оператор^ <I2> else операторг <13> Здесь действиями являются: И увеличить номер метки образовать код для перехода к метке, если выражение ложно поместить номер метки в стек 12 увеличить номер метки образовать код для перехода к метке извлечь из стека метку, допустим, Lk установить Lk в коде поместить в стек метку, которая выше применялась в безусловном переходе 13 извлечь из стека метку, скажем, Lj установить Lj в коде При использовании данной грамматики будет создан следующий код. код для вычисления выражения tj = not выражение if tj goto Lj код для оператора^ goto Lj Lt код для onepamopa2 Ч 2. while (выражение) оператор Приведенный выше оператор while можно реализовать путем добав- ления к грамматике следующих действий. while <W1> (выражение) <W2> оператор <W3> Здесь действиями являются: W1 увеличить номер метки установить метку в коде поместить метку в стек W2 увеличить номер метки образовать код для перехода к метке, если выражение ложно поместить метку в стек W3 извлечь из стека метку, скажем, Lj извлечь из стека метку, скажем, Lk образовать код для безусловного перехода к Lk установить Lj в коде 200 Гпава 8. Гэнерация кода
В результате будет создан следующий код: L|: код для вычисления выражения tj = not выражение if tj goto L2 код для оператора goto Lj Lj; Процесс назначения меток нетривиален и требует использования сте- ка времени компиляции. Он включает в себя переходы вперед и назад по коду, а применимое и определяющее вхождения меток обычно вклады- ваются “обычным образом’’. В то же время следует быть осторожными в тех случаях (например, внутри действия W3), когда порядок появления меток в стеке не совсем соответствует порядку, в котором они требуются. Стек меток может быть отдельным стеком времени компиляции или мо- жет объединяться с другими стеками времени компиляции. 8.2.2. Р-код Перейдем к рассмотрению другого типа промежуточного кода, а именно Р- кода. Этот код является промежуточным кодом на основе стека, созданным специально для реализации языка Pascal и широко используемым для этой цели. Каждая инструкция P-кода имеет следующий формат. F Р Q Здесь F — код функции, а р или (иногда — и) Q могут отсутствовать в за- висимости от конкретного кода. При наличии этих параметров р может применяться для определения уровня статического блока, a Q — для оп- ределения офсета внутри фрейма или промежуточного операнда (например, константы). Инструкции без параметров применяются к верхним элементам стека и включают следующие инструкции. • AND применяет булев оператор AND к верхним двум элементам сте- ка, удаляет их и оставляет результат действия оператора (истина или ложь) на вершине стека. • dif применяет оператор разности множеств к верхним двум эле- ментам стека, удаляет их и оставляет результат действия опера- тора (множество) на вершине стека. • NGI изменяет знак целого значения на вершине стека. • flt преобразует значение на вершине стека из целого в действительное. • flo преобразует значение второго сверху элемента стека из целого в действительное. • inn проверяет на предмет принадлежности к множеству, используя верхние два элемента стека как параметры и оставляя вместо них значения “истина”или “ложь” 82. Создание промежуточного кода 201
Для загрхзки значения на вершин}* стека или сохранения адреса на вершине стека используются одно- или двухадресные инструкции. Например, LDCI загружает целую константу 4 LODI 0 5 загружает целое значение по адресу (0.5) LDA С б загружает адрес (0,6) STRI 1 4 сохраняет целое значение по адресу (1,4) Здесь адреса времени компиляции представлены в виде пары целых чисел. (статический уровень, офсет) P-код также включает в себя инструкции переходов, например, UJP безусловный переход к ь7 FJP переход к если значение на вершине стека — ложь Метки могут устанавливаться в коде, например, Единичные инструкции определяются таким образом, чтобы к значению на вершине стека можно было применить стандартные функции, например, CSP ATAN Здесь функция arctg применяется к значению на вершине стека, оставляя результат действия функции на месте этого значения. Другой пример: CSP WLN Здесь к файлу, который определяется верхним элементом стека, приме- няется инструкция writein. Приведем P-код, создаваемый для операторов if и while, описанных ранее в этом разделе. Предполагается, что при вычислении выражения подсчитанное значение выражения оставляется на вершине стека. 1. if (выражение) оператор^ else оператор2 будет генерировать: код для помещения значения выражения на вершине стека FJP Li код для выполнения оператора^ UJP ь2 Li код для выполнения оператора2 L: 2. while (выражение) оператор будет генерировать: Li код для помещения значения выражения на вершину стека FJP код для выполнения оператора 202 Глава 8. гВНепяниа глпз
UJP Li L»2 При каждом применимом вхождении переменной образуется код для по- мещения в стек адреса или значения переменной (в зависимости от об- стоятельств). Например, LDA 1 7 будет загружать адрес (1, 7) на вершину стека, а LODI 1 7 будет загружать значение целой переменной с адресом (1,7) на вер- шину стека. Реализация присваивания, в простейшем случае, включает в себя ко- пирование значения верхнего элемента стека в адрес второго сверху эле- мента стека. После этого два верхние элемента стека удаляются. Это можно сделать с помошью простой адресной инструкции. STOI В более обшем случае наличия массивов и записей, когда необходимо скопировать множество последовательно расположенных значений, при- сваивание осуществляется с помошью инструкции MOV m Она переносит т значений, начиная с исходного адреса, в соответствующее число адресов, начиная с целевого адреса (целевой и исходный адреса распо- лагаются на вершине стека). В это же время два адреса удаляются из стека. Несмотря на то, что P-код в дальнейшем может быть откомпилирован в машинный код для конкретной машины, чаше он выполняется посред- ством использования интерпретатора. Широко распространенный в кон- це 70-х перенос Pascal из одной среды в другую во многом был связан с тем, что при наличии компилятора Pascal, написанного на языке Pascal, который требовалось использовать в новой среде, нужно было всего лишь написать интерпретатор P-кода для этой среды, что занимало около месяца работы. В действительности, многие компиляторы "немного не доходят4’ до создания действительного машинного кода. В некоторых случаях, например, генерируется ассемблерный код, который позже (посредством системного ассемблера) преобразуется в машинный код. 8.2.3. Байт-код Байт-код представляет собой промежуточный язык для Java Virtual Ma- chine (JVM). Он, подобно P-коду для языка 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). В каждом файле содержится виртуаль- ный машинный код для используемых классом методов (функций/процедур), информация таблицы символов (набор констант в Java), соединений с суперклассами и т.д. Для эффективной работы файл имеет двоичный формат, но для удобства просмотра его можно преобра- зовать в символьную форму. Существенной особенностью реализаций Java является наличие верификатора классификационного файла (class file verifier), который, помимо всего остального, контролирует, чтобы файл, поступивший с ненадежного источника, не вызвал сбой в работе интер- претатора, оставив его в неопределенном состоянии, или аварию хоста. В частности, верификатор байт-кода используется для проверки байт-кода внутри методов на предмет: • наличия команд ветвления, обращающихся к неправильным адресам; • ошибок типов в кодах инструкций; • некорректного управления стеком по отношению к условиям пе- реполнения и опустошения; • методов, вызываемых с неправильным числом или типом аргументов. Важной особенностью реализаций Java является то, что верификация происходит до выполнения программы, что позволяет избавиться от по- тенциально трудоемкой проверки в процессе выполнения. В то же время верификация обходится недешево: она основывается на разновидности средства доказательства теорем, имеющего некоторые теоретические ог- раничения. Существует более 160 различных инструкций байт-кода, многие из них отличаются только типами операндов. Для верификации важным яв- ляется хранение информации о типах в байт-коде, множество имеюших- 204 Глава 8. Генерацня кода
ся инструкций по-разному поддерживают различные типы данных! Ос- новные типы инструкций байт-кода можно выделить в такие группы: • работа со стеками; • выполнение арифметических операций; • оперирование объектами и массивами; • поток управления; • вызов методов; • обработка исключительных ситуаций и параллельная работа. Например, как и в P-коде, существуют инструкции для помещения констант и локальных переменных в стек, собственно работы со стеком и запоминания значений из стека в локальных переменных. Инструкция Значение incos с_4 загружает в стек целую константу 4 inload_4 загружает в стек значение локальной переменной номер 4 pop отбрасывает верхнее значение стека dup копирует верхний элемент стека swap меняет местами два верхних элемента стека istore_4 заносит значение верхнего элемента стека в локальную переменную номер 4 Примеры инструкций для выполнения арифметических операций. Инструкция Значение iadd суммирует две целых величины на вершине стека f add суммирует две величины типа float на вершине стека fmul умножает две величины типа float на вершине стека Доступ к массивам осуществляется с помощью инструкций, подобных приведенной ниже. Инструкция Значение iaload помещает значение элемента массива на вершину стека (предполагается, что ссылка массива и индекс индекса уже находятся в стеке) Существуют инструкции условного и безусловного ветвления, а также инструкции входа в подпрограммы и инструкции таблицы переходов. К каждой из этих инструкций относится один или несколько параметров метки. Например, Инструкция ifeq L| if__ionpne L| goto Lj Значение переход к Lh если целое значение на вершине стека равно нулю переход к Lt, если два целых значения на вершине стека различны переход к 8.2. Создание промежуточного кода 205
Выше названы лишь несколько из богатого набора инструкций, пред- лагаемых JVM. JVM может использоваться (и в некоторой степени ис- пользуется) как промежуточный этап для языков компиляции, отличных от Java (например. Ada). Приведем байт-код. генерируемый для рассмотренных ранее в этом разделе управляющих структур языка С. 1. if (выражение) оператору else оператор. Данный оператор в байт-коде представляется в следующем виде. байт-код для помещения значения выражения на вершину стека ifeq Li байт-код для выполнения оператора, goto L2 Li байт-код dw выполнения оператора^^ ь2 2. while (выражение) оператор Данный оператор в байт-коде будет представлен в следующем виде. байт-код для помещения значения выражения на вершину стека if eq L2 байт-код для выполнения оператора goto Li L2 Следует отметить, что в байт-коде значению “ложь” соответствует 0, а значению “истина” — 1. Отсюда — использование if eq, а не обратной инструкции ifne. В языке Java булевский тип отсутствует. В подходе, принятом Sun по отношению к реализации Java, были представлены некоторые интересные и существенно новые идеи для реа- лизации языка. Подобно самому языку, методы его реализации продол- жают дорабатываться и улучшаться. В следующем разделе рассмотрены вопросы, связанные с генерацией реального машинного кода, для чего изучаются характеристики некото- рых типичных машинных архитектур. 8.3. Создание машинного кода Прежде всего рассмотрим два основных существующих типа машинных архитектур. • CISC (complex instruction set computer — компьютер с полным на- бором инструкций). • RISC (reduced instruction set computer — компьютер с сокращен- ным набором инструкций). 206 Гпава 8. Генерация кода
Архитектуры CISC разрабатываются с прицелом на последующую реали- зацию языков высокого уровня, следовательно, на первый взгляд являют- ся идеальными кандидатами для использования в компиляторах. Они имеют множество мощных инструкций и ассоциируются с компактным целевым кодом. Их типичными характеристиками являются: • широкий диапазон режимов адресации для обеспечения доступа к массивам, записям, спискам, стековым фреймам и т.д.; • небольшое число регистров (обычно 16 или меньше); • большое число регистров особого назначения, например, может быть выделен регистр для индексации; • двухадресные инструкции, такие как а+в->а, где А, в могут быть сложными адресами; • инструкции переменной длины; • инструкции с побочными эффектами, например, команды автома- тического приращения; • существенно разное время, затрачиваемое на выполнение инструкций; • управление, реализованное микропрограммой. С другой стороны, RISC имеет следующие характеристики: • простые режимы адресации (обычно — только использующие ре- гистры); • большое количество регистров, по меньшей мере, 32; • все регистры являются “универсальными4; • трехадресные инструкции, использующие только регистры, напри- мер, ry=i\+ а; • инструкции фиксированной длины (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: = а + Ь tj = с + d tj = tx * t2 В этой последовательности значения tj и t2 должны храниться до вычис- ления t3. Впрочем, рассмотрим вычисление следующего выражения. a»b+c*d+e*f Имеем такую последовательность трехадресных кодов. ti = а * Ь t2 = с * d t3 = ti + t2 t4 = e ♦ f t5 = tj + t4 В данной последовательности используются пять временных величин, но, очевидно, при этом необязательно использовать пять различных ре- гистров. Например, значения tj и t2 *не будут использоваться после третьей инструкции, поэтому приемлемым будет следующее распределе- ние промежуточных величин. Временная величина Регистр t> 1 ч 2 Определим переменную/временную величину как живую (live), если она содержит значение, которое (предполагая консервативный анализ) 210
должно храниться для возможного дальнейшего использования. Таким образом, t| жива от момента выполнения инструкции 1 до выполнения инструкции 3 и не дольше; подобным образом переменная t2 жива от инструкции 2 до инструкции 3; t? жива от инструкции 3 до инструкции 5. Анализ живучести (liveness analysis) для фрагмента кода осуществляется от его конца к началу. Как правило, в промежуточном коде присутствуют циклы и условные операторы, и нужно изучить, как они влияют на анализ живучести. Кро- ме того, анализ живучести также может применяться по отношению к переменным, которые постоянно используются и должны находиться в регистрах. Рассмотрим следующий фрагмент кода с циклом, представ- ленного в трехадресной форме. (1) п=0 (2) sum 2 = О (3) sum3 = О (4) Lj: t, = n<10 (5) t2 = not t| (6) if t2 goto L2 (7) n = n + 1 (8) m = n*n (9) sum2 = sum2 + m (10) t3 = m*n (11) sum3 = sum3 + t3 (12) goto L| (13) L2: Этот код соответствует фрагменту на языке С. п = 0; sum2 = 0; sum3 = 0; while (n < 10) {n = n + 1; m = n*n; sum 2 = sum2 + m; sum3 = sum3 + m*n; ) Чтобы проанализировать живучесть переменных в цикле, необходимо определить, какие переменные живы на входе в цикл. На входе цикла Должны быть живыми переменные n, sum2 и sum3, поскольку их значе- ния будут использоваться в цикле. В то же время вход в цикл происходит не один раз, а при каждом выполнении цикла. Следовательно, n, sum2 и sum3 должны быть живыми при выполнении инструкции goto Li Кроме того, после окончания цикла могут понадобиться живыми некото- рые другие переменные, хотя при последующем рассмотрении такая воз- 8.3. Создание машинного кода 211
можность будет игнорироваться. Дальнейший анализ позволяет получить следующую информацию о живучести переменных и их возможном рас- пределении по ре ги с трам. Переменная Жива Регистр п 1-12 1 sum2 2-12 2 sum3 3-12 3 ti 4-5 4 m 8-10 4 t2 5-6 4 t. 10-11 4 Из приведенной выше таблицы видно, что требуется только четыре регистра. Обобщая, живучесть переменной определяется уравнениями вида in[n] = use[n]u(out[n] - def[n]) out[n] = и in[s] Здесь use[n] совокупность всех переменных, значения которых используются в операторе гг, out[n] совокупность всех переменных, которые живы после завер- шения оператора п\ def[n] совокупность всех переменных, определенных в операторе л; /л(л] совокупность всех переменных, которые живы при вхождении в оператор л; s преемник оператора л. Второе уравнение необходимо для того, чтобы учесть все возможные преемники оператора л, которые, при наличии цикла, будут включать в себя начальный оператор цикла, который рассматривается как преемник последнего оператора цикла. Для каждого оператора промежуточного кода существуют приведен- ные выше уравнения, одновременное решение которых дает совокуп- ность переменных, что живы на входе и выходе каждого оператора. Ре- шение данной системы уравнений — это пример анализа потока инфор- мации, применяемого, вообще-то, для различных целей. Впрочем, этот процесс может быть довольно трудоемким, его время, в худшем случае, пропорционально л* (а обычно — л2), где л — число задействованных операторов. Помимо названных, существенными являются следующие моменты. • Вызовы функций и процедур требуют, чтобы их входные парамет- ры были живы на входе. • Значения выходных параметров должны быть живы после завер- шения вызовов функций и процедур. • Доступ к (анонимным) переменным посредством указателей нель- зя изучить посредством анализа потока информации. 2/2 Гпзоъ Q ГЛмлпО||М0 адда
• Доступ к элементам массива, чьи индексы неизвестны в процессе компиляции, может привести только к очень консервативным за- ключениям относительно потока информации. Надежное распределение переменных по регистрам можно рас- сматривать как разновидность классической задачи раскрашивания графа: в какие цвета нужно раскрасить узлы графа, чтобы не было двух соседних узлов одинакового цвета? Частным случаем задачи рас- крашивания графа является задача такого раскрашивания государств на двумерной карте, чтобы не было соприкасающихся государств одинакового цвета. 8.3.3. Распределение регистров путем раскрашива- ния графа Опишем алгоритм для распределения регистров, который базируется на раскрашивании графа. Каждая переменная, которую следует поместить в регистр, по возможности представляется в виде узла неориентированного графа, графа взаимодействия (interference graph). Посредством дуги со- единяются два узла, которые не могут находиться в одном регистре. Это, например, соответствует случаю, когда переменные живы одновременно. Алгоритм реализуется следующим образом. Предположим, что в на- личии имеется т регистров. Если узел N граничит с менее чем т соседя- ми, а оставшиеся узлы графа взаимодействия уже раскрашены, то данно- му узлу можно присвоить цвет, который должен отличаться от (< т) цве- тов соседей. Следовательно, этот узел, вместе с соответствующими лугами, можно удалить из графа и поместить в стек, а к оставшейся час- ти графа можно применить такой же подход. Так можно продолжать до тех пор, пока останется только один узел. Его следует раскрасить в лю- бой доступный цвет. Затем нужно вернуть из стека другие узлы вместе с их дугами и раскрасить их так, как описывалось выше. Если какой-то из узлов имеет более т соседей, то при его извле- чении из стека может произойти сброс регистра. В то же время сброс не является обязательной операцией, поскольку цвета соседей дан- ного узла не обязательно должны отличаться друг от друга. На сле- дующем этапе этот узел выделяется как возможный кандидат для сброса, а затем перемешается из графа в стек, как и остальные. Когда дело доходит до его возвращения из стека, то, возможно, он будет окрашен в цвет, который отличается от цветов всех его соседей, а может быть и нет. Если нет, то регистр для него не выделяется, а вы- деляется адрес в памяти, и происходит сброс регистра. На рис. 8.1 изображен пример графа взаимодействия для рассмотрен- ной ранее последовательности промежуточного кода. Для данного при- мера, предполагая, что имеются четыре регистра А, в, с и D, алгоритм бу- дет проходить следующим образом. 8.3. Создание машинного кода 213
Рис. 8.1. i. У угла п имеется шесть соседей, и он может быть сброшен. Он выде- ляется и помешается в стек. После удаления узла и его дуг из графа, последний уменьшается. 2. У узла sum2 имеется пять соседей (узел п уже удален из графа), и он может быть сброшен. Он выделяется и помешается в стек. После уда- ления узла и его дуг граф уменьшается. 3. У узла sum3 имеется четыре соседа, и он может быть сброшен. Он выделяется и помешается в стек. После удаления узла и его дуг граф уменьшается. 4. Узел tl не имеет соседей. Он помешается в стек, вследствие чего граф уменьшается. 5. Узел C2 не имеет соседей. Он помешается в стек, вследствие чего граф уменьшается. 6. Узел m не имеет соседей. Он помешается в стек, вследствие чего граф уменьшается. 7. Узел t3 не имеет соседей. Это последний оставшийся узел. Он окра- шивается в цвет А. 8. Узел m извлекается из стека (вместе с дугами, с которыми он был туда помешен). У него нет соседей, и он также окрашивается в цвет а. 9. Узел t2 извлекается из стека. У него нет соседей, и он также окраши- вается в цвет а. 10. Узел tl извлекается из стека. У него нет соседей, и он также окраши- вается в цвет а. 214 Гпава 8. Генерация кода
11. Узел sum3 извлекается из стека. У него четыре соседа, все окрашен- ные в цвет а, следовательно, его можно окрасить в цвет в. 12. Узел sum2 извлекается из стека. У него пять соседей, четыре окрашены в цвет А и один — в цвет в, следовательно, его можно окрасить в цвет с. 13. Узел п извлекается из стека. У него шесть соседей, четыре окрашены в цвет А, один — в цвет в и один в цвет с, следовательно, его можно окрасить в цвет D. Алгоритм распределения завершился успешно, и окончательное рас- пределение совпадает с распределением, которое мы рассмотрели ранее. Переменная Регистр п D sum2 С sum3 В ti А m А А Ч А 8.4. Оптимизация кода Задача оптимизации кода состоит в создании эффективного (с точки зре- ния размера памяти и времени выполнения) целевого кода. Желаемая степень оптимизации будет зависеть от обстоятельств. Иногда она не нужна, например, если у программы малое время выполнения, умерен- ные запросы к памяти и, возможно, малый срок жизни (программы сту- дентов обычно такого типа). Необходимость оптимизации может требо- ваться для программ с большим временем выполнения либо значитель- ными запросами к памяти и, возможно, с длительным временем существования. Стоимость оптимизации главным образом оценивается в терминах времени компиляции. Некоторые виды оптимизации могут быть дорогостоящими в смысле времени компиляции, другие — сравни- тельно дешевыми. Обычно более дешевые типы оптимизации всегда сто- ит осуществлять, а более дорогие — не всегда. Некоторые компиляторы, в зависимости от требуемой степени опти- мизации, могут работать в более чем одном режиме. Компилятор Borland C/C++ обладает широким диапазоном возможностей. Например, пользо- ватель может выбрать для генерации им быстрый, им компактный код. Некоторые ранние компиляторы PL/1 осуществляли обширную оптими- зацию кода посредством очень большого числа (до 30) дополнительных проходов по исходному коду, на что, соответственно, уходило много вре- мени. В средах, где основной является качественная диагностическая информация, лучше всего полностью отказаться от оптимизации, чтобы избежать возможной путаницы вследствие некорректных сообщений! 3,4, Оптимизация кода 215
Оптимальным оптимизатором яапяется тот, который будет создавать оп- тимальный код выполнения программы для всех возможных входов, однако, это неосуществимо. Создание такого оптимизатора эквивалентно решению проблемы остановки для машины Тьюринга, которая, безусловно, яаляется неразрешимой. В действительности многие “хорошие” оптимизаторы будут давать не самый оптимальный (возможно, даже ухудшенный) код для некото- рых аходов! Разумеется, оптимизация не должна изменять значение кода, а для этого иногда требуется обширный анализ кода. В большинстве случаев такой анализ основывается на анализе потока информации, который будет подробно рассмотрен в этой главе. Будут также рассмотрены различные пре- образования кода с сохранением семантики. Оптимизация может быть: • локальной и основываться на относительно простом анализе и пре- образованиях кода; • глобальной и основываться на относительно сложном анализе и преобразованиях кода. Рассмотрим вначале локальные оптимизации, которые обычно бази- руются на небольшом числе последовательных инструкций. Они всегда дешевы для выполнения и могут быть очень эффективны, особенно, если выполняются внутри внутренних циклов программ, которые занимают много времени при выполнении программы. Приведем примеры хорошо известных локальных оптимизаций: • дублирование констант (constant folding); • снижение стоимости (strength reduction); • исключение ненужных инструкций. Дублирование констант состоит в выполнении в процессе компиляции арифметических операций, которые должны были бы выполняться при выполнении программы. Например, последовательность limit = 10 index = limit - 1 можно заменить последовательностью limit = 10 index = 9 Примером снижения стоимости является замена произведения или деления соответствующими инструкциями сдвига. Примером исключения ненужных инструкций может служить удаление инструкции load, если регистр уже содержит необходимое значение, а также инструкции store, если соответствующий адрес памяти уже имеет значение в регистре. Данные типы оптимизации, которые применялись для кодов, не со- держащих циклов или ветвлений, можно было бы вообще не рассматри- вать как оптимизации, а считать их просто признаком качественной ге- нерации кода. 216 Гпава 8. Генерация код?
Анализ потоков управления и информации дает более амбициозные типы оптимизации: • удаление бесполезного кода (dead-code elimination); • исключение общих подвыражений (common subexpression elimination); • оптимизация циклов. Удаление бесполезного кода заключается в удалении кода, который не будет выполняться при любом выполнении программы. Такой код, воз- можно, требовался в ранней версии программы, но в последующей вер- сии уже является избыточным. Возможно, .конечно, что факт недоступ- ности кода указывает на ошибку в программе, а компилятор не в состоя- нии обнаружить это. Для определения нерабочих участков кода необходим анализ потока информации. Анализ живучести может пока- зать, например, что имеется трехадресный код вида х = а + Ь Здесь переменная х “мертва” (dead) после завершения инструкции. По- нятно, что инструкции такого вида избыточны и должны быть удалены для уменьшения размера кода. Таким образом, исключение бесполезного кода является оптимизацией пространства, тогда как большинство рас- сматриваемых оптимизаций являются оптимизациями процесса выпол- нения программы. Исключение общих подвыражений заключается в определении тех зна- чений в правой части трехадресного кода, что уже были вычислены и ко- торые не нужно вычислять снова. Такое определение может (или даже должно) базироваться на анализе потока информации, для которого тре- буется больше информации, чем для простого определения живучести переменных. Присвоение значения переменной а = Ь + с называется определением переменной а. Это определение а распространя- ется и на некоторые другие инструкции: d = Ь + с если анализ потока информации показывает, что за это время ни а, ни d не могли измениться. Если это условие выполняется, то приведенную выше инструкцию можно заменить инструкцией d = а (не вычисляя повторно Ь + с). В данном контексте полезно ввести понятие доступности (availability ). Ес- ли выражение вычисляется на каждом пути потока управления к оператору и ни один из его операторов не был определен на любом из этих путей, то вы- ражение доступно в операторе и не должно вычисляться повторно. Приве- денные выше уравнения потока данных несложно обобщить для отслежива- ния доступных выражений. Особое внимание следует уделить рассмотрению 8.4. Оптимизация кода 217
псевдонимов, или альтернативных имен (alias), — разным именам для одной и той же переменной. Использование псевдонимов может привести к тому, что изменение одной переменной автоматически будет означать изменение другой. Псевдонимы могут появиться, например, в таких случаях. • Вызов параметров посредством ссылки в языках, подобных Pascal, в которых фактические и формальные параметры являются аль- тернативными именами друг друга. • Присвоение адресов. • Использование одинаковых фактических параметров для двух формальных параметров. Примером последнего случая является вызов процедуры в Pascal comp (х,х) Это соответствует объявлению процедуры procedure comp (var p,q); Здесь p и q — псевдонимы друг друга. Анализ псевдонимов (alias analysis) может определить возможные псев- донимы и устранить ненадежную оптимизацию. В языках со строгой ти- пизацией может подразумеваться, что переменные различных типов не могут быть псевдонимами друг друга, что потенциально увеличивает на- дежность возможного проведения оптимизации. Оптимизация циклов выполняется над исходным кодом или близким к нему представлением и обладает широким диапазоном возможностей. На- пример, в следующем коде а * b вычисляется в каждой итерации цикла: int v [ 10]; void f(void) { int i,x,y,z; for (i = 0; i < 10; i++) v(i] = a * b; ) Этот код можно оптимизировать следующим образом: int v (10]; void f(void) { int i,x,y,z,tl; tl = a * b; for (i = 0; i < 10; i++) v(i) = tl; ) Другими оптимизациями no отношению к циклам являются: • замена остаточной рекурсии (tail recursion) итерацией; • удаление ненужных проверок границ массивов; • развертка цикла (замена цикла фрагментом последовательного кода); 218 Гпава 8. Генерацня кода
и многие другие. Несмотря на то, что нашей целью не было всесторон- нее рассмотрение полного диапазона возможностей оптимизации кода, приведенные примеры должны были дать хорошее общее представление об этих возможностях. Компиляторы очень отличаются по степени опти- мизации кода, начиная от простого создания хорошего (или иногда не совсем хорошего) кода с малыми локальными оптимизациями и заканчи- вая всесторонней глобальной оптимизацией по функциям и процедурам. 8.5. Генераторы генераторов кода Весьма привлекательна идея — создать генератор генераторов кода, кото- рый будет создавать генератор кода с заданными: • описанием промежуточного кода; • описанием машинного кода, который должен быть образован. Использование такого инструментального средства вместе с генератором лексических анализаторов и генератором синтаксических анализаторов будет значительным продвижением по пути автоматизации создания компиляторов. Сопоставление шаблонов промежуточного и целевого кодов можно рассматривать как задачу синтаксического анализа. Например, пре- фиксное представление (оператор, операнд, операнд) синтаксического Дерева может описываться контекстно-свободной грамматикой, которая может содержать действия по созданию целевого кода. Соответствую- щая грамматика является обычно крайне неоднозначной; следователь- но, необходимо решать возможные конфликты перенос/свертка и свертка/с вертка. Их можно решать, используя информацию о стоимо- сти — своеобразных весовых коэффициентах, определяющих (во время выполнения программы) стоимость создания альтернативной последо- вательности кода. Для архитектур RISC количество альтернативных ва- риантов обычно не очень велико, а для CISC количество альтернатив- ных вариантов может быть настолько большим, что время, затрачивае- мое на их рассмотрение — огромно. При отсутствии информации о стоимости предпочтительной считает- ся генерация кода с как можно меньшими шагами. Это означает, что при конфликте перенос/свертка предпочтение отдается операциям переноса, а при конфликте свертка/свертка — более длинной свертке Данный подход направлен на создание “мощных”, в противоположность “менее мощ- ным”, машинных инструкций. Например, он пытается создать инструк- ции с более сложными способами адресации. В дополнение к уже рассмотренным очевидным преимуществам, ге- нераторы генераторов кода также обеспечивают сравнительно простой подход к переносу генераторов кода. В то же время существует ряд слож- ностей, из-за которых генераторы генераторов кода не нашли широкого применения. Перечислим основные из них. 8.5. Генераторы генераторов кода 219
• Соответствующая грамматика может быть очень большой. • Скорость генерации кода явдяется малой, что объясняется боль, шим числом операций с таблицей синтаксического анализа. • Инструкции с побочными эффектами (например, инструкции ав- томатического приращения) не совсем корректно работают при данном подходе. 8.6. Резюме В этой главе было сделано следующее. • Описано создание промежуточного кода, такого как трехадресный код, P-код и байт-код, для выбора характеристик языка. • Изучено, как особенности архитектур S1SC и RISC влияют на ге- нерацию кода. • Рассмотрены такие вопросы генерации кода, как выбор инструкций и распределение регистров. • Описаны методы простой оптимизации кода. • Введено понятие генератора генераторов кода. Дополнительная литература В большинстве упоминавшихся ранее учебников описываются вопросы генерации кода, например, [Aho, Sethi and Ullman, 1985], [Fisher and Le- blanc, 1988], (Loudon, 1997], [Appel, 1997] и [Ullmann, 1994]. P-код для языка Pascal описывается в [Nori at al., 198-1]. Описание Java Virtual Ma- chine можно найти в [Lindholm and Yellin, 1996], а также в [Meyer and Downing, 1997], где ассемблер байт-кода снабжен понятным текстом. Как в работе [Aho, Sethi and Ullman], так и в работе [Appel] широко освещены вопросы генерации кода. Основанные на грамматике генера- торы генераторов кода впервые были представлены в работе [Glanville and Graham, 1978]. Упражнения 8.1. Создайте трехадресный код для каждого из следующих выражений: а) а + b + с б) (a + b)*(c + d)*(e + f) в) x*y*z + -p»q 8.2. Предоставьте аргументы в пользу того, чтобы сперва компилировать в промежуточный код, вместо компиляции непосредственно в ма- шинный код. 220 ГПЯЯЯ Л ГauanQHUQ грДЯ
8.3. Назовите относительные преимущества использования в качестве промежуточных кодов P-кода и трехадресного кода. 8.4. Какие относительные преимущества имеет трансляция P-кода над интерпретацией? 8.5. Укажите причину, по которой Java Virtual Machine не поддерживает тип Boolean. 8.6. Какими преимуществами обладают: а) архитектуры CISC б) архитектуры RISC 8.7. Укажите области, в которых живы следующие переменные, предпо- лагая, что после выполнения кода жива только переменная р. 1. а = с + d; 2. ’ m = 2 * а; 3. n = а + m; 4. k = ш + п; 5. р = с * к * 3; 8.8. Какие переменные живы на входе кодовой последовательности в упражнении 8.7? 8.9. Рассмотрим фрагмент кода С п = 0; sum2 = 0; while (п < 10) {n = n + 1; m = 2*п; sum2 = sum2 + m; } Укажите, каким образом его можно оптимизировать. 8.10. Приведите пример того, как “оптимизация” может увеличить время выполнения программы.

Приложение А Решения упражнений Глава 1 1.1. Соседствующие символы в различных Т диаграммах, находящиеся на одном вертикальном уровне, должны быть одинаковыми. Исход- ный и целевой языки для Т-диаграмм, находящиеся на одном вер- тикальном уровне, должны быть одинаковыми (см. рис. 1.5). 1.2. Нет, отсутствует контекстная информация, чтобы отличить эти пе- ременные. 1.3. См. рис. А.1. а b Рис. А. 1. 1.4. Подсчет числа знаков исходного кода. Подсчет числа символов исходного кода. Подсчет числа процедур и функций исходного кода. 1.5. Лексический анализ. 1.6. Семантический анализ. 1.7. Глобальная память. 1.8. Малое время работы. Повторное использование существующих компонентов, использо- ванных в других проектах. 1.9. Возможность сбоя компилятора при текущей компиляции. 1.10. За — совместимость с Lex и YACC. Против — плохой контроль типов и других небезопасных моментов.
Глава 2 2.1. а. Каждая строка языка состоит из последовательности нуля или большего числа знаков а. б. Каждая строка языка состоит из последовательности одного или большего числа знаков а, за которыми следует один или более знаков Ь. в. Каждая строка языка состоит из последовательности нуля или большего числа знаков х, за которыми следует некоторое число знаков у, а далее идет такое же число знаков z. г. Каждая строка языка состоит из последовательности нуля или большего числа знаков х, одного или нескольких знаков у, за которыми следуют знаки z, число которых равно числу знаковх. д. Каждая строка языка состоит из последовательности нуля ши большего числа знаков х, за которыми следует нуль или более знаков у, после чего идет нуль или более знаков z. 22. а. Нуль или более знаков х, за которым следует нуль или более знаков у. б. Знак х, за которым следует нуль или более знаков х, далее идет у, за которым следует нуль или более знаков у. в. Знак х или у нуль или более раз. г. Знак а или Ь, далее идет нуль или более вхождений а, за кото- рыми следует нуль или более вхождений Ь. д. Знак х или у нуль или более раз. 2.3. а. Регулярна, поскольку грамматика является праволинейной. б. Нерегулярна, поскольку есть как праволинейные продукции, так и леволинейные. в. Нерегулярна, поскольку первая продукция не является ни пра- во-, ни леволиненой. г. Нерегулярна, поскольку ни одна из продукций не имеет прием- лемой формы. 2.4. а. Генерирует регулярный язык, поскольку грамматика является регулярной. б. Замена последней продукции продукцией Y->yY делает грамматику регулярной, поэтому генерируемый язык также является регулярным. в. Генерируемый язык является регулярным, поскольку ниже при- водятся продукции регулярной грамматики, генерирующей ана- логичный язык. S-*aA А-> аА 224
A->b A->bB B->b B-+bB г. Генерируемый язык не является регулярным, поскольку не су- ществует генерирующей его регулярной грамматики. 2.5. Правое порождение имеет такой вид. S=>S+x=>S+x+x=>S+x+x+x=>x+x+x+x Порождение единственное, поскольку на каждом этапе имеется простое правило определения, какую продукцию использовать: применять продукцию 2, если требуется завершить порождение, в противном случае использовать продукцию 1. Ни одна иная стра- тегия не позволит получить заданную строку. 2.6. Первое правое порождение. statement => if expr then statement else 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 => if expr then statement => if expr then if expr then statement else statement if expr then if expr then statement else other =s> 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 имеет следующий вид. S-4 OS| lS|e 2.8. Левое порождение. PROGRAM^ begin DECS; STATS end begin d; DECS; STATS end => begin d; d; STATS end => begin d; d; s; STATS end => begin d; d; s; s; end Правое порождение. PROGRAM^ begin DECS; STATS end begin DECS; s; STATS end begin DECS; s; s; end =$ begin d; DECS; s; s; end => begin d; d; s; s; end 2.9. a. letter (letter] digit] )(letter] digit] )(letter\ digit]) (letter | digit|) (letter] digit]) 6. G = ({letter, digit), {S, R, T, U, V, IV}, P, S) Здесь P включает следующие продукции. S-> letter] letterR R-> letter T\ digit T] letter] digit T -> letter U j digit U [ letter | digit U^> letter V] digit V| letter] digit V -> letter IV | digit IV | letter | digit IV -4 letter | digit 2.10. В языке С отсутствует оператор then, но существует правило: оператор else относится к ближайшему предшествующему if, с которым (при чтении слева направо) еще не соотнесен ни оператор else. Данное пра- вило существует в большинстве языков. Контрпример: язык ALGOL 60, где запрещена комбинация then if. Впрочем, все, что требует подобной структуры, можно создать, используя составные операторы. Глава 3 3.1. Лексический анализ является относительно медленным, поскольку он включает чтение исходного кода знак за знаком, а не символ за символом. 3.2. Значения констант не нужны синтаксическому' анализатору, они важны только для генератора кода. Синтаксический анализатор должен знать только то, что некий символ является константой и не требует значения этой константы. 226 Приложение 4
3.3. Конечный автомат для распознавания идентификатора языка FOR- TRAN показан на рис. А.2 Рис. А.2. Все состояния, за исключением одного, являются конечными. Пе- реход из первого состояние во второе происходит при прочтении буквы (/), все остальные переходы — при прочтении буквы или цифры (d). 3.4. а. См. рис. А.З. Состояние 2 является конечным. Рис. А.З. б. См. рис. А.4. Состояние 3 является конечным. 227
3.5. Для каждого случая приведены только продукции. S — символ предложения. a. S-+IT\! Т-+ IT\dT\/| d б. S->dS|.T T-+dT\d в. S->aT\bT\cT T-*xT\xU U-*a\Ь| с 3.6. См. рис. А.6. Состояния 4 и 7 являются конечными. Ниже приводится код С для реализации автоматизации, int real О {int state; char in; state = 1; in = getchar(); while(isdigit(in) || issign(in) || in || in == 'e') {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; else if (in == '.• > state 3; else error(); break; case 3: if (isdigit (in)) state = 4; else error(); break; 228 Приложение A
case 4: if (isdigit (in)) state = 4; else if (in == 'e') 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 errorO; break; } in = getchar(); > --n. return(state == 4 || state )• 3.7. Я - символ предложения, а продукции имеют следующий вид. R -4 +А | -а | digit А \ .Р А-4 digit А \ .Р Р -> digit Q | digit Q -4 еЕ | digit Q | digit Е+F \-F\ digit G\ digit G -4 digit G | digit Состояние 1 является на- 3.8. Конечный автомат приведен на р • и приводит к изменению чальным и конечным. СчИТЫва^Лпет со^яния. состояния, считывание нуля не м 1 И >( 2 1 Рис. А.7. а. Регулярное выражение. (О* 10* ЮТ б. Вход Lex. valid (0*10*10*)* Решения упражнений 229
3.9. Лексические сбои соответствуют недопустимому знаку или последо- вательности недопустимых знаков. Восстановление — это пропуск знаков, пока нс будет найден допустимый знак. 3.10. Могут использоваться совместные процедуры. Позволяет избежать воз- можных (но крайне маловероятных) случаев интенсивного использова- ния рекурсии, но. пожалуй, такой подход менее интуитивен. Глава 4 4.1. Грамматика с продукциями S-4S + X S-4 X (поскольку она содержит левую рекурсию). 4.2. а. Поскольку для некоторых предложений языка может быть более одного правого порождения, множества первых порождаемых сим- волов для некоторых нетерминалов должны быть непересекаюши- мися — должна существовать возможность неоднозначной замены нетерминала (на некотором шаге порождения). б. См. раздел 4.3. 4.3. В каждом случае приведены только продукции. a. S->xSy S-»a б. S-»xSW|a IV—»у | z в. S-+R\T R-> xRy R-+ a zTy T-> a 4.4. Преимущества — предлагает левоассоциативность (вычисление сле- ва направо), требуется хранить меньше промежуточных результатов. 4.5. Недостатки — не подходит для LL-анализа, в том числе, для анали- за методом рекурсивного спуска. 4.6. а. Последний. б. Первый. 4.7. Продукции грамматик приводятся ниже (в каждом случае S — сим- вол предложения). a. S->0S11 |а б. S->0S|1T|e Т-» OS I с в. S-»1T|0U|e 230 Приложение Л
Г—> 0S| 11/U U-> 1S|07T 4.8. Нет, поскольку приведенные ниже множества первых порождаемых символов пересекаются. DS(S —> АВ) = {х, т) DS(S -4 PQx) = {х, р, q) 4.9. Строки языка принадлежат к одному из двух типов. • Строки из нуля или более знаков р, за которыми следует строка из нуля или более знаков q, за которыми идет знак х. • Строки ху или строки т, за которыми следует строка из одного или более знаков Ь. 4.10. S-4 ЕХР ЕХР-ь TERM MORETERMS MORETERMS -> +TERM MORETERMS | - TERM MORETERMS I e TERM-^ FACT MOREFACT MOREFACT -4'FACT MOREFACT | /FACT MOREFACT I e FACT -> - FACT FACT -> (EXP) FACT -4 VAR VAR -> a | b | c | d | e 4.11. S-^EXP EXP-^ TERM MORETERMS MORETERMS -4 +<A1> TERM <A2> MORETERMS I - <A 1> TERM <A2> MORETERMS I e TERM-» FACTMOREFACT MOREFACT -» '<A1>FACT <A2> MOREFACT | /<A2>FACT <A2> MOREFACT r- |e FACT-<A1> FACT <A2> FACT -» (EXP) FACTVAR <A3> VAR —> a | b | c | d | e Решения упражнений 231
Глава 5 5.1. В первую очередь потому, что не требуется распознавать каждую продукцию, руководствуясь одним терминалом, пока она не будет полностью сформирована в стеке. 5.2. Конфликт перенос/свертка — на определенном этапе синтаксиче- ского анализа кажутся возможными перенос и свертка. Конфликт свертка/свертка — на определенном этапе синтаксиче- ского анализа кажется возможной свертка по более чем одной про- дукции. 5.3. Чтобы грамматика была неоднозначной, должно существовать пред- ложение, которое можно породить (используя правые порождения) несколькими способами. Таким образом, в ходе правого порожде- ния на некотором этапе должны быть возможны несколько дейст- вий, т.е. в таблице LR-анализа должен присутствовать конфликт пе- ренос/свертка или свертка/свертка. Следовательно грамматика не может относиться к типу LR(1). 5.4. Потому что содержимое стека символов ни на каком этапе не влия- ет на прогресс синтаксического анализа. 5.5. Если грамматика аннотирована так, как показано ниже, соответст- вующая таблица SLR(l)-анализа имеет вид, приведенный в табл. А.1 1. S—> I 3- Л.6е3.6 4. J —> зЗдХр 5. /$х10 Таблица А.1 Состояние S F J a X 1 1 S2 2 S3 3 S4 S5 R3 4 R1 5 S6 S8 S10 6 S7 S5 R3 7 R2 8 S9 9 М R4 10 Я5 R5 5.6. Грамматика не может быть LR(1), поскольку не существует способа определения “середины" предложения (когда следует применять продукцию 3 или 4), опираясь на историю произведенного синтак- сического анализа и одного символа предпросмотра. 232 nounnwauufiД
5.7. Если грамматика аннотирована так, как показано ниже, присутству- ет явный конфликт перенос/свертка в состоянии 8, который невоз- можно разрешить, используя единственный символ предпросмот- ра — предпросмотр символа : может указывать как на перенос, так и на свертку! В то же время, два символа предпросмотра позволяют разрешить конфликт, поскольку := указывает на свертку, а все ос- тальное — на перенос. Следовательно, грамматика относится к классу LR(2). Имеющуюся проблему можно устранить на этапе лек- сического анализа, трактуя := как единый символ. S —> 1,бИ?:з = 4Е5 S -> 1,б/-бв7 L l.efe‘9 V -> 1.б/в 5.8. Решение не приводится — зависит от локальной среды. 5.9. За — единообразный подход к восстановлению после ошибок. Против — не используются преимущества того, что восстановление после контекстно-зависимых ошибок часто возможно без влияния на действия синтаксического анализатора. 5.8. Грамматика, приведенная в конце раздела 5.8 для иллюстрации конфликтов свертка/свертка. Глава 6 6.1. Для языка С: • согласованное число индексов массива; • согласованное число параметров функции; • правила видимости; • совместимость типов при присваивании и т.п. 6.2. Нет, поскольку составные операторы обычно не вкладываются до произвольной глубины. 6.3. Нет, поскольку данная договоренность позволяет всего лишь отли- чать целые от действительных и, в любом случае, ее можно обойти. 6.4. Информацию нужно вводить в таблицу символов в ходе прохода, пред- шествующего проходу, в котором эта информация будет использована. 6.5. В языке Pascal рекурсивные типы могут использоваться только вме- сте с указателями, а тип указателя должен определяться непосредст- венно перед типом, на который указывает. 6.6. • Абстракция данных и инкапсуляция. • Полиморфизм. • Наследование. 233
6.7. За — позволяет наследование различных типов методов из различ- ных источников. Против — делает программы сложнее с точки зрения разработчиков и пользователей. Глава 7 7.1. За — упрощает адресацию. Против — требует больше памяти. 7.2. Единственная возможность — использовать одномерный символьный массив плюс таблицу, каждая строка которой включает следующее. • Указатель в массив, соответствующий началу константы. • Целое число, соответствующее количеству знаков в константе. 7.3. Может использоваться куча, но такой подход не является полностью удовлетворительным, поскольку момент, когда память можно будет использовать повторно, предсказуем во время компиляции. В каче- стве альтернативы может использоваться динамическая часть стека, но тогда придется разрешить массиву увеличиваться и уменьшаться во время выполнения программы. Это, в свою очередь, может по- влечь перемещение других значений в динамический стек! 7.4. Да. 7.5. Требуется на один указатель меньше при доступе к элементам мас- сива — см. раздел 7.4. 7.6. Необходимо следить, чтобы при перемещении значения не записы- вались одно поверх другого. 7.7. Программист не должен заботиться об освобождении памяти. Кро- ме того, заранее нельзя быть уверенным, что он проведет этот про- цесс корректно! 7.8. В средах реального времени более предпочтительным является фик- сированный и предсказуемый объем служебных издержек, связан- ных со счетчиками ссылок. Глава 8 8.1. а. ti = а + Ь tz = ti + с б. t: = а + Ь с: = с + d ti = tl * t2 t« = e + f ts = t3 + t4 234 Приложение A
в. tl = X * у t2 = tl * Z tl = ~p ti = t3 * Q ts = tz + tl 8 2. Разделение зависимых и независимых от языка аспектов компиля- тора; легкость переносимости; меньше работы при реализации не- скольких языков и т.д. 8.3. Трехадресный код не является ориентированным на язык, Р-код — является. P-код основан на использовании стеков (трудно сказать, это досто- инство или ограничение’.). 8.4. Интерпретация — облегчение переносимости, хорошая поддержка диагностических средств. Трансляция — эффективнее. 8.5. Помогает ограничить число типов инструкций. 8.6. Архитектуры CISC имеют более мощные инструкции, разработан- ные для реализации высокоуровневых языков. Архитектуры RISC — это больше регистров и более простой набор инструкций. 8.7. Переменная Инструкции а 1-3 с 1-5 d 1 ш 2-4 п з-д k 4-5 Р 5- 8.8. с, d. 8.9. Заменить код следующим. m = 0 ; sum2 = 0; while (m < 20) {m = m + 2; sum2 = sum2 + m; } 8.10. Удаление оператора из цикла увеличит время выполнения програм- мы, если цикл выполняется нуль раз.

Глоссарии Java Virtual Machine. Виртуальная машина, используемая при реали- зации Java. Lex. Генератор лексических анализаторов. Щ1)-грамматика. Грамматика, в которой множества первых порождае- мых символов всех продукций, в левой части которых фигурирует один нетерминал, не пересекаются. ЩА)-грамматика. Обобщение Щ1)-грамматик, в котором множества первых порождаемых символов становятся строками длины к. ЫХЛ)-язык. Язык, для которого существует генерирующая его граммати- ка LL(fc). Ы<(А)-грамматика. Грамматика, в которой все конфликты восходящего синтаксического анализа слева направо можно разрешить, используя фиксированный объем информации, касающейся уже проведенного анализа, и ограниченный объем (не более к символов) предпросмотра. ЬК(А')-язык. Язык, генерируемый грамматикой LR(k). P-код. Промежуточный язык, широко используемый для реализации языка Pascal. SLR(l)-грамматика. Подмножество ЬК(1)-грамматик, в котором все по- тенциальные конфликты разрешаются путем изучения только сим- волов предпросмотра. YACC (Yet Another Compiler-Compiler). Еще один компилятор компилято- ров (генератор синтаксических анализаторов). Абстрактное синтаксическое дерево. Древоподобная структура, используемая для представления необходимых аспектов структуры программы (из представления обычно исключаются знаки пунктуации, скобки, и тл.). Абстракция данных. Описание данных через операции, которые могут производиться с этими данными, в противоположность внутреннему представлению. Автомат магазинного типа. Конечный автомат плюс стек, содержимое ко- торого может влиять не переходы и зависеть от переходов. Адрес. Положение в памяти. Адреса времени компиляции. То, что известно во время компиляции об адресах (времени выполнения) переменных (или других значений). Аксиома. См. символ предложения.
Аксиоматическая семантика. Разновидность определения семантики, ос- нованная на исчислении предикатов, в которой результаты вычисле- ний выражаются через отношения между значениями переменных до и после применения определенных операций. Алфавит. Конечное множество символов. .Анализ живучести. Анализ того, какие переменные "живы” (активны) в конкретном месте программы. Анализатор. Часть компилятора, анализирующая исходный код. Атрибутная грамматика. Контекстно-свободная грамматика, дополненная атрибутными правилами, обычно используемыми для ограничения предложений, которые генерируются грамматикой. Байт-код. Промежуточный язык, обычно используемый в реализациях Ja\a. Виртуальная машина. Цель компилятора, реализованная в программном виде. Восстановление после ошибки. Действия, позволяющие программе син- таксического анализа продолжать работу после прочтения некор- ректного входа. Восходящий синтаксический анализ. Метод анализа, заключающийся в со- кращении предложений языка до символа предложения генерирую- щей его грамматики. Входной символ. Символ, считываемый компилятором. Выбор инструкций. Часть фазы генерации кода, в которой компилятор выбирает конкретные инструкции целевого кода или последователь- ности инструкций целевого кода. Генератор лексических анализаторов. Программный модуль, который мо- жет использоваться для создания лексического анализатора. Генератор синтаксических анализаторов. Программный модуль, который может использоваться для создания синтаксического анализатора. Генерация машинного кода. Создание машинного кода. Генерация машинно-независимого кода. Создание машинно-независимого кода. Генерировать. Создавать посредством применения продукций грамматики. Глобальная память. Память, которую может потребоваться поддерживать до завершения программы. Грамматика. Система для производству предложений языка, состоящая из четверки (Vr, Vv, Р, S), где Уг— алфавит, элементы которого называют- ся терминальными символами; V\—• алфавит, элементы которого назы- ваются нетерминальными символами (или нетерминалами); Р — множест- во продукций; S — нетерминал, именуемый символом предложения. Грамматика 0-го типа. См. рекурсивно перечислимая грамматика. Грамматика 1-го типа. См. контекстно-зависимая грамматика. 238 Глоссзрий
Грамматика 2-го типа. См. контекстно-свободная грамматика. Грамматика 3-го типа. См. регулярная грамматика. Граф взаимодействия (регистров). Граф, узлы которого представляют пе- ременные, а ребра соединяют переменные, которые не могут ис- пользовать один регистр. Действие переноса (в восходящем синтаксическом анализе). Действие, свя- занное с принятием терминала. Действие свертки (в восходящем синтаксическом анализе). Действие, свя- занное с замещением правой части продукции ее левой частью. Денотационная семантика. Разновидность определения семантики, осно- ванная на функциональном исчислении. Детерминированный синтаксический анализ. Метод анализа без необходи- мости отменять уже сделанные шаги. Динамический анализ (программного обеспечения). Анализ программного обеспечения посредством его эксплуатации. Динамическая память. Память, требования к которой будут динамически меняться во время выполнения. Динамические указатели. Указатели (в стек времени выполнения), отра- жающие структуру вызовов во время выполнения. Динамический анализ (программного обеспечения). Анализ программного обеспечения посредством его эксплуатации. Драйверная программа. Часть синтаксического анализатора, независимая от языка. Единичное наследование. Наследование, включающее один родитель- ский класс. Живая переменная. Переменная в определенном месте программы, значение которой может потребоваться позже при выполнении программы. Задача раскрашивания графа. Задача покраски каждого узла графа кон- кретным цветом, выбранным из конечного множества цветов так, чтобы два соседних узла всегда были окрашены в различные цвета. Задача синтаксического анализа. Задача нахождения порождения (если та- ковое существует) конкретного предложения с использованием дан- ной грамматики. Звездочка Клини. Символ используемый в регулярных выражениях для обозначения нуля или большего числа вхождений предшествую- щего элемента. Иерархия Хомского грамматик/языков. Классификация грамматик и язы- ков согласно типам продукций, используемых в грамматике. Инкапсуляция данных. Обособление элементов реализации функций, операторов, и т.д. от их использования. Глоссарий 239
Интегрированная среда разработки. Среда программного обеспечения .пч разработки программного обеспечения и поддержки сопутствующих действий. Интерпретатор. Транслятор языка, выполняющий каждую инструкцию целевого кода, как она создана. Исходный текст или исходный код. Первоначальный вход компилятора, обычно — программа, написанная на языке высокого уровня. Компилятор. Программный модуль, выполняющий процесс компиляции Компилятор компиляторов. Программный модуль для автоматического создания компиляторов. Конечный автомат. Конечный набор состояний плюс множество перехо- дов между состояниями, определяемыми входными символами. Контекстно-зависимая грамматика. Грамматика, для всех продукций которой длина (число символов) левой части не больше длины правой части. Контекстно-зависимый язык. Язык, который можно сгенерировать по- средством контекстно-зависимой грамматики. Контекстно-свободная грамматика. Грамматика, все продукции которой содержат в левой части единственный символ. Контекстно-свободный язык. Язык, который можно сгенерировать посред- ством контекстно-свободной грамматики. Конфигурация в грамматике. Положение перед, после или между симво- лами правой части продукции грамматики. Конфликт перенос/свертка (в восходящем синтаксическом анализе). Ситуа- ция. при которой кажутся возможными и действие переноса, и дей- ствие свертки. Конфликт свертка/свертка (в восходящем синтаксическом анализе). Ситуа- ция, при которой кажутся возможными несколько действий свертки. Левая рекурсия. Разновидность рекурсии, при которой символ из левой части продукции грамматики может генерировать (посредством при- менения одной или нескольких продукций) строку символов, край- ним левым символом которой будет тот же порождающий символ. Левое порождение. Порождение, при котором на каждом этапе заменяет- ся крайний левый нетерминал сентенциальной формы. Леволинейная грамматика. Грамматика, все продукции которой имеют одну из следующих двух форм. А —> Вс A^d Здесь А и В — нетерминалы, а с и d — терминалы грамматики. Лексический анализ. Фаза процесса компиляции, основной целью кото- рой является формирование символов языка из строк знаков. 240 гпессарий
Лексический анализатор. Часть компилятора, выполняющая лексический анализ. Линейно ограниченный автомат. Машина Тьюринга с конечной длиной ленты. Машина Тьюринга. Автомат, включающий состояния, переходы и память, состоящую из бесконечной ленты. Машинно-независимый код. Кодовый выход компилятора, независимый от конкретной машины. Множественное наследование. Наследование, включающее более одного родительского класса. Множество первых порождаемых символов. Множество терминалов, со- вместимых с применением конкретной продукции в процессе нисхо- дящего синтаксического анализа. Наследование (в объектно-ориентированных языках). Механизм, при кото- ром у класса предполагается наличие свойств (например, методов) суперкласса. Наследуемые атрибуты Атрибуты (символов атрибутной грамматики), значения которых передаются от левых частей соответствующим правым частям контекстно-свободных продукций. Неоднозначная грамматика. Грамматика, в которой имеется более одного левого порождения (или синтаксического дерева, или правого поро- ждения), по меньшей мере, для одного генерируемого предложения. Неоднозначный язык. Язык, который невозможно сгенерировать ни одной однозначной грамматикой. Непрямая рекурсия. Рекурсия, затрагивающая более одной продукции. Нетерминальный символ (или нетерминал). Символ, используемый грам- матикой для генерации предложений языка. Нечистая грамматика. Грамматика, все продукции которой используются. Нисходящий синтаксический анализ. Метод синтаксического анализа, за- ключающийся в создании предложений языка из символа предложе- ния грамматики, генерирующей данный язык. Однозначная грамматика. Грамматика, в которой невозможно создать ни одного предложения, содержащего более одного левого порождения (или синтаксического дерева, или правого порождения). Операционная семантика. Разновидность определения семантики, в кото- ром операции языка описываются через действия абстрактной ма- шины, выполняющей программу. Оптимизация кода. Улучшение кода с точки зрения его размера или вре- мени его выполнения. Оптимизация машинного кода. Улучшение машинно-независимого кода относительно использования памяти или времени выполнения. 241 Глоссарий
Оптимизация машинно-независимого кода. Улучшение машинно- независимого кода относительно использования памяти или вре- мени выполнения. Полиморфизм. Использование одного имени оператора или функции с различными значениями в зависимости от типов его/сс парамет- ров/операндов. Порождение. Последовательность шагов, когда предложение языка поро- ждается из грамматики, генерирующей данный язык. Постпроцессор (компилятора). Части компилятора, наиболее близкие к машине. Правая рекурсия. Разновидность рекурсии, при которой символ из левой части продукции грамматики может генерировать (посредством при- менения одной или нескольких продукций) строку символов, край- ним правым символом которой будет тот же символ. Правила разрешения неоднозначности. Правила, разрешающие неодно- значности грамматики. Правое порождение. Порождение, при котором на каждом этапе заменя- ется крайний правый нетерминал сентенциальной формы. Праволинейная грамматика. Грамматика, все продукции которой имеют одну из двух следующих форм. А -> ЬС A->d Здесь А и С — нетерминалы, а b и d — терминалы грамматики. Предложение языка. Строка, принадлежащая языку'. Препроцессор (компилятора). Часть компилятора, ближайшая к исходно- му’ КОДУ’. Продукция грамматики. Правило, которое составляет часть грамматики и определяет, как подстрока сентенциальной формы может замешаться другой подстрокой в ходе порождения предложения. Промежуточный код. Код, создаваемый компилятором в качестве проме- жуточного этапа перед производством целевого кода. Проход. Компонент компилятора, включающий однократное чтение ис- ходного кода или его представления. Процесс компиляции. Преобразование исходного кода, обычно написан- ное на языке высокого уровня, в семантически эквивалентный ма- шинный код или другое представление, близкое к машинному коду. Прямая рекурсия. Разновидность рекурсии, при которой нетерминал ле- вой части продукции также входит в правую часть. Пустая строка. Строка длины нуль. Распределение памяти. Распределение области памяти для значений пе- ременных и т.д. для использования во время выполнения. 242 Гпоссарий
Распределение регистров. Выделение регистров машины переменным и промежуточным данным в холе генерации кола. Реализация. Действие, заключающееся в создании компилятора, или соб- ственно компилятор. Регулярная грамматика. Праволинейная или лсволинейная грамматика. Регулярное выражение. Выражение, представляющее множество строк, состоящих только из символов алфавита и операторов дизъюнкции, сопоставления и звездочки Клини. Регулярный язык. Язык, который может генерироваться регулярной грам- матикой. Рекурсивно перечислимая грамматика. Наиболее общий тип грамматики, разрешенной определением грамматики. Рекурсивно перечислимый язык. Язык, который можно сгенерировать ре- курсивно перечислимой грамматикой. Рекурсивный спуск. Метод нисходящего синтаксического анализа, осно- ванный на написании функции или процедуры на языке реализа- ции, проверяющей все нетерминалы грамматики. Рекурсия (в контекстно-свободной грамматике). Свойство, заключающееся в том, что нетерминал может генерировать строку символов, содер- жащую данный нетерминал. Самовложение. Средняя рекурсия в грамматическом правиле или группе грамматических правил. Сборка мусора. Процесс, в ходе которого очищается область кучи, уже недоступная программе. Семантический анализ. Анализ исходного текста для определения его зна- чения/предполагаемого эффекта. Сентенциальная форма. Любая последовательность символов, которую, используя продукции грамматики, можно породить из символа предложения. Символ-последователь. Символ, который в сентенциальной форме может следовать за данным символом. Символ предложения (или аксиома) грамматики. Нетерминал, используе- мый в начале каждого порождения предложения. Символ предпросмотра. Символ, который следующим считает программа синтаксического анализа на определенном этапе анализе. Синтаксис (языка). Множество строк языка. Синтаксический анализ. Этап процесса компиляции, основная задача ко- торого — определить структуру программы. Синтаксический анализатор. Часть компилятора, выполняющая синтакси- ческий анализ. 243 Глоссарий
Синтаксическое дерево. Древоподобная структура, используемая для пред- ставления структуры программы. Синтезатор. Часть компилятора, создающая целевой код. Синтезируемые атрибуты. Атрибуты (символов атрибутной грамматики), значения которых передаются от правых частей соответствующим левым частям контекстно-свободных продукций. Средняя рекурсия. Рекурсия в продукции, не являющаяся ни левой, ни правой. Стартовый символ (в нисходящем синтаксическом анализе). Терминальный символ, появляющийся в начале строки символов, или (для строки, которая начинается с нетерминала) терминал, который может поя- виться в начале строки, генерируемой нетерминалом. Статическая память. Область памяти, которую нужно распределить на время выполнения программы и требования к которой известны во время компиляции. Статическая семантика. Аспекты семантики, которые можно определить статически. Статические указатели. Указатели (в стек времени выполнения), отра- жающие статическую структуру вызовов исходного кода. Статический анализ (программного обеспечения). Анализ программного обеспечения без его выполнения. Стек символов. Стек, в котором во время восходящего синтаксического анализа хранятся последовательности символов. Стек состояния. Стек, в котором хранятся состояния в ходе восходящего синтаксического анализа. Стековый фрейм. Раздел стека времени выполнения, соотнесенный с од- ной функцией или процедурой. Счетчик ссылок. Счетчик, используемый для отслеживания числа указа- телей, указывающих на определенную ячейку динамической памяти. Таблица меток. Таблица времени компиляции, которая содержит инфор- мацию, касающуюся меток программы. Таблица символов. Таблица, используемая во время компиляции для хра- нения информации об области видимости, типе переменных и дру- гой информации, касающейся переменных. Таблица синтаксического анализатора (или таблица синтаксического анализа) Зависимая от языка таблица, которая используется для управления про- цессом принятия решений программы синтаксического анализа. Таблица типов. Таблица, используемая во время компиляции для инфор- мации, касающейся типов. Таблица функций. Таблица времени выполнения, содержащая информа- цию, касающуюся функций программы. 244 Гпоссарий
Т-диаграмма. Разновидность диаграммы, используемая для отображения трех языков (исходного, целевого и реализации), задействованных в компиляторе. Терминальный символ (или терминал). Символ, который формирует часть грамматики и может появляться в предложениях, генерируемых этой грамматикой. Трехадресный код. Разновидность промежуточного кода, описанная в раз- деле 8.2. Универсальный промежуточный язык (Universal Intermediate Language — UIL). Промежуточный язык, подходящий для реализации большого числа языков на большом числе машин. Фаза. Логический компонент компилятора. Фаза маркировки (процесса сборки мусора). Фаза процесса сборки мусора, в которой определяются и некоторым образом маркируются ячейки памяти кучи, значения которых еше требуется поддерживать. Фаза сжатия (процесса сборки мусора). Процесс перемещения всей тре- буемой памяти кучи в одну область доступного пространства. Характеристический конченый автомат. Представление таблицы нисходя- щего синтаксического анализа в виде конечного автомата. Целевой текст или целевой код. Конечный выход компилятора, обычно — код для реальной машины. Чистая грамматика. Грамматика, не содержащая продукций, которые яв- ляются избыточными или не могут использоваться в любом порож- дении строк терминалов. Эквивалентные грамматики. Грамматики, генерирующие один и тот же язык. Этап. Основной логический компонент компилятора. Этап анализа. Этап процесса компиляции, в основном посвященный анализу исходного кода. Этап синтеза. Этап процесса компиляции, в основном связанный с соз- данием целевого кода. Язык. Множество строк из символов некоторого алфавита. Язык 0-го типа. См. рекурсивно счетный язык. Язык 1-го типа. См. контекстно\зависимый язык. Язык 2-го типа. См. контекстно-свободный язык. Язык 3-го типа. См. регулярный язык. Язык реализации. Язык, на котором пишется компилятор.

Литература Aho Л V., Sethi R. and Ullman, J. D., 19S5. Compilers; Principles. Technique and Tools. Addison Wesley. (Ахо А. В. Сети P. Ульман Д. Д. Компиля- торы: принципы, технологии и инструменты. М.: Издательским дом “Вильямс”, 2001.) Aho, А. V., Hopcroft, J. and Ullman, J. D., 1974. The Design and Analysis of Computer Algorithms, Addison-Wesley. Aho. A. V.. Johnson, S. C., and Ullman. J. D.. 1975. Deterministic parsing of ambiguous grammars. Comm ACM, vol. 18. pp. 441-452. Appel. A. W., 1997. Modem Compiler Implementation in C: Basic techniques, Cambridge University Press. Bennett. J. P., 1990. Introduction to Compiling Techniques: A First Course using ANSI C, LEX and YACC, The McGraw-Hill International Series in Soft- ware Engineering. Chomsky. N.. 1956 Three Models for the Description of Language, IRE Trans- actions on Information Theory’, IT-2-.3, pp. 113-124. (Хомский H. Три модели для описания языка// Кибернетический сборник. — М.: ИЛ, 1961. - Вып. 2. - С. 237-266) Cohen, J.. 1981. Garbage Collection of Linked Data Structures', Computer Sur- veys. vol. 13, no. 3, pp. 341-367. DeRemer, F., 1971. Simple LR(k) grammars, Comm ACM, vol. 14, pp. 453-460. Diller, A., 1988. Compiling Functional Languages, John Wiley and Sons, Ltd. Fenton, N. E., Pfleeger, S. L. 1996., Software Metrics: A Rigorous and Practical Approach, International Thomson Computer Press. Fisher, C. N., Leblanc, R. J., 1988. Crafting a Compiler, Benjamin Cummings. Foster, J. M., 1968. A Syntax Improving Device, Computer Journal, vol. IL Glanville, R. S. and Graham Susan, L., 1978. A new method far compiler code generation. Fifth Annual ACM Symposium on Principles of Programming Languages. G ries, D., 1971. Compiler Construction far Digital Computers, John Wiley and Sons. Johnson, S. C., 1975. YACC — yet another compiler compiler. Computing Sci- ence Technical Report 32, AT&T Bell Laboratories, Murray Hilt, N. J. Jones, R. and Lins, R., 1996. Garbage Collection: Algorithms for Automatic Dynamic Memory Management, John Wiley and Sons, Chichester, England. Klcenc. S. C., 1956. Representation of events in nerve nets in Shannon C. and McCarthy J., Automata studies, Princeton University Press. ПСлини С. K. Представление событий в нервных сетях//Сб. “Автоматы . М.: ИЛ. 1956. — С. 15-67.)
Knuth, D. E., On the translation of languages from left to right, Information and Control, vol. 8, pp. 607-639. (Кнут Д. О переводе (трансляции) языков слева направо// Сб. “Языки и автоматы” — М.: Мир, 1975. — С. 9-42.) Knuth, D. Е., 1968а. Semantics of Context-free languages, Mathematical Sys- tems Theory. 2:2, pp. 127-145. Knuth, D. E., 19686. The Art of Computer Programming, volume 1, Fundamental Algorithms, Addison Wesley. (Кнут Д. Искусство программирования. T. 7. Основные алгоритмы. — 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 (2nd edn). O’Reilly and Associates. Lindholm, T. and Yellin, E, 1996. The Java Virtual Machine Specification, Ad- dison Wesley. Loudon, К. C., 1997. Compiler Construction: Principles and Practice, PWS Pub- lishing Ltd. Lucas, P., 1961. The structure of formula translators, Electronische Rechenanla- gen, vol. 3, pp. 159—166. Meyer, J. and Downing, T., 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, К. V. et al., 1981. Pascal P implementation note, in Barron 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, Н. G., 1985. Introduction to Compiler Construction with Unix, Prentice Hall. Stallman, R, 1994. Using and Porting GNU CC, Gnu ftp distribution (prep.ai.mit.edu), Cambridge, MA, Free Software Foundation. Тепу, 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, D. A., 1997. An extended attribute grammar for Pascal, Report number 11, 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 Experi- ence, vol. 1, pp. 309-333. Wirth, N., 1996. Compiler Construction, Addison-Wesley.
Предметный указатель L Lex, 24; 62-76; 135; 13», 139, 140 ЬЦ О-грамматика, 84\ 86 Щ1)-язык, 87 LR-анализ, 134-132 Р Р-код, 201 и UIL, 19 Y YACC, 23; 75; 76; 135-153 А Адрес, 21; 173 времени компиляции, 185 Адрес времени компиляции, 176 Аксиома, 32 Алгоритм LALR(l), 131 LR(0), 132 LR(1), 132 SLR(1), 129, 131; 149 Алфавит, 30 Анализ, 12 LL(1), 101 восходящий синтаксический, 52, 109-155 живучести, 211 лексический, 16; 57; 71 нисходящий синтаксический, 52, 81—108 псевдонимов, 218 семантический, 157 синтаксический, 17; 27; 51 статический, 18 статический семантический, 165 Аннотации, 145 Архитектура CISC, 206 RISC, 206 Атрибуты наследуемые, 50 синтезированные, 50 Б Байт-код, 203 в Временное имя, 198 Выбор инструкций, 208 Г Генератор лексических анализаторов, 23 синтаксических анализаторов, 23 Генераторы генераторов кода, 219 Генерация кеша, 197 Предметный указатель 249
Грамматика, 31 О-го типа, 34 1-го типа, 35 2-го типа, 35 3-го типа, 35 LL( 1), 84; 86 LR(0), 129 LR(l), 772: 129 LR(k), 132 LR(k)., 112 SLR(l), 129 аннотированная, 120, 121 атрибутная, 49 контекстно-свободная, 35; 47 леволинейная, 36 неоднозначная, 43 нечистая, 88 однозначная, 43 праволинейная, 35 преобразования, 96 регулярная, 36 Грамматики эквивалентные, 34 Граф взаимодействия, 213 д Действия, 102; 104 Дисплей, 182 Доступность, 217 Дублирование констант, 216 3 Звездочка Клини, 29 и Иерархия Хомского, 34 Интегрированная среда разработки, 22 Исключение ненужных инструкций, 216 Исключение общих подвыражений, 217 Исходный код, 12 к Компилятор, 11-22; 40 >Г 97' 135; 215-219 Компилятор компиляторов, 21 Компиляция, 11; 12; 15; /Ч 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; 174-176 освобождение, 175 250
распределение, 21; 173-196 статическая, 21; 174-176 Перенос. ПО, 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 Свертка, ПО, 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 Ф Фаза, 75 Факторизация, 90, 98 Форма Бэкуса-Наура, 93 расширенная, 93 X Характеристический конечный автомат, 122 2S1
ц Целевой код, 12 э Этап, 15 я Язык LL(1), 37 LR(I), 112 LR(k), 112 контекстно-свободный, 39 неоднозначный, 43 определение, 27 реализации, 12 регулярный, 37\ 39
Научно-популярное издание Робин Хантер Основные концепции компиляторов Литературный редактор Т.Т. Шматко Верстка О. В. Линник Художественный редактор А.А. Минъко Обложка Е. П. Дынник Корректор Л.А. Гордиенко Издательский дом “Вильямс”. 101509. Москва, ул. Лесная, д. 43, стр. I. Изд. лиц. ЛР № 090230 от 23.06.99 Госкомитета РФ по печати. Подписано в печать 08.10.2002. Формат 60x88/16. Гарнитура Times. Печать офсетная. Уел. печ. л. 16,0. Уч.-изд. л. 11,0. Тираж 3000 экз. Заказ № 1564. Отпечатано с диапозитивов в ФГУП “Печатный двор” Министерства РФ по делам печати, телерадиовещания и средств массовых коммуникаций. 197110. Санкт-Петербург. Чкаловский пр., 15.
Основные концепции компиляторов