Как создать свой язык программирования: теория, инструменты и советы от практика. Как создать свой язык программирования: теория, инструменты и советы от практика Специальность «Системное программирование»

Здравствуйте, дорогие читатели! Сегодня мы с вами немного окунемся в теорию. Наверняка, вы все когда-то хотели отправить свою супер-пупер программу другу. Но как это сделать? Не заставлять же его устанавливать PascalABC.NET! О том, как это сделать, мы сегодня и поговорим.

Все языки программирования делятся на два типа - интерпретируемые и компилируемые .

Интерпретаторы

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

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

При интерпретации выполнение кода происходит последовательно строка за строкой (от инструкции до инструкции). Операционная система взаимодействует с интерпретатором, а не исходным кодом.

Примеры интерпретируемых языков: PHP, JavaScript, C#, Python.

Скомпилированные программы работают быстрее, но при этом очень много времени тратится на компиляция исходного кода.

Программы же, рассчитанные на интерпретаторы, могут выполняться в любой системе, где таковой интерпретатор присутствует. Типичный пример - код JavaScript. Интерпретатором его выступает любой современный браузер. Вы можете однократно написать код на JavaScript, включив его в html-файл, и он будет одинаково выполняться в любой среде, где есть браузер. Не важно, будет ли это Safari в Mac OS, или же Internet Explorer в Windows.

Компиляторы

Компилятор - это программа, превращающая исходный текст, написанный на языке программирования, в машинные инструкции.

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

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

Примеры компилируемых языков: C, C++, Pascal, Delphi.

Ход работы компилятора.

Препроцессинг

Эту операцию осуществляет текстовый препроцессор .

Исходный текст частично обрабатывается - производятся:

  • Замена комментариев пустыми строками
  • Подключение модулей и т. д. и т. п.

Компиляция

Результатом компиляции является объектный код .

Объектный код - это программа на языке машинных кодов с частичным сохранением символьной информации, необходимой в процессе сборки.

Компоновка

Компоновка также может носить следующие названия: связывание , сборка или линковка .

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

EXE файл.

После компоновки у вас образуется.EXE файл вашей программы. Вы можете кинуть ее другу, и она откроется у него прямо в командной строке, как в старом добром DOS. Давайте попробуем создать.EXE файл. Все действия будут приводится в PascalABC.NET.

Заходим в Сервис -> Настройки -> Опции компиляции. Поверяем, стоит ли галочка напротив 2 пункта. Если стоит, то убираем ее.

Теперь откройте свою программу и запустите ее.

Откройте директорию, в которой у вас лежит исходный код программы.

Вот он,.EXE файл.

Кликаем по приложению. Как вы видите, после ввода данных, окошко сразу закрывается. Для того чтобы окно не закрывалось сразу, следует дописать две строчки кода, а именно: uses crt (перед разделом описания переменных) и readkey (в конце кода, перед оператором end).


Подключаем внешнюю библиотеку crt и используем встроенную в нее функцию readkey.

Теперь окно закроется по нажатию любой клавиши.

На заметку: PascalABC.NET - это интегрированная среда разработки.

Среда разработки включает в себя:

  • текстовый редактор;
  • компилятор;
  • средства автоматизации сборки;
  • отладчик.

На сегодня все! Задавайте любые вопросы в комментариях к этой статье. Не забывайте кликать по кнопочкам и делится ссылками на наш сайт со своими друзьями. А для того, чтобы не пропустить выход очередной статьи, рекомендую вам подписаться на рассылку новостей от нашего сайта. Одна из них находится в самом верху справа, другая - в футере сайта.

