Компилятор и главный репозиторий: GitHub

Здесь я напишу о своём личном проекте — компиляторе к C-подобному языку. Я не являюсь профессиональным разработчиком, изучал эту тему почти самостоятельно и не читал никакие книги по написанию компиляторов (но читал по операционным системам).

Документацию к языку и к его стандартной библиотеке Altlib можно найти в репозитории. Реализация (надеюсь) полностью рабочая (по крайней мере, есть немаленький набор тестов).

Есть вспомогательный репозиторий для упрощения установки компилятора и IDE к нему: GitHub

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


Обзор языка

Alias является языком системного программирования, имеет стандартные черты языка C и по выразительности синтаксиса находится между C и Zig. Язык не является "стабильным" и не следует к какой-либо определённой заведомо выбранной точке. Я пишу то, что приходит мне в голову (предварительно подумав, конечно, не вступит ли это в противоречие с чем либо).

Основные характеристики языка, которым я уделяю большое внимание:

  1. Соизмеримая скорость (наверное, программа на этом языке не должна быть более, чем в пять (так как я не делаю оптимизации) раз медленнее аналогичной на C)

  2. Возможность компоноваться с C (не совсем полная — о нюансах ниже) и Asm

  3. Удобство и привычность использования, соизмеримый современным языкам набор инструментов (например, автоматические тесты)

Первый пример

Начнём с простой программы. Учтите — язык полностью самостоятельный, не имеет никакой runtime среды, а вся его библиотека написана на нём же или Asm-е. Поэтому язык будет непростым для понимания. Здесь я опущу необходимые includes.

func ^._start() -> #V {
    def allocator #TestAllocator
    eval allocator&.init(1024)
    defer eval allocator&.deinit()

    def vec #Vector
    eval vec&.init(allocator&)
    eval vec&.push(1)
    eval puti_(vec&.get(0))

    eval posix_exit(0)
}

Пойдём по тем местам, которые бросаются в глаза и не являются очевидными:

  1. Символ ^. Этот символ отменяет mangling названия функции. Mangling используется для полиморфизма. Мы же хотим, чтобы entry function называлась именно _start, ведь именно эту метку ожидает ld по умолчанию. (В C компоновщик генерирует свою функцию _start, в которой настраивает runtime.)

  2. #TestAllocator — это некий тип, объявленный в подключенном файле (не обязательно структура).

  3. Для типа "указатель на #TestAllocator" объявлен метод init. Чтобы получить из локальной переменной указатель на неё, мы добавляем &.

  4. defer выполняет statement в конце блока.

  5. Все методы для вектора определены для указателя на вектор. Вектор принимает аллокатор, которым будет выделять себе память.

  6. Вернуться из функции _start нельзя (так работают процессы). Необходимо выполнить системный вызов exit.

Типы

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

  1. #I — это целое число размером 8 байт.

  2. #1I — это указатель на целое число.

  3. #2I — это указатель на указатель на целое число.

  4. #C — это целое число размером 1 байт.

  5. #V — это void размером 0 байт.

  6. #S{x: #I, y: #I} — это инстанс (instance, объект структуры), который имеет два поля x и y, которые являются целыми числами.

  7. #F(#I) -> #I — это указатель на функцию, которая принимает одно целое число и возвращает целое число (partial application и closures отсутствуют).

Допускается любая вложенность типов. Константы пока отсутствуют.

Функции

Иногда я буду вставлять примеры из документации языка. Данный пример является тестом, который проверяется автоматически (и если тест проваливается, компилятор считается сломанным).

