ВВЕДЕНИЕ Эта обучающая серия обещает стать возможно
одной из самых долгоиграющих мини-серий в истории, конкурирующей только
с задержкой на Томе IV Кнута. Начатая в 1988, эта серия вошла в четырехлетнюю
паузу в 1990, когда "заботы мира сего", изменения в приоритетах и интересах
и необходимость зарабатывать на жизнь казалось забросили ее после Главы
14. Долготерпевшие из вас были наконец вознаграждены весной прошлого года
долгожданной Главой 15. В ней я начал попытку поставить серию обратно на
рельсы и по ходу дела сделать ее проще для достижения цели, которая состоит
в том, чтобы обеспечить вас не только достаточным пониманием трудных тем
теории компиляции, но также достаточными инструментами в виде фиксированных
подпрограмм и концепций, так чтобы вы были способны продолжать самостоятельно
и стали достаточно опытными для того, чтобы создавать свои собственные
синтаксические анализаторы и трансляторы. Из-за этой длинной паузы я подумал
что следует вернуться назад и повторно рассмотреть концепции, которые мы
до этого охватили а также заново сделать некоторые части программы. В прошлом
мы никогда сильно не касались разработки программных инструментов промышленного
качества... в конце концов я пытался обучать (и обучаться) концепциям,
а не промышленной практике. Чтобы сделать это я старался давать вам не
законченные компиляторы и анализаторы, а только те отрывки кода, которые
иллюстрировали частные случаи, которые мы рассматривали в текущий момент.
Два модуля, с которыми мы будем в основном работать и те, которые больше всего представляют индивидуальность нашего компилятора - это Parser и CodeGen. Теоретически модуль Parser должен изолировать все аспекты компилятора, которые зависят от синтаксиса компилируемого языка (хотя, как мы видели последний раз, небольшое количество этого синтаксиса перетекает в Scanner). Аналогично, модуль генератора кода, CodeGen, содержит весь код, зависящий от целевой машины. В этой главе мы продолжим разработку функций в этих двух важнейших модулях. СОВСЕМ КАК КЛАССИЧЕСКИЙ? Прежде чем мы продолжим, однако, я думаю что должен разъяснить связи между модулями и функциональные возможности этих модулей. Те из вас, кто знаком с теорией компиляции как обучавшиеся в университетах, конечно распознают имена Scanner, Parser и CodeGen, все из которых являются компонентами классической реализации компилятора. Вы можете думать, что я отказался от своих обязательств по отношению к философии KISS и отдрейфовал к более стандартной архитектуре чем мы имели. Более пристальный взгляд, однако, должен убедить вас, что хотя имена схожи, функциональность совершенно различна. Вместе, сканер и парсер классической реализации составляют так называемый "front end", а генератор кода "back end". Подпрограммы "front end" обрабатывают языкозависимые, связанные с синтаксисом аспекты исходного языка, в то время как генератор кода, или "back end", работает с зависимыми от целевой машины частями проблемы. В классических компиляторах два конца (ends) сообщаются через файл инструкций, написанный на промежуточном языке (IL). Как правило, классический сканер это одиночная процедура, оперирующая как сопроцедура с синтаксическим анализатором. Она "токенизирует" исходный файл, считывая его символ за символом, распознавая элементы языка, транслируя их в токены и передавая их синтаксическому анализатору. Вы можете думать о синтаксическом анализаторе как об абстрактной машине, выполняющей "op кода", которыми являются токены. Точно также, синтаксический анализатор генерирует "op кода" второй абстрактной машины, которая механизирует IL. Как правило, IL файл записывается на диск синтаксическим анализатором и считывается снова генератором кода. Наша организация совершенно другая. Мы не имеем лексического анализатора в классическом смысле; наш модуль Scanner, хотя и имеет схожее имя, не является одиночной процедурой или сопроцедурой, а просто набором раздельных подпрограмм, которые вызываются синтаксическим анализатором когда необходимо. Аналогично, классический генератор кода, "back end", в своем роде тоже транслятор, считывающий "исходный" IL файл и выдающий объектный файл. Наш генератор кода не работает таким способом. В нашем компиляторе нет никакого промежуточного языка; каждая конструкция в синтаксисе исходного языка преобразуется в ассемблер как только она распознана синтаксическим анализатором. Подобно Scanner, модуль CodeGen состоит из индивидуальных процедур, которые вызываются синтаксическим анализатором когда необходимо. Философия "кодируй как только найдешь" не может производить самый эффективный код в мире - например, мы не обеспечили (пока!) удобное место для оптимизатора - но она несомненно упрощает компилятор, не правда ли? И этот наблюдение заставляет меня повторить снова то, как нам удавалось сводить функции компилятора к таким сравнительно простым условиям. Я набрался красноречивости на эту тему в прошлых главах, поэтому здесь я не буду слишком ее трогать. Однако, из-за времени, прошедшего с этих последних монологов, я надеюсь что вы предоставите мне совсем немного времени напомнить себе, так же как и вам, как мы попали сюда. Мы дошли до этого применяя несколько принципов, которые создатели коммерческих компилятором редко имеют роскошь использовать. Вот они:
РАСШИРЕНИЕ СИНТАКСИЧЕСКОГО АНАЛИЗАТОРА Хотя я обещал вам где-то в Главе 14,
что мы никогда снова не будем переписывать каждую одиночную функцию заново,
я начал делать это с Главы 15. Единственная причина: эта длинная пауз между
двумя главами делала обзор кажущимся чрезвычайно оправданным... даже необходимым
и для вас и для меня. Более важно, решение собрать процедуры в модули заставило
нас взглянуть на каждую из них снова, хотели мы этого или нет. И, наконец,
откровенно говоря, за последние четыре года у меня появились некоторые
новые идеи, которые гарантировали свежий взгляд на некоторые старые вещи.
Когда я сперва начал эту серию я был искренне поражен и обрадован, узнав
насколько простыми могут быть сделаны подпрограммы анализа. Но в этот последний
раз я удивил сам себя снова и был способен делать их точно также, но даже
немного проще.
{--------------------------------------------------------------}
procedure SignedFactor;
Заметьте, что эта процедура вызывает новую подпрограмму генерации кода Negate: {--------------------------------------------------------------}
procedure Negate;
(Здесь и в других местах в этой серии я собираюсь только
показывать вам новые подпрограммы. Я рассчитываю, что вы поместите их в
соответствующий модуль, который вы должны без проблем определить. Не забывайте
добавлять заголовок процедуры в раздел interface модуля.)
MOVE #3,D0
что действительно, действительно грубо. Мы можем сделать
лучше, конечно, просто предварительно добавив знак минус к строке, передаваемой
в LoadConstant, но это добавляет несколько строк кода в SignedFactor и
здесь я применяю философию KISS очень агрессивно. Более того, сказать правду,
я думаю что подсознательно наслаждаюсь генерацией "действительно грубого"
кода, так как я могу иметь удовольствие наблюдать как он будет становиться
драматически лучше, когда мы примемся за методы оптимизации.
program main;
Мой тест измеряет время, требуемое на компиляцию и связывание и размер сгенерированного объектного файла. Бесспорный проигравший в этом тесте - компилятор DEC C для VAX, который тратит 60 секунд на компиляцию на VAX 11/780 и генерирует объектный файл 50k. Компилятор Джона бесспорно сейчас, в будущем и навсегда король по части размера кода. Для данной пустой программе Whimsical генерирует точно два байта, реализуя одну инструкцию: RET Устанавливая опцию компилятора генерировать
include файл а не автономную программу, Джон может даже урезать этот размер
с двух байт до нуля! Несколько трудно добиться нулевого обьектного файла,
вы не согласны?
ТЕРМЫ И ВЫРАЖЕНИЯ Я уверен вы знаете, что будет дальше.
Мы должны еще раз создать остальные процедуры, которые реализуют синтаксический
анализ выражений по методу рекурсивного спуска. Все мы знаем, что иерархия
процедур для арифметических выражений такая:
Однако сейчас давайте продолжим разработку по шагам и рассмотрим выражения только с аддитивными термами. Код для реализации выражений, включающих возможно первый терм со знаком, показан ниже: {--------------------------------------------------------------}
procedure Expression;
Эта процедура вызывает две другие процедуры для обработки операций: {--------------------------------------------------------------}
procedure Add;
{--------------------------------------------------------------}
procedure Subtract;
Эти три процедуры Push, PopAdd и PopSub - новые подпрограммы генерации кода. Как подразумевает имя, процедура Push генерирует код для помещения основного регистра (D0 в нашей реализации для 68000) в стек. PopAdd и PopSub выталкивают вершину стека и прибавляют или вычитают ее из основного регистра. Код показан ниже: {--------------------------------------------------------------}
procedure Push;
{--------------------------------------------------------------}
procedure PopAdd;
{--------------------------------------------------------------}
procedure PopSub;
Добавьте эти подпрограммы в Parser
и CodeGen и измените основную программу для вызова Expression. Вуаля!
{--------------------------------------------------------------}
procedure PopMul;
{--------------------------------------------------------------}
procedure PopDiv;
Я должен признать, что подпрограмма
деления немного перегружена, но с этим ничего нельзя поделать. К сожалению,
хотя процессор 68000 позволяет выполнять деление используя вершину стека
(TOS), он требует аргументы в неправильном порядке, подобно тому как для
вычитания. Поэтому наше единственное спасение в том чтобы вытолкнуть стек
в рабочий регистр (D7), выполнить там деление, и затем поместить результат
обратно в наш основной регистр D0. Обратите внимание на использование знаковых
операций умножения и деления. Этим неявно подразумевается что все наши
переменные будут 16-разрядными целыми числами со знаком. Это решение затронет
нас позднее, когда мы начнем рассматривать множественные типы данных, преобразования
типов и т.п.
{--------------------------------------------------------------}
procedure Term;
Наш следующий шаг - изменение некоторых имен. SignedFactor теперь становится SignedTerm а вызовы Factor в Expression, Add, Subtract и SignedTerm заменяются на вызов Term: {--------------------------------------------------------------}
procedure SignedTerm;
procedure Expression;
Если память мне не изменяет мы однажды уже имели и процедуру SignedFactor и SignedTerm. У меня были причины сделать так в то время... они имели отношение к обработке булевой алгебры и, в частности, булевой функции "not". Но, конечно, для арифметических операций дублирование не нужно. В выражении типа: -x*y очевидно, что знак идет со всем термом x*y а не просто
с показателем x и таким способом Expression и закодирован.
{--------------------------------------------------------------}
procedure Factor;
К этому моменту ваш "компилятор" должен уметь обрабатывать любые допустимые выражения, которые вы ему подбросите. Еще лучше, что он должен отклонить все недопустимые! ПРИСВАИВАНИЯ Пока мы здесь, мы могли бы также написать код для работы с операциями присваивания. Этот код должен только запомнить имя конечной переменной, где мы должны сохранить результат выражения, вызвать Expression, затем сохранить число. Процедура показана дальше: {--------------------------------------------------------------}
procedure Assignment;
Присваивание вызывает еще одну подпрограмму генерации кода: {--------------------------------------------------------------}
procedure StoreVariable(Name: string);
Теперь измените вызов в Main на вызов
Assignment и вы должны увидеть полную операцию присваивания, обрабатываемую
правильно. Довольно хорошо, не правда ли? И безболезненно также.
<factor>
::= <variable> | <constant> | '(' <expression> ')'
БУЛЕВА АЛГЕБРА Следующий шаг, как мы изучили несколько раз до этого, это добавление булевой алгебры. В прошлом этот шаг по крайней мере удваивал количество кода, который мы должны были написать. Когда я прошел эти шаги в своем уме, я обнаружил, что отклоняюсь все больше и больше от того, что мы делали в предыдущих главах. Чтобы освежить вашу память, я отметил, что Паскаль обрабатывает булевы операторы в значительной степени идентично способу, которым он обрабатывает арифметические операторы. Булево "and" имеет тот же самый уровень приоритета, что и умножение, а "or" то же что сложение. Си, с другой стороны, устанавливает их на различных уровнях приоритета, которые занимают 17 уровней. В нашей более ранней работе я выбрал что-то среднее, с семью уровнями. В результате, мы закончили на чем-то называющемся булевыми выражениями, соответствующим в большинстве деталей арифметическим выражениям, но на другом уровне приоритета. Все это, как оказалось, возникло потому, что мне не хотелось помещать скобки вокруг булевых выражений в утверждениях типа: IF (c >= 'A') and (c <= 'Z') then ... При взгляде назад, это кажется довольно
мелкой причиной для добавления многих уровней сложности в синтаксический
анализатор. Возможно более существенно то, что я не уверен что был даже
способен избежать скобок.
{--------------------------------------------------------------}
Затем, мы должны включить анализ операторов в процедуру Expression: {--------------------------------------------------------------}
(Символы подчеркивания необходимы, конечно, потому что
"or" and "xor" являются зарезервированными словами Turbo Pascal).
{--------------------------------------------------------------}
procedure _Or;
{--------------------------------------------------------------}
procedure _Xor;
И, наконец, новые процедуры генерации кода: {--------------------------------------------------------------}
procedure PopOr;
{--------------------------------------------------------------}
procedure PopXor;
Теперь давайте протестируем транслятор
(вы возможно захотите изменить вызов в Main обратно на вызов Expression
просто чтобы избежать необходимости набирать каждый раз "x=" для присваивания).
x|y~z К сожалению, он также не делает ничего для того, чтобы защитить нас от смешивания булевой и арифметической алгебры. Он радостно сгенерирует код для: (a+b)*(c~d) Мы говорили об этом немного в прошлом.
Вообще, правила какие операции допустимы а какие нет не могут быть применены
самим синтаксическим анализатором, потому что они не являются частью синтаксиса
языка, а скорее его семантики. Компилятор, который не разрешает смешанные
выражения такого вида должен распознать, что c и d являются булевыми переменными
а не числовыми и передумать об их умножении на следующем шаге. Но такая
"охрана" не может быть выполнена синтаксическим анализатором; она должна
быть обработана где-то между синтаксическим анализатором и генератором
кода. Мы пока не в таком положении, чтобы устанавливать такие правила,
потом что у нас нет способа ни объявления типов ни таблицы идентификаторов
для сохранения в ней типов. Так что, для того что у нас на данный момент
работает, синтаксический анализатор делает точно то, что он предназначен
делать.
-(x=0) или функция абсолютного значения (определенно сложный код!): x*(1+2*(x<0)) Пожалуйста, заметьте, что я не защищаю
программирование подобным образом как стиль жизни. Я почти обязательно
написал бы эти функции в более читаемой форме, используя IF, только для
того, чтобы защитить от запутывания того, кто будет сопровождать программу
в будущем. Все же возникает моральный вопрос: Имеем ли мы право осуществлять
наши идеи о хорошей практике кодирования на программисте, написав язык
так, чтобы он не смог сделать что-нибудь не так? Это то, что сделал Никлаус
Вирт во многих местах Паскаля и Паскаль критиковался за это - как не такой
"прощающий" как Си.
MOVE X,D0 (где X это имя переменной) но вы не можете записать ее таким же образом. Для записи вы должны загрузить в регистр адреса адрес X. То же самое остается истиной и для PC-относительной адресации. MOVE X(PC),DO (допустимо)
Когда вы начинаете спрашивать,
как возникло такое неортогональное поведение, вы находите, что кто-то в
Motorola имел некоторые теории о том, как должно писаться программное обеспечение.
В частности, в этом случае они решили, что самомодифицирующийся код, который
вы можете реализовать, используя PC-относительные записи - Плохая Вещъ.
Следовательно, они разработали процессор, запрещающий это. К сожалению,
по ходу дела они также запретили все записи в форме, показанной выше, даже
полезные. Заметьте, что это было не что-то, сделанное по умолчанию. Должна
была быть сделана дополнительная дизайнерская работа, добавлены дополнительные
ограничения для уничтожения естественной ортогональности набора инструкций.
БУЛЕВО "AND" С это небольшой философией, мы можем
приступить к оператору "and", который пойдет в процедуру Term. К настоящему
времени вы возможно сможете сделать это без меня, но в любом случае вот
код:
{--------------------------------------------------------------}
в Parser: {--------------------------------------------------------------}
{--------------------------------------------------------------}
procedure _And;
и в CodeGen: {--------------------------------------------------------------}
procedure PopAnd;
Ваш синтаксический анализатор теперь
должен быть способен обрабатывать почти любые виды логических выражений
а также (если вы хотите) и смешанные выражения.
-a * -b имеет небольшой или совсем никакого смысла и синтаксический анализатор должен отметить его как ошибку. Но то же самое выражение, использующее логическое "not", имеет точный смысл: not a and not b В случае с этими унарными операторами выбор заставить их работать таким же самым способом кажется исскуственным принуждением, жертвованием примлемым поведением на алтаре простоты реализуемости. Хотя я полностью за сохранение реализации настолько простой, насколько возможно, я не думаю, что мы должны делать это за счет приемлемости. Исправления подобные этому, приведут к потере основной детали, которая заключается в том, чтобы логическое "not" просто не является тем же самым что унарный минус. Рассмотрим исключающее "or", которое обычно записывается так: a~b ::= (a and not b) or (not a and b) Если мы разрешим "not" изменять весь терм, последний терм в круглых скобках интерпретировался бы как: not(a and b) что совсем не то же самое. Так что ясно, что о логическом
"not" нужно думать как о связанном с показателем а не термом.
-x <=> 0-x Фактически, в одной из моих более простых
версий Expression я реагировал на ведущий addop просто предзагружая нуль,
затем обрабатывая оператор как если бы это был двоичный оператор. Но "not"
это не эквивалент исключающему или с нулем... которое просто возвратит
исходное число. Вместо этого, это исключающее или с FFFFh или -1.
a & !b | !a & b Обратите внимание, что никаких круглых
скобок не требуется - выбранные нам уровни приоритета автоматически заботятся
обо всем.
{--------------------------------------------------------------}
procedure NotFactor;
и вызовем ее из всех мест, где мы прежде вызывали Factor, т.е. из Term, Multiply, Divide и _And. Обратите внимание на новую процедуру генерации кода: {--------------------------------------------------------------}
procedure NotIt;
{--------------------------------------------------------------} Испытайте ее сейчас с несколькими простыми случаями. Фактически, попробуйте пример с исключающим или: a&!b|!a&b Вы должны получить код (без комментариев, конечно): MOVE A(PC),DO
; load a
Это точно то, что мы хотели получить. Так что, по крайней мере, и для арифметических и для логических операторов наш новый приоритет и новый, более тонкий синтаксис, поддерживают друг друга. Даже специфическое, но допустимое выражение с ведущим addop: ~x имеет смысл. SignedTerm игнорирует ведущий '~' как и должно быть, так как выражение эквивалентно: 0~x, что эквивалентно x.
<not_factor> ::= [!] <factor>
Это большое улучшение предыдущих достижений.
Будет ли сохраняться наша удача когда мы примемся за операторы отношений?
Мы выясним это скоро, но мы должы будем дождаться следующей главы. У нас
выдалась подходящая пауза и я хочу выдать эту главу в ваши руки. Уже прошел
год с выпуска Главы 15. Я боюсь признаться, что вся эта текущая глава была
готова уже давно, за исключением операторов отношений. Но эта информация
совсем не дает вам ничего хорошего, сидя на моем жестком диске, и удерживая
ее пока пока операторы отношений не будут сделаны, я не давал ее в ваши
руки все это время. Пришло время выдать ее чтобы вы смогли получить из
нее что-нибудь ценное. Кроме того, имеется большое количество серъезных
философских вопросов, связанных с операторами отношений, и я предпочел
бы сохранить их для отдельной главы, где я смог бы сделать это корректно.
|