М. Черкашин Журнал "Монитор" N 4 1992 год Компилятор пишется так... Писать компилятор приходится чаще, чем обычно думают. Практически всякая большая система включает в себя входной язык - от примитивного до весьма сложного. Вспомним хотя бы dBASE - это ведь не язык программирования, а система баз данных. На нее даже программы пишутся. А раз нужен входной язык, то бывает нужен и компилятор. И часто нужен быстро. Конечно, всякий предпочтет компактный, быстрый, хорошо оптимизирующий компилятор, но далеко не всякому понравится такой компилятор писать. Чтобы писать сложные, эффективные и быстрые компиляторы, есть много рецептов. Но здесь речь пойдет не о них. Как написать компилятор просто - вот в чем вопрос! Ведь такой компилятор и короче, и отлаживается легче. Здесь не будет каких-то хитрых алгоритмов, позволяющих достигать чудес эффективности или каких- то особенных способов организации данных. Увы, с этими способами одна проблема: заставить их работать может только чудо. А в этой статье - только практические рекомендации, а работают они надежно! Оптимизацию и генерацию кода мы рассматривать не будем. Оптимизация - тема для отдельной статьи, а генерация кода - занятие для самоубийцы. Соломоново решение в данном случае - переводить программу не в коды машины, а на язык, который у вас уже есть. Так и делаем. Время компиляции, конечно, увеличивается, но зато мы избавляемся от большого количества неприятной возни. Модельный компилятор Лучший способ понять, как писать компилятор - написать его самому. Лучший способ объяснить это - предъявить текст программы. Вот мы и предлагаем статью-программу. Впрочем, ее можно читать и не заглядывая в листинг, но при этом вы много потеряете. Компилятор написан на Паскале, выходной язык - Паскаль, входной язык прост до идиотизма (pис.4). Читатели, владеющие с языком Паскаль, узнают и во входном языке знаковые конструкции. Посмотрим на рис 1. Наш модельный компилятор состоит из четырех частей: сканер, блок таблиц имен, основная часть компилятора и семантические подпрограммы. В том или ином виде эти части можно встретить в любом компиляторе. Сканер - это глаза компилятора, который обращается к тексту программы только через него. Сканер читает входной файл и избавляет остальные части копилятора от необходимости следить за каждым входным символом. В таблице имен хранится информация о переменных программы. Обращение к ней осуществляется в основном через процедуры блок таблицы имен. И, наконец, основная часть компилятора...компилирует. Точнее, следит за ходом компиляции. Как только становится ясно, с какой конструкцией мы имеем дело, управление передается специализированной, имеющей дело только с этой конструкцией, процедуре, которая называется семантической подпрограмммой. Сканер Среди всех конструкций, воспринимаемых компилятором, есть минимальные, и которые уже не исеет смысла делить на части. Например: идентификатор, число, строка, ключевое слово. Они называются лексемами. Компилятору нет нужды разбираться в их структуре. Кстати, в описании старого, доброго Алгола-60 использовался термин иероглифы, то есть нечто такое, что можно обозначить значком. Но у компьютера всего-то 256 символов - и приходится писать, например, "procedure". А посему идея следующая: отдельная процедура считывает программу из файла, выделяет оттуда лексемы и передает их основной части компилятора. А той до текста программы уже нет никакого дела. Такая подпрограмма называется сканером. В языке SIMPLE существуют пять видов лексем (pис.2): ключевые слова, идентификаторы, числа, спецсимволы и нечто в квадратных скобках. По поводу первых четырех типов, кажется, все ясно - см. рисунок. А пятый, последний - это наша хитрость. Дело в том, что компиляция выражений - это вопрос особый, и ему будет посвящена следующая статья. Поэтому, пользуясь тем, что выходной язык у нас Паскаль, мы перекладываем эту задачу на плечи компилятора с Паскаля. А чтобы у нас не возникало проблем - информацию, которые мы просто переписываеми в выходной файл, не разбираясь в его внутренней структуре, заключаем в квадратные скобки. Посмотрим на текст программы (Пример 1. Сканер). Основная процедура сканера - Scan. Вызывая ее, мы получаем очередную лексему. Мы можем даже считать ее аналогом процедуры ввода - со странного устройства, которое передает внутрь компьютера лексемы. Процедура Scan по первому символу лексемы определяет, что за лексема нас сейчас ждет, и вызывает "специализированную" процедуру, которая только этим типом лексем и занимается. Единственная загвоздка здесь - ключевые слова путаются с именами. Поэтому они считываются вместе, а уж потом одно отделяются от другого. Обратим внимание на вспомогательные процедуры GetCh и UngetCh. Процедура GetCh заменяет сиволы перевода строки и табуляции на пробелы и пропускает лишние пробелы (так получается, что несколько пробелов подряд эквивалентны одному). Процедура UngetCh используется, если мы "погорячились" и считали "лишний" символ. Тогда с помощью этой процедуры можно сказать: "Возьми обратно!" Процедура GetCh выдаст в следующий раз тот же символ. Таблица имен Когда человек пишет свою программу или разбирается в чужой, ему нужно держать в голове кое-какую информацию. Но машина железная, у нее головы нет. Поэтому необходима структура данных, которая про каждую переменную хранила бы определенную информацию. Эта структура называется таблицей имен. Без нее не обходится ни один компилятор. Таблица имен действительно похожа на таблицу. Она состоит из "строк" - ячеек таблицы имен, которые могут содержать инфорацию об одной простой переменной, например, переменной целого типа. (Обычно эти ячейки называют записями таблицы имен, но здесь сознательно используется другой термин, чтобы не было путаницы с языком SIMPLE). Посмотрим на рис.3. Там изображена структура нашей таблицей имен. Проще всего, когда переменная - целого типа. Для нее нужно хранить всего ничего - имя и тип (integer). В реальных, больших компиляторах, впрочем, есть еще кое-что - ее адрес, например. Но мы, слава Богу, компилируем в Паскаль! С записями будет посложнее. Даже в голове у нас они хранятся не целиком, а как нечто, сложенное из кусочков. Вот фрагмент описаний переменных языка SIMPLE (Рис 3А). В голове он превратится во что-то подобное рис 3Б. А к чему должно быть ближе то, что хранится в таблице имен? Наверное, к тому, что хранится в голове, а не на бумаге. Но в одну ячейку таблицы имен может поместиться информация максимум об одной переменной. Поэтому сейчас наша задача - "раскидать" по ячейкам информацию о структуре записи. Это можно сделать разными способами. Здесь выбран следующий. На любую переменную - i,j,u и т.д. - заводим одну запись таблицы имен. Если эта переменная - запись, то в поле Fields пишется ссылка на описание структуры ее полей. В данном случае ссылка - просто номер ячейки таблицы имен, содержащей заголовок этого описания. Таким образом, число в поле Fields соответствует жирным стрелкам на рис 3Б. Описание структуры записи представляет собой заголовок (слово (record) на Рис 3Б или ячейка со значком " в поле Pname на Рис 3В) и еще несколько - по одной на каждое поле - ячеек. Каждая из этих ячеек (в том числе и заголовок) содержит в поле Ref номер ячейки для следующего поля записи. Таким образом, число в поле Ref соответствует нежирной стрелке на Рис 3Б. Ячейка для последнего поля содержит в поле Ref ноль. Это означает "Дальше полей нет!" и обозначается на Рис 3В диагональным крестом. Если же поле записи снова запись, то соответствующая этому полю ячейка содержит в поле Fields ссылку на описание структуры полей. Первые MaxKey записей таблицы имен на самом деле не имена, а ключевые слова. Имена и ключевые слова вообще очень легко перепутать. Поэтому если имя будет найдено в таблице имен, но в записи с номером не большим, чем MaxKey, то это на самом деле не имя, а ключевое слово. Можно, конечно, завести для ключевых слов отдельный список, но так, как сделано здесь, проще. Для обращения к таблице имен (Пример 2) предусмотрено всего три процедуры: поиск имени в таблице, помещение имени в таблицу и определение по номеру записи, соответствует ли она ключевому слову или нет (это всего лишь сравнение с константой MaxKey - но тем не менее это полезно). Основной блок компилятора. К моменту, когда вы уже готовы сесть и начать писать текст этого блока, вы должны знать, что у вас делают остальные части (сканер, процедуры работы с таблицами и т.д.). Причем желательно, чтобы остальные части делали побольше, а эта поменьше. Отдельную небольшую подпрограмму вы всегда отладите, а вот процедуру, которая вызыввает несколько других процедур, отладить несколько сложнее. Короче, главные проблеы у вас будут все-таки с основной частью. Посмотрите на текст программы (Пример 3). Что должна делать основная часть? Читать описания переменных и заполнать таблицу имен. Потом содержимое таблицы имен должно быть вовремя записано в выходной файл в виде описаний переменных на языке Паскаль. Кроме того, для каждого оператора на языке SIMPLE в выходной файл должен быть записан эквивалентный ему оператор языка Паскаль. В описаниях основная проблема - это описания записей. "Разобраться" с ними - значит поместить их в таблицу имен. В данной программе это сделано рекурсивно. А действительно, как это еще сделать? Ведь и записи определены рекурсивно: "...содержит в качестве полей целые числа и [другие]_записи". Есть также определенные проблемы и с трансляцией описаний. Оператор присваивания или печати мы всегда можем считать целиком в память и уже потом компилировать. А вот оператор цикла сам может содержать внутри себя тысячи операторов. Поэтому нам остается только одно - встретив while...do, записать в выходной файл начало оператора цикла, потом как ни в чем ни бывало компилировать внутренние операторы цикла, а встретив close и вспомнив, что когда-то мы встречали while...do, записать в выходной файл все, что останется. К счастью, мы копилируе в Паскаль, а не в коды. И запоминать на самом деле нам ничего не надо. Достаточно, встретив while...do, написать "while ... do begin", а встретив close, написать "end" - и мы никогда не прогадаем, потому что "begin" ставим всегда. А вот если бы мы транслировали в машинный код нам необходимо было бы знать, с какого адреса начинался этот оператор. А для этого нужна структура данных, называемая стеком. Куда идти дальше? У тех, кто дошел до этого места, возможно, возникнут вопросы. Я не берусь предугадать их все, но на некоторые можно ответить заранее. - Что еще нужно сделать, если с компилятором будут работать часто? - Необходимо вставить диагностику ошибок. На практике компилятор в большинстве случаев выдает не скомпилированный текст, а сообщение об ошибке. Поэтому и писать компилятор нужно так, чтобы проверка на ошибки производилась как можно легче. В частности, чем больше хитрых приемов вы используете (например, ускоряющих компиляцию), тем больше вам придется ломать голову над тем, куда этот алгоритм забредет в случае ошибки и как сделать, чтобы копилятор не сбился с пути и обнаружил не только первую встретившуюся ошибку, но еще и какие-то другие. - Как вставлять новые конструкции в язык? - Если речь идет о структурах данных, то лучше добавить новое поле в запись таблицы имен, чем ломать голову над тем, как сэкономить лишнее поле. Если речь идет об операторах - добавьте еще одну специализированную процедуру, копилирующую данный оператор. Соответственно, вызов этой процедуры должен быть вставлен в процедуру Code. - А если хочется все-таки сделать компилятор более эффективным? - Ради Бога! Только сначала пусть он заработает. А потом его можно оптимизировать по вкусу. Кроме того, прежде чем применить многообещающий алгоритм, хорошенько проверьте, будет ли он столь эффективен в вашем случае. А то ведь все эти алгоритмы на практике хороши в весьма специальных случаях. Подумайте, стоит ли овчинка выделки. - Как отлаживать компилятор? - Компилятор - очень коварная программа. И выловить все ошибки из него обычно не удается. Не полагайтесь на свою интуицию. Сделайте себе набор тестов - небольших, но сам набор пусть будет представительнымм. И когда компилятор будет подходить к готовности, прогоните их все. (Да, в том числе и те, что уже когда-то проходили!) И напоследок еще один совет. Больше простоты! Если бы программисты всегда ему следовали, то работающих программ было бы несомненно больше (а недоотлаженных, брошенных на полдороге - меньше). Пример 1. Сканер (**************************************) (* Данные для сканера *) (**************************************) const ExprLen = 240; Type LexEnum = (LexKeyword, LexNames, LexNumber, LexSpecs, LexExpr); SpecEnum = (SemiColn, Comma, Assign); var LexType: LexEnum; LexVal: integer; (* Буфер для кое-чего в квадратных скобках *) Buf: string ; EndStream, LogEOF: boolean; (* Буфер и указатель буфера строки входного файла*) LineBuf: string ; LinePtr: integer; (* А вот здесь мы храним символ, от которого мы отказались с помощью функции UngetCh *) OldCh: char; (* True, если в OldCh что-то лежит*) Suspend: boolean; procedure IniScan; begin LineBuf:= " "; LinePtr:= 1; Suspend:=False; end; (* Сканер. Оставляет в переменных LexType и LexVal. Нечто в квадратных скобках остается в буфере Buf. Функции Alpha (c) и Num (c) равны True, если их аргумент буква или цифра соответственно. *) procedure Scan; var Ch: char; begin if Ch = " " then (* Бог с Ним! *) else UngetCh; if EndStream then LogEOF:= true (* Конец файла! *) (* Встретили квадратную скобку - впереди выражение *) else if Ch = "[" then Brackets (* Впереди ключевое слово или переменная, а вот что неясно *) else if Alpha (Ch) then KeyOrNames else if Num (Ch) then Numbers (* Число!*) else if Ch = ";" then Spech (SemiColn) else if Ch = "," then Spech (Comma) else if Ch = ":" then begin Ch:= GetCh; Ch:= GetCh; if Ch = "=" then Spech (Assign); (* := *) end; end; (* Сначала прочитать нечто алфавитно-цифровое, а потом уже разобраться, что это - ключевое слово или идентификатор *) procedure KeyOrNames; var LexBuf: bloc; i,t: integer; Ch: char; begin Ch:= GetCh; i:= 1; repeat LexBuf [i] := Ch; i:= i+1; Ch:= GetCh; until not (Alpha (Ch) or Num (Ch)); LexBuf := chr (i); UngetCh; t:= Find (LexBuf); if KeyEntry (t) then begin LexType:= LexKeyword; LexVal:= t; end else if t 0 then begin LexType:= LexNames; Lexal:= t; end else begin LexVal:= LexNames; t:= PutIn (LexBuf); end; end; (* Считать число *) procedure Numbers; Count: integer; Ch: char; begin Ch:= GetCh; Count:= 0; repeat Count:= 10*Count + (ord (Ch)-ord ("0")); Ch:= GetCh; until not Num (Ch); UngetCh; LexType:= LexNumber; LexVal:= Counter; end; (* Установить переменные LexType и LexVal для спецсимволов *) procedure Spech (Sym: EnumSpec); begin LexType:= LexSpecs; LexVal:= ord (Sym); end; (Нечто в квадратных скобках **) procedure Brackets; var Ch: char; i: integer; begin Ch:= GetCh; Ch:= GetCh; i:= 1; while Ch "]" do begin Buf [i] := Ch; Ch:= GetCh; i:= i+1; end; Buf := chr (i-1); LexType:= LexExpr; end; (* Вспомогательные подпрограммы для сканера *) Function GetCh: char; var i: integer; Ch: char; begin if Suspend then begin GetCh:= OldCh; Suspend:= False; end else repeat if LinePtr>legth (LineBuf) begin Ch:= " "; NewLine; end else begin Ch:= LineBuf ; LinePtr:= LinePtr+1; end; until ((Ch" ") and (Chchr (Tab))) or Eof (SRC); if Eof (SRC) then EndStream:= 1; end; procedure UngetCh; begin Suspend:=True; end; procedure NewLine; begin ReadLn (LineBuf); if Eof (SRC) then LinePtr:= 1; LinePtr:= 1; end; Пример 2. Таблица имен. (**************************************) (* Процедуры для таблицы имен *) (**************************************) const WordLen = 32; TabSize = 1024; MaxKey = 12; Type bloc = string ; EnumTag = (Dunno, TagRecord, TagInt); (* Ячейка таблицы имен *) ncell = record Pname: bloc; Tag: EnumTag; Ref: integer; Fields: integer; end; (* Таблица имен *) var TableOfNames: array of bloc; TabLen: integer; (* Размер таблицы имен *) procedure IniTab; begin TabLen:= 0; PutIn("procedure"); PutIn("main"); PutIn("end"); PutIn("int"); PutIn("record"); PutIn("if"); PutIn("then"); PutIn("else"); PutIn("close"); PutIn("while"); PutIn("do"); PutIn("print"); end; (* Найти переменную с данным именем *) int Find (Name: bloc); var i: integer; begin Find:= 0; for i:=MaxKey+1 to TabLen do if Name = TableOfNames [i].Pname; then Find:= i; end; (* Создать новую запись таблицы имен с данным именем *) procedure PutIn (Name:bloc); begin TabLen:= TabLen +1; With TableOfNames do begin Pname:= Name; Tag:= Dunno; end; end; (* Если номер записи не больше MaxKey, то это на самом деле не имя, а ключевое слово *) Function KeyEntry (t:integer) : boolean; begin KeyEntry:= (t>0) and (t LexKey or LexVal KeyProc do Declare; ProgInit; (* Вывести стандартный заголовок программы на Паскале *) CommonTop:= TabLen; while not LogEOF do ThisProc; (* Одна процедура *) ProgClose; (* Вывести стандартное окончание программы на Паскале *) end; (* Скомпилировать одну процедуру *) procedure ThisProc; begin TabLen:= ThisProc; while (LexType = LexKey) and (LexVal = ord (KeyInt) or LexVal = ord (KeyRecord)) do Declare; ProcInit; (* Заголовок процедуры *) Code; ProcClose; (* Окончание процедуры *) end; (* Обработать одно описание *) procedure Declare; var Y:integer; begin if (LexType=LexKey) and (LexVal=ord (KeyInt) then NameList (TagInt,0) else if (LexType=LexKey) and (LexVal=ord (LexRecord)) then begin PutIn (""); TableOfNames .Fields:= 0; Y:= TabLen; ReadFields (Y); NameList (TagRecord,Y); end; end; (* Считать список имен переменных после описателя и запихнуть эти имена в таблицу имен*) procedure NameList (TagVal, FldVal: integer); begin repeat Scan; With TableOfNames do begin Tag:= TagVal; Fields:= FldVal; Ref:= 0; end; Scan; until (LexType=LexSpecs) and (LexVal=ord (SemiColn)); end; (* Обработать поля записи *) procedure ReadFields (Y0: integer); var X,Y,Pre: integer; begin Y:= Y0; repeat Pre:= Y; Dcl1 (X,Y); TableOfNames .Ref:= X; until (LexType=LexKey) and (LexVal=ord (KeyEnd)); Table OfNames [Y].Ref:= 0; end; (* Dcl1 делает почти тоже, что и Declare, только описание, которое он считывает, является полем записи. Поэтому вместо NameList используется другая процедура *) procedure Dcl1 (var X,Y: integer); var C:integer; begin if (LexType=LexKey) and (LexVal=ord (KeyInt)) then NameList1 (TagInt,0) else if (LexType=LexKey) and (LexVal=ord (LexRecord)) then begin PutIn (""); TableOfNames .Fields:= 0; C:= TabLen; ReadFields (C); NameList1 (TagRecord,C,X,Y); end; end; (* Разница между NameList и NameList1 в том, что последняя устанавливает "указатели" Ref. *) procedure NameList1 (TagVal,FldVal: integer; var X,Y: integer); begin X:=0; Pre:=0; repeat Scan; if X=0 then X:=LexVal; Y:=LexVal; With TableOfNames do begin Tag:=TagVal; Fields:=FldVal; end; if Pre0 then TableOfNames .Ref:= LexVal; Pre:=LexVal; Scan; until (LexType=LexSpecs) and (LexVal=ord (SemiColn)); end; (* Скомпилировать все операторы в процедуре *) procedure Code; begin repeat Statement; until (LexType=LexKey) and (LexVal=ord (KeyEnd)); end; (* Скомпилировать один оператор *) procedure Statement; begin if LexType = LexKey then (*Большинство операторов можно распознать по первому ключевому слову*) begin if LexVal = ord (KeyIf) then SempIf else if LexVal = ord (KeyElse) then SempElse else if LexVal = ord (KeyWhile) then SempWhile else if LexVal = ord (KeyClose) then SempClose else if LexVal = ord (KeyPrint) then SempPrint end else if (LexType=LexNames) then SempAssignOrCall; (* Присваивание или вызов процедуры *) end; (* Семантические подпрограммы *) (* If...then *) procedure SempIf; begin WriteLn ("if"); Scan; WriteLn (Buf); WriteLn ("then begin"); Scan; (**) Scan; end; (* Else *) procedure SempElse; begin WriteLn ("end else begin"); Scan; end; (* Print...*) procedure SempPrint; begin Scan; Write ("WriteLn"); id:= TableOfNames .Pname; Write ("(","""",id," = ","""",",",id,")"); Scan; (*;*) Scan; end; (* По первой лексеме нельзя отличить вызов процедуры от присваивания. Поэтому отлдичаем одно от другого в одной семантической подпрограмме по ходу дела *) procedure SempAssignOrCall; begin Write (TableOfNames .Pname); Scan; (*:=*) if (LexType = LexSpecs) and (LexVal = ord (Assign)) (* Присваивание *) then begin Scan; WriteLn (" := ",Buf,";"); Scan; (*;*) end else if (LexType = LexSpecs) and (LExVal = ord (SemiColn)) then WriteLn (";"); (* Вызов процедуры *) Scan; end; (* While...do *) procedure SempWhile; begin WriteLn ("While"); Scan; WriteLn (Buf); WriteLn ("do begin"); Scan; Scan; end; (* Close *) procedure SempClose; begin WriteLn ("end;"); Scan; end; Краткий обзор языка SIMPLE Программа на языке SIMPLE есть: описания переменных, общих для всей программы; procedure имя; begin описание переменных процедуры; операторы; end; .... procedure имя; .... Одна из процедур носит имя main. Эта процедура и есть программа. Описание int список переменных через запятую; record описание; .... описание; end список переменных через запятую; Описание Присваивание: Имя:= [....]; Вызов процедуры: Имя; Печать: Print имя; Условный: if [....] then операторы else операторы close; Цикла: while [....] do операторы close; Ключевые слова (LexType = LexKey) procedure then main else end close int while record do if print Имена (LexType = LexNames) Например: sight gh218 slow1a6 Числа (LexType = LexNumber) Например: 17 0218 33 1 Спецсимволы (LexType = LexSpecs) := , : "Нечто в " (LexType = LexExpr) Например:

Компиляция / Компилятор

Компиляция - это процесс преобразования исходной программы в исполняемую. Процесс компиляции состоит из двух этапов. На первом этапе выполняется проверка текста программы на отсутствие ошибок, на втором - генерируется исполняемая программа (ехе-файл).

После ввода текста функции обработки события и сохранения проекта можно из меню Project выбрать команду Compile и выполнить компиляцию. Процесс и результат компиляции отражаются в диалоговом окне Compiling (РИС. В38). В это окно компилятор выводит ошибки (Errors), предупреждений (warnings) и подсказок (Hints). Сами сообщения об ошибках, предупреждения и подсказки отображаются в нижней части окна редактора кода (рис. В39).

Примечание

Если во время компиляции окна Compiling на экране нет, то выберите из меню Tools команду Environment options и на вкладке Preferences установите во включенное состояние переключатель Show compiler progr ess.

Компилятор

Компилятор

Компилятор - программа, преобразующая текст, написанный на алгоритмическом языке, в программу, состоящую из машинных команд. Компилятор создает законченный вариант программы на машинном языке.

Как работает компилятор?

Создание компилятора – обычная практика для студентов IT-специальностей западных стран: это умение помогает учащемуся глубже понять технологию создания системного программного обеспечения, заглянув за рамки пользовательского интерфейса ОС. К сожалению, в российском обучении такой подход не практикуется, что и приводит к тому, что российского компьютерного софта существует так мало.

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

Как правило, компилятор состоит из нескольких стандартных частей: парсера, лексического анализатора, кодогенератора, оптимизатора. Парсер следит за правильностью структуры программы, лексический анализатор выделяет из потока парсера лексемы, определяет их и составляет внутренние таблицы переменных, вызовов процедур и т.д. Если компилятор является однопроходным, то в процессе анализа лексем может работать кодогенератор, формируя выходной машинный код. Так как компьютеры сейчас очень быстрые, то имеет смысл разработка многопроходных компиляторов: в этом случае вся программа переводится в промежуточный код – к примеру, пи-код. Пи-код интересен тем, что позволяет оптимизировать программу еще на начальном этапе, а кодогенератор способен преобразовать ее не только в коды данного компьютера, но и в выходной код другой платформы. Таким образом можно получить многоплатформенный компилятор, работающий, скажем, под DOS/DOS32/Windows и Linux.

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

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

Предлагаю вам перевод дневника Руи Уэяма (Rui Ueyama), программиста из Google, который он вел во время работы над реализацией компилятора языка C около трех с половиной лет назад.
Этот дневник не несет какой-то практической пользы и не является туториалом, но мне было очень интересно его прочитать, надеюсь и вам эта история тоже понравится:)

