Компиляторы

УНИВЕРСИТЕТ НАЯНОВОЙ

Самарский муниципальный комплекс непрерывного образования



М. А. Шамашов


ОСНОВНЫЕ СТРУКТУРЫ ДАННЫХ И АЛГОРИТМЫ КОМПИЛЯЦИИ




Учебное пособие









Самара

1999
УДК 681.3

Основные структуры данных и алгоритмы компиляции. Учебное пособие./ М.А. Шамашов. Самара: Научно–внедренческая фирма “Сенсоры, модули, системы”, 1999, 115 с.

В пособии рассмотрены методы, алгоритмы и структуры данных, лежащие в основе трансляторов различной природы, но основное внимание акцентируется на принципах построения современных компиляторов и интерпретаторов для алгоритмических языков высокого уровня.
Пособие ориентировано на студентов, изучающих компьютерные науки, и специалистов в области проектирования системного и прикладного программного обеспечения в самых разных областях. Рекомендуется в качестве учебника для использования в учебном процессе специальности 01.02 “Прикладная математика”, специализация 01.02.01 – математическое и программное обеспечение систем (информационные технологии в производстве), а также специальностей “Автоматизированные системы обработки информации и управления” и “Программное обеспечение вычислительных и автоматизированных систем”. Выполнено на кафедре “Информатика”и в лаборатории “Открытые системы” Университета Наяновой.

Ил. 68. Табл. 6. Библ. 18 наимен.






Печатается по решению редакционно–издательского совета научно–внедренческой фирмы “Сенсоры, модули, системы”.










( М. А. Шамашов, 1999.
( Университет Наяновой, 1999.

ПРЕДИСЛОВИЕ

Перед вами учебное пособие, которое задумывалось как вторая часть учебника для одно– или двухсеместрового курса по теории формальных языков и основам построения компиляторов. Напомним, что в первом пособии этой серии – “Теория формальных языков. Грамматики и автоматы”[16] освещается та часть математической теории формальных языков и автоматов, на которой базируется синтаксически управляемый перевод. Данное пособие, напротив, более конструктивно. В нем рассмотрены структуры данных, алгоритмы и реализационные аспекты трансляторов, компиляторов, интерпретаторов и ассемблеров. И в этой связи пособие должно представлять несомненный самостоятельный интерес.
На наш взгляд, нельзя быть специалистом в области Computer Science и не знать принципов функционирования различных трансляторов для языков высокого уровня и ассемблеров, стоящих в ряду наиболее сложных и интересных программных систем.
Кроме того, развитие вычислительной техники невозможно без изобретения новых языков общения с ней. Человеку вообще свойственно создавать новые языки. Современные информационные технологии предполагают привлечение конечного пользователя, ученого или инженера, специалиста в конкретной предметной области, а не вычислительной техники и технологии программирования к решению своих задач на ЭВМ. Для качественного решения этой проблемы между пользователем и ЭВМ должен существовать интеллектуальный интерфейс, – пользователь должен ставить задачу и получать результаты в терминах известной ему предметной области. То есть, нужен предметно ориентированный язык. Думаю, что многим из Вас придется столкнуться с разработкой таких языков. Да и при разработке любой простейшей автоматизированной системы, пакета программ необходимо помнить о входном языке этой системы, знать как его анализировать, контролировать, транслировать и воплощать в действие. И для того, чтобы не “изобретать велосипед”, надо, конечно же, знать методы, алгоритмы, способы организации данных и средства автоматизации, лежащие в основе построения подобных систем.
Предлагаемое пособие и дает основы знаний в области проектирования компиляторов и других транслирующих систем. Разделы пособия сложились на основании конспектов лекционного курса, который в течении ряда лет читается автором в Самарском государственном аэрокосмическом университете и Самарском муниципальном университете Наяновой.
В первой главе пособия кратко рассмотрена вся совокупность задач, решаемых компилятором в их взаимосвязи. Последующие главы посвящены детальному рассмотрению наиболее интересных и важных фаз компиляции. Во второй главе рассмотрены алгоритмы и структуры данных лексического анализатора (сканера). В третьей главе обсуждаются возможные способы организации таблиц компиляторов. Четвертая и пятая, наиболее объемные главы посвящены описанию различных методов синтаксического анализа. Рассматриваются недетерминированные алгоритмы (алгоритмы с возвратами) нисходящего и восходящего синтаксического анализа, наименее эффективные, но подходящие для произвольных контекстно–свободных языков. Обсуждаются детерминированные LL(k) анализаторы, а также анализаторы языков простого и операторного предшествования. В шестой главе дано введение в семантику, обеспечивающую перевод программ во внутреннюю форму (ПОЛИЗ, тетрады) с ее последующей интерпретацией или генерацией кода. В седьмой главе обсуждаются методы машинно–независимой оптимизации программ. В восьмой главе дан обзор машинно-зависимых фаз компиляции. Там же приводятся некоторые сведения о трансляции с языка ассемблера.
ВВЕДЕНИЕ

Транслятор – это программа перевода текста (программы) с одного языка (исходного) на другой (объектный).
Если исходный язык является языком программирования высокого уровня, например, таким как ФОРТРАН, АЛГОЛ, АДА, ПАСКАЛЬ, СИ или МОДУЛА – 2 и, если объектный язык автокод (язык ассемблера) или машинный язык, то транслятор называют компилятором. Машинный язык иногда называют кодом машины, и поэтому объектная программа зачастую называется объектным кодом. Трансляция исходной программы в объектную выполняется во время компиляции, а фактическое выполнение объектной программы во время выполнения готовой программы.
Ассемблер – это программа, которая переводит исходную программу, написанную на автокоде или языке ассемблера, на язык вычислительной машины. Язык ассемблера – это по сути дела машинный язык, ориентированный на человеческое восприятие. Он очень близок к машинному языку с точным символическим представлением команд машины с фиксированным форматом, что позволяет легко их анализировать. В языке ассемблера отсутствуют вложенные инструкции, блоки и т.п. То есть ассемблер значительно проще компилятора. Тем не менее, в процессе изложения материала мы в начале будем говорить о компиляторах, и давать примеры трансляции с языка высокого уровня на язык ассемблера для упрощения пояснения. Трансляцию же с языка ассемблера рассмотрим в последнюю очередь.
Интерпретатор для некоторого исходного языка принимает исходную программу, написанную на этом языке, как входную информацию и выполняет ее. Различие между интерпретатором и компилятором состоит в том, что интерпретатор не порождает объектную программу, которая затем должна выполняться, а непосредственно выполняет исходную программу сам. Для того чтобы выяснить, как осуществить выполнение инструкции исходной программы, чистый интерпретатор анализирует ее всякий раз, когда она должна быть выполнена. Конечно же, это не эффективно. С точки зрения программной реализации интерпретатор обычно разделен на две фазы. На первой фазе интерпретатор анализирует всю исходную программу, почти так же, как это делает компилятор, и транслирует ее в некоторое внутреннее представление. На второй фазе это внутреннее представление исходной программы интерпретируется или выполняется. Перевод во внутреннее представление необходим для сведения к минимуму времени на анализ (расшифровку) каждой инструкции при ее выполнении. Таким образом, начальные фазы компилятора и интерпретатора совпадают.
В процессе изложения материала мы будем рассматривать синтаксически управляемые методы компиляции. Почти половина курса будет посвящена автоматическому распознаванию синтаксиса языков, что в свою очередь основывается на теории формальных языков, которую мы рассматривали в первой части курса и учебном пособии “Теория формальных языков. Грамматики и автоматы”. Вам необходимо вспомнить азы этого курса и, в первую очередь, автоматные и контекстно–свободные языки и грамматики, конечные автоматы и автоматы с магазинной памятью. Знание теории формальных языков позволяет глубже понять процессы компиляции, поможет систематически и эффективно проводить проектирование и реализацию компилятора.
1. КРАТКИЙ ОБЗОР ПРОЦЕССА КОМПИЛЯЦИИ

Компилятор должен выполнить анализ исходной программы, а затем синтез объектной. Сначала исходная программа разлагается на составные части; затем из них строятся фрагменты эквивалентной объектной программы. Для этого на этапе анализа компилятор использует и строит целый ряд таблиц, структур данных, которые затем используются как при анализе, так и синтезе. Анализ процессов компиляции позволяет выделить 7 различных логических задач – фаз компиляции. В практических реализациях грани между этими фазами размыты, часть из них может отсутствовать, совмещаться одна с другой и т.д. Предлагаемая ниже схема показывает общие принципы, которым необходимо следовать при проектировании компилятора.
На рис. 1.1 представлена структурно–функциональная схема компилятора, где выделены основные фазы и базы данных “типичного” компилятора. Отдельные фазы компилятора (функциональные блоки) помечены на рисунке цифрами, а структуры данных (таблицы) компилятора – буквами, управляющие связи изображаются сплошной, а информационные – пунктирной стрелкой. В этой главе мы лишь кратко остановимся на основных фазах и базах данных компилятора.

1). Лексический анализ – распознавание базовых элементов языка, перевод исходной программы в таблицу стандартных символов (лексем), которые в отличие от элементов исходной программы имеют постоянную длину, что делает последующие фазы компиляции более простыми. Лексический анализатор или сканер группирует определенные терминальные символы исходной программы в единые синтаксические объекты – лексемы. Какие объекты считать лексемами, зависит от определения языка программирования. Лексема – это пара вида: тип лексемы, некоторые данные. Первой компонентой пары является синтаксическая категория, такая как “константа”, “идентификатор” или “терминал” (ключевое слово языка или специальный символ: знак операции, разделитель и т.п.), а вторая – указатель: в ней указывается номер элемента таблицы, хранящий подробную информацию об этой конкретной лексеме. Входной информацией сканера является исходная программа и таблица терминалов языка, выходом – цепочка лексем, таблицы идентификаторов и констант.

2). Синтаксический анализ или разбор – использует только первые компоненты лексем – их типы. Информация о каждой лексеме (вторая компонента) используется на более поздних этапах процесса трансляции. Синтаксический анализ призван рассматривать базовые конструкции языка, исследовать цепочку лексем и устанавливать, удовлетворяет ли она структурным условиям, явно сформулированным в определении синтаксиса языка. Основа синтаксического анализа – синтаксические правила или грамматика языка. По предложенной грамматике можно автоматически построить синтаксический анализатор, который будет проверять, имеет ли исходная программа синтаксическую структуру, определяемую правилами грамматики. В последующих разделах мы изложим несколько различных методов разбора и алгоритмов построения синтаксических анализаторов по заданной грамматике.

3). Семантический анализ – определение смыслового значения базовых синтаксических конструкций. Этот процесс синтаксически управляем. То есть фазы 2 и 3 тесно связаны (объединены).
13EMBED Word.Picture.81415 Как только синтаксический анализатор узнает конструкцию исходного языка, он вызывает соответствующую семантическую процедуру или программу, которая контролирует конструкцию с точки зрения семантики и запоминает информацию о конструкции в таблицах идентификаторов и констант, либо в промежуточной (внутренней) форме исходной программы. Например, когда распознается описание переменных или констант, семантическая программа проверяет идентификаторы, указанные в этом описании, чтобы убедиться в том, что они не были описаны дважды и заносит их атрибуты или значения в соответствующие таблицы.
Когда встречается оператор присваивания вида
<переменная>:=<выражение>
семантическая программа проверяет переменную и выражение на соответствие типов, а затем заносит информацию об инструкции присваивания во внутреннюю форму программы (ВФП).
Таким образом, анализаторы 2 и 3 выполняют сложную и наиболее существенную работу по расчленению исходной программы на составные части, формированию ее внутреннего представления с занесением информации в таблицы идентификаторов и констант, осуществляют полный синтаксический и семантический контроль программы, включая действия по локализации, идентификации и нейтрализации ошибок.

4). Машинно–независимая оптимизация ВФП – вынесение общих подвыражений, вычисления над константами, оптимизация переходов в сложных условных операторах, вынесение инвариантных вычислений за цикл и т.п.

5). Распределение памяти – модификация таблиц идентификаторов и констант. Определение адресов идентификаторов и констант. Вставки в ВФП, для генерации и распределения динамической памяти. Выделение временной памяти, выравнивание и т.п.

6). Генерация кода и машинно–зависимая оптимизация. С каждой операцией из ВФП связана кодовая продукция, которая и выносится в код сборки. Оптимизация же проводится с целью более эффективного использования регистров ЭВМ, удаление “лишних” команд, связанных с сохранением и загрузкой промежуточных данных на этапе вычислений и т.п.

7). Сборка и выдача – разрешение символических адресов (трансляция с языка ассемблера) и формирование объектного модуля – (машинного кода и информации для компоновщика и загрузчика).

Сразу же отметим, что фазы 1 – 4 – машинно–независимы и определяются только исходным языком, а фазы 5 – 7 машинно–зависимы и не зависят от исходного языка.

На рис. 1.1 изображены только основные базы данных компилятора, обеспечивающие связь между его фазами. Внутренние базы данных (стеки, таблицы и т.п.) здесь не рассмотрены.

а). Исходная программа – это программа на исходном языке программирования, например, С или Паскаль.

