Вам когда-нибудь приходилось задаваться вопросом, как работает компилятор, но так руки и не дошли разобраться? Тогда этот текст для вас. Мне тоже не доводилось заглядывать под капот, но тут так случилось, что мне нужно прочитать курс лекций о компиляторах местным третьекурсникам. Кто встречался с некомпетентными преподавателями? Здравствуйте, это я :)

Итак, чтобы самому разобраться в теме, я собираюсь написать транслятор с эзотерического языка программирования wend (сокращение от week-end), который я только что сам придумал, в обычный ассемблер. Задача уложиться в несколько сотен строк питоновского кода. Основной репозиторий живёт на гитхабе (не забудьте заглянуть в мой профиль и посмотреть другие tiny* репозитории).

Я разобью сопроводительный текст на несколько статей:

  1. Синтаксические деревья и наивный транслятор в питон (эта статья)

  2. Лексер/парсер

  3. Таблицы символов: области видимости переменных и проверка типов

  4. Стэк и транслятор в питон без использования питоновских переменных

  5. Транслятор в ассемблер

  6. Рейтрейсинг :)

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

Спасибо Василию Терешкову за идею!
Спасибо Василию Терешкову за идею!

Исходный язык wend

Итак, давайте не будем ходить вокруг да около, вот пример программы на языке wend:

fun main() {
    // square root of a fixed-point number
    // stored in a 32 bit integer variable, shift is the precision

    fun sqrt(n:int, shift:int) : int {
        var x:int;
        var x_old:int;
        var n_one:int;

        if n > 65535 { // pay attention to potential overflows
            return 2 * sqrt(n / 4, shift);
        }
        x = shift; // initial guess 1.0, can do better, but oh well
        n_one = n * shift; // need to compensate for fixp division
        while true {
            x_old = x;
            x = (x + n_one / x) / 2;
            if abs(x - x_old) <= 1 {
                return x;
            }
        }
    }

    fun abs(x:int) : int {
        if x < 0 {
            return -x;
        } else {
            return x;
        }
    }

    // 25735 is approximately equal to pi * 8192;
    // expected value of the output is sqrt(pi) * 8192 approx 14519

    println sqrt(25735, 8192);
}

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

Программа на wend состоит из объявления переменных, функций и инструкций. При исполнении функция main() является точкой входа в программу, и каждая из её инструкций выполняется в порядке их объявления.

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

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

Объявление переменной предваряется ключевым словом var , объявление функции предваряется ключевым словом fun. После идентификатора через двоеточие объявляется тип, у функции тип может быть опущен, тогда она не возвращает никакого значения. Переменные и выражения могут быть быть либо булевского типа, либо целочисленные (знаковые, 32 бита). В качестве исключения функции print и println могут иметь в качестве параметра постоянную строку, например, println "hello world!";, однако никаких других операций над строками не предусмотрено.

Представление вещественных чисел при помощи целочисленных переменных

В качестве лирического отступления давайте разберёмся в том, что делает наша программа-пример. Я не стал добавлять в wend ни стандартной плавающей точки ieee754, ни позитов. Это сделать несложно, но раздует компилятор, а я хочу, чтобы он был как можно более компактным. Двух разных типов (bool и int) мне вполне хватит для того, чтобы показать, как работает проверка типов. Очень сильно не исключено, что я захочу написать компилятор wend на самом языке wend, и вот тогда я буду добавлять свистелки. Но пока что это очень гипотетически, поглядим потом.

Что же делать прямо сейчас, если мне хочется уметь работать с вещественными числами? Ну мои 32-битные переменные хоть и называются целочисленными, но на самом деле я их могу интерпретировать вообще как хочу. Давайте представим, что я хочу хранить число пи с точностью до четвёртого знака после запятой. Если я умножу \pi на 10^5 , то получу 31415.9265(...). То есть, я могу написать в коде p = 31415; print p/10000; print "."; print p%10000;и получу на экране заветное 3.1415.