Я написал C компилятор за 40 дней, который назвал 8cc. Это дневник написанный мной в то время. Код и его историю можно посмотреть на GitHub .

День 8

Я пишу компилятор. Он начал работать после написания примерно 1000 строк кода. Вот несколько примеров, которые уже работают:

Int a = 1; a + 2; // => 3 int a = 61; int *b = &a; *b; // => 61

Массивы корректно преобразовываются в указатели, так что код ниже тоже работает. Также поддерживается вызов функций.

Char *c = "ab" + 1; printf("%c", *c); // => b

Реализовать это было не сложно, т.к. я делаю это уже второй раз. Я научился лучше управляться с массивами и указателями.

День 15

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

Конечно ему не хватает многих функций. Эти примеры программ подобраны так, чтобы не использовать их.
Реализация довольно проста; нет даже распределения регистров (register allocation).
Он компилирует программы на стеке, используя стек машины как стек. Каждая операция требует обращения к памяти. Но пока меня это устраивает.

В начале компилятор умещался примерно в 20 строк и единственное на что он был способен это прочитать целое значение со стандартного входа и запустить программу которая тут же завершается возвращая это целое.

Теперь он содержит около 2000 строк. Если посмотреть git, то кажется он развивался таким образом:

  • добавлены "+" и "-"
  • разделены фазы синтаксического анализа и генерации кода
  • добавлены "*" и "/"
  • добавлены переменные (неявно подразумевающие тип int)
  • добавлен вызов функций
  • добавлены строки
  • разделены генератор меток (tokenizer) и анализ синтаксиса
  • поддержка объявления базовых типов
  • добавлены указатели и массивы
  • поддержка выражений инициализации массивов
  • добавлен «if»
  • поддерживается объявление функций
  • добавлены «for» и «return»
  • поддерживается присвоение указателей
  • добавлено "=="
  • добавлены индексация массивов и арифметика указателей
  • добавлены "++", "--" и "!"