б). Таблица терминальных символов – постоянная таблица, в которой записаны все ключевые слова (IF, THEN, ELSE, WHILE и т.п.) и специальные символы языка (пробел, ,’, ;’, (’, (’ и т.п.) в символьной форме. На них ссылаются стандартные символы – лексемы программы.
в) Таблица (строка) лексем (стандартных символов) – состоит из полного или частичного списка лексических единиц, расположенных в том порядке, в каком они встречаются в программе (например, TRM(1(n), IDN(1(m), CON(1(k)). Они создаются при лексическом анализе и используются на этапах синтаксического и семантического анализа.

г). Таблица идентификаторов – содержит информацию обо всех переменных программы, в том числе и временных переменных, хранящих промежуточные результаты вычислений, и информацию, необходимую для ссылок (адресации) и отведения памяти. Создается на фазе лексического анализа (1) (на элементы таблицы идентификаторов ссылаются лексемы), модифицируется на фазах семантического анализа (3) и распределения памяти (5), используется также на фазах генерации кода (6), сборки и выдачи (7).

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

е). Правила грамматики (продукции) – могут представляться и неявно в самом теле программы синтаксического анализа. Они обеспечивают, в соответствии с заданным алгоритмом, автоматический анализ синтаксиса исходной программы. Зачастую, в грамматике содержится информация и о семантических действиях (транслирующие и атрибутные грамматики), которые надо предпринимать в процессе обнаружения тех или иных языковых конструкций.

ж). Внутренняя форма программы (ВФП) – форма обеспечивающая однопроходную генерацию кодов (в компиляторе) или интерпретацию (выполнение интерпретатором). Пример ВФП – ПОЛИЗ (польская инверсная запись) – где арифметические выражения, да и вся программа представляется не в традиционной инфиксной форме, а в постфиксной или суффиксной бесскобочной формах. В ПОЛИЗе операции располагаются за операндами, над которыми они выполняются в порядке их выполнения. Например, оператор
a:=b+c*d/(b–c)–10;
в ПОЛИЗе примет вид
abcd*bc–/+10–:=
Еще чаще в компиляторах в качестве ВФП используется матрица тетрад, где выражение представляется в форме тетрад (оператор, операнд, операнд, результат) в порядке их выполнения. Например, присваивание a:=b+c*d будет представлено как
(
,
C
,
D
,
M1

(
,
B
,
M1
,
M2

((
,
M2
,

,
A





где M1 и M2 временные переменные, образованные компилятором. (При работе компилятора, операндами в приведенных примерах будут не сами символические имена и значения, а лексемы – ссылки на таблицы, где они были описаны.)
ВФП создается на фазе семантического анализа, модифицируется (оптимизируется) на фазе машинно–независимой оптимизации и используется при генерации кода.

з). Кодовые продукции – постоянная таблица, имеющая отдельные элементы, определяющие код для каждой возможной операции ПОЛИЗа или матрицы тетрад (т.е. ВФП). Например, тетрада +, операнд_1, операнд_2, результат или более конкретно +, A, B, M10 может быть представлена следующей кодовой продукцией:

MOV ax, A
ADD ax, B
MOV ax, M10

а тетрада :=, операнд_1,, результат (:=, M20,,ABC) – продукцией

MOV ax, M20
MOV ABC, ax
Таблица кодовых продукций используется на фазе генерации кода.

и). Код сборки – версия программы на языке сборки (аналог языка ассемблера). Создается на фазе генерации кода и используется фазой сборки.

к). Перемещаемый объектный модуль – результат фазы сборки и всей трансляции в целом. Является входной информацией для компоновщика или загрузчика.

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

2. ЛЕКСИЧЕСКИЙ АНАЛИЗ

Лексический анализ – это первая фаза трансляции. Входом компилятора, а следовательно и лексического анализатора (сканера) служит цепочка символов некоторого алфавита. Например, в первых версиях языка ПЛ/1 алфавит терминальных символов содержал всего 60 знаков:
ABC...Z $ @ #
0123...9 пробел
=+–*/(),.;:'&|(>В языках типа Паскаль и Си терминальных символов уже около 200.

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

1). В таких языках, как Паскаль или Си, цепочка, состоящая из одного или более пробелов, обычно рассматривается как один пробел.

2). Группа символов в Паскале, ограниченная символами { } или (( ((, трактуется как комментарий.

3). В большинстве языков есть ключевые слова, такие как BEGIN, END, IF, THEN, ELSE, WHILE и т.д., каждое из которых можно считать одним элементом.

4). Каждая цепочка, представляющая числовую или текстовую константу рассматривается как один элемент текста.

5). Идентификаторы, используемые как имена переменных, функций, процедур, меток, массивов, типов и т.п. также считаются лексическими единицами языка программирования.

Работа лексического анализатора состоит в том, чтобы сгруппировать определенные терминальные символы в единые синтаксические объекты, называемые лексемами. Какие объекты считать лексемами зависит от определения языка программирования. Лексема – это цепочка терминальных символов, с которой мы связываем лексическую структуру, состоящую из пары вида: тип лексемы, некоторые данные. Первой компонентой этой пары является лексическая категория, такая как “константа” или “идентификатор” или “терминал“ (ключевое слово или специальный символ языка), а второе – указатель: в ней указывается адрес области памяти (номер элемента таблицы), хранящей полную информацию об этой конкретной лексеме. Для данного языка число типов лексем предполагается конечным.
Таким образом, сканер – это транслятор, входом которого служит цепочка символов, представляющая исходную программу, а выходом – таблица идентификаторов и констант и, самое главное, последовательность лексем, которые и образуют вход синтаксического анализатора.
Сразу же возникает вопрос, почему лексический анализ нельзя объединить с синтаксическим. Действительно, можно, ведь лексические единицы можно описать традиционно в нормальной форме Бэкуса (БНФ), например:
<идентификатор> ::= буква {буква(цифра}31.
Однако, есть ряд серьезных доводов в пользу отделения лексического анализа от синтаксического. Наибольшее значение имеет то, что замена в программе идентификаторов, констант, терминалов, имеющих различную длину (по количеству символов) на лексемы постоянной длины делает представление программы более удобным для дальнейшей обработки. Лексический анализ уменьшает длину программы, устраняя из нее несущественные пробелы и комментарии. На последующих стадиях трансляции компилятор может несколько раз просматривать те или иные последовательности лексем, например, в случае синтаксического анализа с возвратами. Поэтому, уменьшая с помощью лексического анализа длину представления программы, исключая необходимость повторного сканирования символов, составляющих лексемы, можно сократить общее время компиляции.
Кроме того, синтаксис лексем можно описать в рамках очень простых, автоматных грамматик. Отделив сканирование от синтаксического анализа можно разработать эффективную технику разбора, которая наилучшим образом учитывает особенности этих грамматик.
Заметим также, что в ряде случаев для одного и того же языка существует несколько различных внешних представлений. Например, Алгол–60, в свое время, требовал заключения ключевых слов в апострофы, пробелы при этом игнорировались. В других реализациях уже пробел выступал в роли разделителя. Можно предложить русскоязычную и англоязычную нотацию одного и того же языка программирования. Выделение сканера в отдельную фазу позволяет реализовать один синтаксический анализатор и несколько простых сканеров. При этом каждый сканер получает в результате работы одинаковый набор лексем.
Итак, лексический анализатор решает задачи грамматического разбора исходной программы на базовые элементы – лексемы, строит последовательность лексем, а также таблицы идентификаторов и констант.
Во многих ситуациях выбор конструкций, которые будут выделяться как лексемы, довольно произволен. Например, если в языке разрешены комплексные константы вида
<комплексная константа> ::= (<вещественное>,<вещественное>),
то возможны две стратегии. Можно трактовать <вещественное> как лексический элемент и отложить распознавание конструкции (<вещественное>, <вещественное>) как комплексной константы до синтаксического анализа. Другая стратегия состоит в том, чтобы с помощью более сложного лексического анализатора распознавать эту конструкцию как комплексную константу на лексическом уровне и передавать полученный тип лексемы синтаксическому анализатору, упрощая его работу.
Обычно выделяют всего три типа лексем: идентификатор – IDN, константу – CON и терминал (ключевое слово или специальный символ) – TRM.
Для работы лексического анализатора используется постоянная таблица терминальных символов, которая имеет отдельный элемент для каждого терминала, к которым относятся, например, ключевые слова языка, арифметические операции и другие специальные символы, не являющиеся буквами и цифрами. Элемент таблицы терминалов имеет следующий формат:

Символ
Класс
Приоритеты



Каждый элемент этой таблицы состоит из собственно терминального символа (или группы), указателя его классификации (операция, разделитель и т.п.) и его приоритетов, не только для операций, но и других терминалов языка (подробно об этом смотрите в разделе 6.4 о методе Замельсона – Бауэра для перевода в ПОЛИЗ).
Определим форматы остальных таблиц, формируемых сканером.
Таблица констант – создается при лексическом анализе для того, чтобы описать все константы из исходной программы. Каждой константе соответствует отдельный элемент таблицы, который содержит значение константы; ряд атрибутов (например, тип); адрес, указывающий на местоположение константы во время выполнения программы (заполняется на фазе распределения памяти); другой информации (например, в некоторых реализациях можно различать константы, используемые программой и константы в размерности массива, необходимые только компилятору.). Такой атрибут как тип константы может быть получен из самой константы и сразу же записан в таблицу вместе со значением на этапе лексического анализа. Формат элемента таблицы констант следующий:
Значение константы
Тип
Другая информация
Адрес


Таблица идентификаторов – создается при лексическом анализе для того, чтобы описать все идентификаторы, имеющиеся в исходной программе. Каждому идентификатору соответствует отдельный элемент таблицы. Во время лексического анализа в этот элемент помещается имя идентификатора. Для того чтобы зафиксировать длину элементов таблицы идентификаторов и не резервировать лишней памяти, в таблицу идентификаторов целесообразнее заносить не сами имена, так как их длина может меняться от 1 до 31 и более символов а указатели на имя в таблице (списке, строке) имен переменной длины. Атрибуты и адрес для каждого идентификатора записываются последующими фазами. Элемент таблицы идентификаторов может выглядеть так:

Имя
Тип
Тип
памяти
Границы массива
Объем
Положение в структуре
Начальное значение
Адрес
Прочее


Таблица (строка) лексем – также создается на фазе лексического анализа для того, чтобы представить программу строкой лексем, имеющих постоянную длину. Каждой лексической единице в программе соответствует лексема. (Комментарии и возможно пробелы в строку лексем не записываются). Лексема содержит указатель на таблицу, элементом которой является соответствующая лексическая единица (например, IDN – идентификатор или TRM – терминал) и его индекс (указатель) внутри этой таблицы:
Тип таблицы
Индекс




В основе алгоритма лексического анализа обычно лежит алгоритм разбора автоматного языка. Здесь многое зависит от языка и лексический анализ провести легко, если лексемы, состоящие из нескольких символов, изолированы с помощью знаков, которые сами являются лексемами (обычно это специальные символы: разделители и знаки операций). Разделители помечаются в специальном поле таблицы терминалов. Символы исходной программы читаются, проверяются на корректность и выясняется не являются ли они разделителями. Стоящие подряд символы, не являющиеся разделителями и отличные от пробелов объединяются в лексические единицы. Лексическими единицами являются и сами разделители, они естественно помещаются в строку лексем (за исключением пробела в ряде реализаций).
Группа символов, лежащая между разделителями в простейшем сканере может быть лексемой типа TRM, IDN или CON.
Сначала все лексические единицы сравниваются с элементами таблицы терминалов. В случае совпадения лексическая единица классифицируется как терминальный символ и формируется лексема типа TRM, которая и помещается в таблицу лексем.
Если же лексическая единица не является терминальным символом, при последующем анализе она классифицируется как возможный идентификатор или константа. Так лексические единицы, которые удовлетворяют лексическим признакам, определяющим идентификатор, классифицируются как IDN. Правила при этом могут быть такими:
<идентификатор >::=<буква>{<буква>(<цифра>}
или
<идентификатор >::=<буква>(< буква > < И1>
< И1>::=<буква>(<цифра>(< буква > <И1>(<цифра> <И1>

После того, как лексическая единица классифицирована как идентификатор, опрашивается таблица идентификаторов. Если данного идентификатора в таблице нет, то создается новый элемент таблицы и в него заносится имя идентификатора. Независимо от того, был создан новый элемент или нет, создается лексема типа IDN и помещается в таблицу лексем.
Числа, строки символов, заключенные в кавычки или апострофы, и другие самоопределенные данные классифицируются как константы. Опрашивается таблица констант. Если полученной константы в таблице нет, то создается новый элемент, в который заносится не только сама константа, но и ее атрибуты (обычно тип константы). Затем, независимо от того, создан новый элемент в таблице констант или нет, формируется и помещается в строку лексем элемент типа CON.
Если лексическая единица не подходит ни под одну из этих категорий, выдается сообщение о лексической ошибке.
Ниже показан пример таблиц, которые используются и формируются при лексическом анализе простейшей процедуры, которая представлена на рис. 2.1, где пунктиром выделены отдельные лексические единицы.
13EMBED Word.Picture.81415


Имя
Разделитель

1
((пробел)
да

2
(
да

3
,
да

4
:
да

5
)
да

6
;
да

7
<
да

8
=
да


Имя
Разделитель

9
PROCEDURE
нет

10
VAR
нет

11
INTEGER
нет

12
BEGIN
нет

13
IF
нет

14
THEN
нет

15
END
нет


Таблица 2.1. Таблица терминалов
В таблице терминалов здесь, естественно, представлены только те терминалы, которые встречаются в рассматриваемой программе. Реальная таблица терминалов для любого языка программирования значительно больше.

Таблица 2.2. Таблица идентификаторов

Имя
Атрибуты

1
MAX


2
А


3
В


4
С










Тип
Индекс
Лексема

Тип
Индекс
Лексема

1
TRM
9
PROCEDURE
21
IDN
3
B

2
IDN
1
MAX
2
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·Таблица 2.3. Таблица лексем


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

Лексический анализ производится либо за один просмотр исходной программы, во время которого создается полная строка лексем, либо путем вызова лексического анализатора каждый раз, когда фаза синтетического анализа запрашивает очередную лексическую единицу. Последний вариант, вообще говоря, лучше, поскольку нет необходимости хранить в памяти полную строку лексем. Но при использовании возвратных алгоритмов синтаксического анализа хранить лексемы так и так придется.
В общем случае алгоритм синтаксического анализа может быть не таким простым, как определено выше. Например, рассмотрим, следующие правильные операторы Фортрана, где, напомним, пробелы игнорируются:
(1) DO 10 I=1.15
(2) DO 10 I=1,15
В операторе (1) цепочка DO 10 I – переменная и 1.15 – константа. В операторе (2) DO – ключевое слово, 10 – константа, I – переменная, 1 и 15 – константы.
Если сканер реализован, как сопрограмма и, находясь в начале одного из этих операторов, он выполняет процедуру “Найти очередную лексему”, то он до тех пор не мог бы определить, является ли лексемой DO или DO 10 I, пока не дошли бы до запятой или точки.
Таким образом, лексический анализатор иногда должен заглядывать вперед за интересующую его в данный момент лексему. Еще худший пример встречается в языке ПЛ/1, где ключевые слова могут быть переменными. Глядя на входную цепочку вида DECLARE (Х1,Х2, ... Хn), упомянутый выше лексический анализатор не знал бы, что ему сказать: то ли DECLARE – идентификатор функции или массива и Х1,Х2, ... Хn – аргументы этой функции (индексы массива), то ли DECLARE – ключевое слово, требующее, чтобы у идентификаторов Х1,Х2, ... Хn был атрибут (или атрибуты), расположенный справа от закрывающей скобки. Здесь классификация лексем должна осуществляться с помощью части текста, которая идет после правой скобки. Но так, как число n может быть сколь угодно большим, то работая с языком ПЛ/1, этот лексический анализатор должен заглядывать вперед сколь угодно далеко. Однако существует другой подход к лексическому анализу, менее удобный, но позволяющий избежать проблемы заглядывания вперед.
Определим два простейших подхода к лексическому анализу. Большинство известных способов основано на том или другом из них, а некоторые на их комбинации.
Говорят, что лексический анализатор работает прямо, если для данного входного текста и положения указателя в этом тексте анализатор определяет лексему, расположенную непосредственно справа от указываемого места, и сдвигает указатель вправо от части текста, образующего эту лексему.
Говорят, что лексический анализатор работает не прямо, если для данного входного текста, положения указателя в этом тексте и типа лексемы он определяет, образуют ли знаки, расположенные непосредственно справа от указателя, лексему этого типа. Если да, то указатель передвигается вправо от части текста, образующего эту лексему.
Для примера рассмотрим все тот же текст из Фортрана
DO 10 I=1,15
с указателем, расположенным на левом конце. Непрямой лексический анализатор ответит “да”, если его спросят о лексеме типа DO или о лексеме типа <идентификатор>, но в первом случае указатель передвинется на 2 символа, а в последнем – на пять символов.
Прямой лексический анализатор обследует текст, вплоть до запятой и сделает заключение, что очередная лексема должна быть типа DO. Указатель при этом передвинется только на 2 символа, хотя и было просмотрено больше.
При дальнейшем обсуждении алгоритмов синтаксического анализа мы будем полагать, что лексический анализатор работает прямо. В случае непрямого сканирования можно использовать недетерминированные алгоритмы синтаксического анализа, или алгоритмы с возвратами, которые также будут обсуждаться в данном пособии.

Упражнение на программирование.
2.1. Реализуйте прямой лексический анализатор для произвольного языка программирования с традиционными типами лексем. Таблица терминалов, разделителей и исходная программа представлены в виде текстовых файлов. Результатом является визуализируемые таблицы лексем, идентификаторов и констант.
3. ОРГАНИЗАЦИЯ ТАБЛИЦ КОМПИЛЯТОРА

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

В большинстве алгоритмов эти положения противоречивы и требуют компромиссных решений. Вспомним, а может быть, предложим новые для Вас способы организации таблиц и методы работы с ними.
3.1. ОБЩИЙ ВИД ТАБЛИЦ

Таблицы всех типов имеют общий вид, представленный на рис. 3.1, где слева перечисляются аргументы, а справа соответствующие значения.
13EMBED Word.Picture.81415
Каждый элемент таблицы обычно занимает более одного машинного слова. Если элемент занимает K слов памяти и нужно хранить N элементов, то для организации таблицы необходимо иметь K(N слов памяти. Расположить информацию можно двумя способами :
1. Каждый элемент поместить в K последовательных слов и иметь таблицу из K(N слов.
2. Иметь K таблиц, с именами T1, T2, ...,Tk из N слов в каждой таблице. Весь i–ый элемент будет при этом находиться в словах T1 i, T2 i, ...,Tk i.
Вопрос выбора между этими двумя методами – это только вопрос удобства программирования.
В нашем случае аргументами таблицы являются символы или идентификаторы, а значениями их характеристики (атрибуты). Так как число символов в идентификаторе может быть самым разным, то как уже отмечалось, в аргументе, обычно, вместо самого идентификатора помещают указатель на идентификатор. Это сохраняет фиксированный размер аргумента. Сами же идентификаторы хранятся в специальном списке строк. Число литер в каждом идентификаторе может храниться как часть аргумента или в списке идентификаторов прямо перед ним. На рисунке 3.2 показаны оба эти способа на примере таблицы, содержащей элементы для идентификаторов I, max и J.
Если число элементов в поле значения переменно, то в этом поле таблицы также следует помещать указатель на эту информацию. Формат таблицы лучше оставлять фиксированным.
13EMBED Word.Picture.81415
Рассмотрим существующие способы организации таблиц и оценим время требуемое на поиск элементов и их добавление.
3.2. ПРЯМОЙ ДОСТУП К ТАБЛИЦЕ ИЛИ МЕТОД ИНДЕКСОВ

Суть этого метода состоит в том, что по символам цепочки (идентификатора) вычисляется индекс, который обеспечивает прямой доступ к таблице (индекс массива). В качестве примера рассмотрим проблему идентификации переменных языка MINI–BASIC. Переменная в нем либо английская буква, либо буква, за которой следует цифра. Всего имеется 286 допустимых переменных MINI–BASIC. Так как это число невелико, то можно позволить себе завести по одному элементу таблицы для каждой допустимой переменной. Тогда проблема поиска переменной сводиться к простому преобразованию имени в индекс. Один из способов состоит в присваивание индексу значения от 1 до 26 для каждой входной буквы английского алфавита. Далее, если следующий символ – произвольная цифра d, то к индексу буквы прибавляется число 26((d+1). Это значит, что переменная Z получит номер 26, A0 – 27, а Z9–286.
Индексный метод можно эффективно применять при выполнении 3–х условий. Во–первых, число слов не должно быть слишком большим. Поэтому, такой метод нельзя применять даже для имен переменных ФОРТРАНа (не более шести символов), т.к. их более миллиарда. Во–вторых, индекс должен легко вычисляться. Это условие может исключить даже небольшое множество ключевых слов, так как хороший способ построения индекса по ним вряд ли удастся предложить. В–третьих, объем множества слов фиксируется при построении, так как изменение метода вычисления индекса во время компиляции требует реорганизации всей таблицы. Если учесть эти три условия, то становится очевидным, что рассматриваемым методом можно воспользоваться нечасто. Тем не менее, когда он применим, поиск и включение элемента (отметка что он включен) осуществляется с минимальными затратами времени.
3.3. НЕУПОРЯДОЧЕННАЯ ТАБЛИЦА ИЛИ МЕТОД ЛИНЕЙНОГО СПИСКА

Наиболее простой метод поиска состоит в том, что входное слово сопоставляется с каждым элементом хранимым в памяти массива или списка слов, до тех пор, пока не происходит совпадение (если оно возможно). Добавляются же элементы к текущему концу заполненной части массива или к последнему элементу в списке. У этого метода есть лишь один недостаток: поиск по длинной таблице займет много времени! Если в таблице N элементов, то ожидаемое число сопоставлений, необходимых для обнаружения случайно выбранного элемента, равно (N+1)/2.
3.4. УПОРЯДОЧЕННАЯ ТАБЛИЦА. БИНАРНЫЙ, ДВОИЧНЫЙ ИЛИ ЛОГАРИФМИЧЕСКИЙ ПОИСК

Время поиска по таблице большого объема можно значительно сократить, если элементы таблицы упорядочены по аргументу, например, лексикографически, т.е. по алфавиту. Эффективным методом поиска в таком списке является бинарный (двоичный, логарифмический) поиск. Имя S, которое следует найти, сравнивается с аргументом элемента с номером (N+1)/2 в середине таблицы. Если этот элемент не является требуемым, то мы должны просматривать только блок элементов пронумерованных от 1 до (N+1)/2–1 или блок от (N+1)/2+1 до N в зависимости от того, меньше или больше искомый идентификатор S того элемента, с которым его сравнивали. Затем мы повторяем процесс для блока меньшего размера. Так как на каждом шаге число элементов, которые могут содержать S, сокращается наполовину, то максимальное число сравнений –1+log2N. Если N=2, то необходимо, самое большое, 2 сравнения (N=4, – то 3, N=8, – то 4). Для N=128 потребуется не более 8 сравнений, в то время как среднее время поиска в неупорядоченной таблице того же объема составит 64 сравнения.
Основной недостаток бинарного поиска – необходимость преобразования таблицы при добавлении элементов. Надо не просто добавлять элементы к концу таблицы, а вставлять их туда, где они обязаны находится в соответствии с выбранным порядком, т.е. использовать метод упорядоченных вставок. Он применяется, когда таблица часто просматривается до того, как она полностью заполнена, и, следовательно, должна быть упорядочена на всех этапах. Элемент S вставляется в таблицу следующим образом:
1. Находится k для которого Sk < S < Sk+1.
Элемент N сдвигается на позицию N+1, N–1 – на позицию N и т.д. Элемент с номером k+1 сдвигается на позицию k+2.
S заносится на место k+1.

Если заполнение таблицы предшествует поискам, то упорядочение проще сделать после заполнения.
Процедуру двоичного поиска можно легко применить к бинарным деревьям, где каждый элемент таблицы имеет два указателя. Один из них указывает на середину списка слов, которые идут после данного слова, а другой на середину списка слов, которые предшествую ему. Такое дерево зачастую называют деревом поиска.
На рис. 3.3 показано представление таблицы в виде дерева поиска для некоторого подмножества ключевых слов и стандартных типов языка С.
При поиске слова for в этом дереве оно последовательно сравнивается со словами if, do, enum и for.
Разместив дерево в памяти, можно добавлять к нему слова не нарушая этого размещения. Слово bitset, например, присоединится к слову break, а слово delete к слову default. Но, к сожалению, после таких добавлений результирующее время поиска может превышать логарифмическую границу. Для слова delete, например, потребуется 6 сравнений. Если желательно расширить множество слов, сохранив логарифмическое время поиска, необходимо проводить реорганизацию структуры дерева.
13EMBED Word.Picture.81415
3.5. СБАЛАНСИРОВАННЫЕ ДЕРЕВЬЯ

Для добавления элементов к дереву и реорганизации дерева, лучше всего подходит теория и алгоритмы сбалансированных деревьев, прекрасно изложенные в монографии Н. Вирта [3]. Дерево называется сбалансированным тогда и только тогда, когда высоты двух поддеревьев каждой из его вершин отличаются не более чем на единицу.
Рассмотрим алгоритм включения элемента в сбалансированное дерево. Если у нас есть корень r и левое (L) и правое (R) поддеревья, то необходимо различать 3 возможных случая. Предположим, что включение в L новой вершины, приведет к увеличению на 1 его высоты, тогда возможно:
1. hL = hR(где h – высота дерева). Тогда после включения L и R станут разной высоты, но критерий сбалансированности не будет нарушен.
2. hL < hR, L и R станут равной высоты, то есть сбалансированность улучшится.
3. hL > hR, критерии сбалансированности нарушатся и дерево необходимо перестраивать.
Для примера, возьмем дерево, представленное на рис. 3.4. Вершины с ключами 9 и 11 можно включить, не нарушая сбалансированности дерева; дерево с корнем 10 становится односторонним (случай 1), а с корнем 8 – лишь лучше сбалансированным (случай 2). Отдельное включение ключей 1,3,5 или 7 требует последующей балансировки.
13EMBED Word.Picture.81415
При внимательном изучении этой ситуации можно обнаружить, что существует лишь две по существу различные возможности, требующие индивидуального подхода. Оставшиеся могут быть выведены из этих двух на основе симметрии. Первый случай возникает при включении в дерево на рис. 3.4 ключей 1 или 3, ситуация характерная для второго случая, возникает при включении ключей 5 или 7.
Схематично эти случаи представлены на рисунке 3.5, где прямоугольниками обозначены поддеревья, причем “добавленная” при включении высота отмечена перечеркиванием.
13EMBED Word.Picture.81415
Простые преобразования сразу же восстанавливают сбалансированность. Их результат приведен на рисунке 3.6. Обратите внимание, что допускаются лишь перемещения в вертикальном направлении, в то время как относительное горизонтальное расположение вершин и поддеревьев должно остаться неизменным.
Алгоритм включения и балансировки существенно зависит от того, каким образом хранится информация о сбалансированности дерева. Крайнее решение – хранить эту информацию полностью неявно, в структуре самого дерева. Однако в этом случае сбалансированность узла нужно определять всякий раз, когда включение затрагивает этот узел. Это ведет к очень большим затратам.
13EMBED Word.Picture.81415
Другая крайность – хранить в каждой вершине показатели сбалансированности. В этом случае определение узла дерева представляется в виде(
Type Ptr=Pointer to Node;
Type Balance=[–1..+1];
Type Node=record
count: Integer;
left,right:Ptr;
hol:Balance;
end;
Показатель сбалансированности вершины можно определять как разность между высотой правого поддерева и левого. Процесс включения вершины фактически состоит из трех последовательно выполняемых пунктов(
1. Проходя по пути поиска, пока не убедимся, что ключа в дереве нет.
2. Включение новой вершины и определения результирующего показателя сбалансированности.
3. “Отступления” по пути поиска и проверки в каждой вершине показателя сбалансированности. Если необходима – балансировка.
Хотя при таком методе и требуются некоторые избыточные проверки (если сбалансированность уже зафиксирована, то нет необходимости проверять ее в вышестоящих вершинах), традиционно придерживаются именно этой корректной схемы, так как ее можно реализовать простым расширением процедуры поиска элемента в дереве с включениями. В этой процедуре описываются действия, связанные с поиском в каждой вершине, и в силу рекурсивной природы этой процедуры ее легко приспособить для выполнения дополнительных действий при возвращении вдоль пути поиска. Информация, которую необходимо передавать на каждом шаге, указывает, увеличилось ли или нет высота поддерева, где произошло включение. Поэтому кроме традиционных параметров, x ключ поиска и p – указатель вершины дерева, добавляется булевское значение (параметр – переменная) h, – означающее – “ высота дерева увеличилась”.
Cложность операции балансировки предполагает, что сбалансированные деревья следует использовать, когда поиск информации проводится значительно чаще, чем ее включения. Это утверждение особенно верно, поскольку вершины дерева поиска для экономии памяти обычно хранится в виде плотно упакованных записей. Поэтому решающим фактором, определяющим эффективность операции балансировки, часто бывает доступность показателя балансировки и простота его изменения, ведь для его представления нужно всего 2 разряда.
Исключением из сбалансированного дерева– процесс еще более сложный, чем включение в него. Тем не менее, операции поиска, включения и исключения из сбалансированного дерева выполняются даже в худшем случае за время пропорциональное t((log 2 N), где t – некоторое константа, а N – количество элементов в таблице.
3.6. ДЕРЕВЬЯ ОПТИМАЛЬНОГО ПОИСКА
В той же работе Вирта [3] рассматриваются и деревья оптимального поиска. Рассматривая выше поиск по таблице, мы исходили из предположения, что частота обращения ко всем элементам таблицы одинакова, т.е. все ключи с равной вероятностью фигурируют как аргументы поиска. Однако встречаются ситуации, когда есть информация о вероятности обращения к отдельным элементам таблицы. Обычно для таких ситуаций характерно “постоянство” элементов, то есть в дерево поиска элементы не включаются и не исключаются из него. Типичный пример – как раз сканер транслятора, определяющий, не относится ли идентификатор к классу терминалов (ключевых или зарезервированных слов). Статистические измерения на сотнях транслируемых программ могут в этом случае дать точную картину об относительных частотах появления отдельных элементов (обращений к ним). Для этих деревьев вместо понятия средней длины пути по дереву вводится понятие взвешенной длины пути – суммы всех путей от корня каждой из вершин, умноженных на вероятность обращения к этим вершинам.
В качестве примера рассмотрим множество из трех ключей 1, 2, 3 с вероятностями обращения к ним P1=1/7; P2=2/7; P3=4/7. Эти три ключа можно расставить в дерево поиска пятью различными способами, представленными на рис. 3.7. Там же приведены и взвешенные длины пути для каждого дерева.
13EMBED Word.Picture.81415
Таким образом, оптимальным оказывается не идеально сбалансированное дерево (3.7 в), а вырожденное (3.7 а).
Сканер транслятора, предполагает, что задачу следует ставить при несколько больших общих условиях: слова встречающиеся в тексте не всегда ключевые, на самом деле эта ситуация скорее исключение, чем правило. Обнаружение, что данный ключ не является ключом дерева поиска, можно считать обращением к некоторой гипотетической вершине, включенной между следующей большей и следующей меньшой вершиной. Теория этого вопроса довольно сложна и не является задачей нашего курса. Отметим только, что оптимальное дерево поиска ключевых слов большинства языков программирования весьма далеко от сбалансированного.
3.7. ХЕШ – АДРЕСАЦИЯ

Здесь и далее используются термины “хеш–адресация”, “хеширование”, ”хеш–функция” и т.п., происходящих от английского слова hash, что в дословном переводе означает мешанина, путаница. Представляется разумным обходится одним корнем для всей совокупности терминов, связанных с данным кругом понятий, причем корнем, не загруженным другими значениями и дающий максимальную свободу для образования производных слов. Все попытки воспользоваться известными терминами типа “перемешанные таблицы“, “рандомизация“, “функция расстановки“ приводят к длинным и тяжеловесным словосочетаниям.
Метод хеш–адресации впервые был использован при разработке ассемблера для ЭВМ IBM 701 в 1954 году. Суть метода состоит в том, что включение и поиск по таблице основывается на использовании не самого идентифицируемого слова (ключа), а некоторой информации, зависящей от него. Это метод преобразования слова в индекс элемента в таблице. Индекс получается “хешированием“ слова – выполнением над ним (и, возможно, над его длиной) некоторых арифметических или логических операций. Простой хеш–функцией является внутреннее представление кода первой литеры слова. Так, если двоичное представление символа А – 01000001 – 65, то результат хеширования АВЕ будет 65 (или 1, или 0, если использовать не код ASCII, а порядковый номер буквы).
Пока для двух различных слов результаты хеширования различны, время поиска совпадает с временем, затраченным на хеширование, так как мы имеем дело с прямым доступом к таблице и получаем огромную экономию по сравнению с поиском по неупорядоченной и даже упорядоченной таблице. Однако, возникают затруднения, если результаты хеширования двух различных слов совпадают. Это называется коллизией. Очевидно, что в данной позиции таблицы может быть помещено только одно слово, так что мы должны найти другое место для второго. Хорошая хеш–функция распределяет вычисляемые адреса равномерно на все, имеющиеся в распоряжении адреса, так что коллизии не возникают слишком часто. Хеш– функция, описанная выше, очевидна плоха, так как все идентификаторы, начинающиеся с одной и той же буквы, ссылаются на один и тот же адрес. Далее мы рассмотрим различные хеш–функции. Но сначала обсудим один из способов решения задачи коллизии – рехеширование.
3.7.1. Рехеширование
Предположим, что мы хешируем слово S и обнаруживаем, что другое слово уже заняло элемент h. Возникает коллизия. Тогда сравниваем S с элементом h+P1 (по модулю N, где N – длина таблицы) для некоторого целого P1. Если снова возникает коллизия, сравниваем S с элементом h+P2 и т.д. Это продолжается до тех пор, пока не встретится какой-либо элемент h+Pi (по модулю N), который либо пуст, либо содержит S, либо снова является элементом h (Pi=0). В последнем случае мы прекращаем выполнение программы, поскольку таблица полна.
Таким образом, если возникло i коллизий, будет выполнено i+1 сравнений с элементами hi=h+Pi (по модулю N). Величины Pi должны выбираться так, чтобы ожидаемое число сравнений Е было невелико и чтобы по возможности было рассмотрено большее число элементов.
Рехеширование обычно связывается с термином рассеянной памяти, так как заполненные элементы таблицы оказываются рассеянными по ней. Чтобы отличать пустые элементы от заполненных, все элементы должны быть первоначально заполнены каким–либо значением, которое не может встречаться как символ (слово). Кроме того, таблица должна быть сразу рассчитана на максимальное число элементов. Это объясняется тем, что нет простого способа расширения таблицы (массива), если она заполнена, без повторного вычисления хеш–адресов для всех записанных элементов и занесения их в соответствующие новые позиции. Имеются несколько способов рехеширования, которые и будут рассмотрены ниже.

Линейное рехеширование – старейший и, вероятно, наименее эффективный из них. Он состоит в том, чтобы положить P1=1, P2=2, P3=3 и т. д. То есть сравниваются последовательные элементы. Предположим, например, что символы S1 и S2 были хешированны и записаны в элементы 2 и 4 соответственно (см. рис. 3.8 а)
13EMBED Word.Picture.81415
Теперь предположим, что символ S3 также ссылается на элемент 2. Вследствие коллизии он будет занесен в элемент 3 (рис. 3.8 б). Наконец, предположим, что S4 также ссылается на элемент 2. Возникают последовательно 3 коллизии – с S1, S3 и S2 – прежде чем S4 заносится в элемент 5 (рис. 3.8 в). Причина низкой эффективности этого метода становится достаточно ясной из этого примера; после нескольких коллизий, разрешенных таким образом, элементы скапливаются вместе, образуя длинные цепочки заполненных элементов.
Оценка среднего числа сравнений Е для поиска одного элемента, полученная эмпирическим путем, составляет:
Е = (1 – Lf / 2) / (1 – Lf),
где Lf – коэффициент загрузки. Таким образом, если таблица заполнена на 10% мы можем ожидать 1.06 сравнений, если наполовину – 1.5 сравнений, если на 90%, то – 5.5 сравнений. Заметим, что Е не зависит от размера таблицы, а только от степени заполнения.

Случайное рехеширование снимает проблему скопления за счет выбора в качестве Pi псевдослучайных чисел. Если размер таблицы представляется степенью двойки (N = 2k, для произвольного k), то хорошие результаты дает следующий способ вычислений Pi :
1. При вызове программы положить целое R, равным 1.
2. Вычислить каждое Pi следующим образом
а) установить R=R(5;
b) выделить младшие k+2 разряда R, и поместить результат в R;
с) взять величину из R, сдвинуть ее вправо на 2 разряда и результат назвать Pi.
Важнейшее свойства этого метода, предотвращающего скопление, состоит в том, что все числа Pi+k – Pi различны. Хорошее приближение ожидаемого числа сравнений в этом случае дает формула:
E = – (1 / Lf) ( log (1–Lf),
где Lf – коэффициент загрузки. Так, если таблица заполнена на 10% ожидается 1.05 сравнений, если наполовину – то 1.39, если на 90% – 2.56.

Рехеширование сложением
Положим Pi=i(h, где h – искомый хеш–индекс. Таким образом мы будем пробовать элементы h, 2(h, 3(h, 4(h и т.д.(все значения вычисляются по модулю, равному размеру таблицы). Этот метод хорош, когда размер таблицы N является простым числом, так как все последовательности накрывают полностью все возможные индексы от 1 до N–1.

Очевидный недостаток рассмотренных методов рехеширования – неизменный, рассчитанный на максимум размер таблицы и это скорее не руководство к действию, а экскурс в историю, хотя подобные подходы и до сих пор практикуются при размещении информации на дисках.
3.7.2. Хеш–функция
Как уже отмечалось, выбор хеш–функции очень сильно влияет на эффективность рассматриваемых методов организации таблиц. Обычно, если слово S, являющееся аргументом хеширования, занимает более одного машинного слова, то на первом шаге хеширования по S формируется одно машинное слово S /. Как правило, S / вычисляется суммированием всех слов из S при помощи поразрядного сложения по модулю 2 (то есть при помощи операция XOR – исключающее или).
На втором шаге из S / вычисляется окончательный индекс, причем это можно сделать несколькими способами:
1. Умножить S / само на себя и использовать n средних битов в качестве значения функции хеширования (если таблица имеет 2n элементов). Поскольку n средних битов зависит от каждого бита S /, этот метод дает очень хорошие результаты.
2. Использовать какую–либо логическую операцию, например тот же XOR, над некоторыми частями S /.
3. Если в таблице имеется 2n элементов, расщепить S / на n частей и просуммировать их. Использовать n правых крайних битов результата.
4. Разделить S / на длину таблицы и остаток использовать в качестве хеш–индекса.
Все эти методы применялись и давали удовлетворительные результаты. Можно предложить и другие методы. Нужно только быть уверенным, что на множестве аргументов, к которому будет применяться функция хеширования, она даст достаточно случайные адреса. Это легко проверить, формируя таким образом таблицу терминалов (ключевых, зарезервированных слов языка). В принципе, выделение ключевых слов в отдельную таблицу вовсе необязательно. Их можно поместить в общую таблицу идентификаторов при начальной загрузке компилятора, снабдив соответствующим признаком. Стоит проверить эту первоначальную таблицу на число возникших коллизий. Хеш–функция может быть достаточно хорошей с точки зрения статистики, но может как раз случиться, что 20 зарезервированных слов ссылается на один и тот же адрес.
Например, в компилятора PL/1 F фирмы IBM, реализованного в начале 70–х годов, использовалось следующая хеш–функция:
1. Суммировались последовательные части идентификатора, содержащие по 4 символа, в один 4–байтовый регистр.
2. Результат делился на 211 и определялся остаток R.
3. Значение 2(R использовалось в качестве индекса для ссылки на таблицу из 211 указателей (каждый указателей имеет длину 2 байта).
В этом компиляторе для разрешения коллизии использовался не метод рехеширования, а метод цепочек (гроздей), который, конечно же, предпочтительнее в системах с динамическим распределением памяти.
3.7.3. Метод цепочек или гроздей
В методе цепочек используется одна постоянная по размеру хеш–таблица, содержащая указатели на головы списков слов, вначале указывающих на пустые элементы.
Поиск поступившего слова и его добавление к списку, в случае необходимости, осуществляется за 5 шагов следующим образом:

1. По входному слову определяется хеш–функция или индекс. Этот индекс определяет номер элемента хеш–таблицы, то есть определяет указатель на список слов с полученным индексом. Если этот указатель равен NIL, то есть указывает на пустой список, то выполняется шаг 2, иначе шаг 3.

2. Формируется головной элемент списка слов с данным индексом, в который заносится входное слово. Указатель на сформированный элемент списка заносится в хеш–таблицу на место, определенное индексом слова. Выполняется шаг 5.

3. Найденный элемент хеш–таблицы указывает на некоторый связанный список слов, а именно тех слов, индекс которых совпадает с вычисленным индексом. Поиск в этом списке осуществляется до тех пор, пока не произойдет совпадение входного слова с элементом списка и далее последует шаг 5, или будет достигнут конец списка, и выполнится шаг 4.

4. Множество не содержит входного слова, его нужно добавлять, сформировав новый элемент списка и связав его, например, с последним из рассмотренных элементов. Далее выполняется шаг 5.

5. Алгоритм завершает работу, возвращая указатель на найденный или сформированный элемент.

В литературе неслучайно хеш–методы иногда описываются в терминах “ гроздей “ (buckets). Говорят, что каждый хеш–индекс указывает на некоторую гроздь, и все слова, имеющие этот индекс, принадлежит одной грозди.
Для примера возьмем слова из дерева поиска с рисунка 3.3 и изобразим на рис. 3.9 возможный результат предложенного метода хеширования. Для того чтобы вычислить индекс, в этом примере нужно сложить номера (в алфавитном порядке) двух первых букв слова и взять остаток от деления результата суммирования на 7. Например, индекс слова break получается сложением 2 (номер b) и 18 (номер r) и последующим делением результата (20) на 7. Остаток от деления равен 6. Это значит, что указатель списка, содержащий слово break (если такой список существует), находится в 6–ой ячейке хеш–таблицы.
Реализация предложенной процедуры хеширования зависит от метода вычисления индекса (хеш–функции) и объема хеш–таблицы. Поскольку эти факторы могут оказывать существенное влияние на скорость и затраты памяти процедуры (а фактически и всего компилятора), остановимся на них подробнее.

13EMBED Word.Picture.81415
Единственная цель вычисления индекса – это уменьшение длины списков, в которых должен производится поиск. В идеале нам хотелось бы, чтобы списки были одинаковой длины. К сожалению, разработчик должен выбрать хеш–функцию до того, как он узнает, какие слова появится в ходе компиляции. Поэтому единственное, на что можно рассчитывать, – это то, что новые слова будут попадать в заданные списки с одинаковой вероятностью. Перед разработчиком стоит задача поиска хеш–функции, удовлетворяющей этим свойством псевдослучайности.
Для примера вначале рассмотрим не такую уж случайную хеш–функцию, которая предполагает, что каждое слово относиться к одному из 26 списков по первой букве слова. Так как в английском языке гораздо больше слов, которые начинаются с буквы R, чем слов, начинающихся с буквы О, следует ожидать, что R – список будет содержать гораздо больше элементов, чем О – список. Такая неравномерность еще более усилится, если программирующий на ФОРТРАНе решит, что в его программе все идентификаторы целых переменных будут начинаться с буквы I. Итак, предположенный метод плох всем за исключением способа получения индекса.
Метод индексации по двум буквам, примененный в примере на рис. 3.9, лучше, чем метод индексации по одной букве, так как он ведет к более равномерному заполнению списков. Однако программист может употреблять имена с одинаковым началом (например, alpha1, alpha2, alpha3 и т.д.), что отрицательно повлияет на работу компилятора. Можно добиться быстрого поиска, если вычислять индекс по сумме всех букв. Однако затраты при вычислении индекса могут перекрыть выигрыш во времени поиска. Таким образом, нужно найти компромисс между временем поиска и времени вычисления индекса. На практике используются хеш–функции рассмотренные выше в одноименном разделе.
Определим теперь, насколько большой нужно делать хеш–таблицу. Считая, что слова помещаются в списки случайным образом, можно изучить вопрос об объеме таблицы чисто количественным методом. Пусть в хеш–таблице есть место для С указателей, и пусть случайным образом введены М слов. Если входное слово случайно выбрано из М слов, то ожидаемое число сравнений Е, необходимых для нахождения входного слова, будет равно:
Е = 1 + (М –1) / (2(С).
Чтобы установить, что некоторого нового слова в множестве нет, нужно провести M/C сравнений. Если нам известно число слов, с которыми придется иметь дело, эта формула говорит нам, как именно число сравнений зависит от объема таблицы. В таблице 3.1 помещены результаты вычислений для некоторых значений М и С.
Пусть нас интересует эффективность обработки 500 различных слов, и мы хотим узнать сколько списков выгоднее завести: 500 или 100. Преимуществом 100 списков является то, что экономится место, необходимое для 400 указателей, а недостатком – то, что для идентификации слова необходимо (в среднем) на 2 сравнения больше. Решение сводится к выбору между 400 дополнительными указателями и 2 дополнительными сравнениями, необходимыми для идентификации каждого слова. Но этот поиск выполняются по 10 раз для каждой строки программы. Неизбежно напрашивается вывод, что на размере хеш–таблицы экономить не надо. Еще лучшего эффекта можно достигнуть, сочетая хеш–метод цепочек и деревья поиска.

Таблица 3.1.
Число слов, М


20
100
500
1000
5000


10
1.95
5.99
25.95
50.95
250.95

Объем
50
1.19
1.99
5.99
10.99
50.99

таблицы,
100
1.10
1.50
3.50
5.60
20.00

С
200
1.05
1.25
2.25
3.50
13.50


500
1.02
1.10
1.50
2.00
6.00


1000
1.01
1.01
1.50
1.50
3.50


В компиляторе ФОРТРАНа (IBM 1966 год), работали, например, с 6 деревьями, по одному дереву для идентификаторов, состоящих из 1, 2, 3, 4, 5 и 6 символов соответственно. Имелось, также, по одному дереву для 4, 8 и 16 битовых констант, дерево для меток инструкций и несколько других. Все элементы содержались в одной и той же таблице, каждый элемент которой занимает 52 байт.

Упражнения на программирование.

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

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

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

3.4. Реализуйте алгоритм построения оптимального дерева поиска для множества ключевых слов и служебных символов языка программирования высокого уровня, на котором Вы работаете.
4. ОБЩИЕ МЕТОДЫ СИНТАКСИЧЕСКОГО АНАЛИЗА

Синтаксический анализ - это базовый и в настоящее время наиболее формализованный этап трансляции. Существует масса методов синтаксического анализа, часть которых мы и рассмотрим в данной главе.
Напомним, что синтаксический анализатор для автоматного языка можно тривиально построить, основываясь на автоматной грамматике или конечном автомате, описывающем этот язык. Теория и практика построения такого анализатора были детально рассмотрены в учебном пособии “Теория формальных языков. Грамматики и автоматы” [16]. Но, как известно, класс автоматных грамматик очень узок. Любой язык, допускающий вложенность конструкций не является автоматным языком, и даже язык арифметических выражений с вложенными скобками нельзя описать с помощью А-грамматики. Эти грамматики и анализаторы автоматных языков используются только на первой фазе трансляции - лексическом анализе.
Большинство языков программирования можно описать с помощью контекстно-свободных (КС-) грамматик или автоматов с магазинной памятью (МП-автоматов). Но построить алгоритм анализа по таким грамматикам в общем случае не так просто и эти алгоритмы, как правило, неэффективны. Один из путей анализа цепочек КС-языка следует из теоремы о разрешимости этих языков. Напомним, что любой КС-язык можно описать с помощью КС-грамматики в удлиняющей форме, по которой любая цепочка заданного языка длины n выводится не более чем за n шагов. Для того чтобы определить принадлежность некоторой цепочки с длиной n данному языку необходимо по заданной грамматике вывести все бесповторные цепочки, которые выводятся не более чем за n шагов, и сравнить их с исходной цепочкой. Если мы обнаружим совпадение, то исходная цепочка принадлежит языку, в противном случае - нет. Совершенно очевидно, что подобный потенциально осуществимый алгоритм не имеет практического смысла. Во-первых, необходимо строить вывод миллиардов цепочек, во-вторых, такой алгоритм может ответить только на вопрос принадлежности языку. Локализовать и идентифицировать ошибку, а тем более осуществить трансляцию в процессе синтаксического анализа текста он не способен.
13EMBED Word.Picture.81415
Итак, основная задача синтаксического анализатора сводится к тому, чтобы по заданной КС-грамматике и произвольной цепочке, он мог бы ответить на вопрос о принадлежности цепочки языку. Рассмотрим, например КС-грамматику G1 со следующими перенумерованными продукциями:
(1) S ( aBcD
(2) B ( BaD
(3) B ( d
(4) D ( Dd
(5) D ( d
На рисунке 4.1 показано дерево вывода цепочки adadcdd по заданной грамматике. Ее левый вывод имеет вид: S ( aBcD ( aBaDcD ( adaDcD ( adadcD ( adadcDd ( adadacdd , а левый разбор[16] (последовательность правил, применяемых при левом выводе) - 123545. Правый вывод данной цепочки: S ( aBcD ( aBcDd ( aBcdd ( aBaDcdd ( aBadcdd ( adadacdd и обращенный правый разбор - 352541.
Очевидно, что цепочка adadacdd принадлежит языку L(G1), в чем убеждает дерево ее вывода. Таким образом, для того, чтобы ответить на вопрос принадлежит ли цепочка заданному языку, необходимо построить вывод данной цепочки, а еще лучше дерево вывода.
Отметим, что все рассматриваемые ниже алгоритмы называются алгоритмами анализа слева направо, ввиду того, что они обрабатывают вначале самые левые символы рассматриваемой цепочки и при необходимости продвигаются по цепочке вправо. Можно было подобным же образом определить и анализ справа налево, но он менее естественен. Операторы программы выполняются слева направо, да и мы, как правило, читаем и пишем подобным образом.
Различаются две категории алгоритмов синтаксического анализа: нисходящий анализ (сверху вниз) и восходящий (снизу вверх). Эти термины соответствуют способу построения синтаксических деревьев вывода.
При нисходящем анализе дерево строится от корня, начального символа грамматики, вниз, к концевым узлам. Такой алгоритм зачастую называется предсказывающим анализом, так как на каждом шаге он пытается предсказать очередное правило для левого вывода. На рис 4.2 показаны последовательные шаги построения дерева вывода для цепочки 35 в грамматике целых чисел:
N ( D(ND
D ( 0(1(2(3(4(5(6(7(8(9
На каждом шаге самый левый нетерминал V текущей сентенциальной формы (V( заменяется правой частью ( правила V ( (. Фокус, конечно, в том, чтобы в конечном итоге получить ту сентенциальную форму, которая совпадает с заданной цепочкой.
13EMBED Word.Picture.81415
Алгоритмы восходящего анализа часто называют алгоритмами отсечения основ или алгоритмами типа “перенос-свертка” и здесь при анализе и построении дерева разбора применяют процесс обратный порождению - свертку или редукцию. Заданная цепочка принадлежит языку, если ее удается свернуть к начальному символу грамматики. На рис. 4.3 показаны этапы этого алгоритма на примере той же цепочки 35 и заданной грамматики целых чисел.
На рис. 4.2 и 4.3 выводы различны, но деревья вывода совпадают. Заметим, что в последнем построении на каждом шаге редуцируется основа (самая левая простая фраза) текущей сентенциальной формы и поэтому правее от основы сентенциальная форма всегда содержит только терминальные символы.
13EMBED Word.Picture.81415
В рассмотренных примерах мы не коснулись главных проблем синтаксического анализа. При нисходящем анализе на каждом шаге мы заменяли самый левый нетерминал V на правую часть соответствующего правила. Пусть для нетерминала V существует n правил: V( (1((2(((( n . Как узнать, какой цепочкой ( i надо заменить V? При восходящем анализе на каждом шаге редуцируется основа. Как найти основу и тот нетерминал, к которому она должна приводится?
Одно из решений - выбор некоторого варианта наугад, в предположении, что он верен. Если в дальнейшем обнаружится ошибка, то возврат назад и попытка применить другой вариант. Такой подход - не что иное, как попытка моделирования недетерминированного МП-преобразователя, который в общих чертах мы уже обсуждали в первой части пособия [10]. Такие алгоритмы носят название недетерминированных или алгоритмов синтаксического анализа с возвратами. Попытаемся формализовать эти алгоритмы.
4.1. НИСХОДЯЩИЙ РАЗБОР С ВОЗВРАТАМИ

В алгоритме используется два магазина L1 и L2 и счетчик с текущей позицией входного указателя. Для описания алгоритма воспользуемся стилизованными обозначениями, похожими на описания конфигурации МП-автомата.

Алгоритм 4.1. Алгоритм нисходящего разбора с возвратами.

Вход. Не леворекурсивная КС-грамматика G = (N, (, P, S) и входная цепочка ( = a1, a2, (, an (n ( 0). Предполагается что правила из P пронумерованы числами 1, 2 , (, p.

Выход. Левый разбор цепочки (, если он существует, или слово “ошибка” в противном случае.

Метод.
(1) Для каждого нетерминала А ( N упорядочить его альтернативы. Пусть А j - индекс j-ой альтернативы нетерминала А.
(2) Четверкой (q, i, (, () будем обозначать конфигурацию алгоритма, где:
(а) q - состояние алгоритма;
(б) i - позиция входного указателя (предполагается, что (n+1)-ым “входным символом” служит правый концевой маркер $);
(в) ( - содержимое первого магазина (L1);
(г) ( - содержимое второго магазина (L2).
Будем считать, что верх первого магазина расположен справа, а второго - слева.
Магазин L2 служит для представления “текущей” левовыводимой цепочки, то есть той сентенциальной формы, которая получилась к данному моменту разбора в результате развертки нетерминалов. Верхний символ в L2 будет символом, помечающим активную вершину порожденного к данному моменту частично дерева левого выхода. В L1 представлена текущая история проделанных выборов альтернатив и входные символы, по которым прошла входная головка. Алгоритм имеет три состояния: q- нормальной деятельности, b-возврата, t – заключительное состояние.
(3) Начальная конфигурация алгоритма: (q, 1, (, S$).
(4) Существует шесть типов шагов. Эти шаги будут описаны в терминах их воздействия на конфигурацию алгоритма. Запись (q, i, (, ()(( (q(, i(, ((, (() означает что если (q, i, (, () - текущая конфигурация, то нужно перейти в следующую конфигурацию (q(, i(, ((, ((). Если не оговорено противное, то и i - число от 1 до (n+1), (((((I)*, где I - множество индексов альтернатив, (((((N)*.
Шесть шагов определяется так:

(а) Разрастание дерева

(q, i, (, A()(( (q, i, (A1, (1(), где A((1 правило из P и (1 - первая альтернатива нетерминала A, а A1 – ее индекс. Этот шаг соответствует разрастанию частичного дерева вывода, при котором применяется первая альтернатива самого левого нетерминала дерева.

(б) Успешное сравнение входного символа с порожденным символом

(q, i, (, a()(( (q, i+1, (а, (), при условии, что а i= а (i ( n). Если i-ый входной символ совпадает с очередным порожденным терминалом, то терминал передается из L2 в L1, а позиция указателя увеличивается на единицу.

(в) Успешное завершение

(q, n+1, (, $)(( (t, n+1, (, (). Достигнут конец входной цепочки и найдена левовыводимая цепочка, совпадающая с входной. Левый разбор входной цепочки восстанавливается по цепочке ( с помощью гомоморфизма h, где h(a)=(, для всех а ( ( и h(A j)=p, где p - номер правила А ( ( и ( - j-ая альтернатива нетерминала А.

(г) Неудачное сравнение входного символа с порожденным символом

(q, i, (, а()(( (b, i, (, a(), при условии, что а i ( а. Надо перейти в состояние возврата b, как только обнаружится, что порожденная левовыводимая цепочка не совпадает с входной цепочкой.

(д) Возврат по входу

(b, i, (a, ()(( (b, i(1, (, a() для всех а((. В состоянии возврата входные символы переносятся назад из L1 в L2.

(е) Испытание очередной альтернативы

(b, i, (Aj, (j()((
(е1) (q, i, (Aj+1, (j+1(), если (j+1 - (j+1)-ая альтернатива нетерминала А. ((j в L2 заменяется на (j+1).
(е2) Следующая конфигурация невозможна, если i=1, A=S и S имеет только j альтернатив. (Это условие указывает, что все возможные левовыводимые цепочки, совместимые с входной цепочкой (, уже исчерпаны, а ее разбор не найден).
(е3) (b, i, (, A() в оставшихся случаях. (Здесь исчерпаны все альтернативы нетерминала A и дальнейший возврат происходит путем удаления Aj из L1 и замены в L2 цепочки (j на A).

Алгоритм выполняется следующим образом:
Шаг 1: Исходя из начальной конфигурации, вычислить последующие конфигурации C0(( C1((((( Ci (( ( , пока их можно вычислить.
Шаг 2: Если последняя конфигурация (t, n+1, (, (), - выдать h(() и остановиться, иначе выдать сообщение об ошибке. (

Пример 4.1.
Рассмотрим работу алгоритма на примере грамматикой G для упрошенных арифметических выражений с правилами:

(1)
Е ( Т(E
(E1) -
индекс для альтернативы Т(E

(2)
Е ( Т
(E2)


(3)
T ( F(Т
(T1)


(4)
T ( F
(T2)


(5)
F ( a
(F1)



Для входа a(a алгоритм вычислит следующую последовательность конфигураций (над символом перехода указан индекс шага алгоритма) :
(q, 1, (, E$) ((a (q, 1, E1, T(E$) ((a (q, 1, E1T1, F(T(E$)
((a (q, 1, E1T1F1, a(T(E$) ((б (q, 2, E1T1F1а, (T(E$)
((г (b, 2, E1T1F1а, (T(E$) ((д (b, 1, E1T1F1, а(T(E$)
((е3 (b, 1, E1T1, F(T(E$) ((е1 (q, 1, E1T2, F(E$)
((а (q, 1, E1T2F1, a(E$) ((б (q, 2, E1T2F1a, (E$)
((б (q, 3, E1T2F1a(, E$) ((а (q, 3, E1T2F1a(E1, Т(E$)
((а (q, 3, E1T2F1a(E1T1, F(Т(E$) ((а (q, 3, E1T2F1a(E1T1F1, a(Т(E$)
((б (q, 4, E1T2F1a(E1T1F1a, (Т(E$) ((г (b, 4, E1T2F1a(E1T1F1a, (Т(E$)
((д (b, 3, E1T2F1a(E1T1F1, a(Т(E$) ((е3 (b, 3, E1T2F1a(E1T1, F(Т(E$)
((е1 (q, 3, E1T2F1a(E1T2, F(E$) ((а (q, 3, E1T2F1a(E1T2F1, a(E$)
((б (q, 4, E1T2F1a(E1T2F1a, (E$) ((г (b, 4, E1T2F1a(E1T2F1a, (E$)
((д (b, 3, E1T2F1a(E1T2F1, a(E$) ((е3 (b, 3, E1T2F1a(E1T2, F(E$)
((е3 (b, 3, E1T2F1a(E1, T(E$) ((е1 (q, 3, E1T2F1a(E2, T$)
((а (q, 3, E1T2F1a(E2T1, F(Т$) ((а (q, 3, E1T2F1a(E2T1F1, а(Т$)
((б (q, 4, E1T2F1a(E2T1F1а, (Т$) ((г (b, 4, E1T2F1a(E2T1F1а, (Т$)
((д (b, 3, E1T2F1a(E2T1F1, a(Т$) ((е3 (b, 3, E1T2F1a(E2T1, F(Т$)
((е1 (q, 3, E1T2F1a(E2T2, F$) ((а (q, 3, E1T2F1a(E2T2F1, a$)
((б (q, 4, E1T2F1a(E2T2F1a, $) ((в (t, 4, E1T2F1a(E2T2F1a, ()

В результате получается левый разбор h(E1T2F1a(E2T2F1a)=145245. (

Корректность приведенного алгоритма можно строго доказать [1].
4.2. ВОСХОДЯЩИЙ РАЗБОР С ВОЗВРАТАМИ

Существует общий подход, противоположный тому, который применяется при нисходящем разборе. Алгоритм нисходящего разбора можно представить себе как построение дерева разбора методом проб и ошибок, которое начинается сверху в корне и продолжается вниз к листьям. В противоположность этому, алгоритм восходящего разбора начинается с листьев, (то есть с входных символов), и пытается построить дерево разбора, восходя от листьев к корню.
Опишем восходящий разбор в виде алгоритма синтаксического анализа, который называют алгоритмом типа “перенос-свертка”. Он представляет собой по существу правый анализатор, который перебирает всевозможные обращенные правые выводы, совместимые с входной цепочкой. Один шаг алгоритма состоит в считывании цепочки, располагаемой в верхней части магазина, чтобы выяснить, образуют ли верхние символы правую часть какого-нибудь правила. Если да, то производится свертка, замещающая эти символы левой частью того самого правила. Если возможна более чем одна свертка, то эти свертки упорядочиваются произвольным образом и выбирается первая из них.
Если свертка невозможна, то в магазин переносится следующий символ и процесс продолжается, как и прежде. Всегда перед переносом делается попытка свертки. Если мы дошли до конца цепочки, а свертка все еще невозможна, то надо вернуться к последнему шагу, на котором была сделана свертка. Если здесь возможна другая свертка, то надо испытать ее.
Рассмотрим грамматику с правилами S ( AB, A ( ab и B13SYMBOL 174 \f "Symbol" \s 1214®15 aba. Пусть ababa - входная цепочка. Сначала перенесем в магазин a. Так как свертка пока невозможна, перенесем b. Затем цепочку ab, расположенную наверху магазина, заменим нетерминалом А. Получим частичное дерево, показанное на рис. 4.4 а.

13EMBED Word.Picture.81415
Так как А не допускает другой свертки, переносим в магазин а, а потом b. Затем можно опять свернуть ab к A (см. рис. 4.4 б).
Перенеся в магазин а, мы обнаруживаем, что свертка невозможна. Возвращаемся к последней позиции, в которой была сделана свертка, а именно, когда в магазине была цепочка Aab, то есть дерево было таким, как на рисунке 4.4 а. Так как другая свертка здесь невозможна, то делаем перенос вместо свертки. В магазине окажется Aaba. Тогда aba можно свернуть к B. Получим дерево с рис. 4.4 в. Далее заменим AB на S и получим завершенное дерево с рис. 4.4 г.
Этот метод можно рассматривать как процедуру прослеживания всевозможных последовательностей тестов недетерминированного правого анализатора для данной грамматики. Однако, так же как и в случае нисходящего разбора, надо избегать ситуаций, в которых число возможных последовательностей тактов бесконечно.
Одна из ловушек имеет место для грамматик с циклами, т.е. грамматик порождающих выводы A (+A для некоторого нетерминала A. Число частичных деревьев при этом может быть бесконечным, и мы исключим такие грамматики из рассмотрения. Трудности создаются и аннулирующими правилами, так как они приводят к произвольному числу “сверток” пустой цепочки к нетерминал.

Алгоритм 4.2. Алгоритм восходящего разбора с возвратами.

Вход. КС-грамматика G = (N, (, P, S) без циклов и аннулирующих правил (все правила занумерованы от 1 до p) и входная цепочка ( = a1, a2, (, an (n ( 1).

Выход. Один обращенный правый разбор цепочки (, если он существует, или слово “ошибка” в противном случае.

Метод.
(1) Произвольным образом упорядочить правила грамматики.
(2) Анализатор будет изложен в терминах 4-х компонентных конфигураций, подобных конфигурациям из алгоритма 4.1. В конфигурации (q, i, (, () :
(а) q - состояние алгоритма (их всего три: q - нормальной работы, b - возврата, t - завершения).
(б) i - текущая позиция входного указателя (предполагается, что (n+1)-м символом служит правый концевой маркер $).
(в) ( - содержимое магазина L1, где хранится цепочка терминалов и нетерминалов, из которой выводится часть входной цепочки, расположенная слева от входного указателя. (Верх магазина L1 справа.)
(г) ( - содержимое магазина L2, верх которого расположен слева. В магазине хранится история переносов и сверток, необходимых для получения из входной цепочки содержимого магазина L1.
(3) Начальная конфигурация алгоритма - (q, 1, $, ().
(4) Сам алгоритм начинает работу с попытки применить шаг 1.

Шаг 1. Попытки свертки

(q, i, ((, ()(( (q, i, (A, j (), при условии, что A(( правило из P с номером j в линейном упорядочивании, определенном в (1). Номер этого правила ( j ) записывается в L2. Если шаг 1 применим, то повторить его еще раз. В противном случае перейти у шагу 2.

Шаг 2. Перенос

(q, i, (, ()(( (q, i+1, (a i , m (), при условии, что i ( n+1. Затем перейти к шагу 1. Если i = n+1 перейти к шагу 3.
При выполнении шага 2, i-ый входной символ переносится в верхнюю часть магазина L1, позиция указателя увеличивается на единицу и в магазин L2 записывается символ m, чтобы указать, что сделан перенос.

Шаг 3. Допускание

(q, n+1, $S, ()(( (t, n+1, $S, () .
Выдать обращенный разбор цепочки ( - h((), где h - гомоморфизм, определенный равенствами h(m)= ( и h(j)= j для всех номеров правил. После этого остановиться.
Если шаг 3 неприменим, то перейти к шагу 4.

Шаг 4. Переход в состояние возврата

(q, n+1, (, ()(( (b, n+1, (, (), при условии, что ( ( $S. Перейти к шагу 5.

Шаг 5. Возврат

(а) (b, i, (A, j ()(( (q, i, ((B, k (), если A(( правило из P с номером j, и следующим правилом в упорядочении (1), правая часть которого является суффиксом цепочки ((, является правило A ((( с номером k. (Заметим что (( =((((). Перейти к циклу 1. (Здесь происходит возврат к предыдущей свертке и делается попытка свертки с помощью следующей альтернативы).

(б) (b, n+1, (A, j ()(( (b, n+1, ((, (), если A(( правило из P с номером j и для цепочки (( не остается никакой другой свертки. Перейти к шагу 5. (Если других сверток не существует, надо “взять назад” данную свертку и продолжать возврат, оставляя входной указатель на n+1).

(в) (b, i, (A, j ()(( (q, i+1, ((a, m (), если i (n+1, A(( правило из P с номером j и для (( не осталось никакой другой свертки. Здесь символ а = а i переносится в магазин L1, а m поступает в L2. Перейти к 1. (Мы вернулись к предыдущей свертке, и так как других сверток нет, попробуем сделать перенос).

(г) (b, i, (a, m ()(( (q, i -1, (, (), если вверху магазина L2 находится символ переноса m. (Здесь в позиции i исчерпаны все альтернативы и надо “взять назад” операцию переноса. Вход указателя сдвигается влево, терминал устраняется из L1, а символ переноса удаляется из L2). Если этот шаг невозможен, объявить об ошибке.

Пример 4.2.
Рассмотрим работу алгоритма на примере все той же грамматики G для упрошенных арифметических выражений с правилами:

(1)
Е ( E(T

(2)
Е ( Т

(3)
T ( T(F

(4)
T ( F

(5)
F ( a


Если наверху магазина появятся E+T, то сначала попытаемся сделать свертку, используя E( E+T, а потом E( T. Если же появятся T(F, то сначала попробуем T( T(F, а потом T( F. Для входа а(а восходящий алгоритм пройдет следующие конфигурации:
(q, 1, $, () ((2 (q, 2, $a, m) ((1 (q, 2, $F, 5m) ((1 (q, 2, $T, 45m)
((1 (q, 2, $E, 245m) ((2 (q, 3, $E(, m245m) ((2 (q, 4, $E(a, mm245m)
((1 (q, 4, $E(F, 5mm245m) ((1 (q, 4, $E(T, 45mm245m)
((1 (q, 4, $E(E, 245mm245m) ((4 (b, 4, $E(E, 245mm245m)
((5б (b, 4, $E(T, 45mm245m) ((5б (b, 4, $E(F, 5mm245m)
((5б (b, 4, $E(a, mm245m) ((5г (b, 3, $E(, m245m) ((5г (b, 2, $E, 245m)
((2 (q, 3, $T(, m45m) ((2 (q, 4, $T(a, mm45m) ((1 (q, 4, $T(F, 5mm45m)
((1 (q, 4, $T, 35mm45m) ((1 (q, 4, $E, 235mm45m) ((3 (t, 4, $E, 235mm45m)

Таким образом, получается обращенный правый разбор h(235mm45m)=23545. (

Завершая два этих параграфа, еще раз отметим, что главной проблемой нисходящего и восходящего синтаксического анализа с возвратами является то, что число шагов, необходимых для разбора цепочки, может быть огромным. В частности, справедлива следующая теорема о временной и емкостной сложности этих алгоритмов:
Пусть для каждого символа из магазинных цепочек, участвующих в конфигурациях рассмотренных алгоритмов разбора с возвратами требуется только одна ячейка памяти и пусть число элементарных операций, необходимых для вычисления одного шага алгоритма ограничено. Тогда для входной цепочки длины n алгоритмам требуется емкость памяти С1(n и время C2n , где C1 и C2 - некоторые константы. То есть магазинные списки линейно зависят от длины входной цепочки, а число шагов экспоненциально.

Известно несколько способов модернизации алгоритмов с возвратами, ускоряющих их работу.
(1) Можно упорядочить правила так, чтобы наиболее вероятные альтернативы испытывались первыми. Однако это не поможет в тех случаях, когда входная цепочка синтаксически неверна и для нее придется перебирать все возможные варианты.
(2) Можно ускорить возвраты. Например, в восходящем алгоритме записывать информацию, позволяющую сразу восстановить предыдущую конфигурацию, в которой была сделана свертка.
(3) Можно ограничить количество допустимых возвратов. В частности, в алгоритме нисходящего анализа можно упорядочить альтернативы так, чтобы первыми испытывались наиболее длинные из них. Как только альтернатива, из которой выводится префикс необходимой части входной цепочки, обнаружена, другие альтернативы исключаются из рассмотрения. Конечно, полученный таким образом префикс может оказаться “ошибочным” и тогда попытка разбора окончится неудачей. Но в практических ситуациях это редко создает серьезные проблемы.
(4) Можно заглядывать вперед на k входных символов, чтобы определить надо ли использовать данную альтернативу или свертку. Далее мы увидим, что с помощью подглядывания вперед можно полностью устранить необходимость возвратов.

Но все эти пути конечно полумеры, и на практике лучше использовать алгоритмы без возвратов (детерминированные).

Другая проблема, связанная с возвратным анализом - слабые возможности в смысле локализации, а тем более нейтрализации ошибок. Если входная цепочка не верна, то компилятор должен объявить, какие входные символы причастны к ошибке. Кроме того, когда ошибка найдена, компилятор должен исправить ее так, чтобы анализ мог продолжаться и обнаружить другие ошибки, если они попадутся. Если входные цепочки построены неправильно, то алгоритм с возвратами просто объявит об ошибке, оставив входной указатель на первом входном символе. Это, конечно же, можно обойти, усложнив алгоритм или добавляя в грамматику альтернативные правила, порождающие наиболее распространенные синтаксические ошибки. Благодаря ним синтаксически неправильные цепочки станут правильными с точки зрения новой грамматики. Появление в выходе номера такого “ошибочного” правила служит сигналом, локализующим ошибку во входной цепочке. Но сколько потребуется ошибочных правил? Методы, изложенные ниже, обладают большими способностями к обнаружению ошибок.
Прежде чем обсуждать детерминированные алгоритмы разбора остановимся на оригинальной системе, позволяющей автоматизировать процесс создания синтаксических анализаторов. В основе этой системы лежит алгоритм нисходящего разбора с ограниченными возвратами.
4.3. СИМВОЛЬНЫЙ ПРЕПРОЦЕССОР НА ОСНОВЕ БЭКТРЕКИНГА

Мы уже отмечали, что одной из основных проблем, сдерживающих эффективное применение компьютеров в различных областях науки и техники, является традиционная, исторически сложившаяся технология решения прикладных задач на ЭВМ. Дело в том, что конечный пользователь, ставящий задачу и использующий результаты ее решения, обладая профессиональными знаниями, в общем случае не имеет необходимых знаний о способах использования вычислительной техники для решения его задачи. Посредником между ним и ЭВМ выступает системный аналитик и программист и уже после построения схемы решения происходит почти полное (отчуждение( конечного пользователя от дальнейшего процесса решения задачи. Разработанный и отлаженный программный продукт может дать результаты, не удовлетворяющие пользователя. Однако, при традиционной технологии внесение изменений потребует нового привлечения программистов и аналитиков, которые в соответствии с коррективами постановки задачи должны модифицировать программу, что обычно ничуть не проще разработки новой. После разработки, отладки и оформления программной системы как изделия она поступает к пользователям, но дальнейшая работа с ней не ограничивается решением прикладных задач. Если теоретически и можно предположить существование (безошибочной( программной системы (на практике такое случается крайне редко), то предусмотреть все последующие изменения в постановке задач и требований к их решению в большинстве случаев просто невозможно. Естественное развитие предметной области, условий в которых решается задача, неизбежно приводят к необходимости расширения функциональных возможностей реализованной программы. Таким образом, сопровождение программной системы, связанное с исправлением ошибок и модификацией, выполняется на протяжении всего ее жизненного цикла. Процесс сопровождения в традиционной технологии требует, по крайней мере, такого же количества ресурсов, как и разработка программы. В частности, удваивается число специалистов по программному обеспечению, обслуживающих потребности пользователей. В условиях неуклонного расширения сферы применения ЭВМ потребности в таких специалистах не могут быть реально удовлетворены. Сказанное выше и обуславливает необходимость изменения традиционной технологии использования ЭВМ.
Преодолеть рассмотренную выше кризисную ситуацию можно лишь привлечением конечного пользователя к непосредственному процессу решению его задач на ЭВМ, сопровождению программной системы, разработке прикладных программ. Это можно будет сделать только в том случае, когда между пользователем и ЭВМ будет существовать интеллектуальный интерфейс, позволяющий ставить задачи и получать результаты в терминах его предметной области, без знаний технологии программирования, операционных систем и иных программистских тонкостей. Каким же должен быть этот интерфейс?
Прямое использование пакетов прикладных программ (процедур с параметрами) требует от пользователя знаний программиста. С помощью чисто диалоговых средств достаточно сложную систему описать невозможно. Да и сам диалог предполагает наличие контролируемых ответов. Итак, необходим предметно ориентированный язык, чтобы пользователь описывал свою задачу и получал результаты ее решения ее в терминах области его деятельности. Создание спектра таких языков невозможно без средств автоматизации проектирования - систем построения трансляторов (СПТ). Одним из первых СПТ был генератор компиляторов XPL [12]. Автоматизацию построения трансляторов в ОС UNIX поддерживает программа LEX - генератор сканеров (лексических анализаторов) и программа YACC (Yet Another Compiler Compiler), предназначенная для построения синтаксических анализаторов контекстно-свободных языков.
Этим же целям служит и символьный препроцессор (СП) - программная система, разработанная и реализованная в Куйбышевском авиационном институте автором пособия в 1988 году сначала для PDP-11, а в 1990 году перенесенная и на IBM PC. Эта система использовалась для создания языков описания электротехнических схем, бортовых программно-аппаратных комплексов (СИМПАК), коммутируемых сетей с целью их имитации, исследования, оптимизации и отладки алгоритмов[9]. Упрощенный вариант этой системы до сих пор применяется в учебном процессе [8]. Данный параграф и посвящен описанию этой системы, одним из основных достоинств которой является компактность основных алгоритмов в силу их рекурсивной природы, отражающей рекурсивность КС-грамматик.
Общая схема и фазы функционирования символьного процессора показаны на рис. 4.5.
13EMBED Word.Picture.81415
На рисунке обрамления - обозначают файлы с данными, а - программные компоненты. Указывается также фаза работы СП, на которой используется та или иная связь. Здесь:
Ф1 - фаза анализа и перевода грамматики во внутреннее представление.
Ф2 - фаза трансляции исходного текста (лингвистического описания задачи (ЛОЗ)) в объектный код (информационный образ задачи (ИОЗ)).
Ф3 - фаза интерпретации (выполнения) объектного кода.
Принципы работы этих фаз поясняются в последующих разделах.
4.3.1. Фаза анализа и перевода грамматики во внутреннее представление
На данной фазе осуществляется анализ корректности грамматики и приведение её к виду (внутреннему представлению), ускоряющему грамматический разбор текстов ЛОЗ на специфицируемом языке. При анализе выявляется наличие тупиков (недостижимых и неопределенных нетерминалов) и многократно определенных нетерминалов, ошибки в атрибутах, именах синтермов, следовании метасимволов и т.п.
Синтаксис конструируемого языка задается в системе совокупностью продукций КС-грамматики в расширенной Бэкусовой нормальной форме (PБНФ). Эти расширения сводятся к включению факультативных (необязательных), итеративных и альтернативных элементов с произвольной вложенностью, а так же атрибутов, задающих номера семантических подпрограмм, выполняемых по завершении синтаксического анализа, и синтермов, обозначающих базовые лексические единицы любого проектируемого языка.
Синтаксическая продукция - это конечная последовательность литер (текст), разделенная металингвистическими символами на фразы. Металингвистические символы (разделители) - зарезервированные в системе последовательности литер, представлены ниже:
((( - разделитель синтаксического понятия и синтаксического выражения (левой и правой частей продукции);
(_ - левая скобка нетерминала в правой части продукции;
_( - правая скобка нетерминала в правой части продукции;
?_ - левая скобка альтернативы;
_? - правая скобка альтернативы;
_|_ - разделитель альтернативы,
{_ - левая итеративная скобка;
_} - правая итеративная скобка;
[ _ - левая факультативная скобка,
_ ] - правая факультативная скобка,
(_ - левая скобка семантики,
_) - правая скобка семантики,
@_ - левая скобка синтерма,
_@ - правая скобка синтерма,
(( - конец продукции.

Запись продукции в системе имеет вид:
левая часть продукции ((( правая часть продукции ((

Начальная фраза каждой продукции, заканчивающаяся разделителем (
·
·
·(, рассматривается системой как синтаксическое понятие (нетерминал). Фраза между разделителями (
·
·
·( и (
·
·( (правая часть продукции) является синтаксическим выражением, определяющим множество сентенциальных форм, связанных с синтаксическим понятием, определенным левой частью этой же продукции.
Любой текст в нетерминальных скобках (фраза между разделителями <_ и _>, встречающаяся в правой части продукции) также является нетерминалом. КС-грамматика, задаваемая в системе, должна быть однозначной и не содержать тупиков. То есть каждому нетерминалу из правой части должна соответствовать единственная продукция, содержащая этот нетерминал в левой части. Синтаксис нетерминала определяется произвольной последовательностью литер.
Фраза между разделителями (_ и _) рассматривается как изображение оператора вызова семантического действия. На синтаксический разбор текста эта фраза не оказывает никакого влияния. Синтаксис изображения оператора вызова определяется последовательностью цифр, идентифицирующих номер семантического действия.
Имена синтермов (стандартных синтаксических нетерминалов), выделенных разделителями @_ и _@, обозначают отдельные базовые лексические единицы языка, неизменные в различных языках и не требующие специальной расшифровки в виде отдельных продукций. В рассматриваемой версии системы введены шесть синтермов:

LEX - произвольная лексема (разделитель или элемент между разделителями);
IDN - идентификатор, т.е. набор букв и цифр, начинающихся с буквы (буквы, как латинского, так и русского алфавита);
INT - целое число;
REL - действительное число;
TXT - текстовая константа (в апострофах или кавычках);
EOF - конец исходного файла.

Любой текст S в факультативных скобках рассматривается как синтаксическое выражение, порождающее множество сентенциальных форм, являющихся объединением пустой последовательности и S. Таким образом, правило вида
A
·
·
· B [_S_] C
·
·
представляет собой, по сути дела, два правила:
A
·
·
· BC
·
· и A
·
·
· BSC
·
·.
Здесь и далее S - обозначает синтаксическое выражение.
Любой текст S в итеративных скобках рассматривается как синтаксическое выражение, порождающее множество сентенциальных форм, являющихся объединением пустой последовательности и S, SS, SSS, и т.д. Правило вида
А
·
·
· B {_,S_} C
·
·
представляет счетное множество:
A
·
·
· BC
·
·, A
·
·
· B,SC
·
·, A
·
·
· B,S,SC
·
·, A
·
·
· B,S,S,SC
·
·
·
·(
Альтернативная фраза вида : ?_ S1 _|_ S2 _|_ S3 _|_ ... SN _? , где S1,...,SN - синтаксические выражения, определяет множество сентенциальных форм, являющееся объединением S1,S2,...,SN, то есть правило
А
·
·
· B ?_ S1 _|_ S2 _|_ ( SN _? C
·
·
определяет правила
A
·
·
· B S1 C
·
·, А
·
·
· B S2 C
·
·, ( A
·
·
· B SN C
·
·.
Альтернативные, факультативные и итеративные элементы могут произвольно вкладываться друг в друга (ограничения на уровень вложенности - 30 продиктовано реализационными соображениями).

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

1. Запрещена левая рекурсия, то есть использование правил вида:
A
·
·
· <_ A _> ...
·
·
или
A
·
·
· <_ A1 _> ...
·
·,
A1
·
·
· <_ A2 _> ...
·
·,
...,
AN
·
·
· <_ A _> ...
·
·.
Леворекурсивные правила необходимо устранять, преобразуя их в праворекурсивные с помощью введения дополнительных нетерминалов или используя итерацию.
Так леворекурсивное правило вида
А
·
·
· ?_ B _|_ <_ A _> B _|_ <_ A _> C _?
·
·
можно преобразовать к
A
·
·
· B [_ <_ A1 _> _]
·
·
A1
·
·
· ?_ B _|_ C _? [_ <_ A1 _> _]
·
·
или
A
·
·
· B {_ ?_ B _|_ C _? _}
·
·

2. Если какой-нибудь вариант совпадает с префиксом другого варианта альтернативы, то первым из них должен быть записан вариант, имеющий наибольшую длину. Так, правило вида
A
·
·
· ?_ BCD _|_ BC _|_ B _?
верно, тогда как
A
·
·
· ?_ B _|_ BC _|_ BCD _?
может приводить к ошибкам. В последнем случае анализатор, исследуя цепочку BC или BCD и прочтя в анализируемой строке B, ответит TRUE, не дойдя до C (CD). Алгоритм с ограниченными возвратами, дойдя до первого варианта альтернативы, дающего ответ TRUE, остальные варианты исключает из рассмотрения.
Заметим, что без учета семантики рассматриваемое правило целесообразно представить без вариантов:
A
·
·
· B [_ C [_ D _] _]
·
·.

3. В правилах вида A
·
·
· ( [_ S1 _] S2 (
·
· пересечение множеств терминальных цепочек, выводимых из S1 и S2 должно быть пусто. В частности, запись
A
·
·
· [_ B _] B
·
·
приводит к ошибкам, ее необходимо заменить продукцией
A
·
·
· B [_ B _]
·
·.

В более сложных случаях необходимы и более сложные модификации правил. Так запись вида
A
·
·
· [_ B, _] B, ?_ B _|_ C _? ;
·
·
необходимо преобразовать к виду
A
·
·
· B, ?_ B [_ , ?_ B _|_ C _? _] _|_ C _? ;
·
·
или
A
·
·
· ?_ B, B, ?_ B _|_ C _? _|_ B, ?_ B _|_ C _? _? ;
·
·.

При анализе грамматики осуществляется выявление возможных ошибок: наличия в грамматике внутренних или внешних тупиков, многократного определения нетерминалов, ошибок в следовании метасимволов, неверных имен синтермов и номеров семантических процедур и т.п.
Как было отмечено, безошибочная грамматика преобразуется во внутреннее представление, ориентированное на максимальное ускорение синтаксического анализа. При этом нетерминалы из правых частей заменяются ссылками на соответствующие им продукции, и левые части правил устраняются, так как в таком представлении они просто не нужны. Метасимволы заменяются специальными однобайтовыми кодами, и между ними устанавливается два вида ссылок, показывающих, с какого места необходимо продолжить просмотр продукции в процессе синтаксического анализа. Принцип установки ссылок показан на рис.4.6. Символ ((( указывает место записи ссылки во внутреннем представлении грамматики.
Нижние стрелки (ссылки) факультативного фрагмента (рис. 4.6 а) и итерации (рис. 4.6 б) указывают позиции в грамматике, которые следуют за этими элементами. Попытавшись сопоставить анализируемый текст с факультативным или итеративным фрагментом, и обнаружив их несовпадение, анализатор продолжит разбор, продвинувшись по грамматике сразу за данный фрагмент.
13EMBED Word.Picture.81415
Верхняя стрелка итерации указывает на ее начало и обеспечивает повторение сопоставления итеративного фрагмента с анализируемой цепочкой.
Нижние ссылки альтернатив (рис. 4.6 в) обеспечивают переход к следующему варианту, а верхние ссылки позволяют продвинуться по грамматике за альтернативный фрагмент.
Кроме того, в процессе анализа грамматики определяются разделители описываемого языка, к которым относятся пробел, знаки пунктуации, операций, отношений и всевозможные скобки. Последовательности букв и цифр, расположенные между метасимволами и (или) разделителями трактуются как терминалы (ключевые слова) языка. Выявленные разделители и терминалы заменяются во внутренней форме грамматики ссылками (индексами) на сформированную таблицу.
Таким образом, из корректного исходного текста грамматики (файла *.GRM), который можно подготовить с помощью любого текстового редактора, на фазе Ф1 формируется файл грамматики во внутренней форме (*.GRR) и файл разделителей и терминалов (*.GRT).
Эти файлы вместе с исходным текстом и поступают на вход рассматриваемого символьного процессора, который осуществляет трансляцию текста по трехпроходной схеме, как показано на рис. 4.7.

13EMBED Word.Picture.81415
Ниже мы коротко остановимся на каждом из этих проходов.
4.3.2. Лексичекий анализ в СП

В рассматриваемой версии символьного препроцессора первый проход на основании исходного текста формирует полную строку лексем. Здесь это оправдано, так как отпадает необходимость в многократном сканировании текста при возвратах, что ускоряет процесс синтаксического анализа. Ведь внутренняя форма грамматики уже представляет, по сути дела, набор лексических единиц и синтаксический анализ потребует только сопоставления их типов.
Итак, при лексическом проходе исходный текст разбивается на базовые элементы языка - лексемы (разделители, ключевые слова, идентификаторы, константы и т.п.) и строится промежуточное представление текста в виде последовательности лексем. При этом игнорируются комментарии и “лишние” символы (пробелы, знаки табуляции, перевод строки). По ходу лексического анализа могут быть выявлены и диагностированы ошибки типа “нераспознаваемая лексема” (например, сочетание символов (1В25А7() или “незакрытые скобки комментария”. В случае отсутствия лексических ошибок на первом проходе формируется выходной файл “Строка лексем”, который состоит из записей различной длины, каждая из которых определяет лексему (содержит её атрибуты). Обязательными атрибутами являются тип лексемы, позиция её первого символа в файле исходного текста и количество символов, которое занимает лексема в исходном тексте. Кроме обязательных атрибутов запись лексемы может содержать дополнительную информацию, которая приведена в таблице 4.1.
Таблица 4.1.

Лексема
Код
Начало
Длина
Целое
Действительное
Индекс
Размер лексемы

Идентификатор
1
+
+
-
-
-
6 байт

Целое число
2
+
+
+
+
-
18 байт

Действительное число
3
+
+
+
+
-
18 байт

Конец файла
5
+
+
-
-
-
6 байт

Терминал (ключевое слово)
6
+
+
-
-
+
8 байт

Разделитель
7
+
+
-
-
+
8 байт


Здесь представлены следующие поля:
( Код, Начало, Длина - соответствуют трём обязательным атрибутам,
( Целое - представление лексемы в виде целого числа,
( Действительное - представление лексемы в виде числа с плавающей точкой,
( Индекс - индекс в таблице терминалов или разделителей.
Значком (+( отмечено присутствие соответствующего поля в записи лексемы данного типа, а (-( помечает отсутствие поля.
4.3.3. Синтаксический анализ в СП

В основе универсального синтаксического анализатора рассматриваемого СП лежит алгоритм нисходящего синтаксического анализа с возвратами, который в системах искусственного интеллекта получил название бэктрекинга. Напомним его основные моменты, воспользовавшись образным его изложением, позаимствованным из монографии Гриса [5].
Алгоритм нисходящего разбора строит синтаксическое дерево, начиная с корня (аксиомы грамматики). Описание усложняется главным образом из-за вспомогательных операций, которые необходимы для того, чтобы выполнять возвраты с твердой уверенностью, что все возможные попытки построения дерева были предприняты.
Вообразим, что на любом этапе разбора, в каждом узле уже построенной части дерева, находится по одному человеку. Люди, находящиеся в терминальных узлах занимают места, соответствующие символам предложения языка.
Некоему человеку предстоит провести разбор предложения (. Прежде всего ему необходимо отыскать вывод S (+ ( где S - начальный символ грамматики; следовательно, первым непосредственным выводом должен быть S ( ( , где S (
·( - правило грамматики. Пусть для S существуют правила: S ( X1(X n(Y1(Ym(Z1(Zl. Сначала человек пытается применить правило S ( X1(X n. Если дерево вывода построить нельзя, то он применит правило S ( Y1(Ym и т.п.
Как ему определить, правильно ли он выбрал непосредственный вывод S(X1(Xn. Заметим, что если он правилен, то для некоторых цепочек (i будет иметь место ( = (1((n где X i (( (i, для i = 1(n. Прежде всего человек, выполняющий разбор, возьмет себе приемного сына M1, который должен найти вывод X1(((1 для любого (1, такого что (((1(. Если сыну М1 удалось найти такой вывод он (или любой из его сынов, внуков и т.д.) закрывает цепочку (1 в предложении ( и сообщает своему отцу об успехе. Тогда его отец усыновит М2 , чтобы тот нашел вывод X2 ( ((2 где ((((((( и ждет ответа от него и т.д. Как только сообщит об успехе М i-1 сын, - отец усыновит Мi , чтобы тот нашел вывод X i(( ( i . Сообщение об успехе сына М n означает, что разбор предложения закончен и оно правильно.
Как быть, если сыну M i не удалось найти вывод X i((( i. В этом случае M i сообщает о неудаче отцу, тот от него отрекается и дает старшему брату M i – M i-1 такое распоряжение: “Ты уже нашел вывод, но он неверен! Найди-ка мне другой”. Если M i-1 сумеет найти другой вывод, он вновь сообщит об успехе и все пойдет по-прежнему. Если же M i-1 сообщит о неудаче, то отец отречется и от него и попросит осуществить попытку M i-2 и т.п. Если придется отречься и от M1, то это означает, что вывод S(X1(Xn неверный и человек начинает разбор, воспользовавшись другим выводом – S (Y1(Ym.
Как же действует каждый из M i ? Предположим, что его целью является терминал a i. Входная цепочка имеет вид ((((((i где ((((i уже проанализированы другими людьми. От M i требуется просто проверить, совпадает ли очередной символ грамматики x i с символом строки а i. Если да, то сообщить об успехе, если нет, - то о неудаче.
Если цель M i нетерминал A i, то M i поступит точно также как и его отец. Он начинает проверять правые части правил, относящиеся к этому нетерминалу, и, если необходимо, усыновляет или отрекается от сыновей. Если все его сыновья сообщают об успехе, то он сообщит об успехе своему отцу. Если отец попросит M i найти другой вывод и цель - терминал, то M i сообщит о неудаче, так как другого такого вывода не существует. В противном случае M i просит своего младшего сына найти другой вывод и реагирует на него так же, как и раньше. Если все сыновья сообщают о неудаче, то он сообщает об этом своему отцу, и этот процесс усыновления может быть очень глубоким (все зависит от грамматики фразы). Привлекательность этого метода в том, что каждый человек должен помнить лишь о своей цели, о своем отце, о своих сыновьях, а также о своем месте в грамматике и цепочке, которую он анализирует. И никому не нужны точные сведения о том, что происходит в иных местах. Это как раз то, к чему вообще надо стремиться в программировании: В каждом фрагменте программы, в каждой процедуре надо заботится только о собственной входной и выходной информации и ни о чем больше.
Казалось бы, что приведенный алгоритм достаточно сложен, но важно помнить, что и отец, и сын, и внук делают здесь то же, что и каждый из них. В основе синтаксического анализатора лежит одна рекурсивная функция DESCENT (спуск). Она осуществляет просмотр правой части заданной продукции или ее фрагмента (альтернативы, факультатива, итерации) и возвращает TRUE, если указанный фрагмент продукции был полностью отождествлен с текущим фрагментом анализируемого текста, и FALSE в противном случае. Параметр этой функции - индекс IG, играющий роль курсора в заданной грамматике языка. При обращении к функции DESCENT, ей задается положение первого символа отождествляемой части продукции. Побочный эффект функции DESCENT - сдвиг курсора IG за конец отождествляемой части продукции. Процедуру DESCENT можно рассматривать как синтаксически управляемый монитор, осуществляющий вызов таких ветвей анализа как разделитель, терминал, синтерм, семантика, нетерминал, альтернатива, итерация, факультатив.
Процесс рекурсивного спуска управляется положением двух индексов: IG указывает на позицию в грамматике, а L – на позицию в строке лексем исходной программы. Кроме того, индекс M связан с позицией файла семантических действий. Поскольку процесс спуска связан с рекурсией, все индексы имеют множество локальных копий, которые сохраняются в стеке и восстанавливаются при возврате из рекурсии, обеспечивая возможность спуска по альтернативной ветви и подъема к конечному результату: TRUE или FALSE.
Прежде чем обсудить алгоритм функции DESCENT, приведенный на рис. 4.8, введем некоторые обозначения и соглашения. Обозначим через GRAM [IG] символ грамматики, на который указывает индекс IG. При этом нетерминал вместе со ссылкой на соответствующую продукцию, терминал или разделитель вместе со ссылкой на соответствующую таблицу, синтерм, номер семантического действия, любой метасимвол будем считать одним символом грамматики.
Перемещение индекса IG по грамматике выполняется с помощью процедуры INC (IG), перемещающей его на шаг вправо, на соседний символ, и путем вычисления его нового положения функциями PROD (IG), HI (IG) и LO (IG). Функция PROD вычисляет позицию начала продукции для нетерминала, на который указывает индекс IG. Функция HI\LO выполняет вычисление позиции, на которую указывает верхняя\нижняя ссылка отдельных метасимволов (см. рис. 4.6).
Запись вида GRAM[IG] = <> или GRAM[IG] = @@ следует понимать как символ под курсором - нетерминал или синтерм и т.п.
Процедура SEMANTICS (семантика) формирует очередную запись в промежуточном файле семантических действий, работу с которым мы рассмотрим в разделе 4.3.4. Логическая функция SYNTERM (синтаксический терминал) определяет, имеется ли во входном тексте лексема, соответствующая типу синтерма, указанному в GRAM [IG]. Если это так, то индекс L перемещается по тексту на следующую лексему.
Функции DELIMITER (разделитель) и TERMINAL (терминал) проверяют, есть ли в текущей позиции исходного текста лексема данного типа, и в случае успеха сравнивает табличные индексы из грамматики и текста. При их совпадении возвращает TRUE, иначе FALSE. Эти процедуры нерекурсивны, что определяется природой самой грамматики.
13EMBED Word.Picture.81415 Наиболее простое направление рекурсивного спуска связано здесь с анализом нетерминала. Завершение работы функции DESCENT с результатом TRUE (положительный бэктрекинг) позволяет продолжить анализ входного текста, сообразуясь с положением пары индексов: IG и L.
Процедуры ALTERNATIVE (альтернатива), ITERATION (итерация) и FACULTATIVE (факультатив) характеризуются наличием собственных вызовов DESCENT. Кроме того эти процедуры способны изменить позицию индекса грамматики IG, сдвигая его не только вперед по продукции, но и восстанавливая его старое положение (сдвигая назад). Аналогично они работают с индексами входного текста - L и семантики - M.
По завершении любой из этих процедур, курсор устанавливается за закрывающей скобкой просматриваемого фрагмента грамматики.
Функция DESCENT, представленная на рис. 4.8, написана на языке очень похожем на ПАСКАЛЬ. Надеюсь, что выбранный язык и масса комментариев в тексте программы сделают обсуждаемые алгоритмы прозрачными для читателя, и рисовать здесь еще и блок-схему нет необходимости.
Логическая функция ALTERNATIVE представлена на рис. 4.9.
13EMBED Word.Picture.81415
На рис. 4.10 приведены процедуры ITERATION и FACULTATIVE. Они не являются логическими функциями, так как итеративный и факультативный элемент грамматики могут порождать и пустую цепочку. Следовательно, неудачное отождествление этих фрагментов с исходным текстом еще не означает ошибки, - анализ можно продолжить независимо от результатов работы этих процедур.
При удачном отождествлении итеративного фрагмента индекс по грамматике IG возвращается на позицию начала итерации и осуществляется попытка дальнейшего сопоставления данного фрагмента с входным текстом. При неудаче указатель IG переводится за закрывающую скобку итерации, а позиции в исходном тексте и семантическом файле сохраняют значения, полученные в результате последнего удачного отождествления. Работа факультатива совершенно аналогична, но ограничена не более чем одним экземпляром соответствующего фрагмента в исходном тексте.
13EMBED Word.Picture.81415
Итак, на фазе синтаксического анализа осуществляется сопоставление синтаксических конструкций исходного текста с терминалами, разделителями и синтермами грамматики. При неудаче фиксируются те терминалы или синтермы грамматики, которые могли бы иметь место в тексте. Эти элементы выделяются из тех альтернатив, по которым препроцессор прошел дальше всего, анализируя исходный текст. Именно они и выдаются в качестве диагностики при общей неудаче анализа в виде фраз типа:
“Ожидается (,( , (;(” или “Ожидается идентификатор, целое число”
Если ошибок обнаружено не было, то формируется файл семантических действий, который и обрабатывается на третьем проходе.
4.3.4. Выполнение семантических действий

В синтаксически управляемых трансляторах выполнение семантических процедур, перевод исходного текста всегда совмещен с процессом синтаксического анализа. Распознавание отдельного элемента или языковой конструкции влекло вызов подпрограмм и выполнение семантических действий. Это и привело к тому, что на практике возвратные алгоритмы анализа не нашли применения. Пройдя часть пути по одной из альтернатив той или иной продукции грамматики, и обнаружив расхождение, мы можем достаточно просто вернуться по пройденному тексту, и рассмотреть еще одну альтернативу. Но как аннулировать результаты семантических подпрограмм, которые уже были выполнены? Это принципиальное возражение теоретиков против возвратных алгоритмов трансляции. В рассматриваемом СП эта проблема достаточно просто решается введением еще одного просмотра – выполнения семантических действий, который осуществляется уже после корректного завершения синтаксического анализа. Это снижает эффективность проектируемого транслятора, увеличивает время трансляции, но это дает возможность рассматривать любые, в том числе и недетерминированные КС-языки, что зачастую и необходимо в учебных целях.
Файл семантических действий, формируемый в процессе синтаксического анализа, содержит протокол вызова процедур, реализующих семантические действия, и аргументы, с которыми они будут вызываться. В качестве аргумента семантической процедуры выступает последняя лексема исходного текста, сопоставленная с конструкцией грамматики непосредственно перед тем, как при грамматическом разборе в правой части продукции было встречено изображение оператора вызова данного семантического действия. Например, в результате успешного сопоставления продукций:
A ((( (@_IDN_@ (_2_) ((
или
A ((( (<_B_> (_2_) ((
B ((( (@_IDN_@ ((

семантическая процедура с кодом 2 будет вызываться с параметром-лексемой “идентификатор”, отождествлённым с синтермом IDN.
Если в грамматике несколько ссылок на семантические процедуры стоят подряд, то эти процедуры будут вызываться с одним и тем же аргументом.
Например, для случая:
A ::= ... @_INT_@ (_1_)(_3_)(_5_) ((
семантические процедуры 1, 3, 5 будут вызываться с аргументом-лексемой “целое число”, отождествлённым с синтермом INT.
Семантика языков программирования – это тот раздел трансляции, который с очень большим трудом поддается формализации. Все известные автору попытки включения формального описания семантики в спецификацию языка не находят распространения. Формальное описание семантики слишком тяжеловесно и так называемые атрибутные или транслирующие грамматики чаще всего понятны только их составителям. В рассматриваемой системе написание набора семантических процедур возлагается на разработчика транслятора для конкретного языка.
В заключение раздела поясним принципы расстановки семантических атрибутов (номеров процедур) в спецификации языка для рассматриваемого СП на примере грамматики арифметических выражений:
Выражение ::= [_ <_ OTC _> (_0_) _] (_1_) <_ TEPM _> (_3_)
{_ <_ OTC _> (_2_) <_ TEPM _> (_3_) _} ((
TEPM ::= <_ множитель _>
{_ <_ OTM _> (_2_) <_ множитель _> (_3_) _} ((
множитель ::= ?_ ( <_ Выражение _> ) _|_ @_ IDN _@ (_4_) _|_
@_ REL _@ (_4_) _|_ @_ INT _@ (_4_) _? ((
OTC ::= ?_ + _|_ - _? ((
OTM ::= ?_ ( _|_ / _? ((

Здесь семантические действия призваны перевести заданное арифметическое выражение в ПОЛИЗ:
0 - Отметить наличие унарного минуса.
1 - Если был унарный минус, поместить его обозначение – символ @’ в семантический стек и сбросить признак минуса, установленный действием 0-ой процедуры. В противном случае поместить в семантический стек символ n’;
2 - Поместить символ операции ((, (, (, ( ) в семантический стек;
3 - Выгрузить символ из семантического стека и, если он отличен от n’, поместить его в выходную строку;
4 - Поместить идентификатор или константу в выходную строку.

Упражнения на программирование.

4.1. Разработайте программу, которая по произвольной заданной КС-грамматике строит эквивалентный недетерминированный МП-автомат, моделирующий левый вывод. Программа должна визуализировать грамматику, автомат и последовательность шагов, которые проделывает автомат, допуская или отвергая входную цепочку.

4.2. Разработайте программу, которая по произвольной заданной КС-грамматике строит эквивалентный недетерминированный расширенный МП-автомат, моделирующий отсечение основ. Программа должна визуализировать грамматику, автомат и последовательность шагов, которые проделывает автомат, допуская или отвергая входную цепочку.

4.3. Реализовать алгоритм нисходящего разбора с возвратами. Программа должна для заданной КС-грамматики и входной цепочки визуализировать шаги нисходящего разбора и построение дерева разбора.

4.4. Реализовать алгоритм восходящего разбора с возвратами. Программа должна для заданной КС-грамматики и входной цепочки визуализировать шаги восходящего разбора и построение дерева разбора.

5. ОДНОПРОХОДНЫЙ СИНТАКСИЧЕСКИЙ АНАЛИЗ БЕЗ ВОЗВРАТОВ

Как уже отмечалось, рассмотренные выше недетерминированные, переборные алгоритмы синтаксического анализа с возвратами способны анализировать практически все КС-языки, но их эффективность оставляет желать много лучшего. В общем случае время работы таких алгоритмов экспоненциально зависит от длины анализируемой цепочки. В этой главе мы рассмотрим классы КС-грамматик, для которых можно построить эффективные анализаторы, тратящие на обработку цепочек линейное время. За эту эффективность приходится платить тем, что такие анализаторы не могут обрабатывать все КС-языки без исключения. Однако эти ограниченные классы грамматик и языков адекватно отражают синтаксические черты всех известных языков программирования.
Излагаемые в этой главе алгоритмы разбора характеризуются тем, что входная цепочка считывается один раз слева направо и процесс разбора полностью детерминирован. Другими словами класс КС-грамматик здесь ограничивается так, чтобы для них можно было построить детерминированный левый или правый анализатор.
5.1. LL(k) ЯЗЫКИ И ГРАММАТИКИ

13EMBED Word.Picture.81415

Грамматики, для которых левый разбор работает детерминированно, если позволить ему принимать во внимание k входных символов, расположенных справа от текущей входной позиции, принято называть LL(k)-грамматиками. (Первая буква L (Left- левый) относится к просмотру входной цепочки слева направо, вторая - к используемому левому выводу.)
Дадим вначале неформальное определение LL(k) грамматики. Напомним, что в левостороннем анализаторе дерево вывода цепочки ((( строится по заданной грамматике, начиная от корня (аксиомы грамматики), сверху вниз. Пусть на каком-то шаге анализа уже построено частичное дерево вывода с кроной (A( (см. рис. 5.1). Для продолжения разбора требуется заменить нетерминал A по одному из правил вида A((. Если для однозначного выбора этого правила окажется достаточно знать только ( и первые k символов цепочки ((, то заданная грамматика является LL(k)–грамматикой.

Дадим более строгое определение. Определим два множества цепочек:
FIRST k (() - множество терминальных цепочек, выводимых из (, укороченных до k символов.
FOLLOW k (A)- множество укороченных до k символов терминальных цепочек, которые могут следовать непосредственно за A в выводимых цепочках.
КС-грамматика называется LL(k)-грамматикой для некоторого фиксированного k, если из существования двух левых выводов
S ( ( (A( ( ((( ( ( ((
и
S ( ( (A( ( ((( ( ( ((,
для которых FIRST k (() ( FIRST k ((), следует, что (((.

Пример 5.1. Пусть грамматика G1 состоит из правил S ( aAS(b , A ( a(bSA . Интуитивно G1 является LL(1) грамматикой, так как если дан самый левый нетерминал C в левовыводимой цепочке и следующий входной символ с, то существует не более одного правила, применимого к C и приводящего к терминальной цепочке, начинающейся символом c. Переходя к определению LL(1) грамматики мы видим, что если S ( ( (S( ( ((( ( ( (( и S ( ( (S( ( ((( ( ( (( и цепочки ( и ( начинаются символом a, то в выводе участвует правило S ( aAS и ( = ( = aAS. Альтернатива S ( b здесь невозможна. С другой стороны, если ( и ( начинаются с b, то должно применяться правило S ( b и ( = ( = b. Заметим, что случай ( = ( = ( здесь невозможен, так как из S не выводится пустая цепочка (.
Когда рассматриваются два других вывода с нетерминалом A, то рассуждение аналогично. (

Пример 5.2. Рассмотрим более сложный случай - грамматику G2, определяемую правилами S ( ((abA , A ( Saa(b . Это не LL(1) грамматика, так как, пройдя часть левого вывода S ( abA ( abSaa для входных цепочек abaa или ababbaa и, имея на входе символ a, не ясно какое правило надо применить: S ( ( или S ( abA. Покажем, что G2 – это LL(2)-грамматика.
Допустим, что S ( ( (S( ( ((( ( ( (( и S ( ( (S( ( ((( ( ( (( и первые два символа цепочки ( (если они есть) совпадают с первыми двумя символами цепочки (. Нетрудно видеть, что здесь нет иных возможностей, кроме ( = ( = (, ( и ( начинается с aa, ( и ( начинается с ab. В первых двух случаях в обоих выводах применяется правило S ( ( и ( = ( = (. В третьем случае должно применяться S ( abA и ( = ( = abA. (

Пример 5.3. Рассмотрим грамматику G3 = ({S, A, B}, {0, 1, a, b}, P3, S), где P3 состоит из правил:
S ( A(B
A ( aAb(0
B ( aBbb(1
Здесь L(G3) = {an0bn(n ( 0}({ an1b2 n(n ( 0}. G3 не является LL(k)-грамматика ни для какого k. Интуитивно, если мы начинаем с чтения достаточно длиной цепочки, начинающейся с символов a, то не знаем, какое из правил S ( A или S ( B было применено первым, пока не встретим 0 или 1. Обращаясь к точному определению LL(k)-грамматики, положим ( ( ( ( (, ( ( A, ( ( B, ( ( ak0bk и ( ( ak1b2 k. Тогда выводы

S (0 S ( A ((ak0bk

S (0 S ( B ((ak1b2 k

соответствуют выводам (1) и (2) определения. Первые k символов цепочек ( и ( совпадают, однако заключение ( ( ( ложно. Так как k здесь выбрано произвольно, то G3 не является LL-грамматикой. Можно показать, что для языка L(G3) вообще не существует LL(k)-грамматики. (

Из определения LL(k) грамматики может показаться, что для определения нужного правила надо помнить уже всю проанализированную часть входной цепочки (. Но это не так. Рассмотрим теорему, очень важную для понимания LL(k)-грамматик, которая тривиально доказывается исходя из определения LL(k)-грамматики.
Теорема 5.1. КС-грамматика G = (N, (, P, S) является LL(k)-грамматикой тогда и только тогда, когда для двух различных правил A ( ( и A ( ( из P пересечение FIRSTk((() ( FIRSTk((() пусто при всех таких (A(, что S (((A(. (

Одно из важных следствий определения LL(k)-грамматик состоит в том, что леворекурсивная грамматика не может быть LL(k)-грамматикой ни для какого k.

Пример 5.4. Пусть грамматика G определяется двумя правилами S ( Sa(b. Возьмем, как и в теореме 5.1, вывод S ( i Sa i, где i ( 0, A = S, ( = (, ( = Sa и ( = b. Тогда для i ( k
FIRST k (Saa i ) ( FIRSTk(ba i ) = ba k-1

Таким образом, G не может быть LL(k)-грамматикой ни для какого k. (


Еще одно следствие теоремы 5.1 состоит в том, что если КС-грамматика G не содержит аннулирующих правил, то она будет LL(1)-грамматикой только в том случае, когда для всех A(N каждое множество A-правил A ( (1((2(((( n из P таково, что FIRST1((1), FIRST1((2), (, FIRST1((n) попарно не пересекаются. (Отсутствие (-правил здесь существенно).
Введенная выше функция FOLLOW k (A) как раз и нужна для грамматик с аннулирующими правилами. Для LL(1)-грамматик справедливо следующее утверждение.

Теорема 5.2. КС-грамматика G = (N, (, P, S) является LL(1)-грамматикой тогда и только тогда, когда для двух различных правил A ( ( и A ( ( пересечение FIRST1((FOLLOW 1 (A)) ( FIRST1((FOLLOW 1 (A)) пусто при всех A(N. (

Другими словами G является LL(1)-грамматикой, если для каждого множества A-правил A ( (1((2(((( n
(1) множества FIRST1((1), FIRST1((2), (, FIRST1(( n) попарно не пересекаются,
(2) если ( i ( (, то FIRST1(( j) ( FOLLOW 1 (A) = 0 для 1 ( j ( n, i ( j.


Таким образом, в случае k = 1 для однозначного выбора правила для нетерминала А, достаточно знать только нетерминал A и а – первый символ нерассмотренной части входной цепочки (:
следует выбрать правило A ( (, если а входит в FIRST1(()
следует выбрать правило A ( (, если а входит в FOLLOW1(A).


Прежде чем рассмотреть алгоритм разбора для LL(1)-грамматик отметим, что неразрешима проблема распознавания существования LL(k)-грамматики, эквивалентной КС-грамматике G, которая не является LL(k)-грамматикой. Тем не менее существуют ситуации, в которых отдельные преобразования позволяют из не LL(1)-грамматики получить эквивалентную LL(1)-грамматику. Проиллюстрируем два таких преобразования на примерах.

Пример 5.5. Пусть G – леворекурсивная грамматика S ( Sa(b , которая, как видно из примера 5.4 не является LL-грамматикой. Устраняя левую рекурсию, заменим два эти правила на следующие три:
S ( bS(
S( ( aS(((
получив при этом эквивалентную грамматику G(. С помощью теоремы 5.2 легко показать, что G( – LL(1)-грамматика. (

Пример 5.6. Рассмотрим LL(2)-грамматику G – с двумя правилами S ( aS(a. Проведем левую факторизацию , “вынеся влево за скобку” символ a и, записав правила в виде S ( a(S((). Иными словами, мы считаем, что операция конкатенации дистрибутивна относительно операции выбора альтернативы. Заменив эти правила на
S ( aA
A ( S((
получим тем самым эквивалентную LL(1)-грамматику. (
5.1.1. Предсказывающие алгоритмы разбора и разбор для LL(1)-грамматик

Разбор для LL(k)-грамматики удобно осуществлять с помощью k-предсказывающего алгоритма разбора. Такой алгоритм ( для КС-грамматики G=(N, (, P, S), используя входную ленту, магазин и выходную ленту (см. рис. 5.2), пытается проследить левый вывод цепочки, записанной на его входной ленте.
13EMBED Word.Picture.81415
При чтении анализируемой цепочки, находящейся на входной ленте, входная головка может “заглядывать вперед” на k очередных символов. Эту цепочку из k символов, увиденную впереди входной головкой, принято называть аванцепочкой. На рис. 5.2 аванцепочкой служит подцепочка u входной цепочки (u( .
Магазин содержит цепочку X($, где X( – цепочка магазинных символов, X – верхний символ магазина, а $ - специальный символ, используемый в качестве маркера дна магазина. Алфавит магазинных символов (без $) обозначим через (.
Выходная лента содержит цепочку (, состоящую из номеров правил грамматики, применяемых при левом выводе.

Конфигурацию предсказывающего алгоритма разбора будем представлять в виде тройки ((, X(, (), где
(1) ( – еще не проанализированная часть входной цепочки,
(2) X( – цепочка в магазине (X – верхний символ магазина),
(3) ( – цепочка на выходной ленте.
Например, на рис. 5.2 изображена конфигурация (u( , X(, ().

Работой k-предсказывающего алгоритма A руководит управляющая таблица М, задающая отображение множества ((({$})(( (k в множество, содержащее:
(1) ((,i), где ((( (, а i – номер правила (предполагается, что ( будет либо правой частью i-го правила, либо некоторым ее представлением),
(2) выброс (извлечение из магазина),
(3) допуск,
(4) ошибка.
Алгоритм анализирует входную цепочку, проделывая последовательность тактов, очень похожих на такты преобразователя с магазинной памятью (см. [10] или раздел 4.1 данного пособия). На каждом такте сначала определяется аванцепочка u и верхний символ магазина X. Затем рассматривается элемент M(X, u) управляющей таблицы. Такты алгоритма A мы опишем в терминах отношения перехода (( , определенного на множестве конфигураций. Пусть u = FIRSTk((), тогда в алгоритме A возможны следующие такты:
(1) ((, X(, ()(( ((, ((, (i), если M(X, u) ( ((,i). Здесь верхний символ магазина X заменяется цепочкой ((( ( (правой частью правила X ( () и к выходу добавляется номер этого правила i . Входная головка не сдвигается.
(2) ((, a (, ()(( (((, (, (), если M(X, u) ( выброс и ( ( a((. Когда верхний символ магазина совпадает с текущим входным символом (первым символом аванцепочки), он удаляется из магазина и входная головка сдвигается на один символ вправо.
(3) Если алгоритм достигает конфигурации ((, $, (), работа прекращается и выходная цепочка ( называется левым разбором исходной входной цепочки. Будем предполагать, что всегда M($, () = допуск, и конфигурацию ((, $, () будем называть допускающей.
(4) Если алгоритм достигает конфигурации ((, X(, () и M(X, u) = ошибка, то разбор прекращается и выдается сообщение об ошибке. Эту конфигурацию ((, X(, () называют ошибочной.

Алгоритм построения управляющих таблиц для LL(k)-грамматик в случае k >1 довольно сложен, управляющие таблицы имеют большой объем и на практике такие k-предсказывающие алгоритмы не нашли применения. Синтаксис большинства известных языков программирования описывается LL(1)-грамматиками. Поэтому ниже мы и рассмотрим только один важный частный случай, когда G – LL(1)-грамматика.

Алгоритм 5.1. Построение управляющей таблицы для LL(1)-грамматики.
Вход. LL(1)-грамматика G = (N, (, P, S).
Выход. Управляющая таблица M для грамматики G.
Метод. Будем считать, что $ – маркер дна магазина. Таблица M определяется на множестве (N ( ( ( {$}) ( (( ( {(}) следующим образом:
(1) Если A ( ( – правило из P с номером i, то M(A, a) = ((, i) для всех а ( (, принадлежащих FIRST1((). Если ( ( FIRST1((), то M(A, b) = ((, i) для всех b ( FOLLOW1(A).
(2) M(a, a) = выброс для всех a ( (.
(3) M($, () = допуск.
(4) В остальных случаях M(X, a) = ошибка для X ( N ( ( ( {$} и a ( ( ( {(}. (

Пример 5.7. Рассмотрим построение управляющей таблицы для грамматики G с набором правил:
(1)
E ( T E(
(2)
E( ( ( T E(

(3)
E( ( (
(4)
T ( F T (

(5)
T ( ( ( F T (
(6)
T ( ( (

(7)
F ( ( E )
(8)
F ( a






С помощью теоремы 5.2 можно проверить, что G – LL(1)-грамматика. Предложенная грамматика ни что иное, как результат устранения левой рекурсии из фрагмента хорошо известной нам не LL-грамматики арифметических выражений с правилами:

Е (E(T(T T ( T(F(F F ( (E((a

На шаге (1) алгоритма 5.1 найдем элементы строки таблицы для нетерминала E. Здесь FIRST1(TE() = {(, a}, так что M ( E, ( ) = (TE(, 1) и M ( E, a ) = (TE(, 1). Все остальные элементы этой строки – ошибки. Вычислим теперь строку для нетерминала E(. Заметим, что FIRST1((TE() = (, так что M ( E(, ( ) = ((TE(, 2). Так как есть правило E( ( (, мы должны вычислить FOLLOW1(E() = {(, ) }. Таким образом, M ( E(, ( ) = M ( E(, ) ) = ((, 3). Каждый из остальных элементов строки для E( – ошибка. Продолжая в том же духе, получим управляющую таблицу для G, представленную на рис. 5.3, где ячейки, в которых должна стоять ошибка, оставлены пустыми.

13EMBED Word.Picture.81415
1-предсказывающий алгоритм разбора проанализирует цепочку (a(a) следующим образом:
[(a(a), E$, (] (( [(a(a), TE($, 1] (( [(a(a), FT(E($, 14] ((
[(a(a), (E)T(E($, 147] (( [a(a), E)T(E($, 147] (( [a(a), TE()T(E($, 1
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·Поскольку действия при LL(1)-разборе зависят только от пары “очередной нетерминал - очередной символ”, этот разбор легко запрограммировать, используя и другой не универсальный, но зато довольно прозрачный метод рекурсивного спуска.
5.1.2. Рекурсивный спуск

Суть метода рекурсивного спуска состоит в том, что для каждого нетерминала грамматики X разрабатывается рекурсивная процедура, осуществляющая разбор цепочек, выводимых из X. Процедуре передается позиция входной цепочки, начиная с которой предполагается наличие фрагмента, выводимого из X. Процедура сопоставляет цепочку в указанном месте с правыми частями правил для X и вызывает по мере необходимости другие процедуры для распознавания промежуточных целей.
Чтобы прокомментировать этот метод, запишем процедуры для нетерминальных символов следующей грамматики:
(инстр( ( (перем( (( (выр((if (выр( then (инстр(
(if (выр( then (инстр( else (инстр(
(перем( ( i(i ((выр()
(выр( ( (терм( ((выр( ( (терм(
(терм( ( (множ( ((терм( ( (множ(
(множ( ( (перем( (((выр()
Перепишем эту грамматику, используя расширенную Бэкусову нормальную форму (РБНФ) с включением факультативных (необязательных) фрагментов – ((( и итерации– {(} (фрагмент повторяется ноль или более раз).
(инстр( ( (перем( (( (выр(( if (выр( then (инстр( [ else (инстр( ]
(перем( ( i [ ((выр() ]
(выр( ( (терм( { ( (терм( }
(терм( ( (множ( { ( (множ( }
(множ( ( (перем( (((выр()
Запишем процедуры на языке, подобном Паскалю с соблюдением следующих условий:
(1) Глобальная переменная NxtSymb всегда содержит следующий символ, а точнее очередную лексему исходной программы, подлежащую обработке. При вызове процедуры для поиска новой цели первый символ, который процедура должна исследовать уже находится в NxtSymb. Для того чтобы обеспечить такую работу, перед тем как выйти из процедуры с сообщением об успехе, символ, следующий за уже рассмотренной подцепочкой, помещается в NxtSymb.
(2) Процедура Scan готовит очередной символ (лексему) входной цепочки и помещает его в NxtSymb.
(3) Программа Error вызывается при обнаружении ошибки. Она выводит сообщение об ошибке и для идентификации ошибки ей можно передать код ошибки в качестве параметра. Эта процедура может завершать разбор или пытаться нейтрализовать ошибку.
(4) Для того чтобы начать синтаксический анализ исходной строки, в головной программе сначала вызывается программа Scan, которая поместит первый символ в NxtSymb, а затем вызывается процедура State, анализирующая инструкцию.
Процедуры для всех нетерминалов грамматики и комментарии к ним представлены на рис. 5.4. Преимущества рассмотренного метода очевидны. Программируя компилятор, можно реорганизовать правила так, чтобы они согласовывались с процедурами. Предполагается, что автор компилятора настолько хорошо знаком с исходным языком, что может провести модификацию грамматики, которая избавляет от возвратов. Метод сохраняет свою гибкость и по отношению к семантической обработке. С этой целью в любое место каждой процедуры можно включить группу команд, выполняющих семантические действия по переводу исходного текста.
Основной же недостаток метода состоит в том, что на программирование и отладку синтаксического анализатора затрачивается больше усилий, чем в чисто автоматизированных системах. Тем не менее, он нашел применение при построении целого ряда компиляторов. Кроме того, совсем не трудно написать такую программу, которая воспринимала бы правила подходящей грамматики и порождала бы рекурсивные процедуры на языке программирования высокого уровня для нетерминалов предложенной грамматики.

13EMBED Word.Picture.81415
5.2. ЯЗЫКИ И ГРАММАТИКИ ПРОСТОГО ПРЕДШЕСТВОВАНИЯ

Одна из центральных проблем детерминированного восходящего синтаксического анализа состоит в однозначном определении основы – самой левой подцепочки сентенциальной формы, которую требуется редуцировать на данном этапе разбора. Хотелось бы, “перемещаясь” по цепочке слева направо и рассматривая одновременно только два соседних символа, суметь определить, нашли ли мы хвост основы (конец редуцируемой части). А затем, продвигаясь назад к левому концу найти голову основы, опять таки анализируя лишь два соседних символа. То есть мы сталкиваемся с такой задачей: если задана сентенциальная форма . . . x y . . . (где x, y ( ( ( (), то всегда ли x – хвост основы, или x и y принадлежат основе, или возможны другие варианты? Для решения этой задачи требуется еще до начала разбора исследовать грамматику языка и принять решения относительно каждой пары символов x и y.
Пусть x, y ( ( ( ( грамматики G. Предположим, что в языке, определяемом G, при выводе цепочки языка существует сентенциальная форма . . . x y . . . . На некотором этапе разбора либо x, либо y, либо оба символа одновременно должны войти в основу. При этом возникают три различных варианта:

1). x – часть основы, а точнее ее хвост, а y в основу не входит. Эту ситуацию мы будем обозначать x (( y и говорить, что x больше, а, более строго, x предшествует y, так как x редуцируется раньше, чем y. Заметим, что x в этом случае должен быть последним, хвостовым, самым правым символом в правой части некоторого правила грамматики U ( . . . x (рис. 5.5). Отметим также, что основа располагается слева от y, и следовательно y должен быть терминалом, так как имеет место канонический разбор.

2). Оба символа входят в основу. При этом говорят, что x и y имеют одинаковое отношение предшествования, они редуцируются одновременно и изображают это отношение: x13EMBED PBrush1415y . Очевидно, что в грамматике при этом имеет место правилоU ( . . . x y . . . (рис. 5.6).


3). y – часть основы, а точнее ее голова, а x в основу не входит. Эта ситуация обозначается x (( y и можно говорить, что x меньше y или x следует за y. При этом символ y должен быть первым, самым левым в правой части некоторого правила U ( y . . . (рис. 5.7).

Если сентенциальной формы . . . x y . . . при выводе цепочек в грамматике G не существует, то мы будем считать что между упорядоченной парой символов (x, y) не определено отношение предшествования (обозначим этот факт как x13EMBED PBrush1415y).
Заметим, что ни одно из определенных выше отношений не является симметричным: из x13EMBED PBrush1415y вовсе не следует, что y 13EMBED PBrush1415 x , и уж тем более x (( y не свидетельствует о том, что y (( x .

Пример 5.8.
Рассмотрим в качестве примера грамматику G5 со следующим множеством продукций:
S ( bMb M ( (L ( a L ( Ma)

Языку L, порождаемому грамматикой G5, принадлежат цепочки: bab , b(aa)b , b((aa)a)b , b(((aa)a)a)b и т.д.

Попробуем определить отношения предшествования между символами данной грамматики. На рис. 5.8 представлены различные сентенциальные формы, их синтаксические деревья, основы и те отношения, которые можно получить с их помощью. (
13EMBED Word.Picture.81415
На первый взгляд может показаться, что трудно найти все отношения предшествования между символами грамматики. Создается впечатление, что для этого необходимо построить массу синтаксических деревьев. Да и где гарантия, что все отношения уже найдены?
Дадим более строгие определения отношениям предшествования с тем, чтобы формализовать алгоритм их выявления. Прежде всего определим множество самых левых L(U) и самых правых R(U) символов грамматики для нетерминала U:
L(U) = {x | x ( ( ( ( ( (( (U ( x() or ( (U ( U1() ( (x ( L(U1))},
где здесь и далее U, U1, U2 ( ( (нетерминалы), а (, ( ( (( ( ()( (любые цепочки из терминалов и нетерминалов, возможно пустые).
R(U) = {x | x ( ( ( ( ( (( (U ( (x) or ( (U ( ( U1) ( (x ( R(U1))},
Иными словами, символы, которые записаны слева во всех правых частях правил (альтернативах) нетерминала U и составляют множество самых левых символов U – L(U). И далее, если левым символом U является нетерминал U1, то левыми для U будут и все самые левые символы U1 (L(U1)(L(U)). Множество R(U) определяется аналогично.

Пример 5.9. На рис. 5.9 а представлена таблица самых левых и самых правых символов для нетерминалов грамматики, рассматриваемой в качестве примера 5.8 в данном разделе. На рис. 5.9 б приведена матрица предшествования – наиболее удобная форма представления отношений предшествования между символами грамматики.
13EMBED Word.Picture.81415(
Матрица предшествования с рис. 5.9 б формировалась на основании следующих определений отношений предшествования:

x13EMBED PBrush1415y, если ( (U ( (xy((
То есть x13EMBED PBrush1415y , если они стоят рядом в правой части какой-либо продукции именно в отмеченном порядке (x, а сразу следом за ним y).

x (( y, если ( (U ( (xU1() ( (y ( L(U1))
Если в правой части продукции стоит символ x (терминал или нетерминал), а следом за ним нетерминал U1, то x будет (( всех левых U1 (L(U1)).

x (( y, если ( (U ( (U1y() ( (x ( R(U1)) or
( (U ((U1U2() ( (x ( R(U1)) ( (y ( L(U2))
Если в правой части какой–либо продукции указан нетерминал U1, а следом за ним терминал x (напомним, что разбор канонический), то все правые символы U1 (R(U1)) будут (( x. И далее, если в правой части продукции два нетерминала U1 и U2 стоят рядом, то все самые правые символы U1 (R(U1)) будут (( левых символов U2 (L(U2)).

Как использовать полученные отношения? Если между парой символов более одного отношения предшествования, то они бесполезны. Если же не более одного, то они позволяют достаточно просто найти основу.
Для любой сентенциальной формы x1. . .x n основой является самая левая подцепочка x j . . . x i , такая что x j – 1(( x j , x j13EMBED PBrush1415x j + 113EMBED PBrush1415, ..., x i -1 13EMBED PBrush1415 x i , x i (( x i + 1 . (

Пример 5.10. На рис. 5.10 представлены шаги свертки цепочки b(aa)b к начальному символу грамматики S. Основы, получаемые на каждом шаге свертки, выделены здесь курсивом и подчеркнуты. Для осуществления последней свертки к цепочке добавляется ограничитель слева и справа (x0 и x n). В качестве символа ограничителя здесь взят символ # и предполагается, что # (( x и x (( # для любого x ( ( ( ( из G. (

Контекстно–свободная грамматика G называется грамматикой простого предшествования, или грамматикой (1,1) предшествования или грамматикой предшествования Вирта если:
грамматика G однозначно обратима, то есть никакие два правила грамматики не имеют одинаковых правых частей;
между любыми двумя символами грамматики существует не более одного отношения предшествования. (
5.2.1. Алгоритм Вирта–Вебера для анализа языков простого предшествования

При практическом применении отношений предшествования для распознавания предложений языка потребуется способ компактного представления отношений. Обычно этой цели служит матрица P, элементы которой принимают значения:
P[i,j] = 0, если x i и x j несравнимы (x i13EMBED PBrush1415y j);
P[i,j] = 1, если x i (( x j ;
P[i,j] = 2, если x i13EMBED PBrush1415x j ;
P[i,j] = 3, если x i (( x j .
Для грамматики предшествования такое представление возможно, так как известно что между любыми двумя символами грамматики определено не более одного отношения. Таким образом, под каждый элемент матрицы можно отвести всего два разряда, но для того чтобы не выполнять лишних действий на выделение разрядов следует, видимо, использовать байтовый массив.
Сами правила грамматики должны располагаться в таблице, имеющей такую структуру, которая позволяет по полученной основе найти правило, содержащее данную основу в качестве правой части продукции, а затем указать соответствующую левую часть.
Неформально работу алгоритма Вирта–Вебера, а именно Н. Виртом и Х. Вебером были определены отношения простого предшествования и данный алгоритм в 1966 году, можно представить следующим образом. Символы входной цепочки просматриваются слева направо и заносятся в магазин (стек) до тех пор, пока не окажется, что верхний символ стека находится в отношении (( к следующему входному символу. Это означает, что верхний символ стека является хвостом основы и, следовательно, вся основа уже в стеке. Затем полученную основу находят в списке правил грамматики и заменяют тем нетерминалом, из которого она выводится. Процесс повторяется до тех пор, пока в стеке не окажется символ S (начальный символ грамматики) и следующим входным символом станет ограничитель цепочки (в нашем случае – #).
На рис. 5.11 представлена функциональная схема данного алгоритма. Здесь C – стек, Т – входная цепочка, i – номер (позиция) верхнего символа в стеке, k – текущая позиция входной цепочки. Ограничимся небольшими комментариями к отдельным блокам или их группе, используя номера блоков из схемы:
1). В стек заносится ограничитель цепочки – # и индексам по стеку и входной строке присваиваются начальные (0–ые) значения.
13EMBED Word.Picture.81415
2) – 3). Если между символом из вершины стека – C[i] и очередным входным символом – T[k] отношения не определены, то сообщить об ошибке и завершить работу. В противном случае перейти к блоку 4.
4) – 5). Ищется хвост основы. До тех пор, пока между C[i] и T[k] не обнаружится отношения ((, текущий входной символ помещается в стек, извлекается следующий входной символ и осуществляется переход к блоку 2. Если хвост основы найден (C[i] (( T[k]), то переходим к блоку 6.
6) – 8). Осуществляется поиск головы основы в стеке. На нее будет указывать индекс j.
9). Найденная основа ищется среди правых частей продукций заданной грамматики. Если поиск успешен, то осуществляется переход к блоку 10, иначе – к блоку 12.
10) – 11). Основа в стеке заменяется нетерминалом U – левой частью правила, обнаруженного в блоке 9. Затем выполняется семантическая подпрограмма, связанная с данным правилом грамматики и осуществляется переход к блоку 2.
12)–14). Сюда мы попадаем, когда обнаруженная основа ни к чему не приводится. Это может случиться тогда, когда в стеке кроме левого ограничителя цепочки записан начальный символ грамматики и очередной входной символ – правый ограничитель. В этом случае вся цепочка была свернута к начальному символу грамматики, исходная цепочка принадлежит рассматриваемому языку и алгоритм завершает работу сообщив об успешном окончании. В противном случае, для найденной основы просто не обнаружено соответствующего правила грамматики, и алгоритм также завершит работу, сообщив об ошибке.

Пример 5.11. На рис. 5.12 разобрана по шагам работа изложенного алгоритма для правильной цепочки b(aa)b языка из примера 5.8 с матрицей предшествования с рис. 5.9 б.

Шаги
С0
С1
С2
С3
С4
С5
Отношение
Т k







0
#





((
b
(
a
a
)
b
#

1
#
b




((
(
a
a
)
b
#


2
#
b
(



((
a
a
)
b
#



3
#
b
(
a


((
a
)
b
#




4
#
b
(
M


13EMBED PBrush1415
a
)
b
#




5
#
b
(
M
a

13EMBED PBrush1415
)
b
#





6
#
b
(
M
a
)
((
b
#






7
#
b
(
L


((
b
#






8
#
b
M



13EMBED PBrush1415
b
#






9
#
b
M
b


((
#







10
#
S





#








Рис. 5.12

На рис. 5.13 а представлен пример разбора ошибочной цепочки babb, где причиной ошибки является отсутствие отношений между символами b и b, а на рис. 5.13 б показан пример цепочки ba , где ошибка возникает из–за отсутствия обнаруженной основы bM среди правых частей правил грамматики. (

Завершая обсуждение алгоритма Вирта–Вебера, заметим, что в нем и других подобных распознавателях есть одно очень привлекательное свойство: нет необходимости хранить в памяти одновременно всю цепочку входных символов (если только грамматика не из ряда вон выходящая). Символы считываются с входного носителя по одному и заносятся в стек, но после редукции основы те символы, что входили в нее исчезают. Всю цепочку приходится хранить в памяти только в том случае, когда основа находится в правом конце цепочки, но грамматики языков программирования никогда не строятся подобным образом.

13EMBED Word.Picture.81415
5.2.2. Функции предшествования.
Матрица предшествования P может занимать слишком большой участок памяти. Если в языке 200 символов, нам понадобится матрица из 200 ( 200 элементов, каждый длиной не менее двух битов. Однако, во многих случаях информация, задаваемая в матрице может быть представлена в сжатой форме парой векторов f и g с целочисленными значениями, называемых функциями предшествования, и такими, что

из x i (( x j (P i j = (() следует f i ( g j ;
из x i 13EMBED PBrush1415 x j (P i j =13EMBED PBrush1415) следует f i ( g j ;
из x i (( x j (P i j = (() следует f i ( g j .

для всех символов грамматики. Это называется линеаризацией матрицы, и объем требуемой памяти сокращается с n ( n ячеек до 2 ( n.


x
y

x
13EMBED PBrush1415
((

y
13EMBED PBrush1415
13EMBED PBrush1415


Рис. 5.14
Следует, однако, сразу же отметить, что не для всякой матрицы функция предшествования существует. Нельзя линеаризовать, на пример, даже такую матрицу предшествования размером 2 ( 2, что представлена на рис. 5.14. Если бы функции предшествования существовали, то из данного выше определения следовало бы

f(x) ( g(y), g(y) ( f(y),
f(y) ( g(x), g(x) ( f(x),
что ведет к противоречивому утверждению f(x) ( f(x).

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

Прежде всего, заметим, что если две строки матрицы предшествования P совпадают, то их можно объединить в одну и это не повлияет на существование функции предшествования. По той же причине можно объединить и совпадающие столбцы. Матрицу предшествования, в которой все совпадающие строки и столбцы объединены, назовем приведенной матрицей предшествования. Ясно, что если сначала привести матрицу, то проще будет построение функции.
Алгоритм 5.2. Вычисление функции предшествования по графу линеаризации.

Вход. Матрица P размером n ( m с элементами (( , 13EMBED PBrush1415 , (( и “пусто”.
Выход. Два целочисленных вектора f = (f 1, . . . , f m) и g = (g 1, . . . , g n) , у которых
f i ( g j при P i j ( ((
f i ( g j при P i j ( 13EMBED PBrush1415
f i ( g j при P i j ( ((
или “нет”, если такие векторы не существуют.
Метод.
(1) Построим ориентированный граф (, содержащий не более n ( m вершин называемый графом линеаризации для P. Сначала пометим m вершин буквами F1, F2, . . . , Fm для каждой строки матрицы, а оставшиеся n вершин буквами G1, G2, . . . , G n для каждого столбца матрицы P.
(2) Если P i j ( 13EMBED PBrush1415, то построим новую вершину N, объединяя F i с G j .
(3) Если P i j ( (( , проведем дугу из F i в G j . Если P i j ( (( , проведем дугу из G j в F i.
(4) Если полученный граф содержит циклы, выдать сообщение “нет” и завершить работу.
(5) Если граф линеаризации ациклический, положим значение f i , равным длине самого длинного пути, начинающегося в F i , а g j – длине самого длинного пути, начинающегося в G j . (Точнее, в качестве значений функций предшествования мы будем использовать в дальнейшем величины на единицу большие, чем длины самого длинного пути, зарезервировав нулевые значения для левого и правого ограничителей цепочки.). (

На шаге (4) алгоритма 5.2 для выяснения, циклический или нет ориентированный граф (, можно применить следующий общий метод:
Пусть ( – исходный граф.

(1) Найти в данном графе вершину N, не имеющую прямых потомков. Если таких вершин нет, граф ( – циклический. В противном случае удалить вершину N вместе с дугами, входящими в нее.
(2) Если полученный граф пуст, то граф ( – ациклический. В противном случае повторить шаг (1).

Установив в некоторый момент, что граф ациклический, воспользуемся для нахождения на шаге (5) алгоритма 5.2 длины самого длинного пути, начинающегося в произвольной вершине, следующим методом разметки вершин.
Пусть ( – ориентированный ациклический граф.

(1) Сначала пометить все вершины графа ( единицами.
(2) Повторять шаг (3) до тех пор, пока метки графа не перестанут изменяться. Метка при каждой вершине будет на единицу больше длины самого длинного пути, начинающегося в этой вершине.
(3) Выбрать в ( некоторую вершину N. Пусть N имеет k прямых потомков N1 , N2 , . . . , Nk с метками p1, p2, . . . , p k . Заменить метку вершины N на max (p1, p2, . . . , p k) + 1. (Если k = 0 , то меткой вершины N оставить 1.)

Ясно, что шаг (3) повторяется для каждой вершины не более p раз, где p – длина самого длинного пути в (.
Заметим, что факт наличия в графе циклов может быть обнаружен и без выполнения пункта (4) алгоритма 3.1. Если значение пометки, вычисляемой в пункте (5), у какой-либо вершины превысит 2 ( n, где n – количество символов грамматики, то это говорит о том, что самый длинный путь из данной вершины превышает количество вершин данного графа, т.е. в графе присутствуют циклы.
Определение самого длинного пути можно совместить также с определением наличия циклов по пункту (4) алгоритма 5.2. Если под отдельным шагом данного пункта считать одновременное удаление всех вершин, не имеющих прямых потомков, зафиксированных до выполнения шага, то порядковый номер этого шага и будет определять значение пометки для удаляемой вершины.

Пример 5.12. На рис. 5.15 а представлен граф линеаризации для матрицы предшествования с рис. 5.9 б примера 5.9. В этой матрице совпадают строки для символов L’ и )’, поэтому на графе они представлены одной вершиной F L , ) .
13EMBED Word.Picture.81415
В процессе построения графа на шаге (2) алгоритма 5.2 были объединены вершины (F M , G b и G a), (Fb , G M), (Fa , G ) ) и (F( , G L ).
После выполнения шага (3) алгоритма 5.2 и проведения всех необходимых дуг, определим, содержит ли полученный граф циклы, а заодно и вычислим функцию предшествования. На графе с рис. 5.15 а прямых потомков не имеют вершины (F S ), (G S ) и (F( , G L ). Поставим на них пометку 1 и удалим эти вершины вместе с дугами, которые направлены к этим вершинам. После этого появится не имеющая потомков вершина (Fb , G M ). Поставим на ней отметку 2 и удалим вершину. В результате получим еще две вершины (G ( ) и (FM , G b , G a ), которые не имеют потомков. Поставив на них пометку 3 и удалив, мы получим две изолированные вершины (FL , ) ) и (Fa , G ) ). Осталось пометить их цифрой 4 и после удаления этих двух последних вершин мы получим пустой граф, что говорит об ацикличности исходного графа. Более того, пометки вершин, которые расставлялись перед их удалением и составляют значения функции предшествования. Осталось только свести их в таблицу, приведенную на рис. 5.15 б. (
Метод пересчета для вычисления функции предшествования.

Достоинством рассмотренного выше алгоритма 5.2 является его наглядность, но с точки зрения машинной реализации гораздо эффективнее алгоритм пересчета, который приводится ниже.

Алгоритм 5.3. Вычисление функции предшествования методом пересчета.

Входом здесь является все та же матрица предшествования, а выходом вектора f и g.
(1) Вначале положим f(x i ) ( 1 и g(x i ) ( 1 для всех x i ( (( ( () заданной грамматики.
(2) Просмотрим каждую строку предложенной матрицы предшествования. Если x i (( x j , а f(x i ) ( g(x j ) , то положим f(x i ) ( g(x j ) ( 1.
(3) Просмотрим все столбцы матрицы. Если x i (( x j , а f(x i ) ( g(x j ) , то положим g(x j ) ( f(x i ) ( 1.
(4) Рассмотрим все элементы матрицы с отношением13EMBED PBrush1415. Если x i13EMBED PBrush1415 x j , а f(x i ) ( g(x j ) , то положим f(x i ) и g(x j ) равными max ( f(x i ), g(x j ) ) .
(5) Повторяем шаги (2), (3), (4) до тех пор пока либо:
а) одно из полученных значений f или g не превысит 2 ( n , где n – количество символов матрицы и тогда функции предшествования не существует;
б) на очередном цикле выполнения шагов (2), (3), (4) ни одно из значений f и g не изменились и тогда функция предшествования найдена.

Заметим, что исходя из определения функции предшествования, данного в начале раздела 5.4, она не единственна – если для данной матрицы найдется хотя бы одна пара функций f и g, то для той же матрицы существует бесконечное количество таких функций.

Ясно, что использование функции предшествования в алгоритме Вирта–Вебера, описанного в разделе 5.3, приведет к тому что входные символы будут переносятся в стек до тех пор, пока функция f для символа из вершины стека не станет больше функции g очередного входного символа и тогда хвост основы найден. Об преимуществах использования функции по сравнению с матрицей предшествования мы уже говорили – это меньший объем требуемой памяти ЭВМ. Но надо помнить, что выигрывая здесь мы где–то должны проиграть.
Когда компилятор обнаруживает в программе ошибку, он должен попытаться нейтрализовать ее и продолжить разбор, чтобы обнаружить другие ошибки. Поэтому иногда важно уметь обнаруживать ошибки как можно раньше. Из–за применения функций предшествования процесс обнаружения ошибки может затянуться. Линеаризация ведет к потере информации, потому что стало неизвестным, существует ли в действительности между двумя символами отношение. Утрачивается возможность обнаружения ошибки из–за отсутствия отношений. Эта ошибка в конечном итоге выявится при попытке выполнить свертку, когда окажется, что нет правила с такой правой частью, которая находится в вершине стека. Тем не менее, такая задержка в обнаружении ошибки может оказаться неприемлемо дорогой платой за удобство использования функций предшествования вместо матриц; это зависит от того, насколько важно раннее обнаружение ошибки в конкретном компиляторе.
Пример 15.13. Для иллюстрации вышесказанного попробуем в соответствии с грамматикой G5 из примера 5.8 провести разбор цепочки ba)))))b, используя сначала полную матрицу (см. рис. 5.16 а), а затем функции (рис. 5.16 б).
13EMBED Word.Picture.81415
На рис. 5.16 б ошибка возникает из–за того, что для фрагмента a))))) нет правой части в грамматике G5. При использовании функций для обнаружения ошибки было выполнено на четыре шага больше, чем при использовании матриц. (
5.2.3. Проблемы построения грамматик предшествования

Из определения грамматик простого предшествования следует, что далеко не всякая КС–грамматика является грамматикой простого предшествования. В частности, в произвольной КС–грамматике может не выполняться условие однозначной обратимости. Наличие нескольких одинаковых правых частей в продукциях грамматики приведет к неоднозначности при свертке основ, а следовательно и алгоритма разбора по такой грамматике станет недетерминированным. Но это не фатальная проблема, так как нетрудно показать, что каждый КС–язык порождается по крайней мере одной обратимой КС–грамматикой.
Действительно, если в грамматике имеются правила вида: A ( ( и B ( (, то одно из них, например B ( ( можно удалить, а все правила вида C ( (B( заменить парой правил: C ( ((( и C ( (B(. Причем, последнее правило сохраняется только в том случае, если у нетерминала B есть и другая альтернатива, кроме B ( (.

Пример 5.14. Рассмотрим фрагмент правил грамматики, определяющий синтаксис заголовка процедуры:

(Заголовок проц.( ((( PROCEDURE (имя проц.( ( (список параметров( );
(имя проц.( ((( (идентификатор(
(список параметров( ((( (идентификатор( (
(идентификатор( , (список параметров(

Устраним правило (имя проц.( ((( (идентификатор( и в результате получим обратимую грамматику:

(Заголовок проц.( ((( PROCEDURE (идентификатор( ( (список параметров( );
(список параметров( ((( (идентификатор( (
(идентификатор( , (список параметров( (

Вторая проблема более существенна. Очень часто между двумя символами грамматики имеет место более одного отношения предшествования. Единственное, что мы можем тогда сделать, – это обработать и изменить грамматику так, чтобы обойти конфликт.
Обычно, наличие двух отношений между символами грамматики – это следствие рекурсии, в частности левосторонней. Предположим, что в грамматике существует некоторое правило U ( U... . Если есть другое правило вида V ( ...XU... , то одновременно X13EMBED PBrush1415U и в силу того, что U ( L(U) , – X будет (( U. Иногда можно избавиться от такого конфликта, заменив правило
V ( ... XU ...
парой правил:
V ( ... XU1 ... , U1 ( U ,
где U1 новый нетерминал. При этом получим, что X13EMBED PBrush1415U1 и X (( U. Такой прием называют стратификацией или разделением. Заметим, что аналогично может решаться и конфликт с отношениями (( и 13EMBED PBrush1415 при правосторонней рекурсии.

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

E ( E(T(E(T(T((T
T ( T(M(T(M(M
M ( (E)(i

Из первой группы правил следует, что (’ и (’13EMBED PBrush1415T, а так как T леворекурсивен, получаем также, что (’ и (’ (( T. Аналогичная проблема возникает и с символами (’ и E’. Без ущерба для структуры цепочек языка изменим заданную грамматику на следующую:

E ( E(T1(E(T1(T1((T1
Т1 ( T
T ( T(M(T(M(M
M ( (E1)(i
E1 ( E .

Начальным символом грамматики при этом станет E1, множество самых левых и самых правых символов для нетерминалов полученной грамматики представлено на рис. 5.17, а матрица и функции предшествования на рис. 5.18.
13EMBED Word.Picture.81415(
Этот пример может создать впечатления, что при стратификации изменения не столь значительны. Однако, если в грамматике 100 правил и более 100 символов (а так оно и есть в языках типа Паскаль), то даже искушенный специалист затратит немало времени на то, чтобы переделать такую грамматику в грамматику простого предшествования. В результате может измениться вся структура языка, не говоря о том, что грамматика станет неудобочитаемой. Кроме того, стратификация не всегда спасает, так как она часто приводит к конфликтам иного рода. Если одновременно для двух символов грамматики x и y выполняются отношения x (( y и x (( y, то лучший выход – применить другую технику.

13EMBED Word.Picture.81415
Первопричина проблем метода простого предшествования состоит в том, что решения принимаются с учетом весьма ограниченного контекста возможной основы. В сущности, в каждом случае во внимание принимается только два соседних символа (не случайно грамматика простого предшествования называется грамматикой (1,1) предшествования). Если же рассматривать и другие символы или большее количество символов, то можно надеяться, что конфликтных ситуаций станет меньше.
Проиллюстрируем это на примере сентенциальной формы E(T(F исходной грамматики из примера 5.15. Поскольку отношения ( (( T и (13EMBED PBrush1415T противоречивы, мы не можем всего по двум символам, ( и T , сделать вывод о том является ли T головой основы или ( и T одновременно входят в основу и нужно выполнить сложение. Если же известно два символа ( и ( или же три символа (T(, то интуиция подскажет, что складывать нельзя и следовательно символ ( в основу не входит.
5.3. ОПЕРАТОРНАЯ ГРАММАТИКА ПРЕДШЕСТВОВАНИЯ

Алгоритм разбора Вирта–Вебера и отношения простого предшествования, лежащие в его основе, просты для понимания, безупречны и эффективны с теоретической точки и поэтому именно с них мы начали обсуждение детерминированных алгоритмов восходящего разбора. Но в практике построения компиляторов они не нашли широкого применения из–за причин, рассмотренных в разделе 5.2.3.
Во многих компиляторах, особенно при анализе и трансляции арифметических выражений, используется простой в реализации и эффективный метод операторного предшествования, идея которого была формализована Флойдом еще в 1963 году. Собственно Р. Флойду и принадлежит первая трактовка отношений предшествования и функций предшествования, хотя методы, основанные на этих идеях, интуитивно использовались уже в самых ранних компиляторах.
Операторной грамматикой называется КС–грамматика без аннулирующих правил, в которой правые части правил не содержат смежных нетерминалов. Иными словами в операторных грамматиках запрещены правила вида V ( (UW(, где U, W ( (.
Для операторных грамматик можно задать отношения предшествования только для терминальных символов, игнорируя нетерминалы, хотя в остальном сами отношения операторного предшествования и пути их определения ничем не отличаются от отношений простого предшествования (см. раздел 5.2).
Введем вначале понятие множества самых левых (L) и самых правых (R) терминалов для нетерминала U:

L(U) = {x | x ( ( ( (( (U ( x() or ( (U ( U1x() or (( (U ( U2() ( (x ( L(U2)))},
где здесь и далее U, U1, U2 ( ( (нетерминалы) , а (, ( ( (( ( ()( (любые цепочки из терминалов и нетерминалов, возможно пустые).

R(U) = {x | x ( ( ( (( (U ( (x) or ( (U ( (xU1) or (( (U ( (U2) ( (x ( R(U2)))}

Определим теперь отношения предшествования для терминалов грамматики x и y:

x13EMBED PBrush1415y, если ( (U ( (xU1() ( (y ( L(U1) ;

x13EMBED PBrush1415y, если ( (U ( (U1y() ( (x ( R(U1) ;

x13EMBED PBrush1415y, если ( (U ( (xy(( or ( (U ( (xU1y((.

Пример 5.16. Рассмотрим упрощенную грамматику все тех же арифметических выражений:
E ( E(T(T
T ( T(M(M
M ( (E)(i

Множества самых левых и самых правых терминальных символов для нетерминалов этой грамматики представлены на рис. 5.19 а, а матрица операторного предшествования на рис. 5.19 б.

13EMBED Word.Picture.81415(

Для нахождения основ, состоящих из одного нетерминала, отношения операторного предшествования неприменимы, – для нетерминалов их просто не существует. Если рассмотреть, например, сентенциальную форму M(M из арифметической грамматики примера 5.16, то основой здесь является М, а отношения есть только такие: #13EMBED PBrush1415( и (13EMBED PBrush1415# (как и в разделе 3.4 мы предполагаем, что сентенциальная форма заключена между ограничителями # и что #13EMBED PBrush1415x и x13EMBED PBrush1415# для всех терминалов x). На каждом шаге разбора при использовании операторного предшествования распознается и редуцируется не основа, а так называемая самая левая первичная фраза.

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

Общий вид сентенциальной формы, выводимой в операторной грамматике и заключенный между ограничителями, выглядит следующим образом:

# N1T1N2T2 . . . NnTnNn+1 #

где Ni – нетерминалы, которые могут и отсутствовать, а Ti – терминальные символы. Иначе говоря, сентенциальная форма состоит их n терминалов, причем между каждыми двумя соседними терминалами находится не более чем один нетерминал.
Самой левой первичной фразой сентенциальной формы в операторных грамматиках предшествования является такая самая левая подцепочка NjTj ... NiTiNi+1 , что
Tj–113EMBED PBrush1415Tj , Tj13EMBED PBrush1415Tj+1 , Ti–113EMBED PBrush1415Ti , Ti13EMBED PBrush1415Ti+1 .
Это определение очень похоже на соответствующее определение для простого предшествования. Основное различие состоит в том, что здесь из отношений исключены нетерминальные символы. Тем не менее, нетерминалы, находящиеся слева от Tj и справа от Ti , а также все нетерминалы лежащие между ними всегда принадлежат первичной фразе.
Пример 5.17. Для иллюстрации этого определения проведем разбор цепочки i((i(i), используя матрицу предшествования с рис. 5.19 б. На рис. 5.20 а представлено дерево разбора для этой цепочки, а в таблице на рис. 5.20 б показаны символы, к которым приводятся первичные фразы. (
13EMBED Word.Picture.81415
Как видно из рис. 5.20 б, основываясь на интуиции, первое i мы свернули к T, а второе i – к E, хотя единственное, что очевидно из отношений и грамматики – это свертка от i к M. Заметим, что при поиске самой левой первичной фразы, которую нужно редуцировать, нетерминальные символы вообще не принимаются во внимание.
# i ( ( i ( i ) #
13EMBED PBrush1415 13EMBED PBrush1415
# E ( (
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
Рис. 5.21
В компиляторах с каждым нетерминалом или операндом связана масса семантической информации: тип операнда, его адрес, признак принадлежности операнда к формальным параметрам и т.п. Семантические программы, которые выполняются при редукции первичных фраз, нуждаются только в семантической информации, связанной с нетерминальными символами, а не в самих символах. Например, для семантической подпрограммы связанной с редукцией фразы T(M, безразлично какая это фраза: T(M, M(T или M(M. Она использует только семантику нетерминалов. Да и само появление в грамматике арифметических выражений нетерминалов T и M не связано с какими либо семантическими целями; они позволили сделать грамматику однозначной и определить необходимое предшествование операторов. После того как это выполнено, в грамматике достаточно оставить один нетерминал с тем, чтобы максимально упростить редукцию. Так арифметическая грамматика, которую можно получить из грамматики, представленной в примере 5.16, если оставить в грамматике единственный нетерминал E, имеет вид:
E ( E(E(E(E((E)(i
Рисунок 5.21 иллюстрирует свертку цепочки #i((i(i)#, базирующуюся на полученной грамматике и отношениях операторного предшествования с рис. 5.19 б.

Конечно, семантические программы здесь могут стать более сложными, так как они должны будут проводить более подробную проверку операндов, чем в грамматиках простого предшествования. Но выигрыш здесь более существенен, так как распознаватель в целом становится значительно эффективнее. Отметим, что уменьшается объем памяти под матрицу предшествования, так как она содержит теперь отношения только для терминалов грамматики. Более того, распознаватель в явном виде не выполняет таких редукций, как E ( T или T ( M, поскольку T и M не первичные фразы и такого рода редукции не несут никакой семантической нагрузки.
Упражнения.

5.1. Построить отношения простого предшествования для грамматики с правилами S ( aSb(c(d.

5.2. Построить отношения простого предшествования и функцию предшествования, используя граф линеаризации, для грамматики с правилами S ( aSSb(c(d. Покажите этапы свертки фраз aacdbcb , ab , acb.


1
2
3
4

1
((

((
((

2
((

((


3
((
13EMBED PBrush1415
((


4
13EMBED PBrush1415

13EMBED PBrush1415


5.3. Постройте функцию предшествования, используя два известных метода для следующей матрицы:




5.4. Определить, является ли грамматика логических выражений, правила которой приведены ниже, грамматикой простого предшествования. Если она таковой не является, то преобразовать ее, построить для нее отношения простого предшествования и функцию предшествования, используя граф линеаризации.
E ( E or T(T
T ( T and M(M
M ( a((E) (not M

5.5. Постройте отношения простого предшествования для грамматики с правилами S ( 0S11(011 и терминалами { 0, 1 }. Если данная грамматика не является грамматикой простого предшествования, то преобразуйте ее к такой грамматике.

5.6. Постройте отношения операторного предшествования и функцию предшествования, используя граф линеаризации, для грамматики из упражнения 5.4.

5.7. Преобразуйте грамматику
S ( E
E ( T(TBE((E)
T( a(b(((z
B ( (((((((
к грамматике простого предшествования. Покажите этапы свертки фразы a((b+c).

Упражнения на программирование.

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

5.9. Реализовать программу, которая по заданной грамматике строит отношения простого предшествования. Если грамматика не является грамматикой простого предшествования, то программа должна пытаться привести ее к такому виду. Визуализировать поэтапное построение отношений предшествования и работу алгоритма стратификации.

5.10. Реализовать программу, которая определяет, является ли заданная грамматика операторной, строит отношения операторного предшествования, функцию предшествования и реализует алгоритм синтаксического анализа с использованием этих отношений и функции. Визуализировать процесс построения отношений, функции и работу алгоритма разбора.
6. ВВЕДЕНИЕ В СЕМАНТИКУ

Обычно в компиляторах и интерпретаторах каждому правилу грамматики, каждой альтернативе любого нетерминала ставятся в соответствие семантические подпрограммы. Эти подпрограммы выполняются при синтаксических редукциях по заданным правилам грамматики в восходящем разборе или отождествлении фрагмента входной цепочки с некоторой альтернативой продукции при разборе нисходящем. Как уже отмечалось в разделе 1, в задачи этих подпрограмм входит, в частности, контроль распознанных конструкций языка с точки зрения семантики и фиксация информации о конструкции в таблицах идентификаторов и констант, либо в промежуточной (внутренней) форме исходной программы. Прежде чем обсуждать принципы построения семантических программ рассмотрим структуру результатов их работы – различные виды внутренних форм исходной программы.
6.1. ВНУТРЕННИЕ ФОРМЫ ИСХОДНОЙ ПРОГРАММЫ

В тех случаях, когда исходный язык программирования достаточно сложен или к компилятору предъявляются повышенные требования (например, необходима машинно–независимая оптимизация исходной программы с целью получения более эффективного объектного кода), первоначально исходная программа переводится в некоторую внутреннюю форму, более удобную для простой машинной обработки. В большинстве внутренних представлений операторы располагаются в том порядке, в котором они должны выполняться, что существенно облегчает последующий анализ, интерпретацию или генерацию объектного кода. В этом разделе мы познакомимся с двумя наиболее часто используемыми внутренними формами.
Конечно, следует помнить, что каждое частное внутреннее представление зависит от исходного языка и от назначения транслятора. Например, в языке Паскаль нет необходимости включать во внутреннюю форму исходной программы оператор описания переменных VAR, так как вся информация, содержащаяся в нем, попадает в таблицу идентификаторов и никакие команды генерироваться не будут. Следует также решить насколько подробным должно быть начальное внутреннее представление. Включать ли в него, например, операции преобразования значений из одного типа в другой или это делать позже? Представлять ли цикл эквивалентной группой присваиваний, сравнений, условных и безусловных переходов, или его можно задать с меньшей степенью детализации и транслировать уже на фазе генерации кода? Вообще говоря, первоначальная форма программы лаконичнее и короче, но более полное представление открывает новые возможности для оптимизации и существенно облегчает последующие фазы трансляции.
Все внутренние представления программы обычно содержат элементы двух типов: операторы и операнды. Различия представлений состоят лишь в том, как эти элементы объединяются между собой. В дальнейшем мы будем использовать такие традиционные операторы, как +, (, (, MOD, DIV, (, AND, OR, >, <, = и т. п., а также БП (Безусловный Переход) и УПЛ (Условный Переход по Лжи), точнее условный переход в том случае, когда значение операнда (логического выражения) – ложь (FALSE, 0). Внутри компилятора, конечно же, все они представляются соответствующими лексемами или целочисленными кодами.
Операнды, с которыми мы будем иметь дело, – это простые идентификаторы (имена переменных, процедур и т.п.), константы, временные переменные, генерируемые самим компилятором, и переменные с индексами. Если все идентификаторы и константы хранить в общих таблицах, то за исключением индексируемых переменных, каждый операнд может представляться типом (кодом) таблицы (лексемы) и указателем на соответствующий элемент таблицы.
В поле операнда можно предусмотреть признак косвенной адресации и не заводить для этой цели отдельного оператора. То есть операнд может указывать, что данное значение есть адрес того значения, которое на самом деле требуется. Это значительно упрощает описание индексируемых переменных.
6.1.1. Польская инверсная запись

Вместо традиционного инфиксного представления арифметических и логических выражений в различных вычислителях часто используется польская инверсная запись (ПОЛИЗ), которая просто и точно указывает порядок выполнения операций без использования скобок. В этой записи, впервые примененной польским математиком Я. Лукашевичем, операторы располагаются непосредственно за операндами над которыми они выполняются в порядке их выполнения. Поэтому иногда ПОЛИЗ называют суффиксной, или постфиксной записью. Например, A(B записывается как AB(, A(B(C – как AB(C(, A((B(C(D) – как ABCD(((, а A(B(C(D – как AB(CD((.
В разделах 6.2, 6.4 будут обсуждаться методы перевода инфиксной записи в ПОЛИЗ с использованием синтаксического анализатора и семантических подпрограмм. Пока остановимся на простейших правилах, которые позволяют переводить в ПОЛИЗ вручную:
1). Идентификаторы и константы в ПОЛИЗе следуют в том же порядке, что и в инфиксной записи.
2). Операторы в ПОЛИЗе следуют в том порядке, в каком они должны вычисляться (слева направо).
3). Операторы располагаются непосредственно за своими операндами.

Таким образом, мы могли бы записать следующие синтаксические правила:
(операнд((((идентификатор(константа((операнд((операнд((оператор(
(оператор(((((((((((((

Унарный минус и другие унарные операторы можно представить двумя способами: либо записывать их бинарными операторами, то есть вместо (B писать 0(B, либо для унарного минуса можно ввести новый специальный символ, например @, и использовать еще одно синтаксическое правило ((операнд(((((операнд(@. Таким образом выражение A(((B(C(D) мы могли бы записать AB@CD(((.
С равным успехом мы могли бы ввести префиксную запись, где операторы стоят перед операндами. Таким образом, арифметическое выражение, а далее мы покажем, что не только его, но и любую управляющую конструкцию, можно представить в трех формах записи: префиксной, инфиксной (обычная запись, где операторы располагаются между операндами, а круглые скобки позволяют изменять приоритет операций) и постфиксной. Человек традиционно использует инфиксную запись, тогда как для автоматического вычисления выражений самым удобным способом представления является постфиксная запись или ПОЛИЗ. В разделе 6.1.2 будет показано, как проводятся такие вычисления, но прежде пополним ПОЛИЗ новыми операторами.
ПОЛИЗ расширяется достаточно просто. Нужно только придерживаться правила, что за операндами следует соответствующий им оператор. Так присваивание (переменная((((выражение( в ПОЛИЗе примет вид (переменная((выражение(((. Например, присваивание А((B(C(D(100 запишется в ПОЛИЗе как АBC(D100((((. О специфике вычисления данной бинарной операции будет сказано в разделе 6.1.2.
Индексированную переменную в ПОЛИЗе, а точнее вычисление ее адреса можно представить в виде:
идентификатор(индексные выражения(константа[ ,
где [ – обозначает знак операции вычисления индекса, идентификатор – имя (базовый адрес) индексированной переменной, а константа – количество индексов (мерность массива). Так переменную A[i,j+k] можно представить в виде Аijk+2[ .
Условный оператор
IF (выр( THEN (инстр 1( ELSE (инстр 2(
в ПОЛИЗе будет иметь вид:
(выр( ( m ( УПЛ (инстр 1( ( n ( БП (инстр 2( ,
где
( (выр( – логическое выражение (условие), которое может принимать значения – 0 (FALSE, ложь) или 1 (TRUE, истина);
( ( m ( – номер (место, позиция, индекс) символа ПОЛИЗа, с которого начинается (инстр 2(;
( УПЛ (Условный Переход по Лжи) – оператор с двумя операндами (выр( и ( m (, смысл которого состоит в том, что он изменяет традиционный порядок вычислений и осуществляет переход на символ строки ПОЛИЗа с номером ( m (, если (и только если) (выр( – ложно (равно 0);
( ( n ( – номер символа, следующего за (инстр 2(;
( БП (Безусловный Переход) – оператор с одним операндом ( n (, который также изменяет порядок вычислений по ПОЛИЗу и осуществляет переход на символ с номером ( n (.

Операторы условного и безусловного перехода, как в свое время было показано Дейкстрой, составляют основу внутреннего представления любой структурной управляющей конструкции (циклов типа FOR, WHILE, REPEAT, оператора выбора и т.п.). В рассматриваемых нами примерах потребуется только один условный оператор – УПЛ, хотя никто не мешает определить целую группу таких операторов (смотри, например, мнемонику команды условного перехода языка ассемблера для ПЭВМ с процессором Intel 8086).
Пример 6.1.
В заключении раздела рассмотрим пример перевода в ПОЛИЗ фрагмента программы, включающего условный оператор:
IF (x(y( AND (a( (0( THEN b:=a(b(c ELSE b:=(a(b((c; x:=a(b(c(d;
Ниже приведена строка ПОЛИЗа для этого оператора, где над символами указаны номера их позиций в полученной строке:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
xy( a0( (AND 19 УПЛ b a b c ( ( := 26 БП b a b ( c ( := x a b ( c d ( ( :=
(
6.1.2. Интерпретация ПОЛИЗа

С помощью стека арифметическое выражение в ПОЛИЗе может быть вычислено за один просмотр слева направо. В стеке будут находиться все операнды, которые встретились при просмотре строки ПОЛИЗа или получились в результате выполнения некоторых операций, но еще не использовались в вычислениях. Алгоритм обработки строки ПОЛИЗа состоит в следующем:

1). Если сканируемый символ идентификатор или константа, то соответствующее им значение заносится в стек и осуществляется переход к следующему символу строки ПОЛИЗа. Это соответствует использованию правила

((операнд((((идентификатор(константа

2). Если сканируемый символ унарный оператор, то он применяется к верхнему операнду в стеке, который затем заменяется на полученный результат. С точки зрения семантики это соответствует применению правила

((операнд(((( (операнд((оператор(.

3). Если сканируемый символ бинарный арифметический или логический оператор, то он применяется к двум верхним операндам в стеке, и затем они заменяются на полученный результат. Это соответствует использованию правила

((операнд(((( (операнд((операнд((оператор(.

Одно из исключений – бинарный оператор присваивания, который в ПОЛИЗе имеет вид (пер((выр(((. После его выполнения и (пер(, и (выр( должны быть исключены из стека, так как оператор ((’ не имеет результирующего значения. При этом в стеке должно находится не значение (пер(, а ее адрес, так как значение (выр( должно сохраняться по адресу (пер(.

4). Бинарный оператор УПЛ, операндами которого является уже вычисленное значение логического выражения <выр> и номер символа строки ПОЛИЗа – , также удаляет оба операнда из стека и не формирует в нем результата. Его действие состоит в том, что он анализирует значение выражения и если оно равно 1 (истинно), то следующим сканируется символ, расположенный сразу за УПЛ. Если же значение выражения – 0 (ложно), то мы перемещаемся по строке ПОЛИЗа к символу, позиция которого указана в операнде .

5). Унарный оператор БП, удаляя свой параметр, – номер символа ( n ( из стека, просто переводит сканирование к указанной позиции ПОЛИЗа.
Пример 6.2.
На рис. 6.1 показан пример интерпретации строки ПОЛИЗа AB@CD((( , полученной из инфиксного выражения A(((B(C(D). Значения в стеке отделены друг от друга вертикальной чертой.

13EMBED Word.Picture.81415(

6.1.3. Генерирование команд по ПОЛИЗу

При генерировании команд по ПОЛИЗу также используется стек, в котором вместо значений сохраняются адреса или символические имена операндов, которые в процессе работы алгоритма заменяются на имена ячеек временной памяти для результатов промежуточных вычислений. Основу формирования команд составляют кодовые продукции, о которых мы еще поговорим в Главе 8 данного пособия. Здесь же отметим только, что кодовая продукция – это набор машинных команд, который соответствует отдельному оператору ПОЛИЗа.
Алгоритм генерации команд состоит в том, что строка ПОЛИЗа просматривается один раз слева направо и при встрече операндами (идентификаторами или константами) их имена (символические значения, адреса) заносятся в стек. При встрече с оператором из таблицы выбирается соответствующая ему кодовая продукция. В нее подставляются имена (адреса) операндов, извлеченные из стека, а так же сформированное имя (адрес) результата. Последнее имя замещает операнды данного оператора в стеке, а сформированная продукция помещается в выходной файл. В примерах, рассмотренных ниже мы, конечно же, не будем представлять сгенерированные команды в двоичном машинном коде. Человеку не свойственно хорошо мыслить в терминах чисто цифрового языка машины. Все примеры пока будут даваться с использованием языка ассемблера, – по сути дела машинного языка, ориентированного на человеческое восприятие. Здесь и далее мы откажемся от традиционного определения своего собственного языка ассемблера виртуальной машины, а будем использовать широко распространенный язык ассемблера для IBM PC (процессор Intel 8x86). Если Вы еще не знакомы с этим языком, то надеемся, что комментарии к командам позволят понять их смысл.
Пример 6.3.
На рис. 6.2 показан пример генерирования команд для уже известной нам строки ПОЛИЗа AB@CD(((.

13EMBED Word.Picture.81415Полученная в примере программа далека от оптимальной. Если поменять местами операнды в симметричных операциях (здесь для умножения и сложения), то программа примет вид, представленный на рис. 6.3. Очевидно, что выделенные пары команд можно без ущерба исключить из программы вместе с временными переменными М2 и М3. Оптимизирующий компилятор вполне способен проделать эту работу, реализуя алгоритм “просмотра через щель” пары соседних команд.
13EMBED Word.Picture.81415(
Пример 6.4.
На рис. 6.4 представлена программа, в которую может вылиться строка ПОЛИЗа из примера 6.1.
13EMBED Word.Picture.81415
13EMBED Word.Picture.81415
Если поручить искушенному программисту оттранслировать этот же фрагмент вручную, то он, конечно же, получит более эффективную программу, например ту, которая представлена на рис. 6.5. Уверяю Вас, что это еще не предел. Отметим только, что компилятор, предусматривающий фазы машинно–независимой оптимизации промежуточных форм программы и машинно–зависимой оптимизации при генерации кода (см. об этом в Главах 7 и 8) получит объектный код, весьма приближенный к приведенному ниже. (Объем программы сократился в 3 раза, а скорость ее выполнения возросла более чем в 3 раза).

13EMBED Word.Picture.81415
6.1.4. Тетрады и триады

ПОЛИЗ является прекрасной иллюстрацией для внутренней (промежуточной) формы представления исходной программы. Алгоритмы интерпретации ПОЛИЗа и генерации команд по ПОЛИЗу предельно просты, но с точки зрения машинно–независимой оптимизации эта форма не совсем удобна. Идеальными здесь являются представления бинарных операций в виде тетрад (четверок):
(оператор(, (операнд 1(, ( операнд 2(, (результат(
где (операнд 1( и (операнд 2( специфицируют аргументы, а (результат( – результат выполнения оператора над аргументами. Таким образом, A(B мы могли бы представить, как
(, A, B, M
где M – некоторая временная переменная, которой присваивается результат вычисления A(B. Аналогично A(B(C(D представляется в виде следующей последовательности тетрад:
(, A, B, M1
(, C, D, M2
(, M1, M2, M3

Важно отметить, что в отличии от обычной инфиксной записи тетрады располагаются в том порядке, в котором они должны выполняться. Унарные операторы также оформляются в виде тетрад, но (операнд 2( остается в них пустым. Так, вместо (A появится тетрада “(, A, , M”, что означает “присвоить M значение (A”. Унарный минус, также как и в ПОЛИЗе, мы могли бы заменить другим символом (кодом), чтобы отличать его от бинарного.
Кроме традиционной арифметики в виде тетрад можно представить и любой другой оператор, имевший место в польской записи. Оператор присваивания A:=B представим в виде тетрады “ :=, B, , A ”, а оператор УПЛ запишется в виде тетрады “ УПЛ, <выр>, , ”, где – номер тетрады, на которую будет осуществляться переход, если значение логического выражения (<выр>) – равно нулю (ложно).
К недостаткам тетрад можно отнести большое количество временных переменных, требующих описания. Эти проблемы полностью отпадают при использовании триад (троек), которые имеют следующую форму:
(оператор( (операнд 1(, ( операнд 2(
В триаде нет поля для результата. Если позднее какой–либо операнд окажется результатом данной операции, то он будет непосредственно на нее ссылаться (на операцию, а точнее соответствующую триаду). Например, выражение A(B(C будет представлено следующим образом:
(1) ( B, C
(2) ( A, (1)
Здесь (1) – ссылка на результат первой триады, а не константа, равная 1. Выражение 1(B(C будет записываться так:
(1) ( B, C
(2) ( 1, (1)
Конечно, в компиляторе мы должны отмечать этот тип операнда, используя новый код в первом байте его представления. Триада занимает меньше места, чем тетрада, но следует помнить, что при работе с триадами нам придется хранить описания результатов, значения которых в дальнейшем еще потребуются.
Достоинства использования тетрад и триад с точки зрения машинно–независимой оптимизации по сравнению с ПОЛИЗом очевидны. Представляя их в виде таблицы (односвязного или двухсвязного списка), тетрады или триады можно легко переставлять или удалять “лишние”.
6.2. СЕМАНТИЧЕСКИЕ ПОДПРОГРАММЫ ПЕРЕВОДА ИНФИКСНОЙ ЗАПИСИ В ПОЛИЗ И АСПЕКТЫ ИХ РЕАЛИЗАЦИИ

Идею постановки соответствия семантических программ каждому правилу грамматики мы рассмотрим на примере грамматики арифметических выражений и семантических действий по переводу инфиксной записи в польскую. Мы предполагаем, что всякий раз, когда в сентенциальной форме ( найдена основа ( и ее можно привести к нетерминалу U, синтаксический распознаватель вызывает семантическую подпрограмму, связанную с правилом U ( ( . Подпрограмма осуществляет семантическую обработку символов в ( и выдает ту часть ПОЛИЗа, которая имеет отношение к ( . Здесь нам совершенно безразлично, какой восходящий распознаватель используется в данный момент. Важно помнить, что разбор канонический и если в основе ( встречается нетерминал V, то часть польской цепочки связанной с редукцией к V уже была сгенерирована.
Будем предполагать, что генерируемая строка ПОЛИЗа хранится в одномерном массиве P, а целая переменная p (вначале она имеет значение 1 или 0) содержит индекс, указывающий на первый свободный элемент массива P. Каждый элемент массива P может содержать один символ (идентификатор, константу или оператор). Предположим также, что семантические подпрограммы имеют доступ к символам основы C(0( C(i(, находящимся в синтаксическом стеке C, который используется распознавателем (см., например, раздел 5.2.1).
Дальнейшие рассуждения будем строить, используя грамматику простого предшествования арифметических выражений из примера 5.15, для которой матрица предшествования представлена на рис. 5.18.
Рассмотрим семантическую подпрограмму, связанную с правилом Е1 ( E2 ( T. Если она вызвана, то мы можем считать, что польская запись для E2 и T уже получена. При этом массив P содержит
(код для E2(( код для T(
поскольку E2 расположено левее T. Правее T код еще не генерировался и справа от T в исходной программе расположены только терминалы, так как мы имеем дело с каноническим разбором слева направо. Следовательно, все, что требуется от данной семантической программы, – это занести в ПОЛИЗ знак “(”. В результате инфиксная запись E2 ( T преобразуется в польскую запись E2 T ( . Схематично такую семантическую программу (а точнее семантические действия) можно представить в виде:
P [ p ] (( ( ’ ( INC(p).
Семантическая подпрограмма для правила M ( i , где под i понимается произвольный идентификатор (или константа), также предельно проста. В соответствии с правилами польской записи (см. раздел 6.1.1) идентификаторы и константы предшествуют своим операторам; более того, они встречаются в том же порядке, что и в исходной инфиксной записи. Все что необходимо сделать, – это занести идентификатор (константу) в массив P. Семантическая программа при этом имеет вид:
P ( p ( (( C ( i ( ; INC ( p )
где C[i] – верхний символ стека.
Семантическая подпрограмма, связанная с правилом M ( (E) вообще пуста (не делает ничего), так как скобок в ПОЛИЗе нет, а для E польская запись уже сгенерирована.
Совершенно аналогично строятся и остальные семантические подпрограммы, которые представлены на рис. 6.6 вместе с правилами грамматики простого предшествования для арифметических выражений. Грамматика из примера 5.15 дополнена правилом S ( E1 , что делает грамматику арифметических выражений G пополненной грамматикой, полученной из G. Это новое начальное правило добавляется для того, чтобы связанная с ним свертка сигнализировала анализатору о завершении разбора и допуске цепочки.
13EMBED Word.Picture.81415
Одну и ту же семантическую подпрограмму можно использовать для нескольких правил, схожих по смыслу. Так например, для правил 2, 3, 7 и 8 семантические действия можно заменить на один единственный фрагмент:
P (p( (( C (i (1(; INC(p(
где C (i (1( – элемент синтаксического стека.
Вообще говоря, с каждым нетерминалом может быть связано несколько семантических атрибутов. Однако заметим, что после редукции с применением какого либо правила, например, E1 ( E2 ( T, вся информация, связанная с E2 и T становится не нужной. Обычно нужна только та семантическая информация, которая связана с нетерминалами, входящими в текущую сентенциальную форму. Идеальным местом размещения текущей сентенциальной формы в восходящих алгоритмах разбора является стек. Параллельно с синтаксическим стеком C могут работать несколько семантических стеков C1, C2, (см. рис. 6.7), и семантические подпрограммы должны иметь доступ к каждому из них.
13EMBED Word.Picture.81415
Если, например, C[i] содержит символ E, то C1[i], C2[i], могут содержать тип выражения E, адрес результата выражения во время выполнения откомпилированной программы и т. д. На практике обычно вся эта информация храниться в одном стеке, каждый элемент которого представляет собой запись с отдельными полями для хранения синтаксического символа и его семантических атрибутов.
Каждую семантическую подпрограмму можно оформить в виде отдельной процедуры, но тогда синтаксический анализатор должен знать имя, а точнее адрес каждой процедуры, что ведет ко многим технологическим проблемам. Проще перенумеровать правила грамматики и написать одну–единственную процедуру, скажем SEMANTICS, при вызове которой номер правила передается в качестве аргумента. Когда синтаксический анализатор обращается к процедуре SEMANTICS, в качестве параметра выступает номер правила, определяющего редукцию. На рисунке 6.8 это иллюстрируется фрагментом программы на языке Паскаль, которая генерирует польскую инверсную запись для арифметического выражения в соответствии с семантическими подпрограммами с рисунка 6.6.
13EMBED Word.Picture.81415
Стеки и строка ПОЛИЗа выступают здесь в роли глобальных переменных. В других языках программирования, где конструкция CASE отсутствует, для реализации этой процедуры можно использовать переключатель или вычислимое GO TO.
6.3. СЕМАНТИЧЕСКИЕ ПОДПРОГРАММЫ ДЛЯ ПЕРЕВОДА В ТЕТРАДЫ

Попытаемся провести разбор выражения a((b(c), используя грамматику простого предшествования для арифметических выражений (см. пример 5.15 и рис. 5.18), и сгенерируем тетрады
(, b, c, M1
(, a, M1, M2
по ходу дела составляя семантические подпрограммы. Логичнее всего генерировать первую тетраду в семантической подпрограмме для правила E ( E(T1. Итак, будем выполнять восходящий разбор по алгоритму Вирта–Вебера до того момента, пока не возникнет необходимость в применении этого правила. На рис. 6.9 а приводятся сентенциальные формы, порождаемые на каждом шаге (подчеркнута основа), а на рис. 6.9 б показано частичное семантическое дерево, построенное к этому моменту.
На следующем шаге E(T1 приводится к E; одновременно с этим семантическая подпрограмма должна выдать тетраду. К сожалению, мы не можем ее построить, так как текущая сентенциальная форма не содержит информации об именах операндов E и T1. Эта информация была потеряна, когда выполнялись редукции M ( b и M ( c.
При получении ПОЛИЗа у нас не возникало подобных затруднений, так как при выполнении редукций M ( b и M ( c имена b и c заносились в выходную цепочку. Очевидно, при генерировании тетрад необходимо где–то хранить имена идентификаторов (значения констант) до момента их использования и это сохранение должна выполнять подпрограмма, связанная с правилом M ( i , где i – произвольный идентификатор или константа. И лучшим местом такого хранения, конечно же, является стек. Назовем его вспомогательным стеком и обозначим через V, а индекс m (вначале он равен нулю) будет указывать на вершину этого стека.
13EMBED Word.Picture.81415
Таким образом, семантическая подпрограмма, связанная с правилом M ( i будет иметь вид:
INC(m); V[m]((C[i]
Напомним, что C[i] – вершина синтаксического стека из алгоритма восходящего разбора, рассмотренного в разделе 5.2.1.
Точно также как и при переводе в ПОЛИЗ здесь будут отсутствовать семантические действия, связанные с правилами T(M, T1(T, E(T1 и M((E). С остальными же правилами будет связана процедура формирования тетрады с четырьмя параметрами – PutTetrad(O, X, Y, Z), где O – операция, X и Y – операнды, а Z – результат. В качестве результата Z будут использоваться генерируемые имена M1, M2, ( , обозначающие подвыражения. Для этих целей будет использоваться счетчик n, который вначале имеет нулевое значение. Таким образом, семантическая подпрограмма связанная с правилом E ( E(T1 будет иметь вид:
INC(n); PutTetrad(+’, V[m–1], V[m], Mn); DEC(m); V[m]:= Mn;
Суть семантических подпрограмм такого сорта состоит в том, что генерируется тетрада, где в качестве операндов используется два верхних значения из временного стека V, изначально хранящие имена идентификаторов и значения констант, помещенных туда при редукции по правилу M ( i . После формирования тетрады два верхних значения из стека V (для бинарной операции) заменяются на имя результата тетрады. Полный перечень семантических действий по переводу в тетрады, связанный с правилами грамматики арифметических выражений, представлен на рис. 6.10.
13EMBED Word.Picture.81415
6.4. МЕТОД ЗАМЕЛЬСОНА–БАУЭРА ДЛЯ ПЕРЕВОДА В ПОЛИЗ И ТЕТРАДЫ

Арифметические выражения чаще всего встречались при практическом программировании, особенно на ранних стадиях использования вычислительной техники. Поэтому для них немецкими математиками К. Замельсоном и Ф. Бауэром был предложен достаточно простой метод перевода инфиксных арифметических выражений в ПОЛИЗ с одновременным контролем отдельных ошибок. Предложенный ими метод не использует грамматик в явном виде но в основе приоритетов операций о которых речь пойдет ниже лежат традиционные функции предшествования.

Алгоритм 6.1. Метод Замельсона–Бауэра для перевода инфиксных арифметических выражений в ПОЛИЗ.

Вход. Строка, содержащая инфиксное арифметическое выражение.
Выход. Польская инверсная запись исходного выражения.
Метод. Суть метода состоит в том, что для каждого знака–разделителя (операции, скобки и т.п.) вводят два числа: сравнительный приоритет – PC и магазинный приоритет – PM. Исходное выражение просматривается один раз слева направо. При этом:
Идентификаторы и константы переписываются в выходную строку ПОЛИЗа.
При обнаружении разделителя его сравнительный приоритет PC сравнивается с магазинным приоритетом PM разделителя из вершины магазина операций. Если PC ( PM, то разделитель входной строки помещается в магазин (разделитель из исходной строки поступает в магазин и в том случае, когда магазин пуст). Если PC ( PM, то символ извлекается из магазина и записывается в выходную строку ПОЛИЗа. Далее повторяется пункт (2) все для того же входного символа.
Открывающая скобка – (’ имеет самый высокий сравнительный приоритет и поэтому всегда поступает в магазин. Закрывающая скобка – (’в ПОЛИЗ и магазин не записывается и поэтому магазинный приоритет для нее не важен. По закрывающей скобке входной строки из магазина извлекаются все операции и переписываются в строку ПОЛИЗа вплоть до первой открывающей скобки в магазине. Открывающая скобка также извлекается из магазина, но, как и закрывающая, в ПОЛИЗ не переписывается. После этого осуществляется переход к следующему символу входной строки. Ясно, что если для закрывающей скобки входной строки в магазине открывающая скобка не будет обнаружена, то это послужит сигналом о синтаксической ошибке.
13EMBED Word.Picture.81415
По окончании входной строки содержимое магазина переписывается в строку ПОЛИЗа. (Если при этом в магазине останутся открывающие скобки, то это также является признаком ошибки в записи исходного выражения). (
На рис. 6.11 представлена таблица приоритетов операций и скобок для упрощенных арифметических выражений, а на рис. 6.12 разобран по шагам пример перевода в ПОЛИЗ инфиксного выражения по методу Замельсона–Бауэра. (На рис. 6.12 вершина магазина операций расположена справа).

13EMBED Word.Picture.81415
Предложенный метод можно модифицировать таким образом, что он позволит обрабатывать и операции сравнения, логические операции, операторы присваивания, управляющие конструкции типа IF–THEN–ELSE, WHILE–DO и т. п. Таблица приоритетов для этого случая представлена на рис. 6.13. То, что в ней приоритеты операций изменены, по сравнению с рис. 6.11, роли не играет. Важны отношения между значениями приоритетов, а не сами значения. Обработка скобок, к которым здесь можно отнести и операцию присваивания – ((’, и знак конца оператора – (’, и элементы управляющих конструкций (IF, THEN, ELSE, WHILE и т.п.) выполняется по особым алгоритмам, часть из которых была предложена в работе Л.Ф. Штернберга [17]. При дальнейших рассуждениях, для того чтобы упростить изложение, будем считать, что любая управляющая конструкция, будь то оператор IF–THEN–ELSE или WHILE–DO, не содержит BEGIN, но всегда завершается терминалом END, независимо от количества операторов в отдельной ветви или теле цикла. (Смотрите, например язык МОДУЛА–2).
13EMBED Word.Picture.81415
Операция присваивания ((’ играет роль открывающей скобки для символа (’ (или управляющей конструкции, типа ELSE или END, если символ (’ перед ними необязателен). По (’ из магазина выталкивается все вплоть до операции присваивания и она сама извлекается из магазина и переписывается в ПОЛИЗ. При этом, если символу присваивания в магазине предшествуют “открывающие скобки” иного рода, то это является признаком ошибки. Пример перевода в ПОЛИЗ группы операторов присваивания представлен на рис. 6.14.
13EMBED Word.Picture.81415
Как и всякая открывающая скобка, ключевое слово IF поступает в магазин. Логическое условие, следующее за IF, обрабатывается по общему алгоритму. Терминал THEN в соответствии со своим приоритетом выталкивает из магазина все вплоть до IF. (Если IF не обнаружено, или ему предшествуют другие “открывающие скобки” то это говорит об ошибке в исходном выражении.) Терминал IF также выталкивается из магазина и в ПОЛИЗ записывается операция УПЛ, где метка перехода пока не определена. Ключевое слово THEN помещается в магазин вместо IF вместе с номером позиции в строке ПОЛИЗа, соответствующей метке для УПЛ. В магазине THEN “ведет себя” как открывающая скобка для соответствующей “закрывающей скобки” (для ELSE или END).
Терминал ELSE выталкивает из магазина в ПОЛИЗ все вплоть до THEN, контролируя возможные ошибки. При извлечении из магазина THEN в строку ПОЛИЗа записывается операция БП с неопределенной меткой. На место метки оператора УПЛ, номер позиции которой был записан вместе с извлекаемым THEN, записывается значение индекса по строке ПОЛИЗа, соответствующее позиции, следующей за сформированным БП. Ключевое слово ELSE помещается в магазин вместе с номером позиции для метки операции БП и играет роль “открывающей скобки” для END данного IF – THEN – ELSE.
Терминал END - это “закрывающая скобка” для ELSE, THEN или DO в операторе WHILE - DO, о котором речь пойдет ниже. При встрече с END в исходной строке из магазина в ПОЛИЗ переписывается все вплоть до соответствующей “открывающей скобки”. Последняя также извлекается из магазина, а индекс, указанный вместе с ней, делает тривиальным процесс формирования метки в соответствующем операторе БП или УПЛ.
На рис. 6.15 представлены основные шаги перевода в ПОЛИЗ полного оператора IF - THEN - ELSE, а на рис. 6.16 - перевод в инверсную запись оператора ветвления, в котором ветвь ELSE отсутствует. (Чтобы подчеркнуть отдельные моменты перевода над строкой ПОЛИЗа в ряде случаев отмечаются номера позиций символов строки, а строчные буквы в ключевых словах используются для более компактной записи.)
13EMBED Word.Picture.81415
13EMBED Word.Picture.81415
На рис. 6.17 представлены отдельные шаги перевода в ПОЛИЗ простейшего оператора цикла.
13EMBED Word.Picture.81415
Большинство шагов связанных с арифметикой и операторами присваивания здесь опущены, и внимание концентрируется на представлении самого цикла. Терминал WHILE переписывается из исходной строки в магазин с указанием очередной позиции строки ПОЛИЗа (while, 4). Начиная с этой позиции (позиции 4) будет в дальнейшем записано логическое условие для данного цикла и именно на эту позицию необходимо будет передать управление по завершении цикла. “Закрывающей скобкой” для WHILE, характеризующей завершение условия будет терминал DO. Он традиционно “вытолкнет из стека и перепишет в ПОЛИЗ” все операции предшествующие WHILE. В строку ПОЛИЗа запишется также операция УПЛ с неопределенной меткой, а WHILE в магазине заменит DO, который вместе с позицией начала условия сохранит и позицию для последующего формирования метки перехода для УПЛ (в примере с рис. 6.17 - do, 4, 7’). Это DO станет “открывающей скобкой” для терминала END, завершающего цикл и приводящего, кроме обычных действий по переносу операций из магазина в ПОЛИЗ, формирование операции безусловного перехода на начало цикла - БП, метка перехода для которого уже известна и хранится вместе с DO в магазине. Кроме того, по END в сформированную ранее операцию УПЛ из начала цикла в качестве метки заносится значение индекса конца цикла (позиция в строке ПОЛИЗа, следующая за БП).
Читателям пособия предлагается в качестве упражнения подумать над алгоритмами перевода в ПОЛИЗ циклов с параметром FOR - TO - BY - DO, циклов REPEAT - UNTIL, операторов выбора CASE или переключателей SWITCH и операций индексирования переменных.
Хорошим упражнением может стать и разработка алгоритмов и программ перевода арифметических выражений, да и других элементов программ в тетрады, с использованием обсуждаемых здесь подходов. Сама структура алгоритма останется неизменной. Отличие состоит в том, что имена операндов (или их значения для констант) вначале будут поступать во вспомогательный стек имен (в дальнейшем имена операндов в этом стеке будут замещаться на формируемые имена для промежуточных результатов). Как только будет выполняться условие PC ( PM и операция всплывет из магазина может формироваться тетрада, соответствующая данной операции и использующая в качестве операндов элементы из вспомогательного стека. Более того, если задачи оптимизации кода перед нашим компилятором не стоят, то никто не мешает нам вместо тетрады генерировать фрагмент машинного кода, соответствующий извлекаемой из магазина операции. Именно такой подход практикуется при создании однопроходных компиляторов.

6.5. НЕЙТРАЛИЗАЦИЯ ОШИБОК

Завершая обсуждение методов синтаксического и семантического анализа, заметим, что в процессе анализа необходимо выявить по возможности максимальное количество ошибок в исходной программе. То есть компилятор должен уметь не только локализовать (определить место), идентифицировать (указать причину), но и нейтрализовать ошибки. Нейтрализация ошибки – это процесс определения того, каким образом можно продолжить анализ исходной программы после обнаружения ошибки.
В ряде компиляторов, разработанных в 60-х – 70-х годах (Алгол, PL/C), пытались “исправлять” ошибки, генерировать команды и даже выполнять полученную программу, независимо от количества обнаруженных ошибок. На первый взгляд кажется, что это приведет к необоснованной потере времени, но надо учитывать технологию подготовки и отладки программ того времени. Программа набивалась на перфокарты или перфоленту, сдавалась на машину, запускалась в пакетном режиме и в результате через некоторое время разработчик получал листинг – протокол трансляции и выполнения программы. В этом случае для программиста преимущества очевидны, – уменьшается количество отладочных пусков, поскольку независимо от количества обнаруженных синтаксических ошибок предоставляется дополнительный шанс найти еще и логическую, алгоритмическую ошибку на этапе выполнения программы. Одна ошибка в пробивке перфокарты не станет препятствием для выполнения программы, и с большой вероятностью она будет исправлена разумным образом.
Техника исправления ошибок довольно сложна и опирается на нейтрализацию семантических и синтаксических ошибок, которые будет обсуждаться в разделах 6.5.2 и 6.5.3. Единственный метод, который можно легко объяснить – это метод исправления орфографических ошибок, который мы и рассмотрим в первую очередь.
6.5.1. Исправления орфографических ошибок

Существует несколько случаев, когда компилятор может сомневаться в правильности написания идентификатора:
1. Часто во время синтаксического анализа бывает известно, что следующий символ должен быть словом из некоторого набора служебных (ключевых) слов языка. Если вместо него оказался идентификатор, следует проверить, не служебное ли это слово, искаженное орфографической ошибкой. Именно с этим случаем мы встречаемся в языках типа BASIC, где любой оператор начинается с ключевого слова. Другим примером может служить логическое выражение Фортрана и других языков, в котором должны встречаться операции типа AND, OR, NOT, GE, LE и т.п. Можно было бы также следить за ошибочной “конкатенацией”. Например, если ожидается BEGIN, а встретился BEGINA, то его надо заменить на BEGIN A.
2. Предположим, во время семантического анализа обнаружилось, что идентификатор, определенный как метка, используется в контексте, где может встретиться только имя массива. Тогда этот идентификатор, скорее всего, неправильно написан и его нужно сравнить с именами описанных массивов.
3. Нередко из-за ошибки в написании идентификатор в программе встречается только один или два раза. Ему либо не присваивается никакого значения, либо его значение нигде не используется. Это легко обнаружить, если в каждом элементе таблицы идентификаторов имеется счетчик присваиваний и счетчик обращений к идентификатору. Когда окончен синтаксический и семантический анализ, просматривается таблица идентификаторов и все элементы, в которых один из счетчиков равен нулю, становятся кандидатами для исправления орфографической ошибки. Чаще всего такие ситуации возникают в языках программирования, где описание переменных необязательно.

Далее возникает вопрос, какой из идентификаторов был неправильно написан. В методе Д. Фримана, предложенном в 1963 году, использовалась сложная оценочная функция, вычисляющая “вероятность” того, что один идентификатор является искажением другого. В этой функции использовалась информация о количестве совпадающих букв и о количестве совпадающих букв после одной или двух их перестановок. Учитывались также часто встречающиеся ошибки в пробивке перфокарт (цифра 0 вместо буквы O или цифра 1 вместо буквы l или I ).
Позднее этот метод был заменен более эффективным, но менее мощным методом, в основу которого положен тот факт, что около 80 процентов всех орфографических ошибок попадает в один из следующих четырех классов:

1) неверно написана или пробита одна буква;
2) пропущена одна буква;
3) вставлена одна лишняя буква;
4) две соседние литеры переставлены местами.

Суть этого метода состоит в следующем:

1. В таблице идентификаторов выделяется подмножество, среди которого и надо искать искаженный идентификатор. Для этого можно использовать контекст, в котором встретился ошибочный идентификатор, и принять во внимание длину идентификатора. Если в ошибочном идентификаторе n литер, то его нужно сравнивать только с идентификаторами, состоящими из n-1, n и n+1 символов. Если n ( 2, то нет смысла искать орфографическую ошибку.
2. Затем нужно определить, какой идентификатор из выбранного подмножества можно превратить в заданный искаженный идентификатор, используя одно из четырех указанных выше преобразований.
6.5.2. Нейтрализация семантических ошибок

Под семантическими ошибками, обнаруженными в процессе компиляции, понимаются ошибки, связанные с некорректным использованием идентификаторов и выражений. При этом важно уметь подавлять лишние или повторные сообщения об ошибках.
Во многих случаях, когда неправильно используется идентификатор, достаточно вывести сообщение об ошибке и продолжить компиляцию. Например, если анализируется оператор A((B; где A – переменная типа REAL, а B – переменная типа BOOLEAN, то мы можем просто сообщить о несовместимости типов для A и B в операторе присваивания и продолжить работу, так как нет необходимости связывать с нетерминалом (инструкция присваивания( какую либо “семантику” и, следовательно, лишние сообщения выводится не будут.
Рассмотрим теперь переменную с индексами A[e1, (, en], когда с идентификатором связана некоторая “структура”. Положим, что в программе A не объявлено, как массив. Тогда будет выдано сообщение об ошибке и продолжится грамматический разбор индексов. После окончания разбора индексов их число будет сравниваться с размерностью массива, которая указана в элементе таблицы идентификаторов для A. Так как A не имя массива, то появится второе сообщение об ошибке. Такую ошибку можно довольно просто нейтрализовать и подавить лишние сообщения, относящиеся к данному идентификатору, если заменить его в исходной программе “корректным” идентификатором. В таблицу идентификаторов заносится новый корректирующий элемент с правильными, насколько это возможно, атрибутами. Программе, формирующей сообщения об ошибке, передается в качестве параметра указатель элемента таблицы идентификаторов, который вызвал ошибку. Программа проверяет, не является ли этот элемент корректирующим идентификатором, вставленным для нейтрализации ранее обнаруженной ошибки, и если это так, то лишнее сообщение об ошибке не формируется.
Если неописанный или неверно описанный идентификатор встречается в нескольких местах программы, то повторные сообщения можно легко устранить, используя подход, приведенный выше. Можно также с каждым элементом таблицы идентификаторов, связать список элементов, описывающих все разновидности некорректного использования этого идентификатора. Если идентификатор используется неправильно, то нужно просмотреть этот список, и если ранее встречалась такая же некорректность, то выводить повторное сообщение об ошибке не следует. Если раньше такой вид некорректности использования не встречался, то выдается сообщение об ошибке и эта новая некорректность добавляется в список.
Если у программиста возникнет желание точно знать все места некорректного использования идентификатора, то количество сообщений тоже можно сократить, добавив к каждому элементу списка, описывающего некорректное использование идентификатора, перечень номеров строк исходной программы, где эта некорректность встречается. После того как анализ завершен, каждое сообщение можно вывести только один раз, сопроводив его перечнем номеров строк, в которых встретилась данная ошибка.
6.5.3. Нейтрализация синтаксических ошибок

На любом этапе грамматического разбора исходная программа имеет следующий вид x T t, где x – обработанная часть, T – следующий сканируемый символ, а t – остальная часть исходной программы. Предположим, что встретилась ошибка. При нисходящем разборе это означает, что построено частичное дерево, опирающееся на x, но его нельзя расширить так, чтобы оно опиралось и на T. При восходящем разборе может оказаться, что либо между хвостом x и символом T не определено отношение предшествования, либо никакой хвост x не является основой и т.п.
Здесь мы должны решить, как изменить программу, чтобы “подправить” ошибку. Проще всего воспользоваться одним из следующих способов (или их комбинацией):

1. Исключить T и попытаться продолжить разбор.
2. Вставить цепочку q, состоящую из терминалов, между x и T (получится цепочка xqTt) и начать разбор, используя голову цепочки qTt. Эта вставка позволит целиком обработать qT, прежде чем возникнет другая ошибка.
3. Вставить цепочку q между x и T (получится цепочка xqTt), но разбор начать с T. (При восходящем разборе q необходимо поместить в стек.)
4. Исключить несколько последних символов из цепочки x.

Способы 1 и 2 предпочтительнее для нейтрализации, так как они не меняют содержимое стека, а значит, и не требуют изменения семантики. Так как цепочка x уже обработана, с ней, возможно, уже связана семантическая информация. Добавление q к x или выбрасывание части x означает, что нужно соответствующим образом изменить и семантическую информацию, а сделать это совсем не просто.
Казалось бы, что можно добавить “ошибочные” правила в грамматику и заранее принять меры против ошибок. Например, мы могли бы добавить правило
(присваивание( ( (((выражение(
и, таким образом, предусмотреть случай, когда переменная в левой части присваивания опущена. Однако грамматика при этом быстро разрастется. Гарантии, что мы учли все ошибочные ситуации нет, да и саму грамматику очень трудно привести к виду, который приемлем для детерминированного грамматического разбора.
Рассмотрим метод нейтрализации ошибок при нисходящем разборе, предложенный в 1963 году Е. Айронсом, на примере грамматики в расширенной форме Бэкуса-Наура:
P ( A;
A ( i((E
E ( T{(T}
T ( F{(F}
F ( i((E)
Напомним, что элемент в фигурных скобках здесь обозначает итерацию.
Предположим, что грамматический разбор выполняется без возвратов. Это означает, что либо параллельно выполняются альтернативные варианты разбора и отбрасываются те из них, которые привели в тупик, либо для выбора подходящего правила на каждом шаге используется контекст.
На любом шаге разбора мы имеем дело с одним или несколькими синтаксическими деревьями, в которых есть несколько неполных кустов. Например, на рис. 6.18 а, сплошными линиями показано, как выглядит частично построенное дерево, а пунктирными – как можно было бы дополнить кусты с именами P и E.
Неполный куст U соответствует применению правила
U(X1X2(Xi-1Xi(Xn
где X1X2(Xi-1 – построенная, а Xi(Xn – недостающая часть куста. На рис. 6.18 а неполный куст с именем P соответствует применению правила P(A; , а “(” – недостающая часть куста. Неполный куст E соответствует применению правила E ( T{(T}. Чтобы дополнить куст необходим нетерминал T, за которым следует любое количество цепочек “(T”. Недостающей частью, следовательно является T{(T}.
13EMBED Word.Picture.81415
Эти недостающие части кустов играют большую роль при нейтрализации ошибки. По существу они говорят нам, что может или что должно появиться далее в исходной программе.
Предположим теперь, что во время разбора возникла ошибка, т.е. никакое частично построенное дерево не может строиться дальше. Тогда выполняются следующие действия по нейтрализации ошибки:
1. Строится список L из символов недостающих частей неполных кустов.
2. Головной символ T в цепочке Tt проверяется и отбрасывается (при этом каждый раз получается новая цепочка Tt) до тех пор, пока не найдется символ T, такой, что U(( T( для некоторого U(L (либо U=T, либо U(( T().
3. Определяется неполный куст, который на шаге 2 стал причиной появления символа U в списке L.
4. Определяется терминальная цепочка q, такая что, если ее вставить непосредственно перед T, то продолжение разбора привело бы к правильной привязке T к неполному кусту, найденному на шаге 3. С этой целью исследуется неполный куст и все кусты поддерева, которые он определяет. Для каждого такого неполного куста генерируется цепочка терминалов, дополняющая этот куст, а конкатенация этих цепочек дает цепочку q.
5. Цепочка q вставляется непосредственно перед T, и разбор продолжается, начиная с головного символа цепочки q, который становится входным символом.

Рассмотрим пример грамматического разбора, изображенного на рис. 6.18 а. Ошибка была обнаружена, когда входным символом была скобка “)”. Строим список L={; , T, (}. На шаге 2 пропускается символ “)”. Неполный куст, вызвавший появление “;” в L, есть P ( A;. Мы должны, следовательно, вставить цепочку q, чтобы дополнить куст E ( T{(T}. Проще всего вставить идентификатор i (см. рис. 6.18 б).
Рис. 6.19 а иллюстрирует, как предлагаемая схема нейтрализации использует “глобальный” контекст. Кажется, что ошибка такая же, что и на рис. 6.18 а: за “+” идет “)”. Однако теперь L={; , ), T, (} и на этот раз скобка на шаге 2 не выбрасывается. Неполный куст, вызвавший появление “)” в L, - правило F ( (E). Для того, чтобы “)” была связана с этим кустом, мы должны вставить цепочку, чтобы дополнить куст , и такой цепочкой снова будет i (см. рис. 6.19 б). Заметим, что открывающая скобка могла стоять намного дальше от места ошибки, и все же она учитывалась бы при нейтрализации, поскольку при нейтрализации ошибки принимаются во внимание все неполные кусты.
13EMBED Word.Picture.81415
При использовании одной из разновидностей нисходящего разбора – рекурсивного спуска (см. раздел 5.1.2), частично построенное синтаксическое дерево явно не представлено, и здесь применяется несколько иной подход. Если рекурсивная процедура обнаруживает ошибку, то она выводит сообщение о ней и в зависимости от очередного входного символа T можно выбрать одну из двух альтернатив – либо что-то вставить и таким образом исправить ошибку, после чего продолжить работу, либо вернуться в вызывающую программу с указанием об ошибке. Например, если программа для правила
F ( i((E)
не находит “(” или “i”, то она может вставить “i” и продолжить разбор. Если она нашла “(E”, но не обнаружила закрывающей скобки, то она может предположить, что эта скобка есть, и вернуться в вызывающую программу.
В любой момент каждая рекурсивная процедура на данном этапе представляет неполный куст дерева. Выполняемая процедура пытается нейтрализовать ошибку, используя входной символ и неполный куст, который она представляет. Если нейтрализация невозможна, то она сообщает об этом вызывающей программе, и уже вызывающая программа пытается нейтрализовать ошибку, действуя по тому же принципу.
В некоторый момент этот процесс должен завершиться. “Особые” программы типа (инструкция(, (оператор(, (блок( не должны возвращаться в вызывающую программу, а должны пропускать символы исходной программы до тех пор, пока не будет возможна нейтрализация, т.е. пока не встретится так называемый синхронизирующий символ типа END, точки с запятой или начала нового оператора.
Подобный подход используется и при восходящем разборе. Так в системе построения компиляторов XPL [12] разработчик компилятора должен занести в массив STOPIT “особые” синхронизирующие символы типа “;” и “END”. Если обнаружена ошибка, то выполняется следующее:
1. Символы в цепочке Tt последовательно просматриваются и выбрасываются до тех пор, пока один из них не совпадет с каким-либо символом из STOPIT.
2. Получен новый символ T. Теперь просматриваются и выбрасываются символы цепочки x до тех пор, пока символ не состыкуется правильно с оставшимися символами x.

Упражнения.

6.1. Перевести в ПОЛИЗ, используя метод Замельсона – Бауэра, следующий фрагмент программы:
i((a(b(c(
if ( (i ( 0) and (y ( x(10) ) then begin
x (( b(10(a(y((b-x)( j((15(
while (j ( 20) do begin y(((y(2)(a( j((j(1( end(
i(((i (a)(b(c(
end( a((a (b(c (d(
Проинтерпретируйте полученную строку ПОЛИЗа и сгенерируйте по ней эквивалентный набор команд на языке ассемблера.

6.2. Перевести в тетрады, используя метод Замельсона – Бауэра, следующий фрагмент программы:
if ( (x (( 100) or (y ( ( x(10) and (y ( b) then
x (( ((a(b DIV 10)(y MOD b-x(
else begin y((a(b(c(d( if y ( a then y((y(2(a( x ((y(a(b( end;
a((a(b(c(d(
Сгенерируйте по полученным тетрадам эквивалентный набор команд на языке ассемблера.

Упражнения на программирование.

6.3. Программно реализуйте алгоритм Замельсона – Бауэра для перевода ПОЛИЗ арифметических выражений, оператора присваивания и ряда управляющих конструкций. Напишите программу, интерпретирующую строку ПОЛИЗа и формирующую набор команд на языке ассемблера по заданной строке.
6.4. Программно реализуйте алгоритм Замельсона – Бауэра для перевода тетрады арифметических выражений, оператора присваивания и ряда управляющих конструкций. Напишите программу, интерпретирующую тетрады и формирующую набор команд на языке ассемблера по заданному набору тетрад.

7. МАШИННО-НЕЗАВИСИМАЯ ОПТИМИЗАЦИЯ ПРОГРАММ

Делая в первой главе обзор процесса компиляции, мы уже выделили четыре основных направления оптимизации, которые не зависят от конкретной ЭВМ и ее машинного кода, а связанны только с языком программирования и алгоритмами, представленными на этом языке. К ним относятся:
( исключение общих подвыражений в арифметических и логических выражениях операторов присваивания или условий;
( вычисления во время компиляции;
( оптимизация булевских выражений с целью формирования эффективных условных переходов;
( вынесение инвариантных вычислений за цикл.
Идеальной исходной информацией для машинно-независимой оптимизации может служить промежуточная форма программы, представленная в матрице известных нам тетрад. Каждую тетраду, при этом, лучше всего представлять элементом двухсвязного списка, содержащего ссылки на предыдущую и последующую тетрады. То есть форма тетрады при этом примет вид:
(операция( (операнд 1( (операнд 2( (результат(
(прямой указатель( (обратный указатель(
Первые три направления реализуются с помощью достаточно простых алгоритмов. Тем не менее, для того, чтобы решить, включать или не включать ту или иную схему оптимизации в компилятор необходимо сопоставить ожидаемый выигрыш от увеличения эффективности объектной программы с дополнительными накладными расходами, связанными с увеличением времени и сложности трансляции.
Ниже мы обсудим все четыре направления оптимизации. Нам кажется, что обсуждаемый здесь материал будет интересен не только с точки зрения предлагаемых алгоритмов, но и поможет Вам впредь писать более эффективные программы, для которых компьютерная оптимизация уже не понадобиться.
7.1. ИСКЛЮЧЕНИЕ ОБЩИХ ПОДВЫРАЖЕНИЙ

Сразу оговоримся, что общие подвыражения должны быть идентичными, иначе машина вряд ли их обнаружит, а также они должны содержаться в одном операторе. Например, в программе
X((A((B ( C)( B((B ( 2( Y((A((B ( C ( ((()(
мы не сможем исключить одинаковые элементы матрицы тетрад, соответствующие подвыражению B(C, поскольку значение B изменяется между вычислениями двух выражений.
Надо также учесть тот факт, что в большинстве языков программирования есть возможность написать собственные программы обработки прерываний, которые способны изменить значения переменных в любой момент времени. Это может привести к тому, что два подвыражения будут неэквивалентными, даже в случае их расположения в одном операторе.
Рассмотрим работу алгоритма исключения общих подвыражений на конкретном примере. Пусть нам задан следующий фрагмент программы:
B((A( A((C(D((D(C(B);
Матрица тетрад для этого фрагмента представлена на рис. 7.1 а.
13EMBED Word.Picture.81415
Алгоритм исключения общих подвыражений может быть следующим:
1). Упорядочить операнды симметричных операций в лексикографическом порядке, проверяя каждый элемент и меняя их местами, если они не упорядочены (в нашем примере изменяются только тетрады с номерами 3 и 4).
2). Найти границы операторов - в данном случае конец каждого блока характеризуется тетрадой с операцией присваивания - ((’. После шагов 1) и 2) матрица примет вид, представленный на рис. 7.1 б.
3). Найти общие подвыражения (совпадающие тетрады) и исключить одно из них (в нашем примере совпадают тетрады 2, 3 и мы исключаем третью тетраду, соединяя в цепочку тетрады с номерами 2 и 4). Если одинаковых тетрад не обнаружено перейти к пункту 6) алгоритма.
4). В результирующей матрице отразить исключение элемента, изменив ссылки на его результат (в нашем случае все ссылки к M2 меняются ссылками к M1). После шагов 3) и 4) мы получим матрицу представленную на рис. 7.1 в).
5). Повторить шаги 1) - 4) алгоритма.
6). Исключить из таблицы идентификаторов все элементы временной памяти, которые нам больше не нужны (в рассматриваемом примере элемент M2).
7.2. ВЫЧИСЛЕНИЯ ВО ВРЕМЯ КОМПИЛЯЦИИ

Проводя вычисления над константами в процессе компиляции, мы можем существенно сократить объем и время выполнения объектной программы. Это особенно полезно в тех случаях, когда такие вычисления встречаются в теле цикла. Например, если задан оператор
A((((((((((((B
компилятор может выполнить умножение и деление и заменить все выражение в правой части на 14(B. Это позволит исключить две тетрады, уменьшая тем самым количество кодов объектной программы.
При этом, простой алгоритм оптимизации будет сводиться к поиску в матрице операций, у которых оба операнда являются литералами. Когда такая операция найдена, она выполняется и порождается новая константа. Тетрада с найденной операцией исключается и все ссылки на ее результат заменяются ссылками на новую константу. Затем просмотр матрицы тетрад продолжается в поисках новых возможных вычислений. Приведенный алгоритм иллюстрируется преобразованиями матрицы на рис. 7.2.
13EMBED Word.Picture.81415
7.3. ОПТИМИЗАЦИЯ БУЛЕВЫХ ВЫРАЖЕНИЙ

Мы можем использовать некоторые свойства булевых выражений для того, чтобы сократить их вычисления. Например, в операторе IF a OR b OR c THEN (, где a, b, c – булевы выражения, прежде чем генерировать код, который каждый раз будет проверять все эти выражения, мы можем генерировать такие команды, при выполнении которых в случае, если a истинно, выражение для b и c вычисляться не будут, и аналогично для выражения b.
Традиционная грамматика логических выражений имеет вид:
S ( E
E ( T(E OR T
T ( F(T AND F
F ( i((E)(NOT F
Здесь и далее все идентификаторы i – это переменные типа BOOLEAN, которые принимают значения TRUE (истина) или FALSE (ложь). Для них определены три операции: OR, AND и NOT, смысл которых традиционен (см. рис. 7.3).

13EMBED Word.Picture.81415

Из синтаксиса видно, что AND имеет более высокий приоритет, чем or, а у not самый высокий приоритет из этих трех операций. Заметим, что NOT NOT A эквивалентно A. Обычный способ вычисления таких выражений тот же, что и для арифметических, то есть операции выполняются слева направо с учетом их приоритетов и скобок. Польская запись для A AND (B OR NOT C) будет следующей: A B C NOT OR AND. Таким образом, можно было бы легко написать семантические программы для перевода логических выражений в ПОЛИЗ или тетрады, по аналогии с семантикой для арифметических выражений (см. разделы 6.2, 6.3). Однако существует более эффективный способ.
Рассмотрим выражение A AND (B OR NOT C). Если переменная A имеет значение FALSE, то нет необходимости вычислять остальную часть выражения, так как результат всегда будет FALSE. Аналогично если A и B имеют значение TRUE, то не нужно вычислять NOT C. Поэтому мы хотим вычислять выражения слева направо, прекращая вычисления сразу, как только становится известным окончательный результат. Для предыдущего выражения можно считать эквивалентной следующую запись:
IF A THEN
IF B THEN TRUE ELSE NOT C
ELSE FALSE
Переопределим синтаксис и семантику логических выражений следующим образом:
S ( E
E ( T(T OR E
T ( F(F AND T
F ( i((E)(NOT F

Где: C OR D определяется, как IF C THEN TRUE ELSE D;
C AND D определяется, как IF C THEN D ELSE FALSE;
NOT C определяется, как IF C THEN FALSE ELSE TRUE;
При генерации тетрад используются идентификаторы, константы 0 (FALSE) и 1 (TRUE), тетрада (((, K, , X) – что соответствует X (( K и тетрада (B, Y, i, j). Последняя тетрада имеет следующий смысл: если идентификатор Y имеет значение TRUE, то осуществляется переход на i-ю тетраду, в противном случае на j-ю.
На рис. 7.4 приведено несколько примеров, при разборе которых надо учесть, что результат выражения всегда заносится в X. В первой тетраде в X заносится 1, то есть значение выражения вначале предполагается равным TRUE. Если обнаруживается, что выражение и в самом деле имеет значение TRUE, то выполняется переход с пропуском оставшихся тетрад, в том числе и последней. Если же значение выражения FALSE, то происходит переход на последнюю тетраду, в которой в X заносится 0.

13EMBED Word.Picture.81415
Идентификаторы в тетрадах с рис. 7.4 располагаются в том же порядке, что и в исходном выражении. И наконец, для оператора NOT тетрады не генерируются. Если A представляется, как (B, A, i, j), то NOT A, как (B, A, j, i), то есть меняются местами адреса переходов по TRUE и FALSE.
Главная проблема здесь, конечно же, в правильном формировании адресов переходов. В монографии Д. Гриса [5] показаны подходы к решению этой проблемы с помощью организации списков переходов, как при реализации семантических программ в восходящем разборе, аналогичном алгоритму Вирта-Вебера, так и в методе рекурсивного спуска.
7.4. ВЫНЕСЕНИЕ ИНВАРИАНТНЫХ ВЫЧИСЛЕНИЙ ЗА ЦИКЛ

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

( Распознавание инвариантных вычислений – это наиболее сложная из них. Например, требуется определить, что можно вынести из цикла в следующей программе:

FOR i ((1 TO 10 DO BEGIN
(((
(((
FOR j ((1 TO 10 DO BEGIN
LA ( A ((10;
LB ( B (( Y[i +2];
LC ( C (( C+10
END;
END;

Оператор с меткой LA приводит всегда к одному и тому же результату и поэтому может быть вынесен за цикл. В операторе LB индексное выражение i + 2 также не меняется при выполнении внутреннего цикла и может быть без ущерба вынесено из него. Можно также проверить, изменяется ли во внутреннем цикле значение Y и B и если они не меняется, то за внутренний цикл можно вынести весь оператор с меткой LB. Оператор LC включает в себя переменную C, значение которой меняется во внутреннем цикле, и, следовательно, этот оператор из цикла выносить нельзя. Заметим, что, если перед LA имеются операторы (внутри цикла), которые ссылаются на A или B, то в этом случае ничего кроме вычисления индексного выражения мы из цикла вынести не сможем.

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

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

FOR i ((1 TO 100 DO A[ i ] ((0
FOR j ((1 TO 100 DO B[ j ] ((3(C[ j ]

можно объединить в один цикл:

FOR k ((1 TO 100 DO BEGIN A[ k ] ((0( B[ k ] ((3(C[ k ] END;

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

FOR i ((1 TO 100 DO
IF X > Y THEN A[ i ] (( B[ i ] + X
ELSE A[ i ] (( B[ i ] ( Y;

можно превратить в

IF X > Y THEN
FOR i ((1 TO 100 DO A[ i ] (( B[ i ] + X
ELSE
FOR i ((1 TO 100 DO A[ i ] (( B[ i ] ( X;

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

FOR i ((1 TO 100 DO Q[ i ] ((i ( 5;

можно заменить на

J((5; FOR i ((1 TO 100 DO BEGIN Q[ i ] (( J; J (( J ( 5 END;

Умножение i ( 5 в исходном цикле заменено более быстрым сложением: J ( 5.

Трудно найти компилятор, в котором были бы реализованы все перечисленные методы оптимизации. Да, скорее всего, это и не нужно, - такой компилятор работал бы чрезвычайно медленно. Мы здесь лишь коснулись возможных методов оптимизации, не давая готовых рецептов реализации. От вашего учебного компилятора не потребуется оптимизировать все и вся. Этот материал дан в первую очередь для того, чтобы вы сами разрабатывали более эффективные программы, не смотря на сверхпроизводительность вашего компьютера. Эффективное программирование – это немаловажный фактор, по которому судят о квалификации программиста.
8. МАШИННО-ЗАВИСИМЫЕ ФАЗЫ КОМПИЛЯЦИИ

Все рассмотренные выше четыре фазы компиляции являются машинно-независимыми и определяются только языком программирования, для которого создается компилятор. Напротив, на остальные три фазы язык уже не оказывает влияния. Это машинно-зависимые фазы, определяемые архитектурой ЭВМ, на которой будет выполняться откомпилированная программа. Промежуточная форма программы, создаваемая на этапах лексического, синтаксического и семантического анализа позволяет логически отделить их от машинно-зависимых фаз распределения памяти, генерации кода и сборки. Рамки пособия не позволяют подробно рассмотреть машинно-зависимые фазы и ниже будут лишь обозначены основные задачи, решаемые на этих фазах.
8.1. РАСПРЕДЕЛЕНИЕ ПАМЯТИ

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

Алгоритмы этой фазы очень сильно зависят от организации памяти ЭВМ для которой создается компилятор и способах эффективного доступа к ней. Важно знать какие регистры имеются в наличии, допустима ли косвенная адресация, имеются ли базовые и индексные регистры и т.п. При наличии базовых (сегментных) и индексных регистров память для глобальных переменных и констант (статическую память) целесообразно представлять в виде последовательных блоков или сегментов, указывая с помощью регистров начало каждого блока. В этом случае при распределении памяти необходимо определять номера сегментов и вычислять смещения внутри сегментов. Фаза генерации кода будет использовать эти смещения для формирования адресов в командах, и генерировать код, загружающий указатель соответствующего блока памяти (адрес начала сегмента) в регистр во время выполнения программы.
При выделении памяти используется счетчик адреса с нулевым начальным значением, которое затем меняется по мере распределения памяти. Фаза распределения памяти просматривает таблицы идентификаторов и констант и если встречается глобальная (статическая) переменная или константа, то выполняются следующие четыре шага:
(1) При необходимости выравнивается значение счетчика адреса.
(2) В адресное поле таблицы для соответствующей переменной или константы заносится текущее значение счетчика адреса.
(3) Подсчитывается размер памяти, необходимый для данной переменной или константы (анализируются атрибуты).
(4) К содержимому счетчика адреса прибавляется вычисленная длина переменной или константы.
Если в нашем компиляторе все фазы жестко разделены, то после того, как всем статическим данным присвоен относительный адрес, в матрицу тетрад добавляется элемент, который дает возможность при генерации кода зарезервировать необходимое количество памяти. Для всех констант и переменных, которым присваивается начальное значение, в матрицу добавляются тетрады, указывающие, что фаза генерации кода должна поместить нужное значение в соответствующий участок памяти. Но в реальных компиляторах область статических данных объектного модуля формируется уже в процессе распределения памяти, а на фазе генерации кодов формируются лишь команды загрузки соответствующих сегментных регистров.

Временная память распределяется по-другому, поскольку любой оператор исходной программы может повторно использовать временную память (область промежуточных результатов), назначенную предыдущему оператору. Последовательность тетрад от начальной установки до последнего использования временной переменной Mi называют зоной ее существования. Приводимый ниже алгоритм Данцига-Рейнольдса позволяет минимизировать число используемых ячеек даже в тех редких случаях, когда зоны временных переменных Mi и Mj частично пересекаются.
Алгоритм использует стек C, который вначале содержит набор адресов памяти, предназначенных для временных переменных. При первой встретившейся записи в Mj, которая открывает зону Mj, из стека берется адрес и присваивается Mj (из стека этот адрес естественно выталкивается). При последнем чтении из Mj, которое закрывает ее зону, адрес, присвоенный Mj, записывается в стек C, так как содержимое этой ячейки уже больше не нужно. Формирование адресов для временных переменных вполне можно совместить с генерацией кода. Алгоритм предполагает, что мы знаем тетраду, осуществляющую последнее чтение из Mj. Информацию об этом можно хранить в отдельном поле таблицы идентификаторов для каждой временной переменной.

Параметры и локальные переменные процедуры можно отнести к автоматической или внутренней памяти, в том смысле, что она локальна, и обращаться к ней можно только в той процедуре, в которой она определена. Место для этой памяти выделяется только тогда, когда процедура активируется, то есть при входе в процедуру, и освобождается после выполнения процедуры (возврата из процедуры). Идеальным местом хранения таких данных является стек, и адресация для параметров и локальных переменных ведется относительно указателя стека. При входе в новую процедуру для назначения памяти достаточно передвинуть указатель стека на новый участок. При возврате из процедуры, память освобождается простым сдвигом указателя стека на участок, относящийся к вызывающей программе. В том же стеке сохраняется содержимое регистров и адреса возвратов из процедур.
Масса интересных задач возникает и при выделении динамической памяти, распределении памяти для массивов, структур данных (записей), одноименных переменных в программах с блочной структурой, например, в случае вложенных процедур. Подходы к решению этих проблем можно почерпнуть из ряда монографий, указанных в списке литературы.
8.2. ГЕНЕРАЦИЯ КОДА И СБОРКА

Назначение фазы генерации кода состоит в формировании кода на языке ассемблера или машинном языке. Эта фаза в качестве исходной информации использует промежуточную форму программы (ПОЛИЗ или матрицу тетрад), а также кодовые продукции (чаще всего макроопределения), которые определяют все операции, появляющиеся в промежуточной форме. Она, кроме того, обращается к таблице идентификаторов и констант для генерации соответствующих адресов и преобразований типов.
На рис. 8.1 представлены примеры кодовых продукций в виде ассемблерных фрагментов для ряда операций из матрицы тетрад.
13EMBED Word.Picture.81415
В учебных компиляторах имеет смысл для каждой определяемой Вами операции подготовить макроопределения и представлять каждую тетраду макрокомандой, возложив формирование кода на стандартный транслятор с языка ассемблера. Пример макроопределения для операции сложения, где операнды могут быть словами или байтами, а результат всегда – слово представлен на рис. 8.2.
13EMBED Word.Picture.81415
В реальных компиляторах формирование кода зачастую также осуществляется с помощью специализированных макропроцессоров. При этом директивы условной трансляции в макроопределениях операций могут обеспечить не только преобразование типов данных, но и машинно-зависимую оптимизацию, связанную с сохранением значений регистров во временной памяти и их загрузку, эффективном использовании всей совокупности регистров процессора и формировании более коротких и быстродействующих регистровых команд [6].

Фаза сборки зависит от того, что является результатом фазы генерации кода. В простейшем случае фаза сборки должна обработать все метки объектной программы, сформировать объектный модуль и информацию для загрузчика (таблицу переместимых и внешних имен). Функционально фаза сборки в этом случае похожа на второй просмотр ассемблера. В другом случае, если фаза генерации оставляет коды и метки в символическом виде, фаза сборки должна осуществлять, по сути дела, полную трансляцию с языка ассемблера и решать следующие задачи:
( разрешить все символьные ссылки;
( вычислить адреса;
( сгенерировать двоичные машинные команды;
( подготовить информацию для загрузчика.
8.3. ТРАНСЛЯЦИЯ С ЯЗЫКА АССЕМБЛЕРА

Большинство конструкций языка ассемблера с точки зрения синтаксиса, исключая арифметические выражения, которые могут присутствовать в поле операндов, описываются с помощью автоматных грамматик и в основе синтаксического анализатора большинства ассемблеров лежит модель конечного автомата. Нейтрализация ошибок здесь тривиальна, так как каждая команда на языке ассемблера представляется отдельной строкой и, встретив ошибку в строке и проидентифицировав ее, транслятор просто переходит к анализу следующей строки.
13EMBED Word.Picture.81415
На рис. 8.3 представлена упрощенная структура двухпроходного ассемблера. Напомним, что транслятор ассемблера обычно называется ассемблером, в отличие от языка ассемблера, который ассемблер транслирует в машинный код.
Цель первого прохода – получить всю информацию о местоположении идентификаторов (имен, меток), а второго, – непосредственно генерировать код. Для локализации имен в ассемблере предусмотрен счетчик адреса (ячеек). Идентификатор, обнаруженный в поле метки анализируемого оператора заносится в таблицу имен и ему ставится в соответствие текущее значение счетчика адреса. При просмотре программы происходит увеличение счетчика адреса на число байт, занимаемых операторами. При переходе от одного сегмента программы к другому счетчик адреса обнуляется. Таким образом, счетчик адреса – это указатель, динамически фиксирующий относительные позиции (смещения) операторов (команд или директив) внутри одного сегмента. Итак, на первом проходе строится таблица имен (см. таблицу 7.1), а на втором проходе эта таблица используется для формирования адресов операндов.

Таблица 8.1.
Имя
Смещение
Сегмент, в котором определено имя
Тип
Размер и прочее

@Data
00H

сегмент


Vari
00H
@Data
переменная


Array
0AH
@Data
переменная


(
(
(
(
(

_Text
00H

сегмент


Start
00H
_Text
метка


Repeat
09H
_Text
метка


(
(
(
(
(


При работе ассемблер использует также две постоянные таблицы зарезервированных имен, содержащих всю необходимую информацию о командах и директивах. Там находятся мнемоники, коды операций и форматы, информация о длине, необходимая для увеличения счетчика адреса и т.п.
В отличие от рассмотренного, однопроходный ассемблер работает эффективнее. Он может легко генерировать команды, где имена операндов уже известны, определяются в программе до их использования. Иное дело, когда команда ссылается на имя пока неизвестное и, определяемое, например, как имя переменной или метка команды идущей за анализируемой строкой. В этом случае в таблице имен (идентификаторов) появляется дополнительная информация – признак определения метки. Если какое-либо неопределенное ранее имя встречается в поле операнда команды или директивы, то это имя помещается в таблицу с отметкой о том, что адрес (смещение) для этого имени еще не известно. Вместо смещения в таблицу помещается указатель головы списка, хранящего адреса команд ссылающихся на данную, пока неопределенную метку. После того, как данное имя появится в программе в поле метки и для него будет определено смещение, то ассемблеру достаточно “пробежаться по списку” ссылавшихся на данное имя команд и сформировать для них адреса операндов.
ЗАКЛЮЧЕНИЕ

Завершая пособие, заметим, что его рамки не позволили рассмотреть и малой толики того объема знаний, который накоплен в данной области. Здесь приведены лишь наиболее важные фрагменты стройной теории компиляции и перевода. Заинтересованный читатель откроет для себя массу полезного, если познакомиться с работами, приведенными в списке литературы. Обсуждаемые там методы и алгоритмы играют большую роль не только в компиляции, - это часть общей культуры программирования и искусственного интеллекта.
Наиболее современный учебник “Compilers: principles, techniques, and tools” [18] подробно излагает большинство тем курса, но у нас, к сожалению, он практически недоступен. В замечательной, хотя и несколько устаревшей монографии Д. Гриса [5] основное внимание уделяется реализационным вопросам конструирования компиляторов. Вместе с двухтомником А. Ахо и Д. Ульмана [1, 2], освещающим теоретические аспекты рассматриваемого предмета, они удачно дополняют друг друга. Небесполезные сведения содержатся и во многих других книгах, в названии которых встречаются слова "компилятор" или "транслятор", “теория формальных грамматик и языков”. Наиболее интересные из них также приведены в списке. Мы сочли возможным включить в список литературы и ряд работ, которые отражают скромный вклад автора в теорию и практику компиляции.

СПИСОК ЛИТЕРАТУРЫ

Ахо А., Ульман Д. Теория синтаксического анализа, перевода и компиляции. Том 1. Синтаксический анализ. – М.: Мир, 1978.
Ахо А., Ульман Д. Теория синтаксического анализа, перевода и компиляции. Том 2. Компиляция. – М.: Мир, 1978.
Вирт Н. Алгоритмы и структуры данных. – М.: Мир, 1989.
Гамин П.В., Куликов В.В., Шамашов М.А. Система автоматизации проектирования синтаксических анализаторов. ( В кн.: Автоматизация производства пакетов прикладных программ (Автоматизация проектирования трансляторов). (Тезисы докладов Всесоюзного семинара. (Таллин: ТПИ, 1980, с.176-180.
Грис Д. Конструирование компиляторов для цифровых вычислительных машин. – М.: Мир, 1975.
Донован Д. Системное программирование. – М.: Мир, 1975.
Ингерман П. Синтаксически ориентированный транслятор. – М.: Мир, 1969.
Кораблин М.А., Симонова Е.В., Шамашов М.А., Мажаров Л.Г. Учебно-исследовательская система конструирования формальных языков “Грамматика”. Методические указания. – Самара, СГАУ, 1997.
Кораблин М.А., Шамашов М.А. Языковые оболочки - интеллектуальный интерфейс пользователя пакетов прикладных программ. В кн.: Интеллектуальные системы в машиностроении. Материалы Всесоюзной конференции. Часть 3. Интеллектуальные системы в научных исследованиях. Программно-аппаратные средства для разработки интеллектуальных систем. – Самара: ИМАШ АН СССР, 1991, с. 85-88.
Куликов В.В., Шамашов М.А. Автоматизация проектирования синтаксических анализаторов проблемно - ориентированных языков систем автоматизации эксперимента. В кн.: Автоматизация экспериментальных исследований. Межвузовский сборник. – Куйбышев: КуАИ, 1982, с. 94-100.
Льюис Ф., Розенкранц Д. Стирнз Р. Теоретические основы построения компиляторов. – М.: Мир, 1979.
Маккиман У., Хорнинг Д., Уортман Д. Генератор компиляторов. (М.: Статистика, 1980.
Семантика языков программирования. Сборник статей. – М.: Мир, 1980.
Р.Хантер. Проектирование и конструирование компиляторов. - М.: Финансы и статистика, 1984.
Хопгуд Ф. Методы компиляции. – М.: Мир, 1972.
Шамашов М.А. Теория формальных языков. Грамматики и автоматы. – Самара: Университет Наяновой, 1996.
Штернберг Л.Ф. Теория формальных грамматик. – Куйбышев: КуАИ, 1979.
Aho A., Sethi R., Ullman J. Compilers: principles, techniques, and tools. Addison-Wesley, Reading, MA, 1986.



СОДЕРЖАНИЕ
13 TOC \o "1-3" 14ПРЕДИСЛОВИЕ 13 PAGEREF _Toc454013807 \h 14315
ВВЕДЕНИЕ 13 PAGEREF _Toc454013808 \h 14415
1. КРАТКИЙ ОБЗОР ПРОЦЕССА КОМПИЛЯЦИИ 13 PAGEREF _Toc454013809 \h 14515
2. ЛЕКСИЧЕСКИЙ АНАЛИЗ 13 PAGEREF _Toc454013810 \h 141015
3. ОРГАНИЗАЦИЯ ТАБЛИЦ КОМПИЛЯТОРА 13 PAGEREF _Toc454013811 \h 141615
3.1. ОБЩИЙ ВИД ТАБЛИЦ 13 PAGEREF _Toc454013812 \h 141615
3.2. ПРЯМОЙ ДОСТУП К ТАБЛИЦЕ ИЛИ МЕТОД ИНДЕКСОВ 13 PAGEREF _Toc454013813 \h 141715
3.3. НЕУПОРЯДОЧЕННАЯ ТАБЛИЦА ИЛИ МЕТОД ЛИНЕЙНОГО СПИСКА 13 PAGEREF _Toc454013814 \h 141715
3.4. УПОРЯДОЧЕННАЯ ТАБЛИЦА. БИНАРНЫЙ, ДВОИЧНЫЙ ИЛИ ЛОГАРИФМИЧЕСКИЙ ПОИСК 13 PAGEREF _Toc454013815 \h 141815
3.5. СБАЛАНСИРОВАННЫЕ ДЕРЕВЬЯ 13 PAGEREF _Toc454013816 \h 141915
3.6. ДЕРЕВЬЯ ОПТИМАЛЬНОГО ПОИСКА 13 PAGEREF _Toc454013817 \h 142215
3.7. ХЕШ – АДРЕСАЦИЯ 13 PAGEREF _Toc454013818 \h 142315
3.7.1. Рехеширование 13 PAGEREF _Toc454013819 \h 142315
3.7.2. Хеш–функция 13 PAGEREF _Toc454013820 \h 142515
3.7.3. Метод цепочек или гроздей 13 PAGEREF _Toc454013821 \h 142615
4. ОБЩИЕ МЕТОДЫ СИНТАКСИЧЕСКОГО АНАЛИЗА 13 PAGEREF _Toc454013822 \h 142915
4.1. НИСХОДЯЩИЙ РАЗБОР С ВОЗВРАТАМИ 13 PAGEREF _Toc454013823 \h 143115
4.2. ВОСХОДЯЩИЙ РАЗБОР С ВОЗВРАТАМИ 13 PAGEREF _Toc454013824 \h 143415
4.3. СИМВОЛЬНЫЙ ПРЕПРОЦЕССОР НА ОСНОВЕ БЭКТРЕКИНГА 13 PAGEREF _Toc454013825 \h 143815
4.3.1. Фаза анализа и перевода грамматики во внутреннее представление 13 PAGEREF _Toc454013826 \h 144015
4.3.2. Лексичекий анализ в СП 13 PAGEREF _Toc454013827 \h 144415
4.3.3. Синтаксический анализ в СП 13 PAGEREF _Toc454013828 \h 144415
4.3.4. Выполнение семантических действий 13 PAGEREF _Toc454013829 \h 144915
5. ОДНОПРОХОДНЫЙ СИНТАКСИЧЕСКИЙ АНАЛИЗ БЕЗ ВОЗВРАТОВ 13 PAGEREF _Toc454013830 \h 145215
5.1. LL(k) ЯЗЫКИ И ГРАММАТИКИ 13 PAGEREF _Toc454013831 \h 145215
5.1.1. Предсказывающие алгоритмы разбора и разбор для LL(1)-грамматик 13 PAGEREF _Toc454013832 \h 145515
5.1.2. Рекурсивный спуск 13 PAGEREF _Toc454013833 \h 145715
5.2. ЯЗЫКИ И ГРАММАТИКИ ПРОСТОГО ПРЕДШЕСТВОВАНИЯ 13 PAGEREF _Toc454013834 \h 146015
5.2.1. Алгоритм Вирта–Вебера для анализа языков простого предшествования 13 PAGEREF _Toc454013835 \h 146315
5.2.2. Функции предшествования. 13 PAGEREF _Toc454013836 \h 146615
5.2.3. Проблемы построения грамматик предшествования 13 PAGEREF _Toc454013837 \h 147015
5.3. ОПЕРАТОРНАЯ ГРАММАТИКА ПРЕДШЕСТВОВАНИЯ 13 PAGEREF _Toc454013838 \h 147215
6. ВВЕДЕНИЕ В СЕМАНТИКУ 13 PAGEREF _Toc454013839 \h 147715
6.1. ВНУТРЕННИЕ ФОРМЫ ИСХОДНОЙ ПРОГРАММЫ 13 PAGEREF _Toc454013840 \h 147715
6.1.1. Польская инверсная запись 13 PAGEREF _Toc454013841 \h 147815
6.1.2. Интерпретация ПОЛИЗа 13 PAGEREF _Toc454013842 \h 147915
6.1.3. Генерирование команд по ПОЛИЗу 13 PAGEREF _Toc454013843 \h 148015
6.1.4. Тетрады и триады 13 PAGEREF _Toc454013844 \h 148315
6.2. СЕМАНТИЧЕСКИЕ ПОДПРОГРАММЫ ПЕРЕВОДА ИНФИКСНОЙ ЗАПИСИ В ПОЛИЗ И АСПЕКТЫ ИХ РЕАЛИЗАЦИИ 13 PAGEREF _Toc454013845 \h 148515
6.3. СЕМАНТИЧЕСКИЕ ПОДПРОГРАММЫ ДЛЯ ПЕРЕВОДА В ТЕТРАДЫ 13 PAGEREF _Toc454013846 \h 148715
6.4. МЕТОД ЗАМЕЛЬСОНА–БАУЭРА ДЛЯ ПЕРЕВОДА В ПОЛИЗ И ТЕТРАДЫ 13 PAGEREF _Toc454013847 \h 148915
6.5. НЕЙТРАЛИЗАЦИЯ ОШИБОК 13 PAGEREF _Toc454013848 \h 149415
6.5.1. Исправления орфографических ошибок 13 PAGEREF _Toc454013849 \h 149515
6.5.2. Нейтрализация семантических ошибок 13 PAGEREF _Toc454013850 \h 149615
6.5.3. Нейтрализация синтаксических ошибок 13 PAGEREF _Toc454013851 \h 149715
7. МАШИННО-НЕЗАВИСИМАЯ ОПТИМИЗАЦИЯ ПРОГРАММ 13 PAGEREF _Toc454013852 \h 1410115
7.1. ИСКЛЮЧЕНИЕ ОБЩИХ ПОДВЫРАЖЕНИЙ 13 PAGEREF _Toc454013853 \h 1410115
7.2. ВЫЧИСЛЕНИЯ ВО ВРЕМЯ КОМПИЛЯЦИИ 13 PAGEREF _Toc454013854 \h 1410215
7.3. ОПТИМИЗАЦИЯ БУЛЕВЫХ ВЫРАЖЕНИЙ 13 PAGEREF _Toc454013855 \h 1410315
7.4. ВЫНЕСЕНИЕ ИНВАРИАНТНЫХ ВЫЧИСЛЕНИЙ ЗА ЦИКЛ 13 PAGEREF _Toc454013856 \h 1410515
8. МАШИННО-ЗАВИСИМЫЕ ФАЗЫ КОМПИЛЯЦИИ 13 PAGEREF _Toc454013857 \h 1410715
8.1. РАСПРЕДЕЛЕНИЕ ПАМЯТИ 13 PAGEREF _Toc454013858 \h 1410715
8.2. ГЕНЕРАЦИЯ КОДА И СБОРКА 13 PAGEREF _Toc454013859 \h 1410815
8.3. ТРАНСЛЯЦИЯ С ЯЗЫКА АССЕМБЛЕРА 13 PAGEREF _Toc454013860 \h 1411015
ЗАКЛЮЧЕНИЕ 13 PAGEREF _Toc454013861 \h 1411215
СПИСОК ЛИТЕРАТУРЫ 13 PAGEREF _Toc454013862 \h 1411315
СОДЕРЖАНИЕ 13 PAGEREF _Toc454013863 \h 1411415
15

13PAGE 1411515




. . . . . . x y . .

U

Рис. 5.5

U

Рис. 5.6

. . . . x y . . . .

Рис. 5.7

U

. . x y . . . . . .

# b ( a a ) b #
(( (( (( ((
# b ( M a ) b #
(((( ((13EMBED PBrush141513EMBED PBrush1415 ((
# b ( L b #
(((( 13EMBED PBrush1415 ((
# b M b #
(( 13EMBED PBrush1415 13EMBED PBrush1415 ((
# S #
Рис. 5.10



Root Entry (!!!!-а- !! ((!!!е$! !!!я%! !!* !

Приложенные файлы

  • doc 7731122
    Размер файла: 5 MB Загрузок: 0

Добавить комментарий