Привет, Хабр! В этой статье я хотел бы поделиться опытом создания своего языка программирования.
Предыстория
Мне 14. Обучаясь на втором году Яндекс Лицея, нужно было написать несколько проектов. Первым из них стал проект на PyQT5. Я долго думал над идеей и вспомнил, что летом я хотел создать свой язык, но у меня этого не получилось (Тогда я не понимал как работает парсер и абстрактное синтаксическое дерево, поэтому забросил). И вот, мне пришла идея - сделать свой язык программирования и написать для него IDLE (т.к. тема проекта все-таки QT). Ещё полгода назад я изучал асинхронность и многопоточность, поэтому именно одну из этих идей я хотел воплотить в своём языке. В данной статье я хотел рассказать устройство интерпретируемых языков и как их создать.
Чем отличается интерпретируемый язык от компилируемого
Вся разница в исполнении кода. Интерпретируемые языки работают по следующей схеме:
Lexer.py ---> Parser.py ---> AbstractSyntaxTree.py ---> result
Ниже я более подробно рассказываю про работу всех компонентов кроме AST, потому что на момент написания языка я не понимал как это работает.
Компилируемые языки программирования исполняют код не построчно (как интерпретируемые). Процесс компиляции включает препроцессинг исходного кода, где удаляются комментарии и вставляются содержимое файлов, компиляцию кода в ассемблерный или промежуточный код, а затем ассемблирование этого кода в машинный код, который линкуется в исполняемый файл с разрешением внешних ссылок. Проще говоря, написанный код переводится либо на ассемблер, либо на другие компилируемые языки, которые его исполняют.
Устройство языка
Я решил, что мой язык будет состоять только из Лексера и Парсера (окончательное понимание синтаксического дерева пришло ко мне под конец). Я сразу отказался использовать такие библиотеки, как PLY и RPLY, поэтому мой проект написан на чистом Python. Про компоненты языка я буду объяснять неформально, то-есть так, как понял это я.
Лексер
В каждом языке программирования есть Лексер и Парсер. Лексер - класс, который принимает строку кода и разбивает её на токены. Токены нужны для проверки грамматики на этапе парсинга.
Токены, которые представлены на картинке, являются объектами класса Token, Данный класс принимает в __init__: тип токена и его значение.
NUMBER = "NUMBER"
STRING = "STRING"
class Token:
def __init__(self, type, value):
self.type = type
self.value = value
def __str__(self):
return f'Token({self.type}, {self.value})'
def __repr__(self):
return self.__str__()
Помимо класса Token, есть вышеупомянутый класс Lexer, который посимвольно проходит строку циклом и возвращает объекты класса Token:
class Lexer:
def __init__(self, text):
self.text = text
self.pos = 0
self.current_char = self.text[self.pos]
def advance(self):
self.pos += 1
if self.pos < len(self.text):
self.current_char = self.text[self.pos]
else:
self.current_char = None
def return_pos(self, pos):
self.pos = pos
if self.pos < len(self.text):
self.current_char = self.text[self.pos]
else:
self.current_char = None
def skip_whitespace(self):
while self.current_char is not None and self.current_char.isspace():
self.advance()
def get_next_token(self):
while self.current_char is not None:
if self.current_char.isspace():
self.skip_whitespace()
continue
if self.current_char.isdigit() or self.current_char == "-":
token_value = ""
while self.current_char is not None and (self.current_char.isdigit() or self.current_char == "." or self.current_char == "-"):
token_value += self.current_char
self.advance()
try:
return Token(NUMBER, int(token_value))
except:
try:
return Token(NUMBER, float(token_value))
except:
return Token(MINUS, "-")
if self.current_char == '"' or self.current_char == "'":
self.advance()
string_value = ""
while self.current_char is not None and self.current_char != '"' and self.current_char != "'":
string_value += self.current_char
self.advance()
if self.current_char == '"' or self.current_char == "'":
self.advance()
return Token(STRING, '"' + string_value + '"')
else:
print(f"Некорректный символ: {self.current_char}")
sys.exit()
if self.current_char == '=':
self.advance()
return Token(ASSIGN, '=')
if self.current_char == '+':
self.advance()
return Token(PLUS, '+')
if self.current_char == '-':
self.advance()
return Token(MINUS, '-')
if self.current_char == '*':
self.advance()
return Token(MULTIPLY, '*')
if self.current_char == '/':
self.advance()
return Token(DIVIDE, '/')
if self.current_char == '%':
self.advance()
return Token(PR, '%')
if self.current_char == '(':
self.advance()
return Token(LPAREN, '(')
if self.current_char == ')':
self.advance()
return Token(RPAREN, ')')
if self.current_char == '^':
self.advance()
return Token(DEG, '^')
if self.current_char == ',':
self.advance()
return Token(COMMA, ',')
if self.current_char.isalpha():
token_value = ""
while self.current_char is not None and (self.current_char.isalpha() or self.current_char.isdigit() or self.current_char == "_"):
token_value += self.current_char
self.advance()
if token_value == "int":
return Token(INT, "int")
elif token_value == "float":
return Token(FLOAT, "float")
elif token_value == "str":
return Token(STR, "str")
elif token_value == "None":
return Token(NONE, "None")
elif token_value == "print":
return Token(PRINT, "print")
elif token_value == "input":
return Token(INPUT, "input")
elif token_value == "True" or token_value == "False":
return Token(BOOL, token_value)
else:
return Token(VAR, token_value)
print(f"Неверный синтаксис: {self.text}")
sys.exit()
return Token(EOF, None)
Это лишь половина класса Лексера. Он проходится по каждому символу и проверяет его существование. Если Лексер нашёл символ, то возвращает объект класса Token с переданными типом символа и самим символом. Помимо этого существуют ещё три класса, которые хранят значения локальных переменных и код функций:
Парсер
Парсер, по моему мнению, является сложнейшей частью языка. В моём случае он выполняет задачу проверки синтаксиса и исполнения кода. Вот частичный код получившегося класса:
import math
from lexer import *
# Определение парсера для вычисления выражений
class Parser():
def __init__(self, lexer, symbol_table):
"""
Конструктор класса Parser.
Параметры:
- lexer: экземпляр класса Lexer для токенизации входного текста.
- symbol_table: экземпляр класса SymbolTable для отслеживания переменных.
symbol_table - объект класса SymbolTable, используется для
хранения и управления переменными.
"""
self.lexer = lexer
self.current_token = self.lexer.get_next_token()
self.symbol_table = symbol_table
def eat(self, token_type):
"""
Функция проверяет текущий токен и токен, с которого
мы хотим перейти на другой токен.
То есть у нас не получиться будучи на Token(VAR, "x") перейти на
следущий токен с того, которого мы передаём в функцию:
Текущий токен: VAR, мы передали в функцию токен PRINT -
получаем ошибку, потому что мы находимся на токене VAR, а хотим перейти
на следующий токен с токена PRINT.
"""
if self.current_token.type == token_type:
self.current_token = self.lexer.get_next_token()
else:
raise Exception(f"Error eating {self.current_token.type} token!")
def factor(self):
"""
Функция отвечает за использование встроенных функций языка или других
токенов которые возвращают значения:
... = input()
... = int(x)
... = True
"""
token = self.current_token
if token.type == NUMBER:
self.eat(NUMBER)
return token.value
elif token.type == STRING:
string_value = token.value
self.eat(STRING)
return string_value
elif token.type == NONE:
self.eat(NONE)
return None
elif token.type == BOOL:
result = token.value
if result == "True":
result = True
else:
result = False
self.eat(BOOL)
return result
elif token.type == VAR:
var_name = token.value
self.eat(VAR)
return self.symbol_table.lookup(var_name)
elif token.type == INT:
self.eat(INT)
self.eat(LPAREN)
result = int(self.expr())
self.eat(RPAREN)
return result
elif token.type == FLOAT:
self.eat(FLOAT)
self.eat(LPAREN)
result = float(self.expr())
self.eat(RPAREN)
return result
elif token.type == STR:
self.eat(STR)
self.eat(LPAREN)
result = str(self.expr())
self.eat(RPAREN)
return result
elif token.type == LPAREN:
self.eat(LPAREN)
result = self.expr()
self.eat(RPAREN)
return result
def term(self):
"""
Обработка терма (произведения или частного).
Возвращает:
- Результат вычисления терма.
"""
result = self.factor()
while self.current_token.type in (MULTIPLY, DIVIDE, PR, DEG):
token = self.current_token
if token.type == MULTIPLY:
self.eat(MULTIPLY)
result *= self.factor()
elif token.type == DIVIDE:
self.eat(DIVIDE)
result /= self.factor()
elif token.type == PR:
self.eat(PR)
result %= self.factor()
elif token.type == DEG:
self.eat(DEG)
num = self.factor()
result = math.pow(result, num)
return result
def expr(self):
"""
Обработка выражения (суммы или разности).
Возвращает:
- Результат вычисления выражения.
"""
result = self.term()
while self.current_token.type in (PLUS, MINUS):
token = self.current_token
if token.type == PLUS:
self.eat(PLUS)
result += self.term()
elif token.type == MINUS:
self.eat(MINUS)
result -= self.term()
return result
def statement(self):
"""
Функция отвечает за встроенные функции и конструкции,
которые не возвращают значения:
print()
var = ...
if ...
"""
if self.current_token.type == VAR:
var_name = self.current_token.value
self.eat(VAR)
if self.current_token.type == ASSIGN:
self.eat(ASSIGN)
value = self.expr()
self.symbol_table.define(var_name, value)
else:
sys.exit()
elif self.current_token.type == PRINT:
self.eat(PRINT)
self.eat(LPAREN)
values = []
while self.current_token.type != EOF and self.current_token.type != RPAREN:
values.append(self.expr())
if self.current_token.type == COMMA:
self.eat(COMMA)
self.eat(RPAREN)
print(*values)
else:
self.current_token = self.lexer.get_next_token()
def parse(self):
"""
Парсинг входного текста и выполнение выражений.
Эта функция запускает парсинг входного текста и последовательно выполняет
выражения, включая присваивание переменных и вывод значений.
"""
while self.current_token.type != EOF:
self.statement()
Теперь рассмотрим на примере, как работает класс. На вход подаётся строка:
x = 5
Она проходит через лексер:
Token(VAR, "x")
Token(ASSIGN, "=")
Token(NUMBER, 5)
Token(EOF, None)
После токены используются в парсере:
Token(VAR, "x") ---> self.parse() ---> self.statement() ---> self.expr() ---> self.term() ---> self.factor()
| |
x = <--- self.expr() <--- self.term() <--- 5
|
self.symbol_table.define("x", 5)
На вход поступает первый токен — Token(VAR, «x»). Класс запускается при использовании функции self.parse(). Функция self.parse() вызывает функцию self.statement(), которая вызывает функцию self.expr() и ждет от неё возвращаемое значение. Функция self.expr() вызывает self.term() и тоже ждёт возвращаемое значение и т.д.
Возникшие проблемы
Основными проблемами стали GIL и низкая скорость Python. Язык работал слишком медленно (Был вариант переписать на C++, но приближалась сдача проекта), а параллельные циклы, реализованные с помощью asyncio, не работали вовсе (т.к. в моём случае корутины не работали из-за блокируемых операций). Asyncio пришлось заменить библиотекой multiprocessing, которая запускает несколько Python интерпретаторов. Проблему с низкой скоростью я решил с помощью Cython. Он помог разогнать язык в 90 раз! Поэтому, если вы захотите написать что-нибудь на моём языке, Вам придётся установить Cython и выполнить компиляцию файлов .pyx (делается это просто, расписал инструкцию в репозитории).
Выводы
В этой статье я постарался рассказать, как работают интерпретируемые языки программирования. Язык я завершил после добавления функций и многопоточных циклов. документацию, примеры и код к языку вы можете найти в моём репозитории. На написание данной статьи меня подтолкнуло малое количество информации на подобную тему. Буду рад комментариям и Вашим замечаниям. До скорых встреч!
Комментарии (38)
sshikov
26.11.2023 09:15+7Я сразу отказался использовать такие библиотеки, как PLY и RPLY, поэтому мой проект написан на чистом Python.
То что вы PLY не взяли - наверное даже правильно, потому что по описанию это порт LEXX/YACC, а это ну очень старый подход к построению интерпретаторов. Можно и что-то получше найти взамен.
А вот то что вы про грамматику своего языка ничего не написали, а сразу код стали приводить - это зря. По-хорошему, если уж вы делаете язык - опишите грамматику. А потом уже из нее можно было бы построить (автоматически) таблицы для разбора одним из проверенных методов, типа LR(n) или даже самым простым LL(1). А для лексера напрашивается конечный автомат.
В вашем же коде, не порывшись достаточно глубоко, не понять, что за метод вы применили. И кстати, именно поэтому у вас парсер сложный, в том числе для вас. В случае же например LL(1), парсер это строк 20 кода, примерно. Остальное таблицы, которые автоматически строятся из грамматики. В этом случае вам зачастую вообще не нужно код вашего парсера понимать, потому что он строится автоматически, а вы для него дописываете функции, выполняемые например при распознавании в тексте какой-то части грамматики.
SpiderEkb
26.11.2023 09:15+2Вообще, если цель стоит в том, чтобы один раз написать скрипт, а потом его многократно использовать, лучше использовать промежуточную его компиляцию в байт-код. Т.е. то самое дерево, которое на заключительном этапе фигурирует, сохранять отдельно в двоичном виде.
LEX/YACC это не то чтобы старый подход. Это потоковые (насколько помню) интерпретаторы. Они позволяют (в том числе) строить и командные процессоры, а не только интерпретаторы скриптовых языков.
Но описание грамматики нужно, да.
sshikov
26.11.2023 09:15LEX/YACC это не то чтобы старый подход.
Это именно что очень старый подход. На сегодня даже тупо взять ANTLR, и шарахнуть из пушки по воробьям - будет намного, намного удобнее. И не сильно дольше.
Ну то есть, я бы их не стал советовать, но с другой стороны - чтобы воспользоваться одним из таких инструментов, все-таки придется понять, что такое грамматика, и какие они бывают, и попытаться ее написать. А это позже непременно пригодится.
Так что может быть и стоило бы их взять - но желательно понимать, что есть и другие подходы, более современные.
SpiderEkb
26.11.2023 09:15Ну я этой темы не касался очень давно - то что делал, делал лет 15 назад. И тогда какого-то серьезного выбора не было.
То что оно громоздко и медленно было - да. Но тогда решил это байткодом т.к. там набор скриптов писался один раз (т.е. постоянно их править не было нужды, иногда что-то поправить, иногда новый написать). А вот гонялись они постоянно. И скомпилированные в байткод скрипты работали достаточно шустро т.к. разбора уже не требовали (там вообще был отдельный раннер для них).
sshikov
26.11.2023 09:15ANTLR это вообще-то на минутку, 1992 год. Так что выбор был.
SpiderEkb
26.11.2023 09:15Возможно, но тогда (2005-й год или раньше даже) что-то найти было сложнее, особенно когда не знаешь точно что ищешь :-)
Так или иначе, поставленная задача тогда была решена в установленные сроки и с потребным качеством. С тех пор не приходилось заниматься подобными вещами.
vkni
26.11.2023 09:15то что делал, делал лет 15 назад.
OCaml, ocamllex/ocamlyacc — они хотя бы типы не стирают.
vkni
26.11.2023 09:15LEX/YACC это не то чтобы старый подход.
Если под этим подходом вы имеете ввиду генераторы парсеров, то, конечно, не старый. А если конкретно пару Lex/Yacc — то это из времён, когда динозавры бегали по болотам. Мало того, что вместо них flex/bison, так и те стирают типы, передавая в основную программу.
Из современного antlr4, BNFC, ocamllex/menhir.KvanTTT
26.11.2023 09:15antlr4
Едва ли его можно назвать современным, но с учетом отсутствия альтернатив сгодится.
slonopotamus
26.11.2023 09:15ну очень старый подход к построению интерпретаторов. Можно и что-то получше найти взамен
Подходы кагбэ не портятся со временем. Вы предлагаете LL вместо LR. Но LL уступает LR в некоторых аспектах.
sshikov
26.11.2023 09:15Я не предлагаю LL и даже LR. Вам показалось. Я предлагаю взять любой инструмент, который умеет генерировать код по любой типовой грамматике. Как правило это будет LR, кстати.
x86128
26.11.2023 09:15+1Направление верное взяли. Фишка ручного разбора в том (а не готового генератора типа PLY/FLEX/YACC), что можно выдавать человекопонятные сообщения об ошибках (забытая скобка, или перменная) и за один проход находить не только первую ошибку, а несколько, например в разных функциях вашего языка.
raspberry-porridge
26.11.2023 09:15+5Каждая такая статья кидает камень в четырнадцатилетнего меня, который по видеокурсам пытался свой первый сайт на php сделать (получилось очень плохо).
vkni
26.11.2023 09:15Каждая такая статья кидает камень в четырнадцатилетнего меня, который по видеокурсам пытался свой первый сайт на php сделать (получилось очень плохо).
Пфуй, у нас в 14 лет учителя говорили, что не надо заниматься всякой низкоинтеллектуальной чепухой вроде програзма! :-)
MountainGoat
26.11.2023 09:15Я никогда не интересовался созданием языков программирования, но регулярно натыкался на утверждения что в Rust макросы настолько разнообразные, что на них можно делать новые и новые языки.
rutexd
26.11.2023 09:15По факту, макросы дают вполне уверенный доступ к компилятору. А дальше дело лишь за чёрной магией и упорством.
Myclass
26.11.2023 09:15+3Даже, если и соглашусь со всеми комментаторами, что подход у автора и не самый оптимальный и маловато инфы. Но принимая во внимание, возраст и неопытность - скажу - неплохо и желаю удачи в будущем.
domix32
26.11.2023 09:15-1Ох, любят же конечно люди копипасту. Чем вас словари-то обидели что вы их не использовали?
KvanTTT
26.11.2023 09:15+4Парсер, по моему мнению, является сложнейшей частью языка.
А для меня парсер - одна из простейших частей языка. Говорю как программист, работающий над компилятором языка Kotlin.
D7ILeucoH
26.11.2023 09:15+3Ну так ты сравнил божественный Котлин и чё-попало Питон) точнейшая типизация делает парсинг сильно проще. Байт-код ART выглядит отлично, он намного понятнее даже того же Ассемблера (который вообще "обёртка над байт-кодом") - именно такое сравнение мне пришло на ум...
Ну так то Котлин сложнее из-за поддержки хотя бы того же замыкания, которое убил в Питоне данный ТС.
Хотя если честно я не очень понял в чём смысл этой работы, в то время как основная её фишка "многопоточный цикл" работает неправильно xD. Во избежание неадекватных минусов поясню для тех кто не допёр: посмотрите на output цикла от 0 до 4 в три потока, который выдаёт значение 0 четвертым. Подсказка: потоки работают последовательно, зачем ноль в конце?
Коммент про парсинг говорю как энтузиаст, изнедавна ковыряющий SMALI (продукты жизнедеятельности Котлина)
Viacheslav01
26.11.2023 09:15Потоки работают паралельно, на то они и потоки.
Ну а если дело в том, что 3 потока при обработке 4 выдали 0 в конце, ну так то же ничего сверестественного. Планируем на выполнение 4 задачи, 0 забирает первый поток и тут у него отбирают управление и передают второму, дальше дела, дела, потом управление возвращают первому, после всех остальных. Бинго )
vkni
26.11.2023 09:15А для меня парсер - одна из простейших частей языка.
Это немножечко напоминает анекдот про то, как мальчики в классе мерялись писюнами. :-)
StreamThread
26.11.2023 09:15+1Очень хорошо. Можно только код рефакторить с применением подхода Don't Repeat Yourself (DRY). А именно, использовать новый оператор Match вместо elif'ов и try-except блоков, и словари.
Ничего больше усложнять не стоит, чисто лишь код в порядок привести.
Alexander554 Автор
26.11.2023 09:15+1Спасибо за совет! Использовать словари действительно хорошая идея.
vkni
26.11.2023 09:15+2Очень хорошо!
Одно замечание — вы смешиваете понятия «языка» и «интерпретатора». Язык — это соглашения, это не программа, он не может работать быстро или медленно. :-) Но это сейчас не важно.
Вы сделали tree-walking interpreter. А дальше вы можете воспользоваться техниками из Crafting interpreters, чтобы сделать компилятор в байт-код, и интерпретировать уже байт-код. См https://craftinginterpreters.com/
См. также https://github.com/true-grue/Compiler-Development — «Что читать о разработке компиляторов». И можно задавать народу вопросы в телеге «Compiler Development».
Успехов!Alexander554 Автор
26.11.2023 09:15Спасибо! Я уже думал, сделать язык компилируемым. Сейчас только изучаю теорию.
vkni
26.11.2023 09:15На всякий случай — вы смотрели курс SICP, который старый?
https://bmstu-iu9.github.io/scheme-labs/sicp.pdf
SpiderEkb
Поздравляю с изобретением велосипеда :-)
Вообще-то есть LEX и YACC (или аналоги их - FLEX и BISON) Там достаточно описать грамматику языка чтобы получить исходный код интерпретатора на С/С++ Дальше уже можно играться с компиляцией в байт-код (например, для ускорения выполнения программы).
Когда-то давно занимался этим - нужно было написать скриптовый язык для работы со специфической платой расширения для компа (фактически там были ЦАП/АЦП - что-то к ней цеплялось, нужна была возможность писать скрипты для работы с тем что к ней прицепили). Делал интерпретатор скрипта (для тестов) и компилятор скрипта в байткод + интерпретатор байткода (когда скрипт уже протестирован).
Получился вполне себе типизированный процедурный язык с
вистом и профурсеткамипеременными, условиями, циклами + набором встроенных специфических функций для работа с платой расширения.Так вот делал как раз на С++ + FLEX + BISON. Даже работало :-)
Alexander554 Автор
Добрый день! Я рассматривал использование Flex и Bison. Основная цель проекта - самому написать Лексер и Парсер и приблизительно понять, как это работает изнутри. Также возникли проблемы с изучением Bison. Когда я начинал, я не понимал как описывать грамматику.
sshikov
Потому что и FLex и Bison, опять же - это очень старые технологии. Причем ориентированные на один целевой язык. Посмотрите на ANTLR, для начала. Он вам построит парсер на питоне, если вам так удобнее.
В принципе, написать самому что-то типа LL(1) на языке высокого уровня вообще не сложно, это будет примерно столько же кода, что вы тут показали. Все из чего состоит генератор парсеров - это по сути, снова парсер метаязыка описания грамматики (EBNF обычно), а дальше некоторый объем возни с таблицами символов конкретной грамматики (для LL(1), опять же - это например построение транзитивных замыканий множеств символов, с которых может начинаться нетерминал). Ничего принципиально сложного там нет. Главное начать с хорошей книжки по теории.
Alexander554 Автор
Спасибо за совет! Я рассмотрю использование LL(1)
auddu_k
К своим, если есть интерес, очень рекомендую после такого упражнения попробовать ANTLR4 или какие ещё генераторы умеют в Python, просто для сравнения.
rsashka
Удачи в языкописательстве!
Viacheslav01
Исползуя готовые инструменты, очень сложно понять как оно работает внутри. В данном конкретном случае я за велосипеды.
Но в свое время когда надо было свой "язык" в "прод", то в бой пошел ANTLR )