День 17

Я успешно реализовал структуры. Структура - это такой объект, который может занимать больше одного машинного слова. Их сложнее реализовать, чем примитивные типы, но это было легче чем я ожидал.

Кажется, это работает как надо; я могу определить структуру, содержащую структуру. Я могу определить указатель на структуру и разыменовать его. Структуры, содержащие массивы и массивы структур также работают. Хотя я уже знал, что код теоретически должен работать, я все равно обрадовался, когда он действительно заработал, даже в таком сложном случае.

Однако, я не чувствую, что я полностью понимаю, почему этот код работает правильно. Он ощущается немного магическим из-за его рекурсивной природы.

Я не могу передавать структуры в функции. В соглашении о вызовах для x86 структура копируется на стек и указатель на нее передается функции. Но в x86-64 вы должны разделить структуру на несколько частей данных и передать их через регистры. Это сложно, так что это я пока отложу. Передача структур по значению нужна реже, чем передача указателей на них.

День 18

Реализовать объединения было легче, т.к. это просто вариант структуры, в котором все поля имеют одинаковое смещение. Также реализован оператор "->". Проще простого.

Организовать поддержку чисел с плавающей точкой было тяжело. Похоже неявное преобразование типов между int и float работает, но числа с плавающей точкой не могут быть переданы в функции. В моем компиляторе все параметры функций сначала помещаются на стек, а затем записываются в регистры в порядке определенном в соглашении о вызовах x86-64. Но в этом процессе видимо есть баг. Он возвращает ошибку доступа к памяти (SIGSEGV). Его сложно отлаживать, рассматривая ассемблерный вывод, потому что мой компилятор не оптимизирует ассемблер для чтения. Я думал, что смогу закончить с этим за день, но я ошибался.