Идея очень простая: мы выбираем некий множитель m, и для представления нужного числа (в данном случае пи) мы храним целую часть числителя дроби (pi*m)/m. Размер числа m нам даёт фиксированную точность представления вещественного числа. Исторически чаще всего выбирают в качестве множителя степени двойки, поскольку можно заменить (когда-то) дорогие операции деления и нахождения остатка от деления на дешёвые сдвиг и побитовое "И". Например, если m выбрано равным 2^31, то можно хранить числа от -1 до 1 с шагом примерно 4,7*10^-10; а если e выбрано равным 2^10, то можно представлять числа от -2'097'152 до 2'097'151 с шагом 0,0009765625.

Давайте представим, что у меня есть переменная p, в которой хранятся 32 вот таких бита: 00000000000000000110010010000111. Если я сделаю print p;, то получу на экране 25735. Однако же в я хочу интепретировать как целое число только старшие 19 битов, а вот младшие 13 - это дробная часть. Если я сделаю print p/8192; print "."; print ((p%8192)*10000)/8192;то получу на экране 3.1414, то есть, число \pi с точностью в 13 битов после запятой, что чуть-чуть грубее одной десятитысячной.

Сложение и вычитание чисел с фиксированной точкой выполняются с помощью целочисленной арифметики при условии, что экспоненты левого и правого операндов одинаковы. Если у нас есть два числа p и q с фиксированной точкой одинаковой точности и их соответствующее целочисленное представление p*m и q*m, то сума их представлений p*m+q*m является представлением суммы самих чисел p+q, поскольку p*m/m + q*m/m = (p+q)*m/m. С разностью то же самое. С умножением, однако, нужно чуть поработать: (p*m) * (q*m) = p*q*m^2, поэтому для представления числа p*q с точностью m нужно поделить на m произведение представлений p*m и q*m.

Вычисление квадратного корня методом Ньютона

Моя программа-пример вычисляет квадратный корень из пи с точностью до 13 битов. Сам алгоритм очень простой: если мы хотим извлечь корень x = \sqrt{a} для какого-то положительно числа a , то мы можем использовать итеративный метод: выберем произвольное число x_1 (например 1) и определим последовательность

x_{n+1} = \frac12\left(x_n + \frac{a}{x_n}\right).

Интуитивно это вполне очевидно: еслиx_nслишком большой (x_n>\sqrt a) , то a/x_n будет слишком маленьким (a/x_n < \sqrt a) , и их среднее арифметическое x_{n+1} будет ближе к a. Этот алгоритм был известен ещё в Вавилоне больше трёх тысяч лет назад: знаменитая табличка YBC7289 содержит значение \sqrt 2 с точностью до шести знаков. Три тысячи лет спустя было обнаружено, что этот алгоритм эквивалентен методу Ньютона нахождения корня уравнения f(x) = x^2 - a: имея приближение корня x_{n}, мы можем приблизить функцию f её касательной при помощи разложения в ряд Тейлора первой степени f(x_n) + f'(x_n)(x-x_n) , приравнять эту функцию нулю и получить улучшенное приближение корня x_{n+1}= x_n - \frac{f(x_n)}{f'(x_n)} , выведя ровно Вавилонскую формулу.

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

Синтаксические деревья

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

На вход у нас будет кусок текста (просто длинная строка), на выход мы тоже должны выдать кусок текста. И между этими текстами должна сохраниться какая-то сущность, смысл. Нам нужен способ этот самый смысл выразить, уходя от голого текста, с которым работать крайне неудобно.

Неотъемлемой частью любого (императивного? пуристы, поправьте меня) языка программирования являются выражения, как арифметические, так и логические. Пионером извлечения смысла из записи выражений был Ян Лукасевич, который знаменит, помимо прочего, своей обратной польской записью. Например, рассмотрим булевское выражение в инфиксной записи:

((B\wedge A)\wedge(\neg(B\wedge A))) \wedge ((\neg C) \rightarrow A)

