На работе я пишу почти исключительно на Python, с университетской скамьи остались некоторые знания C/C++, в одном pet-project использовал Haskell. С таким багажом знаний я взялся за написание компилятора на основе LLVM - зачем и что получилось я уже рассказывал в предыдущей статье. Эту статью я пишу для тех, кто так же, как и я, заинтересован в изучении Haskell, создании собственных языков программирования, или хочет поиграться с LLVM, но не знает с какого конца подойти к задаче. Я кратко расскажу про необходимый минимум знаний Haskell, про свои ошибки и к каким решениям я пришел - а так же про решения, о которых я узнал позже - и как их можно интегрировать в ваш pet-компилятор. На всё это я по возможности дам ссылки на изучение. Haskell - настраиваем проект и необходимые знания языка Основы языка - синтаксис, парсер, AST Кодогенерация с LLVM Заключение Код из этой статьи, разложенный по шагам: https://github.com/VoidDruid/habr-hs-llvm Репозиторий по этой ссылке можно использовать как "интерактивный учебник" в паре со статьей. Как можно понять из вступления к статье - я в haskell совершенно не специалист. Эта статья - не мастер-класс по разработке компиляторов и не учебник по этому языку, а скорее практическое пособие на тему того, как подойти к жутко звучащей задаче - написанию компилятора на хаскеле, и не пойти на дно из-за "наслоения сложности". Далее я пошагово разберу этот процесс на примере своей дипломной работы, выкинув некоторые фичи, не нужные в рамках этой статьи (декораторы, например). Благодаря этому проекту и нескольким после него я сильно продвинулся в haskell, но к началу работы над компилятором я успел написать на этом языке только telegram-бота, который помогает мне мониторить личный сервер. Для программистов примерно с такими же базовыми познаниями в хаскеле написана эта статья. В дальнейшем я рассчитываю, что вы знаете (хотя можно разобраться и по ходу): Базовый синтаксис языка Модули и импорты Как работать с монадами (не обязательно уметь писать свои), хотя бы поверхностно do-нотация IO монада Рекурсия, частичное применение функций, объявление типов Желательно: typeclasses, алгебраические типы данных, records Я попытаюсь объяснить, зачем и как используется каждая фича языка, указанная в пункте "желательно", но будет проще, если вы с ними знакомы. Собирать проект мы будем с помощью stack - установить его довольно просто (инструкция), пользоваться тоже. Создавать проект с нуля не придется - можно клонировать этот репозиторий, и идти в нем по шагам параллельно со статьей (readme есть в каждой папке плюс глобальный - ознакомьтесь с ними, чтоб упростить сборку и запуск кода). Или можно делать все самостоятельно, все же создав новый проект - с помощью самого stack (инструкция) или используя этот шаблон для cookiecutter. Неплохая статья для начинающих про stack на русском Книга про haskell на английском, которую можно использовать как онлайн-справочник   Если вы решили работать со статьей совместно с кодом из репозитория с пошаговым разбором, то перед тем как мы пойдем дальше, зайдите в папку step-00, и запустите stack run. Если программа с вами поздоровалась, то мы готовы идти дальше. Сейчас мы находимся в директории step-00, соответствующей ШАГ 0. В дальнейшем, переходы к следующему шагу (и, соответственно, к другой папке с кодом) будут отмечены именно так - ШАГ Х. ШАГ 1 Первое, что мы попробуем сделать - это написать простой парсер. В стандартной библиотеке хаскеля есть отличный модуль Parsec (Parser Combinators - описание и документация; туториал, по которому учился я), позволяющий в декларативном стиле описывать парсеры. Для сборки необходимо добавить его в .cabal файл в секцию build-depends. Туториал по Parsec на русском На данном этапе, весь код все еще будет помещаться в модуле Main. Добавим несколько импортов: Что мы импортировали: parse - функция, прогоняющая парсер на входном потоке данных Parser - определение типа простого парсера, принимающего на вход строки emptyDef - "пустое" определение синтаксиса языка, на основе которого мы сделаем свое Text.Parsec.Token - основной модуль с функциями-парсерами Создадим новое определение лексера, с помощью функции makeTokenParser, передав ей на вход определение синтаксиса нашего языка: Тут мы создали новый record под названием style, в котором описали базовый синтаксис нашего языка, пока что крайне простого - си-подобные комментарии, базовые операции (+, -, *, /) и зарезервированные слова if и else. Получившийся лексер мы теперь можем передать конструкторам парсеров из Tok, чтобы получить конкретный функции-парсеры для нашего языка: Попробуем написать парсер, который понимает простую операцию сложения целых чисел и возвращает результат. Для парсеров определены монадные операторы, и если посмотреть определение типа, то видно что мы можем использовать их совместно с собственными монадами, state-ами и т.д. Когда я только начинал работать с Parsec, я этого не знал, и просто использовал do-нотацию, потому что так было написано в туториалах. Выше я вставлял ссылки на документацию, там можно увидеть что ParserT это на самом деле монадный трансформер, для которого определена куча инстансов монадных тайп классов, большинство из которых я до сих пор не понимаю. Для наших целей достаточно знать, что сами парсеры возвращают значения, а парсер-комбинаторы принимают одни парсеры и возвращают новые. Зная это, определеить парсер для сложения можно так: Вернуть из парсера можно по-большому счету что угодно, тип просто поменяется на Parser ЧтоУгодно. Функция sumParse принимает строку, и разбирает ее как бы по инструкции, соответствущей последовательности операции в do-блоке: Берет первое число first <- integer Считывает зарезервированный оператор "+" Берет второе число second <- integer Возвращает результат сложения return (first + second) У функции runSumParse один входной параметр - строка, которую она передает на разбор sumParse. "<stdin>" -  указание на "источник" входных данных, используется это для сообщений об ошибках, например. Если войти в ghci (команда stack ghci), и запустить runSumParse "1 + 2" -  вернется Right 3. Это значит что наш парсер отработал без ошибок, и вернул результат 3 - у функции parse тип возвращаемого значения Either ParseError a - где a - тип, возвращаемый sumParse, то есть Integer. В качестве подведения черты под этим этапом, перепишем функцию main так, чтобы она читала строку из консоли, парсила сложение, и выводила результат или ошибку:  Во-первых, нам надо добавить операцию "++" в список зарезервированных операций: ops = [ "++", "+", "*", "-", "/"]. Затем пишем аналогичный парсер: ШАГ 2 Один из самых интересных этапов в проектировании языка, по-моему опыту - это описание формата синтаксического дерева. Именно тут закладывается фундамент для всех конструкций языка. В случае с хаскелем, описать "обычное" дерево легко - это рекурсивный алгебраический тип данных: Тут Leaf и Node - конструкторы данных типа Tree a, где a - тип данных, которые содержат узлы дерева. Аналогичным образом мы можем описать и структуру выражения в нашем языке (еще раз напомню, что хоть синтаксис и похож на Си, мы пишем не сишный компилятор, а реализацию "учебного" си-подобного языка): Таким образом, мы объявили самые базовые конструкции (см. комментарии в коде). Можно заметить, что здесь есть ссылки на дополительные типы данных - ExprType, CodeBlock. Первый - это просто "enum" с типами, поддерживаемыми нашей программой. Сейчас это только инты и функции, но оставим задел на будущее и объявим несколько дополнительных: CodeBlock - это массив выражений, определен просто как: Разница между типами  и CodeBlock Expr чисто формальная - CodeBlock я использую там, где список выражения является "телом" чего-либо (функции, цикла и пр.), а  - например, как список аргументов функции. Для такого же "семантического" удобства определим тип AST, который будем использовать только в высокоуровневых функциях: В отличии от CodeBlock, AST определен строго для Expr . Мне это показалось логичным в контексте того, что каждый из них "представляет" в программе. Однако если читателю это кажется излишним нагромождением типов, от всего этого можно избавиться и просто везде использовать . В синтаксическое дерево в реальном компиляторе конечно хочется засунуть гораздо больше информации - начиная от типов всех выражений (как это сделал я будет показано далее), и до, например, номеров строк на которых находились выражения - для отладки и формирования сообщений об ошибках. Я осознал это довольно поздно, и решил не переделывать, благо для моих задач мне это не понадобилось, но в хаскеле для подобных "многослойных" структур часто используется подход "Recursion Schemes". Вот неплохая статья про него - как можно видеть, от структуры того, что я делаю в этой статье, отличается не сильно, однако для применения этого подхода требуется существенно более высокий уровень знания языка. Так что если вдруг после прочтения статьи вы вдохновитесь на написание "серьезного" компилятора, можете спокойно использовать все, про что я рассказываю в этом разделе, но перед переходом к написанию кодогенератора попробовать внедрить рекурсивные схемы. После описания структуры языка можно перейти к написанию функций-парсеров. Пока у нас нет кодогенерации, смотреть что выдает парсер можно будет только распечатывая дерево Expr. Если положиться на derive Show, то в консоль будет вываливаться просто стена текста, так что попробуем определить для наших типов инстанс Show такой, чтоб можно было легко понимать структуру полученной программы. Кода в этой секции много, весь его я здесь приводить не буду, только основные моменты. Остальное, как всегда, можно посмотреть в репозитории туториала. Определить Show для типов довольно просто: Гораздо интереснее его определять для Expr. Довольно быстро я понял, что определять именно Show не слишком правильно - например, красиво распечатанный If может занимать несколько строк, что не подходит для Show, поэтому я создал аналогичный тайп класс: Я хотел иметь функцию prettifyAST, которой я мог бы передать AST (массив выражений) и получить список отформатированных строк - дальше их можно печатать отдельно или соединить в большое текстовое представление структуры программы (intercalate "\n" - каждый String на отдельной строке). Определил я эти функции так: Теперь дело осталось только за определением инстанса Pretty Expr. Как это сделал я, можете посмотреть тут (некоторые используемые функции определены в модуле StringUtils), общая структура такая: Все еще ШАГ 2 Теперь рассмотрим написание парсера. Для этого мы будем использовать лексер, объявленный в шаге 1. Для парсера я создал отдельный модуль Parser (parser.hs) и вынес все связанное с лексером из первого шага в модуль Lexer (lexer.hs). Тут не будет ничего принципиально нового, просто продолжение принципов парсер-комбинаторов, которые я уже показывал. Вот что мы будем использовать (есть еще импорты из стандартной библиотеки, их я опускаю): Первая и самая запутанная часть - объявить парсеры операций. Её я вынес под спойлер, кому интересно - можете посмотреть, а остальным предлагаю сразу переходить к парсерам выражений, я до сих пор под впечатлением, насколько элегантно это можно сделать. Как всегда, полный код этого модуля (для данного этапа) в репозитории. Для начала объявим парсер для операторов вида " + ", " * " и так далее: Результат работы op это String для стуктуры Затем нам надо объявить валидные операторы, ассоциативность операций и их приоритет: Объявим сначала верхнеуровневую функцию-парсер для любого выражения - она нам понадобится для использования внутри других: Функция factor - это комбинация парсеров для всех существующих Expr. Она должна попытаться их применить с помощью try, и вернуть первый получившийся результат: Уже известным нам способом объявим утилитарные функции для прогона парсера, и можно переходить к написанию парсеров самих выражений. Здесь я не буду приводить все функции, только несколько примеров - остальные можно посмотреть в репозитории. Самый простой пример - парсер интов. Библиотечной функцией integer мы достаем из входного потока число и создаем Exprс помощью конструктора Int: Так же мы можем использовать парсеры внутри парсеров, совмещая их с библиотечными комбинаторами: Объединив все вместе, разберем чуть более сложный парсер: Осталось попробовать что-нибудь распарсить. Переопределим main так, чтоб он принимал на вход имя файла, и печатал результат парсинга кода в нем. Импорты я опускаю, а сам main выглядит теперь так: Вот как выглядит main.hs и main.grt (файл с кодом) у меня (и у вас, если вы идете по шагам). Запустим это с помощью  и полюбуемся на результаты нашей работы - красиво распечатанное AST. Теперь можно двигаться дальше - к типизации. ШАГ 3 После создания базового синтаксического дерева, настало время заняться типизацией. После добавления типов, мы хотели бы в каждом узле дерева иметь дополнительное поле типа ExprType, который мы заранее завели выше: Но такой код, естественно, не делает то что нужно - "внутри" TypedExpr все еще лежит обычный Expr. Решение, которое придумал я - не оптимальное, но делает все, что надо. Это добавление "промежуточного контейнера" TExpr и собственно определение TypedExpr. Исходники - тут. Прелесть такого способа в том, что он также позволяет добавлять любые аннотации (в том же месте где и ExprType), однако многословен и не совсем идиоматичен. Описание моего тернистого пути под спойлером, а мы идем дальше. Тут-то и оказываются нужен тот самый подход рекурсивных схем. Как я уже говорил, информации по написанию компиляторов на хаскеле, доступной для понимания новичку, почти нет, поэтому я просто пытался получить что-нибудь работающее, а компилятор - наверное, из возможных пет-проджектов самый требовательный к предварительному планированию. Когда я, посоветовавшись с корифеями, понял, что для добавления дополнительной информации в дерево надо использовать рекурсивные схемы или что-то такое (близкое по смыслу),  - я уже имел парсер и кодогенератор, работающие с Expr. Пробовал делать что-то типо: И определять: type Expr = Expr' Expr - ругается на цикл в определении type Expr = Expr' Expr' -  ругается, вполне очевидно, что для последнего Expr' не хватает аргумента newtype Expr = Expr (Expr' Expr) - а это уже близко к тем самым рекурсивным схемам, не хватает только правильно заиспользовать Fix, но на этом моменте я сломался В общем, не повторяйте моих ошибок и сразу используйте параметризованные типы, Data.Fix и прочие прелести. Я же решил - сделаем TypedExpr аналогом Expr, но с типами и соответствущими поправками. Идея такая - сначала получив дерево из Expr, применим алгоритм, который трансформирует его в дерево типа TypedExpr - в котором все выражения будут аннотированы. Есть несколько способов это сделать: Можно перенести все конструкторы (с заменой Expr на TypedExpr) в определение TypedExpr, и добавить во все типы - data TypedExpr = ... | TBlock ExprType (CodeBlock TypedExpr), но в таком случае "тип" это не аннотация, а "часть" объявления синтаксиса - мне это не понравилось Можно сделать аналогично, но добавить тип не во все конструкторы, а в один, "главный" - data TypedExpr = ... |  TypedExpr ExprType TypedExpr - но тогда функции принимающие TypedExpr смогут принимать как "главное" объявление, так и инстансы без типов Вариант, на котором я остановился - промежуточный тип TExpr, с конструкторами аналогичными Expr, но ссылающимися на TypedExpr (т.е. определение дерева, с "узлами" из TypedExpr), и тип data TypedExpr = TypedExpr ExprType TExpr. Вместе с view-паттернами это в коде выглядит не так многословно, как может показаться, это будет видно далее. Ради историчности, и чтоб сразу показать, где код можно улучшить, я оставил все свои первоначальные TODO-пометки в исходниках. На этом шаге мы добавляем модуль AST, в котором находятся все функции для обработки синтаксического дерева. "Верхнеуровневая" функция - processAST - объявлена тут и либо превращает AST в типизированные дерево (TAST), либо возвращает список ошибок. Все, что она делает сама - это применяет по очереди функции-обработчики обычного AST (сейчас это только desugarFunc - удаление из определения функций "сахара" в виде объявления имени возвращаемой переменной и явного ее указания в конце тела), а затем передает получившееся в annotateTypes (которая просто делает deduceType рекурсивно для каждого expr), и возвращает ее результат. Логика deduceType довольно простая - она принимает аннотируемый expr, который, если все пройдет успешно, "заменится" своим typed-аналогом, и "карту типов" - ассоциативный массив известных "языковых единиц" (переменных, функций) с их типами - изначально пустой, и заполняющйся по мере углубления в дерево. Большая часть типов просто подсматиривается в определении, либо выводится из операций (int + int = int, int / int = float и т.д.). auto разрешается максимально просто - если известно значение присваемого выражения (арифметическая операция или вызов функции), то назначается этот тип, иначе возвращается ошибка. Полные исходники - тут, либо в файле step-03/AST/Typing.hs. Добавим processAST в main и посмотрим что нам выведет наша программа теперь для простейшей функции. Исходник: Типизированное представление: Тут i= - интовое присвоение, а b< - операция сравнения, которая (очевидно) возвращает bool. ШАГ 4 В этом разделе мы воспользуемся инфраструктурой LLVM для создания машинного когда из нашего синтаксического дерева. Для этого нам нужна библиотека llvm-hs (github, hackage), которую, вместе с некоторыми другими, надо добавить в зависимости нашего проекта. В моем stack.yaml это выглядит так: Если же вы идете по туториалу по шагам из репозитория, то переходите в step-04. Необходимо установить и саму LLVM - как это сделать, описано в документации llvm-hs. Инструкция. Соответствущие изменения в конфигурационных файлах проекта - stack.yaml и habrhs.cabal Это - финишная прямая. Тут мы добавим модуль Codegen, главная его функция - buildIR, выдающая промежуточное представление LLVM IR. Сразу добавим этот этап в main следующим образом, чтобы сосредоточиться только на кодогенерации (обратите внимание - появилось много импортов и флаг --emit). Codegen состоит из трех файлов, главный - Builder.hs, где и происходит генерация IR, остальные содержат утилиты, по которым я предлагаю быстро пройтись (обратите внимание, что тут везде импортированы модули LLVM.*, функции с разными "компиляторными" названиями типо allocate или reference - это оттуда). 1) ASTBridge - разные утилиты для перевода типов и операции нашего TAST в соответствущие для LLVM typeMap - словарь из пар "наш идентификатор типа" - "тип в LLVM" функции вида allocateX - генераторы вызовов LLVM для аллокации памяти под сам тип или указатель на него. функции вида referenceX - генерации LLVM-ных "обращений" к ячейке памяти под какой-либо тип напрямую или по указателю convert - по нашим IntType и FloatType генерирует операции приведения типов для LLVM opTable и findOperation - соответственно словарь и функция для поиска в нем соответствующих операций. Словарь содержит для IntType и FloatType еще по вложенному словарю, с парами "строка операции" (+, -, ....) - "LLVM операция соответствующего типа" (интовое умножение, дробное умножение и т.д.) Каждая из этих функций (за исключением последней) возвращает Operand - это LLVM-примитив, обозначающий, как ни странно, операнд - не самостоятельное выражение, а некий объект, который должен быть или "записан в переменную" (store в llvm), или использован в составе другого операнда. findOperation же возвращает (а opTable - содержит) функции, принимающие два операнда и возвращающие третий - то есть бинарные операции. Кроме уже упомянутых reference и allocate, тут используется еще несколько часто встречаемых конструкций из LLVM. Это named, генерирующий именованные выражения и AST.PointerType, оборачивающий любой другой LLVM-тип и объявляющий указатель. Особенно стоит отметить, что такое qualified имя обозначает что это не из "нашего" AST, а из модуля LLVM.AST. 2) Primitives - содержит сокращенные объявления функций LLVM. Например, объявлние типа указателя на int требует определить адресное пространство и вид инта (i16, i32, i64). Мы для простоты используем выравнивание по 4, i32 и дефолтное адресное пространство - поэтому удобно создать утилитарные функции, которые подобные параметры проставляют сразу. Такие сокращения и содержит этот модуль. И, наконец, то, ради чего мы тут собрались - генерация IR из AST, модуль Builder.  Благодаря рекурсивному объявлению синтаксического дерева, по сути нам надо лишь описать функцию, обрабатывающую единственный тип TypedExpr со специализациями под конкретные конструкторы (выражения) - emit (по ссылке - ее исходный код). Мы также гарантировали, что все верхнеуровневые выражения - это функции, так что сделаем их особым кейсом, и тогда вся схема кодогенерации выглядит так: Тип функции emit следующий - emit :: (MonadFix m, MonadIRBuilder m) => TypedExpr -> m Operand. То есть это функция, которая работает с монадой IRBuilder из LLVM - это крайне удобно, т.к. с помощью do-блока и специальных фунций LLVM мы, по сути, просто объявляем (с помощью паттерн-матчинга) для каждого нашего выражения какой ему соответствует IR-код почти один-в-один для человеческого глаза. Т.к. большинство из того что "возвращают" функции LLVM можно передать далее по монаде, написание emit - довольно тривиально. Разберем пример (тут используется функция view в паттерне для удобства - она сразу достает тип и составные части выражения) - генерация для If: Тут есть несколько утилитарных функций. Самая простая - emitExit, она просто генерирует код выхода из блока с сохранением результата: Вторая - buildBranch. Аналоги веток есть не только у If, но в некотором смысле и у циклов, просто там в конце происходит прыжок в начало. Расписав таким образом emit для каждого вида выражений (некоторые реализации занимают одну строку), мы получаем генератор IR кода. Посмотрим, что у нас получилось в итоге для все той же простейшей функции (те кто следуют по туториалу - это main.grt).  Запустим stack run -- -e main.grt еще раз и полюбуемся на результат (флаг -e нужен для печати IR - ведь на этом этапе мы уже расчитываем скоро иметь компилятор, а они обычно не выводят на экран IR): Этот код сгенерирован без каких-либо оптимизаций, поэтому он многословнее чем "должен быть", но для нас это только лучше - нагляднее. Ура, мы наконец имеем LLVM IR из исходников нашей программы. Запустить его уже можно, например перенаправив вывод в файл, который затем дать на вход lli - интерпретатору IR. Если хочется создать исполняемый файл, то дело за малым - обратиться к встроенному в LLVM функционалу компиляции модулей. Это уже ШАГ 5, директория step-05. Чтобы максимально быстро получить результат, будем использовать все параметры по умолчанию - благо, для этого в llvm-hs есть готовые обертки. По большому счету, все что нам надо сделать - это склеить пустой контекст, настройки хостовой машины по умолчанию, создать llvm-модуль из нашего LLVM.AST.Module с помощью соответствующей функции, и скормить финальный результат билдеру объектников, после чего этот файл слинковать. Код для дешёвого и сердитого создания объектных файлов выглядит так (полный файл): И так добавляется в main (полный файл): С добавлением этого, наша программа после запуска сгенерирует файл output.o, который только останется слинковать вашим любимым линкером - у меня на маке это выглядит как ld -lSystem output.o -o output. Так как ввод/вывод мы не реализовали, наша программа просто возвращает числа, которые будут выходными кодами. Соответствено, посмотреть результат исполнения можно распечатав exit code последней команды. Я написал утилитарный скрипт (проверял только на macOS) для упрощения этого процесса, который запускает наш комплиятор, линкует объектный файл, запускает получившуюся программу и печатает результат: Итак, мы прошли все основные этапы написания компилятора - парсер, обработка AST, генерация IR, даже затронули генерацию финального машинного кода. Иначе говоря, мы разработали фронтенд компилятора и научились взаимодействовать с бэкендом (LLVM). Конечно, как я уже указывал, многое можно сделать лучше - надеюсь я дал достаточно ссылок на другие материалы, чтобы вы смогли избежать моих ошибок, а мой туториал стал хорошей стартовой точкой. Буду рад комментариям (постараюсь ответить на них), а также PR-ам в репозиторий туториала - версия кода, используемая в этой стате, зафиксирована в README и самой ссылке в начале, так что перепройти туториал можно в любой момент. Любите и используйте функциональные языки, и спасибо за внимание! Разработчик