День 19

Я потратил время впустую, потому что забыл что в соответствии с соглашением о вызовах x86-64 кадр стека должен быть выровнен на 16 байт. Я обнаружил, что printf() падает с SEGV, если передать ей несколько чисел с плавающей точкой. Я попытался найти условия при которых это можно воспроизвести. Оказалось что имеет значение положение стекового кадра, что заставило меня вспомнить про требования ABI x86-64.

Я совсем не позаботился об этом, так что кадр стека был выровнен только по 8 байт, но print() не жаловалась пока принимала только целые числа. Эта проблема может быть легко исправлена корректировкой стекового кадра до вызова инструкции CALL. Но таких проблем не избежать если, тщательно не читать спецификацию перед написанием кода.

День 20

Я поменял отступы в коде компилятора с 2 на 4. Я больше привык к использованию 2-ух пробельных отступов, потому что такие используются на моей работе в Google. Но по некоторым причинам мне кажется отступы в 4 пробела больше подходят для «красивой программы с открытым исходным кодом».

Есть еще одно, более значимое, изменение. Я переписал тесты из shell скриптов на C. До этого изменения каждая тестовая функция компилируемая моим компилятором, связываллась с main() которая компилировалась GCC и затем запускалась shell скриптом. Это было медленно, т.к. порождало много процессов для каждого теста. У меня не было выбора когда я начинал проект, т.к. у моего компилятора не было многих функций. Например, он не мог сравнить результат с ожидаемым значением из-за отсутствия операторов сравнения. Теперь он достаточно мощный, чтобы компилировать код тестов. Так что я переписал их, чтобы сделать их быстрее.