Для вычисления выражения мы должны произвести операции в определённом порядке, который соответствует следующему дереву:

Для того, чтобы вычислить значение в узле дерева, мы должны оценить всех его потомков. То есть, в нашем вычислителе нам нужно уметь выделять память для хранения промежуточных результатов. На обычных калькуляторах для этого часто используются кнопки = (для получения промежуточного результата) и m+ (для сохранения его в память). Ян Лукасевич предложил использовать стек, и каждый раз, вычисляя значение в узле, просто брать выражения с верхушки стека, что соответствует обходу нашего дерева в глубину. В нашем примере мы кладём на стек A , затем C. Операция \neg унарная, поэтому берём со стека только один элемент, оцениваем его, и кладём результат на стек. В данный момент на стеке у нас два элемента, A и \neg C, поэтому бинарная операция \rightarrow их возьмёт оба и превратит в один результат на стеке ((\neg C)\rightarrow A). Если мы продолжим обход дерева в глубину, то все узлы будут посещены в следующем порядке:

A C \neg \rightarrow A B \wedge \neg A B \wedge \wedge \wedge

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

Разрешу себе отвечься: польская (постфиксная) запись позволяет избавиться от скобок в записи и от использования кнопки '=' на калькуляторе, что изрядно укорачивало программы для этих калькуляторов. В СССР были широко распространены программируемые калькуляторы МК-61 и МК-52, не имевшие кнопки равенства. Как же я упивался в младших классах, притащив в школу калькулятор, которым никто кроме меня не мог воспользоваться! :)

В начале 80х годов Hewlett-Packard рекламировала свой знаменитый HP-12C вот такой картинкой:

Поиграю в Капитана Очевидность: перечёркнутый знак равенства тут обыгрывается одновременно как калькулятор, на котором нет кнопки '=', а также как то, что компании нет равных. И лично я получаю дополнительное извращённое удовольствие от ношения футболки с таким принтом в 20х годах XXIго века, хотя и не люблю быть ходячим рекламным биллбордом. Но количество едких комментариев от непонимающих окружающих перевешивает все моральные неудобства от рекламы на пузе :)

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

У main есть две вложенные функции, которые являются потомками списка функций fun, а также список инструкций body. Нужно понимать, что это дерево выражает структуру программы, но не само её выполнение: в отличие от предыдущего примера, для вычисления значения в узле println нам может понадобиться сделать несколько рекурсивных вызовов оценки соседнего узла sqrt, однако это дерево является абстракцией, которую мы строим из текста программы на wend, и из которой можно сгенерировать программы на различных языках, и сегодня я напишу код, который переводит это дерево в код на питоне.

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

  1. объявления функций

  2. инструкции

  3. выражения

Давайте создадим модуль syntree.py, который будет исключительно хранить данные, никакой логики в нём не будет. Тогда класс объявления функции может выглядеть как-то так:

class Function:
    def __init__(self, name, args, var, fun, body, deco=None):
        self.name = name       # function name, string
        self.args = args       # function arguments, list of tuples (name, type)
        self.var  = var        # local variables, list of tuples (name, type)
        self.fun  = fun        # nested functions, list of Function nodes
        self.body = body       # function body, list of statement nodes (Print/Return/Assign/While/IfThenElse/FunCall)
        self.deco = deco or {} # decoration dictionary to be filled by the parser (line number) and by the semantic analyzer (return type, scope id etc)

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

Ровно таким же образом я написал классы инструкций Print, Return, Assign, While, IfThenElse, а также выражений ArithOp, LogicOp, Integer, Boolean, String, Var и FunCall. Всё, больше ничего для компилятора wend не нужно! Код модуля содержит 64 строчки, и я не буду его тут приводить полностью, поскольку в нём нет вообще никакой логики, это чисто хранилище. Он больше не должен изменяться до самого конца (ну, по крайней мере, если у меня не совсем кривые руки :) ) Исходник доступен на гитхабе.