test demo_higher_order_functions {
    def twice := \(f #F(#I) -> #I, x #I) -> #I {
        return f(f(x))
    }
    return test_equal(twice(\(x #I) -> #I x * x, 2), 16)
}
  1. twice — это лямбда-функция. Она принимает в качестве первого аргумента тип #F(#I) -> #I, то есть, указатель на функцию, которая принимает целое число и возвращает целое число. Посмотрев на тело это функции видим, что она дважды применяет функцию к своему второму аргументу и возвращает результат.

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

Методы

func #I.add_one(x #I) -> #I {
    return x + 1
}

func #I.add(x #I, y #I) -> #I {
    return x + y
}

test demo_methods {
    def a := 3
    def b := a.add_one()
    def c := 5.add_one()
    def d := b.add(c)
    return test_equal(d, 10)
}

Здесь всё довольно очевидно: мы можем объявить метод для любого типа (и первый аргумент этого метода обязан быть равен этому типу).

Но есть важный момент: в языке отсутствуют closures, а значит в этом примере мы не сможем сделать так: def d := b.add, ведь это значило бы взятие значения b.

Контроль потока

Здесь есть несколько интересных моментов.

  1. Блок является expression-ом и всегда что-то возвращает (это может быть void).

  2. В языке Zig выполняют возвращение из блока с помощью break-а. В то же время им же возвращают из цикла. Приоритет у цикла, поэтому блок всегда необходимо помечать меткой (почти всегда это blk).

  3. Я захотел их разделить. В Alias-е break возвращает только из цикла, в то время как return возвращает не из функции, а из блока (что непривычно). Метки здесь присутствуют и для break, и для return.

  4. If и while являются expression-ами и всегда возвращают. Если они возвращают не void, они обязаны иметь else (else у цикла может некоторых удивить).

Посмотрим на реализацию strnlen теперь из документации к стандартной библиотеке:

func ^.strnlen_(a #1C, n #I) -> #I {
    def i := 0
    return while (i < n) {
        eval if (a[i] = '\0') { break i }
        i := i + 1
    } else n
}
  1. return здесь возвращает результат вычисления цикла. Если цикл завершится break-ом, возвратиться значение у break-а. Иначе возвратиться результат вычисления else.

  2. Мы не можем написать if, так как он не является statement-ом. Нам необходимо написать eval expression, который вычислит expression и забудет результат.

  3. Мы не можем написать break без фигурных скобок, так как break является statement-ом, а if должен в качестве ветвей иметь именно expression-ы. Фигурные скобки объявляют блок, который возвращает void, а поэтому if тоже возвращает void.

Сложный пример с указателями

Рассмотрим метод push для Vector-а (Vector не полиморфен и может хранить только целые числа). Здесь есть ещё несколько неочевидных мест.

func ^#1Vector.push(this #1Vector, x #I) -> #V {
    eval if (this->size = this->reserved) {
        def buffer #1I := this->allocator.alloc(this->reserved * 2 * $#I)
        eval puti_(this->data as #I)
        eval _memcpy(buffer, this->data, this->size * $#I)
        this->data& <- buffer
        this->reserved& <- this->reserved * 2
    }
    this->data[this->size]& <- x
    this->size& <- this->size + 1
}
  1. $#I возвращает размер типа #I.

  2. as выполняет смену типа. Это reinterpret_cast.

  3. a->b возвращает поле b в инстансе a.

  4. a->b& возвращает указатель на поле b в инстансе a.

  5. a <- b копирует значение b по указателю a (что в C делает *a = b).

  6. a[b] возвращает значение по адресу a + b * sizeof(*a).

  7. a[b]& возвращает значение a + b * sizeof(*a).


Обзор реализации

  1. Реализация написана только на C.

  2. Не используется C stdlib и какой-либо код за пределами проекта. Для системных вызовов и некоторых функций написана реализация на Asm. Написана "своя stdlib", которая используется компилятором.

  3. Написана stdlib на Alias-е под названием altlib. Все программы на Alias-е используют её (то есть, код на Alias-е не требует код на C (но требует на Asm-е)).

  4. Фронтенд написан вручную. Генерация кода для архитектуры x86_64 (пока только на неё) написана вручную. Никаких оптимизаций не выполняется. Intermediate representation отсутствует (но является одной из ближайших целей).

Далее я рассмотрю каждый модуль компилятора подробнее.

Настройки

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

Ничего сложного здесь нет.

Лексер

Лексер выполняет здесь сразу две задачи.

  1. Стандартная — получить строку и вернуть список токенов.

  2. Сформировать подсветку токенов для языкового сервера (конечно, здесь есть language server)

Здесь тоже нет ничего сложного.

Синтаксер

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

Я дам подсказки, как написать синтаксер с минимальной болью.

  1. Все конструкции разделяются на statement-ы и expression-ы.

  2. Блок — это последовательность statement-ов.

  3. Определить statement обычно можно по первому в нём токену.

  4. Primary — это expression без операторов на верхнем уровне (например, a.new(5, b + 7) — primary, а a + 4 — нет).

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

Мой код для синтаксера выглядит примерно так:

считать_блок() {
  считать '{'
  А = []
  пока дальше не '}':
    A.положить(считать_statement())
  считать '}'
  вернуть А
}

считать_statement() {
  если дальше и считать 'def':
    Def def
    def->тип = считать_тип()
    def->значение = считать_expression()
    вернуть def
  иначе ...
}

считать_expression() {
  А = [] // стек операторов
  Б = [] // стек primaries
  пока истина:
    если дальше и считать '(':
      А.положить('(')
    иначе если дальше и считать ')':
      пока А.конец() не '(':
        Node node = подвесить_за_операцию(А[-1], Б[-1], Б[-2])
        А.удалить()
        Б.удалить()
        Б.удалить()
        Б.положить(node)
      A.удалить()
    иначе если дальше_оператора():
      ...
    иначе:
      Б.положить(считать_primary())
}

Генератор кода

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

  1. Регистры общего назначения, регистр eflags

  2. Арифметические инструкции: mov, add, sub, mul, div

  3. Инструкции прыжков: jmp, jl, je, call, ret

  4. Инструкции для стека: push, pop

  5. Как устроен stack frame и как вызываются функции

Теперь о том, как написать какую-то генерацию. Пусть мы обрабатываем expression. Будем класть результат expression-а на стек. Как написать бинарную операцию?

  1. Выполним генерацию первого аргумента. Он положится на стек.

  2. Добавим фиктивную локальную переменную. (Но ведь в C переменные в функции кладутся на стек снизу. Однако я посчитал это неоправданно неудобным и кладу переменные сверху вниз в блоке, а не в функции.)

  3. Выполним генерацию второго аргумента. Он положится на стек после фиктивной переменной.

  4. Уберём фиктивную переменную, достанем операнды из стека в регистры, выполним операцию и положим результат на стек.

Когда я выполняю генерацию, в каждый момент времени я храню некий контекст:

  1. Локальные переменные и их позиции в stack frame

  2. Функции и методы

  3. Метки в блоках и циклах и их позиции в stack frame (кстати, реализация goto statement весьма сложна)

Отмечу генерацию вложенных инстансов. Пусть у нас есть инстанс с двумя инстансами. В Alias-е инстансы являются packed, как в Asm-е

Стек растёт вправо. Символами | отделены блоки размером 8 байт (natural alignment)
Адрес начала инстанса должен делиться на natural alignment.
...|--------|--------|--------|--------|

Скомпилируем первый вложенный инстанс.
...|-----+++|++++++++|--------|--------|

Создадим фиктивную переменную. Скомпилируем второй вложенный инстанс.
...|-----+++|++++++++|------++|++++++++|

"Спрессуем" все инстансы.
...|---+++++|++++++++|++++++++|--------|

Важно то, что мы не пишем это сжатие, а пишем генерацию Asm-кода, который выполнит это сжатие.

Языковой сервер

Компилятор может быть запущен в режиме сервера. Я не воспользовался стандартным Language Server Protocol, потому что не смог понять его документацию (как и любую документацию от Microsoft) потому что хотел придумать свой самостоятельно.

Языковой сервер — это http сервер, имеющий два endpoint-а:

  1. Для подсветки синтаксиса

  2. Для проверки кода на ошибки

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

Была реализована примитивная IDE на Qt, которая поддерживает данный языковой сервер.

Intermediate Representation

До сих пор отсутствовал, так как я не представлял, что и как следует абстрагировать в языках ассемблера. Однако теперь у меня это представление есть, поэтому intermediate representation появится.

Стандартная библиотека

Должна быть полностью самостоятельной.

  1. Как мы выводим текст в stdout без использования функций? С помощью системного вызова write, который принимает дескриптор (изначально для stdout это 1, но можно с этим поиграться), указатель на строку и длину строки.

  2. Как мы запускаем процесс? С помощью системных вызовов fork и execve. Обратите внимание, что системного вызова execvp нет — необходимо написать поиск по переменной PATH самостоятельно.

  3. Как мы выделяем память? С помощью системного вызова mmap. После выделения памяти на нём следует построить кучу, как структуру данных.

(Кстати, что вы думаете о слове heap? Я под ним понимаю и сегмент процесса, и структуру данных для выделения памяти, построенную над отрезком памяти, и структуру данных корневое дерево, которое поддерживает отношение порядка между вершинами и их потомками. Второе часто называют аллокатором, но тогда и с этим словом возникают проблемы, ведь у нас получаются аллокаторы, требующие аллокаторы (как, например,arena allocator как-то должен получить свои буферы)).


Дополнение

Предложение: автопроталкивание аллокаторов

Здесь я напишу об одной видимой мне проблеме в Zig. В этом языке (и Alias-е) мы избегаем использования внешних аллокаторов в библиотеке, и часто требуем передавать их в функции. При этом обычно для "поддерева" программы у нас есть один аллокатор, который мы используем, а затем удаляем. Идея: пусть функция автоматически передаёт вызываевым функциям свой аллокатор, если обе функции его требуют (что мы будем помечать специальным синтаксисом).

Выглядеть это может примерно так.

func foo@(a: int, b: int) {
  boo(a + b);
}

func boo@(a: int) {
  @.alloc(a);
}

func doo(a: int) {
  @ := heap.page_allocator;
  foo(a, a * a);
  @ := heap.FixedBufferAllocator;
  doo(a);
}

Принимаются предложения

Мне интересно мнение о том,

  1. каких инструментов не хватает в компиляторе,

  2. что в языке сделано неудачно,

что сильно снижает комфорт использования компилятора и раздражает.

Я выделил список вещей, которые ухудшают мне опыт пользования языком:

  1. Отсутствие формата "проекта". Организация даже тривиального проекта весьма сложна (посмотрите на Makefile в примере GitHub).

  2. Отсутствие пакетного менеджера.

  3. Отсутствие поддержки Language Server Protocol, что требует пользоваться именно своей IDE (которая, по сути, является блокнотом с вкладками и подсветкой синтаксиса и ошибок). Свой протокол имеет недостатки в проектировании.

  4. Генерация кода только под одну архитектуру. Для поддержки большего числа архитектур желательно иметь intermediate representation.

  5. Отсутствие возможности передавать в функции инстансы и отсутствие float-ов. Поддержка этого требует дополнительного изучения Application Binary Interface-а.

  6. Отсутствие большого количество синтаксических конструкций: удобного movement-а <- как в C, циклов.

  7. Отсутствие closures (я пока не представляю, как их писать).

  8. Отсутствие вывода типов.

  9. Отсутствие comptime (но они будут) и полиморфизма вообще (макросов нет и не будет).

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


  1. kozlyuk
    25.01.2025 13:51

    Синтаксер по списку токенов строит синтаксическое дерево (или по научному, преобразует регулярную грамматику в контекстно-свободную).

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

    В компиляторе неудачно то, что нет отдельного шага типизации. Из-за того, что она делается вместе с построением AST, появляются не очень логичные obj->field vs obj.method() (а если поле — указатель на функцию?), оператор <- и необходимость добавлять повсеместно &. Вы планируете полиморфизм, значит, у вас будут конструкции, которые синтаксически одинаковы, но типизированы по-разному.


  1. vadimr
    25.01.2025 13:51

    Соглашусь с коллегой выше. Си-подобный синтаксис очень сильно (и неочевидно) контекстно-зависим, и у вас при внимательном рассмотрении начнут вылезать неразрешимые альтернативы в грамматике. Если бы вы использовали bison или другой компилятор компиляторов, это привело бы к ошибкам на этапе компиляции грамматики. А так будут просто нежданчики в поведении компилятора.

    Ну и, судя по написанному в статье, вы себе не очень хорошо представляете, что такое closure (замыкание).


    1. Panzerschrek
      25.01.2025 13:51

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


  1. Filipp42
    25.01.2025 13:51

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


    1. firehacker
      25.01.2025 13:51

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

      Зачем мне ваш printf, если я пишу под код, который будет работать в среде где вообще нет понятия терминала (например это ЭБУ коробки передач или мозги квадрокоптера).

      Зачем мне ваши функции управления процессами, если в среде, где будет выполняться код, может вообще не быть понятия процесса. Или быть, но это будут не процессы-в-духе-unix или процессы-в-духе-Windows.

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


      1. Panzerschrek
        25.01.2025 13:51

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


        1. firehacker
          25.01.2025 13:51

          В вас говорит плюсовик.

          Первоначально термин «стандартная библиотека» предполагал именно стандартную библиотеку функций.

          Скрытый текст

          Хотя вы можете возразить, что и в стандартной библиотеки Си есть функция qsort, которая представляет собой алгоритм с callback-ом.


          1. Panzerschrek
            25.01.2025 13:51

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


    1. Panzerschrek
      25.01.2025 13:51

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


      1. Filipp42
        25.01.2025 13:51

        Хм... Скажите, а почему годы?

        Вы не очень много времени можете уделять ему?


        1. Panzerschrek
          25.01.2025 13:51

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


  1. kekoz
    25.01.2025 13:51

    В C компоновщик генерирует свою функцию _start

    Вы это серьёзно? Компоновщик генерирует код? В C какой-то особенный, отдельный компоновщик? Обычный, работающий с COFF там или ELF без оглядки на то, из чего он был получен, не подходит?


    1. SIISII
      25.01.2025 13:51

      Ну, если совсем строго говорить, компоновщик временами код генерирует-таки -- по крайней мере, на определённых платформах. Но уж точно не код _start.

      Например, для 32-разрядных ARMов он при необходимости генерирует так называемые veneer -- куски кода для обеспечения перехода от одной подпрограммы к другой, если обеспечить переход модификацией адреса в команде перехода невозможно (в ARM невозможно указать в командах переходов полный 32-разрядный адрес, поэтому, если на этапе компоновки окажется, что целевой адрес слишком далёк, приходится лепить этот самый veneer, что и выполняется компоновщиком).


  1. firehacker
    25.01.2025 13:51

    что в языке сделано неудачно,

    Отвратительный вырвиглазный синтаксис.

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

    Вот решили вы делать не просто язык, а C-подобный — ну и отличное решение. Прекрасное решение. Я считаю синтаксис С идеальным синтаксисом, почти что «золотым сечением» в области языков программирования, результатом эволюции подходов, отброса неудачнях подходов и закрепления удачных.

    Ну и оставайтесь в рамках C-подобности. Зачем какое-то func, какие-то дурацкие ->?

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

    Например поддержку явного указания endianness отдельно взятых переменных или полей структур.

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

    Или, например, такая фишка, как структуры переменного размера с полями переменного размера, где размер полей привязан к значению предешствующих полей, а смещения полей вычисляются в рантайме?

    Или 24-битные переменные из коробки. Или переменные произвольной битности — при этом, если архитектура позволяет, компилятор использует особенности архитектуры, а если же нет — эмулирует переменные нужной битности. Хотите 96-битное целое — да пожалуйста, объявляйте, присваивайте значения, складывайте (компилятор сам развернет одну операцию сложения в несколько с применением флагового бита переноса разряда).

    Отсутствие формата "проекта". Организация даже тривиального проекта весьма сложна (посмотрите на Makefile в примере GitHub).

    Ни в коем случае. Если вы не делаете моноязык, который «вещь в себе», а провозглашаете такие принципы как линкуемость с другими языками (си, асм), то какого черта ваш язык должен навязывать какой-то свой формат проекта?

    Пишет Вася программный продукт — четверть на Алиасе, четверть на Си, четверть на ассемблере, четверть на (прости-господи) Паскале. Все это вася линкует в один исполняемый файл. Вопрос: с какого перепуга проджект-файл должен быть составлен именно по правилам и законам, которые задает язык Alias — один из четырёх примененных в проекте языков?

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

    Все что мне нужно от языка и компилятора: пожалуйства, пусть он возьмёт исходник и в ответ выплюнет объектный файл в формате COFF, ELF, или ещё какой. Чтобы я потом взял свой любимый линкер и слинковал много объектных файлов (написанных, возможно, на разных языках) в один исполняемый. Или скормил их своему союственному линкеру, генерирующиму исполняемые файлы моего собственного формата под мою собственную ОС, которую я сам пишу. Иначе что это за системное программирование, если язык и компилятор не оставляют мне такой возможности.

    Линкуемость с другими объектными файлами и использование одинакового ABI — это все, что мне нужно. Не нужно никакой стандартной библиотеки. Если мне нужна будет какая-то библиотека, я слинкуюсь с ней сам, например, со стандартной библиотекой Си от GNU. Или, если вы условия не позволяют, сам реализую нужные функции.

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

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

    В общем-то, те же самые аргументы.


  1. funny_falcon
    25.01.2025 13:51

    Вдохновляюще! Меня постоянно останавливают сомнения, а Вы взяли и сделали.

    Понимаю, откуда берётся обилие амперсандов: пытаетесь не допустить «ссылки» как сущности в концепт языка. Если задуматься, то в C «ссылки» возникают помтоянно, но о них не принято говорить, прикрываясь терминами lvalue. Но когда в C++ пытаются обобщить внутренние механики разименования C на результаты вызовов функций (а не только проход по полям структур и поинтерам), то без явной сущности «ссылка» уже не вырулить. Мне не очень понятно, смогли вы распутать клубок или пошли не в ту сторону. В любом случае, о причине мы явно думаем похожим образом.

    Попытку уйти от «ссылки» я видел еще в одном языке. К сожалению, быстро не вспомню. Поищу в закладках потом.

    else в циклах - это зачёт! Питон имеет else в циклах, но почему функциональные языки до него не «догадываются» (тот же Ocaml, например), для меня загадка.

    Что плохо - способ описания примитивных типов и поинтеров на типы. Вырвиглазненько.

    Ещё не очень понимаю потребность в eval. Зачем вам такое хардкорное разделение на стейтменты и экспрешены? Мне кажется, разделения на декларации и экспрешены будет достаточно: если следующий элемент не декларация, начинающаяся с ключевого слова (тот же func), значит это - экспрешн. Присвоение тоже можно оставит экспрешеном, как в С.


  1. funny_falcon
    25.01.2025 13:51

    Нашел язык: https://austral-lang.org/tutorial/borrowing . Они, правда, обзывают поинтеры ссылками (reference). Проблему решают несколько иначе: вместо неявного взятия значения и явного взятия адреса поступают ровно наоборот - цепочка перехода по поинтерам имеет простой синтаксис, а вот взятие значения по принтеру потом явное.

    Мне кажется, должен быть какой-то третий вариант. И синтаксис разименования ближе к паскалевскому.

    type Vec2 struct {
      x: float
      y: float
    }
    
    type VecPair struct {
      v1: Vec2
      v2: ^Vec2
    }
    
    var v VecPair
    
    &v^&v1^&x ^= 1
    &v^&v1^&y ^= v.v1.x + 4
    &v^&v2 ^= malloc(sizeof(Vec2))
    v.v2^&x ^= 2
    v.v2^&y ^= v.v2^.x + 5

    Выглядит, правда, по-наркомански или слегка шизоидно. Зато все операции довольно однозначны, и нет «ссылок».


    1. funny_falcon
      25.01.2025 13:51

      Хотя, я туплю: если изначально считать переменные «указателями на место на стеке», и оператор . - взятием адреса на элемент структуры, то всё выходит более-менее стройно без вырвиглазного синтаксиса:

      type Vec2 struct {
        x: float
        y: float
      }
      
      type VecPair struct {
        v1: Vec2
        v2: ^Vec2
      }
      
      var v VecPair
      
      v.v1.x ^= 1 // используя адрес v, получить адрес v1,
                  // потом адрес x и записать по этому адресу 1
      v.v1.y ^= v.v1.x^ + 4 // нужно получить значение, лежащее по адресу v.v1.x
      v.v2 ^= malloc(sizeof(Vec2)) // записали поинтер на выделенную память
                                  // по адресу поля v.v2
      v.v2^ ^= v.v1^ // получили значение v.v1, как структуру (а не её адрес),
                     // и записали её по адресу, хранящемуся в v.v2
      v.v2^.x ^+= v.v2^.y^

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

      К тому же возникает вопрос, какова сущность v.v1^ ? Это получается «значение структуры без адреса». Можно ли у него взять значение поля x(тоже без адреса)? Каждой для этого может быть синтаксис? Можно ли дать ему имя, не копируя по другому адресу? И потом получить значение его поля x?

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

      xv = v.v1^ // xv - безадресное значение, и потому "иммутабельно"
      // xv.x = 3 - так сделать нельзя
      // xv.x ^= 3 - и так тоже
      v.v2^.x ^= xv.x // а так можно. xv.x не нужно разименовывать,
                      // т.к. это уже значение, а не адрес
      v.v2^ ^= xv // и так можно

      Но не очень нравится, что оператор . становится полиморфным.


  1. SaemonZixel
    25.01.2025 13:51

    Задам свой любимый вопрос: а с отладкой как обстоят дела?
    80% времени и нервов уходит именно на этот процесс. Что Вы сделали, чтоб программисту на Вашем языке было удобно отлаживать свой код?


  1. Panzerschrek
    25.01.2025 13:51

    Есть у меня ряд замечаний и предложений:

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

    Почему привычный if не возможен - не ясно. Зачем вместо него городить eval?

    Подход с ручным выделением памяти и передачей аллокаторов как в Zig - тупиковый. Код становится запутанным и чреватым ошибками. Подход с RAII, как в C++ или Rust гораздо удачнее.

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

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

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

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


    1. vadimr
      25.01.2025 13:51

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

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


      1. Panzerschrek
        25.01.2025 13:51

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