Еще я реализовал большие типы, такие как long и double. Написание кода было веселым потому что я очень быстро преуспел в реализации этих функций.

День 21

Я почти закончил реализацию препроцессора C за один день. На самом деле это порт из моей предыдущей попытки написать компилятор.

Реализация препроцессора C не простая задача.

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

День 31

Реализовал функции для varargs, а именно va_start, va_arg и va_end. Они используются не часто, но я нуждался в них для компиляции функций, например printf.

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

Грубо говоря, ABI для x86-64, стандартизированное AMD, требует чтобы функции с переменным числом аргументов копировали все регистры в стек, чтобы подготовиться к последующему вызову va_start. Я понимаю, что у них не было другого выбора, но это все равно выглядит неуклюже.

Мне стало интересно как другие компиляторы обрабатывают функции с переменным числом аргументов. Я посмотрел на заголовки TCC и, похоже, они не совместимы с ABI x86-64. Если структура данных для varargs отличается, то функции передающие va_list (такие как vprintf) становятся несовместимыми. Или я ошибаюсь? [И я действительно ошибаюсь - они совместимы.] Я также посмотрел на Clang, но он выглядит запутанным. Я не стал читать его. Если я буду читать слишком много кода других компиляторов, это может испортить веселье от собственной реализации.

День 32

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