А вот теперь давайте писать интересный код. Созадим синтаксическое дерево из нашего примера:

    abs_fun = Function('abs',       # function name
        [('x', {'type':Type.INT})], # one integer argument
        [],                         # no local variables
        [],                         # no nested functions
        [                           # function body
            IfThenElse(
                LogicOp('<', Var('x'), Integer(0)),           # if x < 0
                [Return(ArithOp('-', Integer(0), Var('x')))], # then return 0-x
                [Return(Var('x'))])                           # else return x
        ],
        {'type':Type.INT})          # return type

    sqrt_fun = Function('sqrt',                                      # function name
        [('n', {'type':Type.INT}), ('shift', {'type':Type.INT})],    # input fixed-point number (two integer variables)
        [
            ('x', {'type':Type.INT}),                                # three local integer variables
            ('x_old', {'type':Type.INT}),
            ('n_one', {'type':Type.INT})
        ],
        [],                                                         # no nested functions
        [                                                           # function body
            IfThenElse(
                LogicOp('>', Var('n'), Integer(65535)),             # if n > 65535
                [Return(ArithOp('*', Integer(2),                    #    return 2*sqrt(n/4)
                        FunCall('sqrt', [ArithOp('/', Var('n'), Integer(4)), Var('shift')])))],
                []),                                                # no else statements
            Assign('x', Var('shift')),                              # x = shift
            Assign('n_one', ArithOp('*', Var('n'), Var('shift'))),  # n_one = n*shift
            While(Boolean(True), [                                  # while true
                Assign('x_old', Var('x')),                          #     x_old = x
                Assign('x',                                         #     x = (x + n_one / x) / 2
                    ArithOp('/',
                        ArithOp('+',
                            Var('x'),
                            ArithOp('/', Var('n_one'), Var('x'))),
                        Integer(2))),
                IfThenElse(                                         #     if abs(x-x_old) <= 1
                    LogicOp('<=',
                        FunCall('abs', [ArithOp('-', Var('x'), Var('x_old'))]),
                        Integer(1)),
                    [Return(Var('x'))],                             #        return x
                    []),                                            #     no else statements
            ]),
        ],
        {'type':Type.INT})                                          # return type

    main_fun = Function('main', # function name
        [],                     # no arguments
        [],                     # no local variables
        [sqrt_fun, abs_fun],    # two nested functions
        [Print(                 # println sqrt(25735, 8192);
            FunCall('sqrt', [Integer(25735), Integer(8192)]),
            True)
        ],
        {'type':Type.VOID})     # return type

О том, как создавать такое дерево из текста исходника автоматически, мы поговорим в следующий раз. А пока что сравните этот код с рисунком дерева. Внимательный читатель заметит некоторые несоответствия, например, унарная операция -x у меня представляется как бинарная операция 0-x, но это, как говорится, детали имплементации: программист на wend должен иметь возможность написать -x, а уж как это выражение превращается в бинарный узел в синтаксическом дереве внутри компилятора - не его забота.

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

От синтаксического дерева к коду на питоне

Конечный код может быть сгенерирован одним простым обходом нашего синтаксического дерева в глубину. Наример, если мы посетили узел Assign, он гарантированно имеет только два потомка: узел Var, который представляет собой переменную, в которую мы записываем, и один из узлов- выражений ArithOp, LogicOp, Integer, Boolean, Var и FunCall. Обратите внимание, что что узел Var сам является выражением (мы же можем присвоить значение другой переменной?). Впрочем, как вы могли заметить, я не упарывался по иерархии классов, и никакого наследования у меня нет в принципе, деление на выражения и инструкции весьма условно.

Возвращаясь к генерации кода во время посещения узла инструкции Assign, этот узел синтаксического дерева содержит два потомка: строкуAssign.name и ссылку на другой узел-выражениеAssign.expr. Мой код компилятора выглядит следующим образом:

def stat(n):
    [...]
    if isinstance(n, Assign):
        return '%s = %s\n' % (n.name, expr(n.expr))
    [...]

То есть, мы генерируем код типа x = src, где src - это код выражения, сгенерированный вызовом функции expr(n.expr). Полный исходник генератора кода из синтаксического дерева можно найти на гитхабе, там всего 50 строчек. Натравив наш недокомпилятор на вручную построенное синтаксическое дерево, получим следующий результат:

def main():
    def sqrt(n, shift):
            x = None
            x_old = None
            n_one = None

            if (n) > (65535):
                    return (2) * (sqrt((n) // (4), shift))
            else:
                    pass

            x = shift
            n_one = (n) * (shift)
            while True:
                    x_old = x
                    x = ((x) + ((n_one) // (x))) // (2)
                    if (abs((x) - (x_old))) <= (1):
                            return x
                    else:
                            pass
    def abs(x):
            if (x) < (0):
                    return (0) - (x)
            else:
                    return x

    print(sqrt(25735, 8192), end='\n')
  
  main()

При запуске этого файла на экране напечатается 14519, что является представлением числа \sqrt\pi с точностью до 13 битов после запятой! Про вывод 3.1414 вместо 14519 мы поговорим в следующий раз, поскольку неохота мне больше строить синтаксические деревья вручную. Так что тема следующего разговора - парсинг исходников.

Комментарии (28)


  1. abcdsash
    14.01.2024 17:11
    +6

    главное - это дело не забросить ))) по моему много было уже подобного без продолжения или с продолжением, но не до конца


    1. haqreu Автор
      14.01.2024 17:11
      +8

      В данном случае можно не беспокоиться, доведено будет до конца :)


  1. fujinon
    14.01.2024 17:11
    +10

    У нас это было на 2-м курсе в таком виде: сначала немного теории - грамматики Хомского, виды грамматик типа ГПП, LR(n)... потом задание - дан язык, написать грамматику, сгенерировать таблицу, готово. Первый язык - просто скобочные выражения, в качестве примера, можно скомпилировать без создания дерева, только стек. Очень интересная тема))) ждем рейтрейсинг)))


    1. Ioanna
      14.01.2024 17:11

      А мы писали компиляторы на третьем курсе, по системному программированию.


  1. MasterMentor
    14.01.2024 17:11
    +3

    Подобное сделано на более высоком уровне с компилятором и виртуальной машиной на С++. См. си-подобный язык, Basheyev https://habr.com/ru/articles/560356/

    Но это, конечно, не значит, что Вам не следует проделать это вновь.

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

    PS Где-то год назад заплюсовал Ваш tinyraycaster (хабр + отсылка к Ламоту) на Гитхабе (486 lines of C++: old-school FPS in a weekend). Поэтому не сомневаюсь, что и с данным компилятором у Вас получится.

    PPS ИМХО интересней и полезней для сообщества было бы написать, например, короткий разборщик JS->AST на регулярках, чтобы цеплять к нему компиляторы на любых языках. Но это ИМХО.


    1. haqreu Автор
      14.01.2024 17:11
      +6

      Про разработку новых полезных инструментов - это не ко мне. Точнее, ко мне тоже, но не в данном контексте.

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


      1. MasterMentor
        14.01.2024 17:11

        "Сложная это работа - из болота тащить бегемота." (с)

        Мы все пытаемся. :)

        PS Пользуясь случаем, тем кто захочет почитать на тему. Тоже когда-то заплюсовал там же. https://ps-group.github.io/compilers/ast


  1. Abobcum
    14.01.2024 17:11
    +2

    Я просто оставлю это здесь...
    https://ppci.readthedocs.io/en/latest/howto/toy.html


    1. haqreu Автор
      14.01.2024 17:11

      Очень уж маленький язык, это просто калькулятор, и даже не очень программируемый :(

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

      Hidden text
      ,''''''''''''''''''''''''''''''''~~~~~~~~~~~~=====++:[<?[/<O +=~~~~~~~~~'''''''',
      '''''''''''''''''''''''''''''~~~~~~~~~~~~~~=====++::;[&O &/;:++===~~~~~~~~~''''''
      '''''''''''''''''''''''''~~~~~~~~~~~~~~=====+++:< XXO      ? <;+======~~~~~~~''''
      '''''''''''''''''''''~~~~~~~~~~~~~~===++++++:::;;            <;::+++=======~~~~''
      ''''''''''''''''''~~~~~~~~~~~====+:o&/< /[[[&&?<&&oX       O?&<//?/;:::::;/&+=~~~
      '''''''''''''~~~~~~~~=========+++::[&o    O                        x&<o xOO x:==~
      '''''''''~~~~==============+++++:;[/<?X                                    /:++==
      ''~~~~~=::+++++++====++++++:::;[O                                        X [;:++=
      ~~====++:?&/[;;:;;x /;;::::;;;[                                              O?:=
      =====++::;[<o   x O   O x&///<&                                             ?[:+=
      ===++:::;&<o#               xxO                                               :+=
      ::;/ <<o&o                                                                 x/:+==
      /<<?x                                                                     [;:++==
      /<<?x                                                                     [;:++==
      ::;/ <<o&o                                                                 x/:+==
      ===++:::;&<o#               xxO                                               :+=
      =====++::;[<o   x O   O x&///<&                                             ?[:+=
      ~~====++:?&/[;;:;;x /;;::::;;;[                                              O?:=
      ''~~~~~=::+++++++====++++++:::;[O                                        X [;:++=
      '''''''''~~~~==============+++++:;[/<?X                                    /:++==
      '''''''''''''~~~~~~~~=========+++::[&o    O                        x&<o xOO x:==~
      ''''''''''''''''''~~~~~~~~~~~====+:o&/< /[[[&&?<&&oX       O?&<//?/;:::::;/&+=~~~
      '''''''''''''''''''''~~~~~~~~~~~~~~===++++++:::;;            <;::+++=======~~~~''
      '''''''''''''''''''''''''~~~~~~~~~~~~~~=====+++:< XXO      ? <;+======~~~~~~~''''
      '''''''''''''''''''''''''''''~~~~~~~~~~~~~~=====++::;[&O &/;:++===~~~~~~~~~''''''
      ,''''''''''''''''''''''''''''''''~~~~~~~~~~~~=====++:[<?[/<O +=~~~~~~~~~'''''''',
      


      1. Abobcum
        14.01.2024 17:11
        +1

        Библиотека textx позволяет создавать практически любые языки. Библиотека ppci позволяет их компилировать, но возможно и интерпретировать или транслировать, например в С.


        1. haqreu Автор
          14.01.2024 17:11
          +3

          Библиотеки это хорошо, но толковые тексты - это ещё лучше. Они позволяют научиться создавать новые и улучшать старые библиотеки.


          1. Abobcum
            14.01.2024 17:11
            +2

            Согласен. Я тоже раньше писал компиляторы.

            Просто хочу оставить для новеньких в этом деле заметку о "профессиональных" фреймворках для dsl.


            1. haqreu Автор
              14.01.2024 17:11
              +3

              Во, спасибо, так понятнее. А то я как-то чаще видел фразу "я просто оставлю" в смысле того, что автор пропустил что-то широкоизвестное, и выставляет себя на позор :)


        1. ObyWan23
          14.01.2024 17:11
          +2

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


  1. lgorSL
    14.01.2024 17:11
    +2

    Если захочется разобраться поглубже, можно почитать эту книжку (на сайте есть бесплатная версия): https://craftinginterpreters.com/contents.html
    Автор тот же, что и у Game Programming Patterns.


  1. Panzerschrek
    14.01.2024 17:11
    +3

    Я тоже как-то раз решил на выходных компилятор написать. "Выходные" длятся уже 7 с лишним лет.


    1. haqreu Автор
      14.01.2024 17:11

      Ну вот вам и отличный повод :)

      Я, в принципе свой уже дописал, в выходные уложился. Теперь осталось отполировать и описать.


      1. Panzerschrek
        14.01.2024 17:11

        Вы видимо меня не допоняли. В те выходные я действительно кое-чего написал. А вот "отполировать и описать" длиться до сих пор - язык уж очень оброс фичами, был написан self-hosted компилятор, документация и примеры немаленькие и т. д. Короче - по-крупному в проект ввязался.


        1. haqreu Автор
          14.01.2024 17:11
          +1

          Здорово! Я вот думаю, стоит ли мне идти дальше и добавлять управление памятью, структуры-классы, или ну его на фиг, слишком сложно будет для объяснения...

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


          1. demoth
            14.01.2024 17:11

            Было бы интересно почитать продолжение. Управление памятью, мне кажется, вообще мастхев. Классы, имхо, чуток оверкил. Можно, например, структуры с методами + интерфейсы. В этом случае будет охвачена довольно интересная тема про инкапсуляцию и таблицы виртуальных функций, но при этом вполне можно обойтись без наследования, полиморфизма и ограничений доступа (private/protected).


  1. stasukas
    14.01.2024 17:11

    А не проще ли (и правильнее) идти не через велосипед, а через https://ru.m.wikipedia.org/wiki/LLVM ? Мировое сообщество уже давно делает компиляторы на базе стандартных проверенных временем решений. И какая задача стоит перед тобой - дать понимание студентам как делать компиляторы и как они работают или научить белое сложной работе с языком программирования?


    1. haqreu Автор
      14.01.2024 17:11
      +1

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

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

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

      В данном конкретном коде у меня нет замыканий, у меня статическое лексическое связывание переменных, но отдельно я покажу моим студентам, как реализовать динамическое связывание.

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

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


      1. fujinon
        14.01.2024 17:11

        Сборщик мусора будет покруче трассировки! Алгоритм Бейкера? Держите в курсе!


        1. haqreu Автор
          14.01.2024 17:11

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

          У меня есть лишь отдалённое представление о предмете, но я точно знаю, что никакой магии там нет :)

          Всё как обычно, жертвуем либо памятью, либо временем исполнения. Можно начать с чего-нибудь вообще тривиального типа линейного прохода по всем объектам, потом добавить каких-нибудь поколений, дефрагментацию. Фиг знает, я в этом не разбираюсь, надо будет поглядеть. Любые советы приветствуются!


  1. fujinon
    14.01.2024 17:11

    Наверняка есть более подходящие книги, но я со сборкой мусора разбирался по старой как мир книге Джеффа Элджера с незатейливым названием "C++" (легко гуглится). Это учебник по C++ а не руководство по алгоритмам. С точки зрения C++ книга давно неактуальна, но идиомы умных указателей там очень хорошо разобраны. Уплотнение памяти и сборка мусора - 2 последние главы.

    (прошу прощения-промахнулся с ответом)


    1. haqreu Автор
      14.01.2024 17:11

      Да, спасибо, посмотрю. В целом, насколько я понимаю, алгоритмов сбора мусора миллиард, всё зависит от паттернов доступа к памяти конкретного приложения.

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

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


  1. true-grue
    14.01.2024 17:11
    +1

    Любопытный проект! Очень хорошо, что нацелились на простоту, минимализм и не используете LLVM.

    От постоянных isinstance в коде можно было бы отказаться с помощью match/case (эту конструкцию для компиляторщиков и придумали, я почти не шучу). В этом докладе я агитирую за нее: https://github.com/true-grue/python-dsls


    1. haqreu Автор
      14.01.2024 17:11

      О, спасибо, попробую match, впервые о нём слышу :)

      Хотя, в принципе, оно и не сильно облегчит код, структурно он останется таким же (+одна строчка), но меньше будет рябить в глазах