НАЧАЛО Если вы прочитали введение, то
вы уже в курсе дела. Вы также скопировали программу Cradle в Turbo Pascal
и откомпилировали ее. Итак, вы готовы.
x = 2*y + 3/(4*z) В самом начале я буду двигаться очень маленькими шагами для того, чтобы начинающие из вас совсем не заблудились. Вы также получите несколько хороших уроков, которые хорошо послужат нам позднее. Для более опытных читателей: потерпите. Скоро мы двинемся вперед. ОДИНОЧНЫЕ ЦИФРЫ В соответствии с общей темой
этой серии (KISS-принцип, помнишь?), начнем с самого простого случая, который
можно себе представить. Это выражение, состоящее из одной цифры.
{---------------------------------------------------------------}
procedure Expression;
И добавьте строку “Expression;” в основную программу, которая должна выглядеть так: {---------------------------------------------------------------}
Теперь запустите программу. Попробуйте
ввести любую одиночную цифру. Вы получите результат в виде одной
строчки на ассемблере. Затем попробуйте ввести любой другой символ и вы
увидите, что синтаксический анализатор правильно сообщает об ошибке.
ВЫРАЖЕНИЯ С ДВУМЯ ЦИФРАМИ Теперь, давайте немного улучшим то, что у нас есть. По общему признанию, выражение, состоящее только из одного символа, не удовлетворит наших потребностей надолго, так что давайте посмотрим, как мы можем расширить возможности компилятора. Предположим, что мы хотим обрабатывать выражения вида: 1+2
Для того, чтобы сделать это,
нам нужна процедура, распознающая термы и сохраняющая результат, и другая
процедура, которая распознает и различает «+» и «-» и генерирует
соответствующий код. Но если процедура Expression сохраняет свои
результаты в регистре D0, то где процедура Term сохранит свои результаты?
Ответ: на том же месте. Мы окажемся перед необходимостью сохранять
первый результат процедуры Term где-нибудь, прежде чем мы получим следующий.
{---------------------------------------------------------------}
procedure Expression;
Затем выше Expression наберите следующие две процедуры: {--------------------------------------------------------------}
procedure Add;
{-------------------------------------------------------------}
procedure Subtract;
Когда вы закончите, порядок подпрограмм должен быть следующий:
Посмотрите на полученный объектный код. Можно сделать два замечания. Во первых, сгенерированный код не такой, какой бы написали мы. Последовательность MOVE #n,D0
неэффективна. Если бы мы писали этот код вручную, то,
возможно, просто загрузили бы данные напрямую в D1.
{-------------------------------------------------------------}
procedure Subtract;
Теперь наш код даже еще менее
эффективен, но по крайней мере выдает правильный ответ! К сожалению, правила,
которые определяют значение математических выражений, требуют, чтобы условия
в выражении следовали в неудобном для нас порядке. Опять, это только один
из фактов жизни, с которыми вы учитесь жить. Все это возвратится снова,
чтобы преследовать нас, когда мы примемся за деление.
ОБЩАЯ ФОРМА ВЫРАЖЕНИЯ В реальном мире выражение может состоять из одного или более термов, разделенных "addops" ('+' или '-'). В БНФ это может быть записано как: <expression> ::= <term> [<addop> <term>]* Мы можем применить это определение выражения, добавив простой цикл к процедуре Expression: {---------------------------------------------------------------}
procedure Expression;
Эта версия поддерживает любое
число термов, и это стоило нам только двух дополнительных строк кода. По
мере изучения, вы обнаружите, что это характерно для нисходящих синтаксических
анализаторов… необходимо только несколько дополнительных строк кода чтобы
добавить расширения языка. Это как раз то, что делает наш пошаговый метод
возможным. Заметьте также, как хорошо код процедуры Expression соответствует
определению БНФ. Это также одна из характеристик метода. Когда вы станете
специалистом этого метода, вы сможете превращать БНФ в код синтаксического
анализатора примерно с такой же скоростью, с какой вы можете набирать текст
на клавиатуре!
ИСПОЛЬЗОВАНИЕ СТЕКА В этом месте я собираюсь нарушить свое правило, что я не представлю что-либо сложное, пока это не будет абсолютно необходимо. Прошло достаточно много времени, чтобы не отметить проблему с генерируемым кодом. В настоящее время синтаксический анализатор использует D0 как «основной» регистр, и D1 для хранения частичной суммы. Эта схема работает отлично потому что мы имеем дело только с "addops" (“+” и “-”) и новое число прибавляется по мере появления. Но в общем форме это не так. Рассмотрим, например выражение 1+(2-(3+(4-5))) Если мы поместим «1» в D1, то где мы разместим «2»? Так
как выражение в общей форме может иметь любую степень сложности, то мы
очень быстро используем все регистры!
-(SP)
Итак, изменим EmitLn в процедуре Expression на EmitLn('MOVE D0,-(SP)'); и две строки в Add и Subtract: EmitLn('ADD (SP)+,D0')
и
соответственно. Теперь испытаем компилятор снова и удостоверимся
что он работает.
УМНОЖЕНИЕ И ДЕЛЕНИЕ Теперь давайте возьмемся за действительно серьезные дела. Как вы знаете, кроме операторов "addops" существуют и другие… выражения могут также иметь операторы умножения и деления. Вы также знаете, что существует неявный приоритет операторов или иерархия, связанная с выражениями, чтобы в выражениях типа 2 + 3 * 4, мы знали, что нужно сначала умножить, а затем сложить.
(Видите, зачем нам нужен стек? )
<term> ::= <factor> [ <mulop> <factor ]* Что такое показатель? На данный момент это тоже, чем был
раннее терм - одиночной цифрой.
{---------------------------------------------------------------}
procedure Factor;
{--------------------------------------------------------------}
procedure Multiply;
{-------------------------------------------------------------}
procedure Divide;
{---------------------------------------------------------------}
procedure Term;
{--------------------------------------------------------------}
procedure Add;
{-------------------------------------------------------------}
procedure Subtract;
{---------------------------------------------------------------}
procedure Expression;
Конфетка! Почти работающий транслятор в 55 строк Паскаля! Получаемый код начинает выглядеть действительно полезным, если не обращать внимание на неэффективность. Запомните, мы не пытаемся создавать сейчас самый компактный код. КРУГЛЫЕ СКОБКИ Мы можем закончить эту часть синтаксического анализатора добавив поддержку круглых скобок. Как вы знаете, скобки являются механизмом принудительного изменения приоритета операторов. Так, например, в выражении 2*(3+4) , скобки заставляют выполнять сложение перед умножением. Но, что гораздо более важно, скобки дают нам механизм для определения выражений любой степени сложности, как, например (1+2)/((3+4)+(5-6)) Ключом к встраиванию скобок в наш синтаксический анализатор является понимание того, что не зависимо от того, как сложно выражение, заключенное в скобки, для остальной части мира оно выглядит как простой показатель. Это одна из форм для показателя: <factor> ::= (<expression>) Здесь появляется рекурсия. Выражение
может содержать показатель, который содержит другое выражение, которое
содержит показатель и т.д. до бесконечности.
{---------------------------------------------------------------}
procedure Expression; Forward; procedure Factor;
Заметьте снова, как легко мы
можем дополнять синтаксический анализатор, и как хорошо код Паскаля соответствует
синтаксису БНФ.
УНАРНЫЙ МИНУС На данном этапе мы имеем синтаксический анализатор, который поддерживает почти любые выражения, правильно? ОК, тогда испробуйте следующее предложение: -1 Опс! Он не работает, не правда ли? Процедура Expression ожидает, что все числа будут целыми и спотыкается на знаке минус. Вы найдете, что +3 также не будет работать, так же как и что-нибудь типа: -(3-2). Существует пара способов для исправления этой проблемы. Самый легкий (хотя и не обязательно самый лучший) способ – вставить ноль в начало выражения, так чтобы -3 стал 0-3. Мы можем легко исправить это в существующей версии Expression: {---------------------------------------------------------------}
procedure Expression;
Я говорил вам, насколько легко мы сможем вносить изменения! На этот раз они стоили нам всего трех новых строчек Паскаля. Обратите внимание на появление ссылки на новую функцию IsAddop. Как только проверка на addop появилась дважды, я решил выделить ее в отдельную функцию. Форма функции IsAddop должна быть аналогична форме функции IsAlpha. Вот она: {--------------------------------------------------------------}
function IsAddop(c: char): boolean;
ОК, внесите эти изменения в программу
и повторно откомпилируйте. Вы должны также включить IsAddop в базовую копию
программы Cradle. Она потребуется нам позже. Сейчас попробуйте снова ввести
-1. Вау! Эффективность полученного кода довольно плохая… шесть строк кода
только для того, чтобы загрузить простую константу… но, по крайней мере,
правильно работает. Запомните, мы не пытаемся сделать замену Turbo Pascal.
СЛОВО ОБ ОПТИМИЗАЦИИ Раннее в этой главе я обещал
дать несколько подсказок как мы можем повысить качество генерируемого кода.
Как я сказал, получение компактного кода не является главной целью этой
книги. Но вам нужно по крайней мере знать, что мы не зря проводим свое
время… что мы действительно можем модифицировать анализатор для получения
лучшего кода не выбрасывая то, что мы уже сделали к настоящему времени.
Обычно небольшая оптимизация не слишком трудна… просто в синтаксический
анализатор вставляется дополнительный код.
Это понятие «щелевой» оптимизации. Основная идея в том, что известно какие комбинации инструкций компилятор собирается произвести и также известно которые из них «плохие» (такие как код для числа -1). Итак, все что нужно сделать – просканировать полученный код, найти такие комбинации инструкций и заменить их на более «хорошие». Это вид макрорасширений наоборот и прямой пример метода сопоставления с образцом. Единственная сложность в том, что может существовать множество таких комбинаций. Этот метод называется «щелевой» оптимизацией просто потому, что оптимизатор работает с маленькой группой инструкций. «Щелевая» оптимизация может драматически влиять на качество кода и не требует при этом больших изменений в структуре компилятора. Но все же за это приходится платить скоростью, размером и сложностью компилятора. Поиск всех комбинаций требует проверки множества условий, каждая из которых является источником ошибки. И, естественно, это требует много времени. В классической реализации «щелевого» оптимизатора, оптимизация выполняется как второй проход компилятора. Выходной код записывается на диск и затем оптимизатор считывает и обрабатывает этот файл снова. Фактически, оптимизатор может быть даже отдельной от компилятора программой. Так как оптимизатор только обрабатывает код в маленьком «окне» инструкций (отсюда и название), лучшей реализацией было бы буферизировать несколько срок выходного кода и сканировать буфер каждый раз после EmitLn. В этом методе выполняется проверка дополнительных условий перед выводом кода. Как тривиальный пример, мы должны были бы идентифицировать нуль и выдать CLR вместо загрузки, или даже совсем ничего не делать, как в случае с прибавлением нуля, например. Конкретней, если мы решили распознавать унарный минус в процедуре Factor вместо Expression, то мы должны обрабатывать –1 как обычную константу, а не генерировать ее из положительных. Ни одна из этих вещей не является слишком сложной для реализации… просто они требуют включения дополнительных проверок в код, поэтому я не включил их в программу. Как только мы дойдем до получения работающего компилятора, генерирующего полезный выполнимый код, мы всегда сможем вернуться и доработать программу для получения более компактного кода. Именно поэтому в мире существует «Версия 2.0». Способ заключается в том, чтобы избежать частого использования стека, лучше используя регистры центрального процессора. Вспомните, когда мы выполняли только сложение и вычитание, то мы использовали регистры D0 и D1 а не стек? Это работало, потому для этих двух операций стек никогда не использовал более чем две ячейки. Хорошо, процессор 68000 имеет восемь регистров данных. Почему бы не использовать их как стек? В любой момент своей работы синтаксический анализатор «знает» как много элементов в стеке, поэтому он может правильно ими манипулировать. Мы можем определить частный указатель стека, который следит, на каком уровне мы находимся и адресует соответствующий регистр. Процедура Factor, например, должна загружать данные не в регистр D0, а в тот, который является текущей вершиной стека. Что мы получаем заменяя стек в RAM на локальный стек созданный из регистров. Для большинства выражений уровень стека никогда не превысит восьми, поэтому мы получаем достаточно хороший код. Конечно, мы должны предусмотреть те случаи, когда уровень стека превысит восемь, но это также не проблема. Мы просто позволим стеку перетекать в стек ЦПУ. Для уровней выше восьми код не хуже, чем тот, который мы генерируем сейчас, а для уровней ниже восьми он значительно лучше. Я реализовал этот метод, просто для того, чтобы удостовериться в том, что он работает перед тем, как представить его вам. Он работает. На практике вы не можете в действительности использовать все восемь уровней... вам, как минимум, нужен один свободный регистр для изменения порядка операндов при делении. Для выражений, включающих вызовы функций, также необходимо зарезервировать регистр. Но все равно, существует возможность улучшения размера кода для большинства выражений. Итак, вы видите, что получение лучшего кода не настолько трудно, но это усложняет наш транслятор... это сложность, без которой мы можем сейчас обойтись. По этой причине, я очень советую продолжать игнорировать вопросы эффективности в этой книге, усвоив, что мы действительно можем повысить качество кода не выбрасывая того, что уже сделано. В следующей главе я покажу вам как работать с переменными и вызовами функций. Я также покажу вам как легко добавить поддержку многосимвольных токенов и пробелов. |