Я пытался реализовать поддержку функций, имеющих более шести параметров, но не смог закончить это за один день. В x86-64 первые 6 целочисленных параметров передаются через регистры, а оставшиеся через стек. Сейчас поддерживается передача только через регистры. Передачу через стек не сложно реализовать, но она требует слишком много времени для отладки. Я думаю в моем компиляторе нет функций, имеющих более шести параметров, так что я пока отложу их реализацию.

День 33

Еще три файла скомпилированы сегодня. Итого 6 из 11. Но если считать строки кода, то это около 10% от общего числа. Оставшиеся файлы намного больше, так как содержат код ядра компилятора.

Еще хуже то, что в файлах ядра я использую относительно новые особенности C, такие как составные литералы и назначенные инициализаторы. Они сильно усложняют самокомпиляцию. Я не должен был использовать их, но переписывать код на простом старом C будет не продуктивно, поэтому я хочу поддерживать их в своем компиляторе. Хотя на это потребуется время.

День 34

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

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

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

Генератор кода позволяет широко использовать рекурсию, потому что он генерирует фрагменты ассемблерного кода, когда обходит абстрактное синтаксическое дерево. Так я смог реализовать печать мини трассировки стека для каждой строки ассемблерного вывода. Я если я замечаю что-то неправильное, я могу проследить за генератором кода, посмотрев на его вывод.

Большинство внутренних структур данных имеют функции для преобразования в строку. Это полезно при использовании printf для отладки.

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

День 36

Реализовал составные литералы и переписал инициализатор структур и массивов. Мне не нравилась предыдущая реализация. Теперь инициализатор стал лучше. Я должен был написать красивый код с самого начала, но поскольку я понял это, только написав рабочий код, переписывание было неизбежным.

Думаю, единственная особенность, которой не хватает для самокомпиляции, это присваивание структур. Надеюсь все будет работать как задумано без особой отладки, когда она будет реализована.

День 37

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

Мне это напоминает фильм «Начало». Мы должны идти глубже, чтобы воспроизвести этот баг. Это забавная часть отладки самокомпилирующегося компилятора.

День 38

Я исправил проблему, возникавшую во втором поколении, если лексический анализатор был самоскомпилирован. Она вызывала баг при котором -1 > 0 иногда возвращало true (я забыл про знаковое расширение). Есть еще один баг в размещении структур (struct layout). Осталось только три файла.

День 39

Генератор кода теперь тоже может скомпилировать себя. Осталось два файла. Работа почти закончена, хотя мне, возможно, не стоит быть чересчур оптимистичным. Могут оставаться еще неожиданные подводные камни.

Я исправил много проблем, вызванных плохим качеством кода, который я писал на ранней стадии этого проекта. Это утомило меня.

Я верил, что имел все возможности для самокомпиляции, но это не правда. Нет даже префиксных операторов инкремента/декремента. Для некоторых особенностей C99 я переписал часть компилятора, чтобы сделать его более удобным для компиляции. Так как я не ожидал добраться до возможности самокомпиляции так быстро, я использовал столько новых особенностей C, сколько хотел.

День 40

Ура! Мой компилятор теперь может полностью себя скомпилировать!

Это заняло около 40 дней. Это очень короткий промежуток времени, для написания самокомпилируемого компилятора C. Вы так не думаете? Я считаю, что мой подход - вначале сделать небольшой компилятор для очень ограниченного подмножества C, и затем преобразовать его в настоящий компилятор C очень хорошо сработал. В любом случае сегодня я очень счастлив.

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

Осталось много багов и нереализованных особенностей. Я, наверное, закончу с ними, а потом начну работу по улучшению выходного кода.

Рассказывает программист Вильям В. Вольд

На протяжении последних шести месяцев я работал над созданием языка программирования (ЯП) под названием Pinecone. Я не рискну назвать его законченным, но использовать его уже можно - он содержит для этого достаточно элементов, таких как переменные, функции и пользовательские структуры данных. Если хотите ознакомиться с ним перед прочтением, предлагаю посетить официальную страницу и репозиторий на GitHub .

