Вкратце расскажу о его многолетней разработке и что я узнал в процессе. Альтернативное название статьи: «Как написать компилятор за десять лет или меньше».
(В конце есть TL;DR, если вас не волнует предыстория).
Демо компилятора
tim ~/pp/malcc master 0 > ./malcc Mal [malcc]
user> (println "hello world")
hello world
nil
user> (+ 1 2)
3
user> (def! fib2 (fn* (n) (let* (f (fn* (n1 n2 c) (if (= c n) n2 (f n2 (+ n1 n2) (+ c 1))))) (f 0 1 1))))
<lambda>
user> (fib2 25)
75025
user> ^D%
tim ~/pp/malcc master 0 > ./malcc examples/hello.mal hello world
tim ~/pp/malcc master 0 > ./malcc --compile examples/hello.mal hello gcc -g -I ./tinycc -I . -o hello hello.c ./reader.c ./printer.c ./hashmap.c ./types.c ./util.c ./env.c ./core.c ./tinycc/libtcc.a -ledit -lgc -lpcre
-ldl
tim ~/pp/malcc master 0 > ./hello hello world
tim ~/pp/malcc master 0 >
Успешные неудачи
Почти десять лет я мечтал написать компилятор. Меня всегда очаровывала работа языков программирования, особенно компиляторов. Хотя я представлял компилятор как тёмную магию и понимал, что сделать его с нуля невозможно простому смертному вроде меня.
Но я всё равно попытался и учился по ходу!
Во-первых, интерпретатор
В 2011 году я начал работу над простым интерпретатором для вымышленного языка Airball (airball можно перевести как «мазила»). По названию можете оценить степень моей неуверенности, что он будет работать. Это была довольно простая программа на Ruby, которая анализировала код и ходила по абстрактному синтаксическому дереву (AST). Когда интерпретатор всё-таки заработал, я переименовал его в Lydia и переписал на C, чтобы сделать быстрее.
Помню, синтаксис Lydia казался мне очень умным! До сих пор наслаждаюсь его простотой.
Хотя Lydia была далека от идеального компилятора, но она вдохновила меня продолжить эксперименты. Впрочем, меня по-прежнему мучили вопросы, как заставить компилятор работать: во что компилировать? нужно ли изучать ассемблер?
Во-вторых, компилятор и интерпретатор байт-кода
В качестве следующего шага в 2014 году я начал работу над scheme-vm — виртуальной машиной для Scheme, написанной на Ruby. Я думал, что виртуальная машина с собственным стеком и байт-кодом станет переходным этапом от интерпретатором с AST-проходами и полноценным компилятором. И поскольку Scheme формально определена, не придётся ничего изобретать.
Я возился со scheme-vm более трёх лет и многое узнал о компиляции. В конце концов, я понял, что не смогу закончить этот проект. Код превратился в настоящий хаос, а конца не было видно. Без наставника или опыта, я словно блуждал в темноте. Как выяснилось, спецификация языка — не то же самое, что руководство по нему. Урок усвоен!
К концу 2017 года я отложил scheme-vm в поисках чего-то лучшего.
Встреча с Mal
Где-то в 2018 году мне попался Mal — интерпретатор Lisp в духе Clojure.
Mal изобретён Джоэлом Мартином как учебный инструмент. С тех пор разработано более 75 реализаций на разных языках! Когда я смотрел на эти реализации, то понимал, что они очень помогают: если я застрял, то могу пойти поискать подсказки в версии Ruby или Python. Наконец хоть кто-то говорит на моём языке!
Я также подумал, что если смогу написать интерпретатор для Mal, то смогу повторить те же шаги — и сделать компилятор для Mal.
Интерпретатор Mal на Rust
Сначала я приступил к разработке интерпретатора согласно пошаговому руководству. В то время я активно изучал Rust (оставлю это для другой статьи), поэтому написал собственную реализацию Mal на Rust: mal-rust. Подробнее об этом эксперименте см. здесь.
Это была совершенное наслаждение! Не знаю, как выразить благодарность или похвалить Джоэла за создание превосходного руководства по Mal. Каждый шаг описан детально, есть блок-схемы, псевдокод и тесты! Всё, что нужно разработчику, чтобы создать язык программирования от начала до конца.
К концу руководства я сумел запустить свою Mal-реализацию для Mal, написанную на Mal, поверх моей реализации на Rust. (два уровня глубины, ух). Когда она сработала в первый раз, я от волнения запрыгнул на стул!
Компилятор Mal на C
Как только я доказал жизнеспособность mal-rust, то сразу начал исследовать, как написать компилятор. Компилировать в ассемблер? Смогу ли я компилировать непосредственно машинный код?
Я видел ассемблер x86, написанный на Ruby. Он меня заинтриговал, но мысль о работе с ассемблером заставила остановиться.
В какой-то момент я наткнулся на этот комментарий на Hacker News, где упоминался Tiny C Compiler как «бэкенд компиляции». Это показалось отличной идеей!
У TinyCC есть тестовый файл, показывающий, как использовать libtcc для компиляции кода C из программы C. Это стартовая точка для “hello world”.
Опять вернувшись к пошаговому руководству Mal, вспоминая свои знания C, за пару месяцев свободных вечеров и выходных я смог написать компилятор Mal. Это было сплошное удовольствие.
Если вы привыкли к разработке через тестирование, то оце?ните наличие предварительного набора тестов. Тесты ведут к рабочей реализации.
Не могу много сказать об этом процессе, разве что повторю: руководство по Mal — настоящее сокровище. На каждом шагу я точно знал, что нужно делать!
Трудности
Оглядываясь назад, вот некоторые сложности при написании компилятора Mal, где пришлось повозиться:
- Макросы должны компилироваться на лету и быть готовы к выполнению во время компиляции программы. Это слегка озадачивает.
- Нужно обеспечить «окружение» (дерево хэшей / ассоциативных массивов / словарей с переменными и их значениями) как для кода компилятора, так и для итогового кода скомпилированной программы. Это позволяет определять макросы во время компиляции.
- Поскольку среда доступна во время компиляции, изначально Malcc во время компиляции ловил неопределённые ошибки (доступ к переменной, которая не была определена), и в паре мест это нарушило ожидания набора тестов. В конце концов, чтобы пройти тесты, я отключил эту функцию. Было бы здорово добавить её обратно в качестве дополнительного флага компилятора, поскольку так можно заранее выловить много ошибок.
- Я скомпилировал код C, записывая в три строки структуры:
top
: код верхнего уровня — здесь функцииdecl
: объявление и инициализация переменных, используемых в телеbody
: тело, где производится основная работа
- Целый день я размышлял, не написать ли собственный сборщик мусора, но решил оставить это упражнение на потом. Библиотека сборщика мусора Boehm-Demers-Weiser легко подключается и доступна для многих платформ.
- Важно просматривать код, который пишет ваш компилятор. Всякий раз, когда компилятор встречал переменную окружения
DEBUG
, он выдавал скомпилированный код C, где можно просмотреть ошибки.
Что я бы сделал иначе
- Писать код C и пытаться сохранить отступы было непросто, тут я бы не отказался от автоматизации. Мне кажется, некоторые компиляторы пишут уродливый код, а затем специальная библиотека «украшает» его перед выдачей. Это нужно изучить!
- Добавление в строки при генерации кода немного сумбурно. Можно было рассмотреть возможность создания AST, а затем преобразования в последнюю строку кода C. Это должно привести код в порядок и придать гармонии.
Теперь совет
Мне нравится, что на компилятор ушло почти десятилетие. Нет, правда. Каждый шаг на пути — это приятное воспоминание, как я постепенно становился всё лучшим программистом.
Но это не значит, что я «закончил». Есть ещё много сотен методов и инструментов, которые нужно изучить, чтобы чувствовать себя настоящим автором компилятора. Но могу уверенно сказать: «Я сделал это».
Вот весь процесс в сжатом виде, как сделать собственный компилятор Lisp:
- Выберите язык, на котором вы чувствуете себя комфортно. Вы же не хотите одновременно изучать новый язык и то, как написать другой новый язык.
- Следуя руководству Mal напишите интерпретатор.
- Порадуйтесь!
- Снова следуйте инструкциям, но вместо выполнения кода напишите код, который выполняет код. (Не просто «рефакторинг» существующего интерпретатора. Надо начать с нуля, хотя копипаст не запрещён).
Я считаю, что этот метод можно использовать с любым языком программирования, который компилируется в исполняемый файл. Например, вы можете:
- Написать интерпретатор Mal на Go.
- Модифицировать ваш код, чтобы:
- создать строку кода Go и записать её в файл;
- скомпилировать этот результирующий файл с помощью
go build
.
В идеале, лучше контролировать компилятор Go как библиотеку, но это тоже способ сделать компилятор!
С помощью руководства Mal и своей изобретательности вы можете всё это сделать. Если даже я смог, то и вы сможете!
Комментарии (3)
Sly_tom_cat
05.04.2019 15:30+2Я помнится как дипломную работу написал интерпретатор LISP на Pascal (Borland TurboPascal), а мой одногруппник приделал к интерпретатору борлондовый IDE (он был доступен в исходниках). Потом нашей разработкой даже в учебном курсе пользовались какое-то время. Это был конец 90-х.
Веселая была история написания: около месяца я выписывал и отлаживал динамическую сборку мусора. А потом взялся за собственно интерпретатор и так с наскоку давай его на If-else/case делать… а что — язык то по лексике — элементарный… угу элементарный, только вот минимальная обработка ошибок порождала монструозный код… Один раз закопался в коде, взялся второй раз писать — опять закопался. Мозг кипит, а продвинутся не могу… и так почти 2 месяца…
Сдался, взял конспект по формальным языкам и грамматикам, за пару часов нарисовал граф автомата лексического разбора, и за один вечер написал интерпретатор на этом автомате.
Пошел показывать дипломной руководительнице и когда рассказал ей как я за один день сделал то что не мог два месяца сделать она сказала: «ну видимо всетаки я не зря вас учила, если ты хотя бы и через два месяца мучений, но все-таки сделал все правильно»… :)
FForth
Интересно, что в списке реализаций MAL засветились и языки Factor и Forth.
Factor язык имеет и наследственность Лисп, а Форт по использованию скобок — «антaгонист» к Лисп. :)
P.S. И «наоборот» есть проекты: Staapl — A Racket-based Forth/Macro Assembler on steroids for PIC18F.
bentall
Фактор — это фортолисп :).