Толчком к написанию данного транслятора послужило изучение учебника Никлауса Вирта "Построение компиляторов" , в бумажном варианте - здесь . После освоения построения лексического и синтаксического анализатора у меня зачесались руки. Я не стал продвигаться далее - оставил таблицу символов и генератор кода на потом и взялся за синтаксический анализатор Оберон-0.
Оберон-0 является подмножеством языка Оберон, выделенное Н. Виртом для своего курса "Построение компиляторов". Поддерживаемые типы - INTEGER и BOOLEAN. Арифметические и логические операции. Модульная система. Определение процедур с параметрами, без областей видимости. Операторы управления - IF, WHILE. Операторы присваивания и вызов процедур. Определение именованных простых типов, массивов (в том числе многомерных и массивов структур), структуры (записи). Определение переменных простых типов и составных (массивов и записей).
Синтаксис Оберон-0, определенных в виде РБНФ:
ident = letter {letter | digit}.
integer = digit {digit}.
selector = {"." ident | "[" expression "]"}.
factor = ident selector | integer | "(" expression ")" | "~" factor.
term = factor {("*" | "DIV" | "MOD" | "&") factor}.
SimpleExpression = ["+"|"-"] term {("+"|"-" | "OR") term}.
expression = SimpleExpression [("=" | "#" | "" | "=" | ">" | ">=") SimpleExpression].
assignment = ident selector ":=" expression.
ActualParameters = "(" [expression {"," expression}] ")" .
ProcedureCall = ident [ActualParameters].
IfStatement = "IF" expression "THEN" StatementSequence
{"ELSIF" expression "THEN" StatementSequence}
["ELSE" StatementSequence] "END".
WhileStatement = "WHILE" expression "DO" StatementSequence "END".
RepeatStatement = “REPEAT” StatementSequence “UNTIL” expression.
statement = [assignment | ProcedureCall | IfStatement | WhileStatement | RepeatStatement].
StatementSequence = statement {";" statement}.
IdentList = ident {"," ident}.
ArrayType = "ARRAY" expression "OF" type.
FieldList = [IdentList ":" type].
RecordType = "RECORD" FieldList {";" FieldList} "END".
type = ident | ArrayType | RecordType.
FPSection = ["VAR"] IdentList ":" type.
FormalParameters = "(" [FPSection {";" FPSection}] ")".
ProcedureHeading = "PROCEDURE" ident [FormalParameters].
ProcedureBody = declarations ["BEGIN" StatementSequence] "END".
ProcedureDeclaration = ProcedureHeading ";" ProcedureBody ident.
declarations = ["CONST" {ident "=" expression ";"}]
["TYPE" {ident "=" type ";"}]
["VAR" {IdentList ":" type ";"}]
{ProcedureDeclaration ";"}.
module = "MODULE" ident ";" declarations
["BEGIN" StatementSequence] "END" ident "." .
Компилятор (точнее, голый синтаксический анализатор) реализован в виде консольной программы в стиле Free Pascal Compiler.
Из преимуществ консольной прогаммы перед оконным редактором я бы выделил возможность автоматизации тестирования компилятора. Запускать компилятор, передавая ему в качестве параметров список юнит-тестов (см. далее) проще, чем GUI-приложение. Далее, не хочется тратить время на изобретение редакторов-велосипедов.
В качестве параметра подается имя одного или нескольких файлов (модулей). Сообщения выводятся в ту-же консоль. Предполагалось, что компилятор будет генерировать obj-файлы в формате coff. Линковка исполняемого файла будет осуществляться линкером от MASM. Почему MASM7 Незадолго до этого я поигрался с Ассемлером, читая прекрасную книгу В.Пирогова "Ассемблер для Windows".
Тип компиляции - раздельный.
Раздельная компиляция - это когда каждый модуль (текстовый файл c расширением "ob0") можно скомпилировать отдельно. На выходе получается объектный файл "obj".
Подав головной obj-файл на сборщик от MASM, получаем на выходе исполняемый файл exe.
Считаю, что раздельная компиляция более совершенна, чем совместная, как например, у Free Pascal. Раздельная компиляция обладает следующими преимуществами:
Отпадает необходимость генерировать PE-файл, уменьшается объем кода генератора.
Повышается скорость работы компилятора, программист может организовать инкрементальную компиляцию большого проекта.
Появляется возможноть (в теории) использовать obj-модули, подготовленные с помощью других языковых пакетов, например Visual Prolog. Данное преимущество достаточно спорное и трудновыполнимое. Широко известна борьба obj-форматов между Visual Studio и Delphi. Искусственная постройка барьеров между языками и средами привели к грустной картине, когда практически все среды программирования представляют из себя "вещи в себе". Это разрушает преимущества специализированного подхода к программированию и не дает разные части программы разрабатывать на разных языках. Например, общий каркас - на Паскале, искуственный интеллект - на Прологе, а критически важные части расчетов - на Ассемблере. Линкер же призван объединять все модули в единое целое. В результате же подавляющая часть программ разрабатывается на языках общего назначения - c++, Pascal или, упаси Господи, Java. В результате подавляющее большинство программ - окошки, и операции плюс-минус. О большей интеллектуальности ПО никто не мечтает. Попытка тотальной интеллектуализации ПО провалась в прошлом веке. С другой стороны, существующие языки, предназначенные для ИИ, в частности, Prolog, предназначены именно для программирования интеллектуальных и логических операций. Если сравнивать Prolog с C, то программировать например, перебор, лексический разбор и перевод текстов, то Prolog действительно, эффективнее. Но программировать GUI и элементарные повседневные задачи - не для слабонервных. Кроме задач ИИ, можно найти множество других задач, для реализации которых использовать язык общего назначения крайне неэффективно и желательно использовать (да и разрабатывать) специализированные языки. Но для этого необходимо иметь возможность раздельной компиляции модулей, написанных на различных языках. Существующая возможность в Visual Studio и Delphi ассемлерных вставок я считаю костылем, хотя и решающий частично задачу работы с Ассемлером, но крайне неэффектывным. Inline-assembler всегда значительно отстает от наборов команд последних процессоров, даже последние версии сред поддерживает не все команды. Впрочем, возможно, в Visual Studio поддеживается сборка с obj-модулями от других сред, и я зря сотрясаю воздух. Если это так, сообщите мне.
Синтаксический анализатор возвращает управление после первой ошибки в тексте прошраммы. Синхронизация и продолжение разбора, которое было описано у Вирта в его учебнике я не реализовал для простоты разработки и тестирования.
Из практики своей профессиональной деятельности знаю, что для разработки программы достаточно сообщения о первой встреченной ошибке.
Большое количество ошибок, которые компилятор выдает после первой, являются "наведенными" от первой встреченной. Например, если имеется синтаксическая ошибка в определении переменной, то с большой долей вероятности будет, что дальнейшие выражения с этой переменной будут генерировать ошибки, говорящие об отсутствии определения переменной.
"Наведенные" синтаксические ошибки обычно затруднены в анализе и приводят программиста в недоумение. Они сложно исправляются. Попытки программиста исправить наведенные ошибки обычно приводят к посеиванию дополнительных ошибок, которые в конце-концов приводят к тому, что код "рушится".
Исправляя ошибку в коде, необходимо быть уверенным, что выше в тексте ошибок нет.
Необходимость помнить о всех ранее исправленных после последней компиляции ошибках, необходимость анализа очередной ошибки на вопрос, является ли она наведенной требует от программиста больших усилий и внимания в работе.
Запуск компиляции одтельного модуля обычно (по крайней мере, в Delphi, не будем обсуждать Java) обычно занимает несколько секунд и является несущественным. Компиляция должна запускаться как можно чаще. Программист должен получать сообщения об совершенных ошибках как можно быстрее, пока он не вышел из того кода (процедура, класс), который он редактирует. Ctrl-F9, Ctrl-F9, Ctrl-F9!!! А не в конце дня.
Поэтому принимает следующую модель работы. Программирование ведется инкрементно. Пишется процедура. Запускается компиляция. После каждого прогона и получения сообщения об ошибке программист исправляет ошибку, сообщение о которой вернул ему компилятор, затем сразу запускает компиляцию заново, снова исправляет ошибку и так далее, пока компилятор не вернет ОК. Пишем вызов данной процедуры и начинаем ее отлаживать.
Для того, чтобы обеспечить качественное тестирование компилятора, по ходу реализации каждой синтаксической конструкции разрабатывались юнит-тесты.
Юнит-тест - файл программы на Оберон-0, с расширением "ob0". Компилятор запускается в командной строке с передачей в качестве параметра имя файла с юнит-тестом. В консоли выводится сообщение об успешной трансляции или об обнаруженной в тексте лексической или синтаксической ошибки.
На одну синтаксическую конструкцию пишется один или несколько положительных юнит-тестов. В положительном тесте написан модуль с правильным применением этой конструкции. Компилятор должен транслировать этот модуль без выдачи синтаксических ошибок. Также создается несколько тестов для тестирования вывода ошибок, которые компилятор находит в тексте программы. На каждую синтаксическую ошибку, которую компилятор может обнаружить в тексте программы, создается свой юнит-тест. В этом тесте присутствует тот-же текст, что и в положительном тесте, но намеренно добавлена ошибка, которую должен в этом тесте вернуть компилятор.
Множество модулей можно описать в одном файле-списке и подавать его компилятору за один раз. Компилятор будет анализировать каждый модуль из этого списка. Если при его анализе обнаруживается синтаксическая ошибка, компилятор выводит соответствующее сообщение и начинает анализ следующего по списку модуля . Это дает возможность прогонять все юнит-тесты после каждого изменения кода компилятора, автоматизировать тестирование и обеспечить полное его покрытие. При компиляции положительных тестов выводится сообщение ОК, при компиляции отрицательных - наименование модуля, номер строки, код и текстовое сообщение. Для отрицательных тестов код этого сообщения должно совпадать с тем, которое должно быть выводиться при компиляции данного теста. Чтобы автоматизировать тестирование таких тестов, введен режим -autodebug.
Файл списка (test.lst) при этом имеет следующий формат:
utest_module1_0.ob0=0
utest_module_20002.ob0=20002
utest_module_20003.ob0=20003
utest_module_20004.ob0=20004
utest_module_20005.ob0=20005
utest_module_20006.ob0=20006
utest_module_20007.ob0=20007
После знака "=" указывается код ошибки, который должен возвращен для этого теста. Пример содержимого командного файла для тестирования компилятора: pc3.exe -ltest.lst -ad -coff > autodebug.out pspad.exe autodebug.out ltext.lst - список модулей, фрагмент которого приведен выше. -ad - ключ режима "autodebug". В этом режиме, если синтаксический анализатор вернет код ошибки, отличный от того, что указан после "=", компилятор выводит код и содержимое этой ошибки. Если коды совпадают, то сообщение не выводится. Просмотрев вывод, можно быстро обнаружить, при трансляции каких конструкций компилятор работает не тем образом, каким задумывалось. Это очень эффективная техника и она очень помогла мне в реализации этого анализатора. Рекомендую к использованию.
При разработке транслятора я отказался от применения исключений (Exception). Хотя Delphi, в которой разрабатывался транслятор, имел exception handling, я решил не использовать его. Это решение было продиктовано двумя соображениями.
Планировалось после написания первой минимальной версии компилятора ("насоса"), способной генерировать исполняемые программы, портировать код компилятора с Delphi на Оберон, и в дальнейшем дорабатывать компилятор уже на языке Оберон и с помощью его самого. То есть, работающий компилятор должен производить сам себя и компилировать свой собственный текст. Естественно, производимый компилятор не должен отличаться от родителя.
В Обероне исключений нет. Причины описаны здесь (http://oberon2005.oberoncore.ru ).
Для обработки ошибок применялась глобальная переменная ошибки с кодом и номером строки. При обнаружении ошибки в тексте программы код сохраняется в глобальной переменной ошибки и производится выход из текущей процедуры. Каждый вызов процедуры синтаксического (и лексического) анализатора сопровождается анализом данной переменной. Если в переменная ошибки содержит код, отличный от нуля, производится выход из процедуры. И так далее, подобная раскрутка производится до головной процедуры синтаксического анализатора. Вторая причина, по которой я не использовал исключения, это желание попробовать, возможно ли программировать без исключений и насколько увеличится трудоемкость и сложность кода. Для примера приведу процедуру ParseExpression, служащую для разбора арифметических и логических выражений.
procedure TpndOberonOSP.ParseExpression(var x: TpndOberonOSGItem);
var
op: integer;
y: TpndOberonOSGItem;
begin
ParseSimpleExpression(x); if IsError then Exit;
if (FOSS.Sym >= seql) and (FOSS.Sym = sgtr) then begin
op := FOSS.Sym;
FOSS.GetSymSkipEOL; if IsError then Exit;
ParseSimpleExpression(y); if IsError then Exit;
end
else if not (
(FOSS.Sym = srparen)
or (FOSS.Sym = srbrak)
or (FOSS.Sym = ssemicolon)
or (FOSS.Sym = send)
or (FOSS.Sym = scomma)
or (FOSS.Sym = sthen)
or (FOSS.Sym = selse)
or (FOSS.Sym = selsif)
or (FOSS.Sym = sdo)
or (FOSS.Sym = sof)
) then begin
if FOSS.Sym > sident then begin
Mark(20027, MSG20027, FOSS.Line, FOSS.Pos);
end;
end;
end;
procedure TpndOberonOSP.ParseSimpleExpression(var x: TpndOberonOSGItem);
var
op: integer;
y: TpndOberonOSGItem;
begin
if FOSS.Sym = splus then begin
FOSS.GetSymSkipEOL; if IsError then Exit;
ParseTerm(x); if IsError then Exit;
end
else if FOSS.Sym = sminus then begin
FOSS.GetSymSkipEOL; if IsError then Exit;
ParseTerm(x); if IsError then Exit;
end
else begin
ParseTerm(x); if IsError then Exit;
end;
while (FOSS.Sym >= splus) and (FOSS.Sym = sor) do begin
op := FOSS.Sym;
FOSS.GetSymSkipEOL; if IsError then Exit;
if op = splus then begin
end
else if op = sminus then begin
end
else begin
end;
ParseTerm(y); if IsError then Exit;
end;
end;
Как видите, после вызова ParseSimpleExpression и ParseTerm необходимо проверять переменную ошибки (с помощью вспомогательной функции IsError). В случае обнаружения синтаксической ошибки в переменную записывается код ошибки с помощью процедуры Mark и форсируется выход из процедуры.
if FOSS.Sym > sident then begin
Mark(20027, MSG20027, FOSS.Line, FOSS.Pos);
Exit;
end;
Здесь FOSS - экземпляр класса лексического анализатора, FOSS.Sym - код очередного символа (токена) текста. sident - целочисленная константа. Текст символа находится в FOSS.Id. MSG2007 - текстовая константа с сообщением об ошибке.
По результатам опыта работы с этим компилятором могу сказать, что код действительно распухает, затрудняя его чтение. Добавляется возможность ошибки, когда при вызове, допустим ParseTerm(); забываешь выражение if IsError then Exit;. Но никакой трагедии в этом нет, работать вполне можно. Структура вызовов процедур и обработки ошибок разбора регулярная, сами такие пропуски "if IsError" отлавливаются юнит-тестами.
В приложении - исходный код лексического и синтаксического анализатора Oberon-0, написанный на Delphi. Код каждого анализатора разбит на две части - базовых класс и наследник для oberon-0.
Оболочку программы, разбор параметров, загрузка файлов, запуск анализаторов я не выкладываю. Поэтому, если захотите реализовать компилятор с использованием вышеприведенных классов, Вам придется писать программу самостоятельно. Соответственно, проект Delphi не выкладываю.
Запуск анализатора прозводится следующим образом:
procedure CompileModule(var aParam: TParam; aFileName: String; aErrNumDebug: integer);
var
Compiler: TpndOberonOSP;
source: TStringList;
begin
if not FileExists(aFileName) then begin
WriteFileNotFound(aFileName);
Exit;
end;
Compiler := TpndOberonOSP.Create;
source := TStringList.Create;
Compiler.Source := source;
try
if aErrNumDebug > -1 then begin
WriteBeginAutoDebug(aFileName);
end
else begin
WriteBeginCompileModule(aFileName);
end;
try
Source.LoadFromFile(aFileName);
with Compiler do begin
Init;
Compile;
end;
if aErrNumDebug > -1 then begin
if LastError.ErrorNum > aErrNumDebug then begin;
WriteError(aFileName, LastError);
WriteAutoDebugError(aFileName, LastError, aErrNumDebug);
end
else begin
WriteAutoDebugSuccesfull(aFileName);
end;
end
else begin
if LastError.ErrorNum > 0 then begin
WriteError(aFileName, LastError);
WriteCompileModuleWithError(aFileName);
end
else begin
WriteCompileModuleSuccesfull(aFileName);
end;
end;
except
WriteCompileModuleWithError(aFileName);
end;
finally
source.Clear;
source.Free;
Compiler.Free;
end;
end;
В папке release представлены исполняемый файл компилятора (точнее, синтаксического анализатора), юнит-тесты и список тестов "Test.lst". а также скрипт для запуска "autodebug.cmd".
Скрипт запускает компиляцию юнит тестов по списку в режиме autodebug. Текстовый сообщения, выводимые на консоль, перенаправляются в текстовый файл "autodebug.out".
В скрипте необходимо поправить строку "pspad.exe autodebug.out", если Вы пользуетесь не pspad, а другим тектсовым редактором.
Данный синтаксический анализатор был разработан мною в качестве упражнения при изучении курса Н.Вирта и поэтому не претендует на коммерческое применение.
Базовые классы лексического и синтаксического анализатора достаточно проработаны, чтобы использовать их в коммерческих проектах. Для реализации транслятора с другого языка необходимо наследовать классы анализаторов аналогично классам для Оберон-0.
У меня есть желание разработать язык программирования, такой же компактный, как Оберон, но более развитый. По поводу Оберона у меня есть множество критических замечаний, которые возможно, я как-нибудь оформлю. Считаю, что все-таки, Оберон мало привлекателен для коммерческой разработки ПО. Если базовый язык компактен (как Оберон), то он должен обладать большой возможностью развития, для построения коммерческого языка.
Хотелось бы создать язык, либо реализовать тот-же Оберон, для разработки ПО для микроконтроллеров. Ассемлер, хотя и прекрасный язык, но все-таки, недостаточно производительный. Нет в нем того "драйва", той той гладкости, той волны, которая несет тебя при разработке приложения на Паскале и Делфи, позволяя писать сотни строк зараз, эффективно, безошибочно, получая от программирования огромное удовольствие.
В общем, возможностей много.
Разработка компиляторов, а далее - языков программирования - очень интересное и захватывающее дело. Хотя и очень, очень трудоемкое. Жалко только, что шансы на коммерческий успех в нынешней рыночной ситуации крайне малы. Но даже если я не разбогатею на продажах своего продукта, ничего страшного. Работая над этим простейшим компилятором, я получил огромное удовольствие.
Никлаус Вирт. Построение компиляторов / Пер. с англ. Борисов Е.В., Чернышов Л.Н. - М.:ДМК Пресс, 2010. -192 с.: ил.
Niklaus Wirth, Compiler construction, Slightly revised version of the book published by Addison-Wesley in 1996, http://www.inf.ethz.ch/personal/wirth/Articles/CompilerConstruction/CBE.pdf .
Оборон технологии в Росси. http://oberoncore.ru .