Введение

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

Тем не менее, я написал абсолютно новый язык. И он работает. Наверное, я что-то делаю правильно.

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

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

Первые шаги

«А с чего вообще начинать?» - вопрос, который другие разработчики часто задают, узнав, что я пишу свой язык. В этой части постараюсь подробно на него ответить.

Компилируемый или интерпретируемый?

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

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

Я хотел создать простой и при этом производительный язык, каких немного, поэтому с самого начала решил сделать Pinecone компилируемым. Тем не менее, интерпретатор у Pinecone тоже есть - первое время запуск был возможен только с его помощью, позже объясню, почему.

Прим. перев. Кстати, у нас есть краткий обзор - это отличное упражнение для тех, кто изучает Python.

Выбор языка

Своеобразный мета-шаг: язык программирования сам является программой, которую надо написать на каком-то языке. Я выбрал C++ из-за производительности, большого набора функциональных возможностей, и просто потому что он мне нравится.

Но в целом совет можно дать такой:

  • интерпретируемый ЯП крайне рекомендуется писать на компилируемом ЯП (C, C++, Swift). Иначе потери производительности будут расти как снежный ком, пока мета-интерпретатор интерпретирует ваш интерпретатор;
  • компилируемый ЯП можно писать на интерпретируемом ЯП (Python, JS). Возрастёт время компиляции, но не время выполнения программы.

Проектирование архитектуры

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

Лексический анализатор / лексер

Строка исходного кода проходит через лексер и превращается в список токенов.

Первый шаг в большинстве ЯП - это лексический анализ . Говоря по-простому, он представляет собой разбиение текста на токены, то есть единицы языка: переменные, названия функций (идентификаторы), операторы, числа. Таким образом, подав лексеру на вход строку с исходным кодом, мы получим на выходе список всех токенов, которые в ней содержатся.

Обращения к исходному коду уже не будет происходить на следующих этапах, поэтому лексер должен выдать всю необходимую для них информацию.

Flex

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

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

Моё решение

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

Синтаксический анализатор / парсер

Список токенов проходит через парсер и превращается в дерево.

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

Bison

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

Преимущества кастомных программ

С лексером моё решение писать и использовать свой код (длиной около 200 строк) было довольно очевидным: я люблю задачки, а эта к тому же относительно тривиальная. С парсером другая история: сейчас длина кода для него - 750 строк, и это уже третья попытка (первые две были просто ужасны).

Тем не менее, я решил делать парсер сам. Вот основные причины:

  • минимизация переключения контекста ;
  • упрощение сборки;
  • желание справиться с задачей самостоятельно.

В целесообразности решения меня убедило высказывание Уолтера Брайта (создателя языка D) в одной из его статей :

Я бы не советовал использовать генераторы лексических и синтаксических анализаторов, а также другие так называемые «компиляторы компиляторов». Написание лексера и парсера не займёт много времени, а использование генератора накрепко привяжет вас к нему в дальнейшей работе (что имеет значение при портировании компилятора на новую платформу). Кроме того, генераторы отличаются выдачей не релевантных сообщений об ошибках.

Абстрактный семантический граф

Переход от синтаксического дерева к семантическому графу

В этой части я реализовал структуру, по своей сути наиболее близкую к «промежуточному представлению» (intermediate representation) в LLVM. Существует небольшая, но важная разница между абстрактным синтаксическим деревом (АСД) и абстрактным семантическим графом (АСГ).

АСГ vs АСД

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

Запуск

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

Варианты компиляции

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

Написать свой компилятор

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

LLVM

LLVM - это коллекция инструментов для компиляции, которой пользуются, например, разработчики Swift, Rust и Clang. Я решил остановиться на этом варианте, но опять не рассчитал сложности задачи, которую перед собой поставил. Для меня проблемой оказалось не освоение ассемблера, а работа с огромной многосоставной библиотекой.

Транспайлинг

Мне всё же нужно было какое-то решение, поэтому я написал то, что точно будет работать: транспайлер (transpiler) из Pinecone в C++ - он производит компиляцию по типу «исходный код в исходный код», а также добавил возможность автоматической компиляции вывода с GCC. Такой способ не является ни масштабируемым, ни кроссплатформенным, но на данный момент хотя бы работает почти для всех программ на Pinecone, это уже хорошо.

Дальнейшие планы

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

Заключение

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

Вот общие советы от меня (разумеется, довольно субъективные):

  • если у вас нет предпочтений и вы сомневаетесь, компилируемый или интерпретируемый писать язык, выбирайте второе. Интерпретируемые языки обычно проще проектировать, собирать и учить;
  • с лексерами и парсерами делайте, что хотите. Использование средств автоматизации зависит от вашего желания, опыта и конкретной ситуации;
  • если вы не готовы / не хотите тратить время и силы (много времени и сил) на придумывание собственной стратегии разработки ЯП, следуйте цепочке действий, описанной в этой статье. Я вложил в неё много усилий и она работает;
  • опять же, если не хватает времени / мотивации / опыта / желания или ещё чего-нибудь для написания классического ЯП, попробуйте написать эзотерический, типа Brainfuck . (Советуем помнить, что если язык написан развлечения ради, это не значит, что писать его - тоже сплошное развлечение. - прим. перев.)

Я делал довольно много ошибок по ходу разработки, но большую часть кода, на которую они могли повлиять, я уже переписал. Язык сейчас неплохо функционирует и будет развиваться (на момент написания статьи его можно было собрать на Linux и с переменным успехом на macOS, но не на Windows).

О том, что ввязался в историю с созданием Pinecone, ни в коем случае не жалею - это отличный эксперимент, и он только начался.




Top