Пока индустрия движется в сторону усложнения компиляторов, я задался вопросом: можно ли создать инструмент, который дает безопасность Rust, гибкость C и при этом не весит сотни мегабайт?

Так появился Flame — системный язык с фронтэндом в 260 КБ, который реализует управление памятью через статический анализ AST и предлагает альтернативный взгляд на обработку ошибок через патчинг дерева токенов.

Почему это не «очередной клон Си»?

Основные проблемы системных языков сегодня — это либо риск утечек (C/C++), либо избыточная сложность анализаторов (Rust). В Flame я реализовал три концепции, которые решают эти задачи иначе:

1. Автоудаление помеченных переменных в куче

Вместо сложного Borrow Checker как в Rust, Flame использует статический анализ AST по специальному алгоритму и вставляет инструкцию удаления. Алгоритм работает так:

  1. Компилятор сканирует с конца;

  2. Когда находит инструкцию, где использованная помеченная autodel — вставляет после этой инструкции удаление;

Циклы считаются как одна инструкция, если переменная используется в последний раз в цикле и объявлен вне цикла — удалится после цикла. Если объявлена внутри цикла — удалится внутри цикла. А ветвления в виде if/else проверяются, и вставляется удаление в каждый блок где использована в последний раз. При передачи переменной по возврату функции или метода — наследуется autodel, но если при передаче используется только значение а не сам адрес — то создастся промежуточная переменная, потом вставится удаление, потом промежуточная передастся в возврат. Данная возможность не до конца проверена, и будет укрепляться и развиваться.

2. Meta-exceptions: патчинг токенов

Вместо тяжёлых try/catch, Flame полуил свой подход. В языке исключение описывается в специальной структуре, где объявляются: места где может появиться исключение, и проверка/замена. Когда компилятор видит последовательность токенов которые описаны каким то исключением — он обращается к этой структуре, и либо откатывается до завершаения прошлой инструкции (';' или '{' или '}'), и вставляет проверку, либо полностью вырезает эту инструкцию и вставляет замену (при описании замены можно вставлять исходную инструкцию с помощью $source). Это позволяет исключить необходимость try/catch и не засорять код if/else, но сохранить возможность перехватывать любые исключения.

Устройство компилятора

Компилятор (по словам GitHub) содержит: 83.6% кода на Си, и 16.4% Shell и LLVM (отладка и тестирование). Сам фронтэнд весит 260 КБ.

В процесс компиляции входят несколько этапов:
1. Препроцессор (#include, #ifdef и прочее)
2. Лексер (разбирает текст на токены)
3. Препарсер (работает с исключениями)
4. Парсер (разбирает токены на AST)
5. Прегенератор (работает с безопасностью памяти)
6. Генератор (разбирает AST на LLVM IR и компилирует в.o, линкует через GCC)

Cсылка на GitHub с исходным кодом

Примеры кода

В основном синтаксис один в один как в Си, но переменные и поля классов объявляются с ключевым словом var, а методы и функции через func.

Прмер объявления класса:

class Name {
  var int a;         //числа имеют свой суффикс типа: 5 - int; 5s - short; 5l - long;
  var short b = 55s;

  func void c(int ab, int bb) {
    self->a = ab + bb;
  }

  new() {         //конструктор
    self->a = 5 + 5;
  }

  delete() {      //деструктор
    self->a = 0;
  }
}

Пример автоудаления:

func int main() {
  var autodel Name *obj = new();
  obj->c(5, 5);
  // <- тут будет delete() obj;
  return 0;
}

Пример описания исключений:

exception DivByZero {
  var int op;
  instruction {
    %n / %:op        //%n - любое число, %i - идентификатор
  }                  //%k - ключевое слово, %: - присваивание значения в переменную

  check {            //есть replace, он полностью заменяет инструкцию на то что 
    if (op == 0) {   //op инициализируется в коде автоматически
      //...
    }
  }
}

Остальные можно найти в README.md в GitHub

Будущее Flame

Flame будет активно разваиваться, улучшать совместимость с другими языками (через настройки компилятора @callconv, который позволит менять последовательность регистров для вызова функции) и добавлять новые фичи (Hot Reload или Restart через функцию из стандартной библеотеки которая через общую память и родительский процесс прогонит данные в новый процесс через таблицу имён). Скоро выйдет документация по языку.

Заключение

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

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


  1. rsashka
    07.03.2026 17:56

    Если весь вопрос в безопасной работы с памятью, то зачем делать для этого новый язык не совместимый ни с каким другим и не проще ли взять уже существующий (например С++) и дополнить его тем же самым статическим анализом AST?


    1. V1tol
      07.03.2026 17:56

      У меня есть немного другой вопрос. Если создавать новый язык, зачем опять вот эти вот * и -> ?


      1. rsashka
        07.03.2026 17:56

        С этим то все как раз понятно. Это широко распространенные операторы доступа по ссылке, которые понятны практически всем. И не нужно учиться использовать какие-то новые способы.


        1. vsting
          07.03.2026 17:56

          Современные языки уходят от таких Сишных штук. Почему не точка, она же корочке?


          1. willy2312 Автор
            07.03.2026 17:56

            Ну вообще я согласен, и в языке вроде как абсолюно без разницы что использовать, -> просто существует


          1. ProMix
            07.03.2026 17:56

            Потому что важно различать разыменование ссылки и прибавление смещения.

            Возьмём, к примеру, такие два обращения к полю:

            struct.f1.f2.f3.value
            ...
            struct->f1->f2->f3->value

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


            1. willy2312 Автор
              07.03.2026 17:56

              В языке так и есть, там в парсере прямо так и написано, что "->" и "." делают одно и то же


    1. willy2312 Автор
      07.03.2026 17:56

      Ну вообще то язык совместимы с Си. И причниой того что С++ не стал основой языка - это раздутость. Я сам недолюлиавю С++, и в разработке я бы использовал С++ из-за ООП и шаблонов, но простота Си мне больше нравится. Можно сказать что за основу был взят Си, потому что синтаксис один в один (за исключением var и func для объявлений функций и переменных...


  1. Nagdiel
    07.03.2026 17:56

    Чем autodel семантически отличается от использования std::uniue_ptr из С++? Как реализовано разделяемое владение ресурсами?


    1. mentin
      07.03.2026 17:56

      Одно отличие я заметил, в С++ объект освобождается по выходу из области видимости, а тут иногда раньше, по месту "последнего" использования. Это память в каких-то случаях экономит, но редко. Зато ломает RAII паттерн.

      Но разница как-то явно не достаточная чтобы избежать нормального borrow checker. А что компилятор делает если этот указатель передается в другие функции из статьи совсем не ясно.


      1. rsashka
        07.03.2026 17:56

        borrow checker и RAII, это разные концепции


        1. mentin
          07.03.2026 17:56

          Выше это и не утверждалось, лишь что небольшая смена семантики не позволит избежать borrow checker'а (при декларируемой memory safety).


          1. rsashka
            07.03.2026 17:56

            Выше это и не утверждалось ...

            Я вами согласен. Более того, мой комментарий наверно следует адресовать автору, а не вам, так как именно автор связал данные термины.


    1. willy2312 Автор
      07.03.2026 17:56

      У Flame своя концепция безопасности, удаление просиходит после последнего использования, а не при выходе из scope. Это снижает нагрузку на программиста.

      Если передать указатель в аргументы функций, удалится после выходе из функции


      1. BorisU
        07.03.2026 17:56

        И что будет, если функция положит этот указатель в глобальную переменную?


        1. willy2312 Автор
          07.03.2026 17:56

          Владение наследуется


  1. kotan-11
    07.03.2026 17:56

    Если мы говорим об языке с автоматическим управлением временем жизни объектов, сразу становится интересно, как он справляется вот с этим бенчмарком, который как раз это и тестирует: https://habr.com/en/articles/955158/


    1. willy2312 Автор
      07.03.2026 17:56

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

      Это как раз то, для чего Flame делался. var Node* node = new Node() — autodel вставит delete автоматически после последнего использования. Иерархия владения выражается естественно: документ владеет карточками, карточки — элементами. Деструкторы вызываются по цепочке. Это уже работает.

      Неизменяемые ресурсы (стили, битмапы) — copy-on-write

      Здесь Flame пока ничего специального не предлагает. Copy-on-write придётся писать руками — завернуть стиль в структуру со счётчиком ссылок и клонировать при изменении. Это решаемо, но языковой поддержки нет. Можно сделать через notdel указатели для "слабого" хранения общих ресурсов.

      Перекрёстные ссылки с автообрывом

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

      Удаление карточки из метода элемента этой карточки

      autodel здесь может конфликтовать — если self удаляется в процессе выполнения метода, это UB. Это общая проблема даже в C++. Flame наследует её.

      Копирование с сохранением топологии

      Нужен явный "deep copy с remapping" — обойти дерево, создать копии, построить таблицу старый указатель → новый, пройти заново и перешить ссылки. Языковой магии нет, но класс с new-конструктором это выразит чисто.

      С недавнего времени autodel автоматический, его писать не надо. Можно написать notdel чтобы предотавратить удаление компилятором...


  1. vsting
    07.03.2026 17:56

    А зачем добавлять суффикс там где вы уже явно указали что это short? Я понимаю в Java там суффиксы или префиксы но там они указываются в приведениях или там где явно не видно что это за тип. Но зачем вам в явном указаннии типа еще и суффикс добавлять и ладно бы это было название переменной но тут аж к значению да еще и в объявлении.


    1. willy2312 Автор
      07.03.2026 17:56

      Ну это облегчает компилятор, например когда передаётся в агументы, нужно будет искать какой там аргумент и подставлять тип short. Вместо этого пока что указывается суффикс s. Это может быть в дальнейшем удалено, так же как и var и func для объявлений. Это только для облегчения компилятора в стадии ранней разработки.


  1. vsting
    07.03.2026 17:56

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


    1. willy2312 Автор
      07.03.2026 17:56

      Потому что check {} в исключении опирается на ; как завершение прошлой инструкции. Это может временно, со временем точки с запятой могут стать необязательным.


  1. mentin
    07.03.2026 17:56

    А какой нибудь практический пример использования таких исключений есть? Не очень понятно, что в примере с делением на ноль можно написать внутри if (op == 0), кроме terminate(), а это не совсем то что обычно понимают под обработкой исключений. Ну может быть альтернативно установить какой-нибудь глобальный флаг ошибки, вернуть какую-нибудь ерунду, и продолжить вычисления? Тоже так себе идея. Да и сделать оба варианта можно эффективнее без лишних if через системные исключения.


    1. mentin
      07.03.2026 17:56

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


      1. willy2312 Автор
        07.03.2026 17:56

        Нет, отличие от других макросов - это то, что вносятся правки на уровне токенов, а не AST (если в тексте написано что это в AST, извиняюсь, статья была отправлена на модерацию 2 недели назад, с тех пор многое изменилось). Это позволяет менять грамматику гибко, например:

        exception Repeat {
          var int count;
        
          instruction {
            repeat %n:count
          }
        
          replace {
            for (int i = 0; i < count; i++)
          }
        }
        // Этого в грамматике нет, но исключение позволяет так писать
        repeat 5 {  //это превратится в for цикл
          log("Hello");
        }

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


        1. ProMix
          07.03.2026 17:56

          И в чём отличия от препроцессора?


          1. willy2312 Автор
            07.03.2026 17:56

            В отличии от препроцессора, исключения могут и вставлять проверки, захватывать значения, и проводить с ними действия. Если написать #define, препроцессор заменит всё что совпадает, не проверяя последующие или предыдущие токены


            1. ProMix
              07.03.2026 17:56

              Можно пример?


              1. willy2312 Автор
                07.03.2026 17:56

                Вот пример:

                exception DivByZero {
                  int op;
                  instruction {
                    %n / %"("^")":op
                  }
                  check {
                    if (op == 0) {
                      out("FATAL");
                      exit(1);
                    }
                  }
                }
                
                int main() {
                  // Здесь вставится:
                  //int op = (6 - 6);
                  //if (op == 0) {
                  //  out("FATAL");
                  //  exit(1);
                  //}
                  int a = 5 / op;
                  return 0;
                }

                Насколько я знаю, так препроцессор не умеет


  1. VyacheslavHere
    07.03.2026 17:56

    Интересная задумка с мемори сейфети, но к сожалению в ней куча подводных камней. Например: что если я создам переменную, передам значение в поле структуры, а затем последний раз использую переменную в цикле? Когда "произойдет" удаление? Как такое обрабатывает компилятор?


    1. willy2312 Автор
      07.03.2026 17:56

      Удаление будет после выхода из цикла. Если передать значение через разыменовывание - это не изменит поведение. А если передать в поле структуры указатель... то autodel сработает криво. Спасибо за замечание, можете писать такое в issues в GitHub. Поработаю над этим!


  1. Martianov
    07.03.2026 17:56

    Как язык разрешает проблему циклической зависимости во избежание утечек? Или модель языка не позволяет такое городить? Я так и не понял)


    1. willy2312 Автор
      07.03.2026 17:56

      autodel на такое не расчитан, если говорить про висячую ссылку. В будущем что нибудь придумаю, спасибо за замечание! Но в Flame это не проблема потому что autodel не считает ссылки — он просто вызывает delete по области видимости. B удалится, потом A удалится. Висячий указатель внутри уже удалённого B — это проблема программиста, но утечки не будет.


      1. rsashka
        07.03.2026 17:56

        autodel на такое не расчитан, если говорить про висячую ссылку. В будущем что нибудь придумаю, ....

        Циклические зависимости и висячие ссылки, это не одно и тоже. Циклические (перекрестные) ссылки не висят, так как ими владеют валидные объекты. И если про это не думать на этапе проектирования языка, а оставить как проблему для программиста, то вы быстро скатитесь к джентльменским соглашениям Rust, которые циклические ссылки решили не считать ошибкой работы с памятью.


        1. willy2312 Автор
          07.03.2026 17:56

          Имел в виду что если поле структуры которая ещё сама не удалена указывает на уже удалённый объект - поле останется висячим


  1. kmatveev
    07.03.2026 17:56

    В С утечка памяти - забыл вызвать free(), в Flame утечка памяти - забыл поставить autodel.

    И ещё вопрос про такой сценарий: могу ли я аллоцировать массив на стеке, и потом пройтись в цикле по элементам, вызывая функцию, в которую передаю указатель на элемент, а в этой функции сделаю autodel, оно освободит память внутри?


    1. willy2312 Автор
      07.03.2026 17:56

      autodel работает только для переменных в куче. Но спасибо за замечание, логичнее сделать notdel чтоб говорить компилятору самому не удалять...


      1. kmatveev
        07.03.2026 17:56

        А как оно понимает, что память выделена в куче? В голову приходят только такие варианты: 1. нельзя получить указатель на что-то, выделенное на стеке 2. есть разные типы указателей.


        1. willy2312 Автор
          07.03.2026 17:56

          Ну, там в имени в начале есть символ звёздочки, а со звёздочкой аллоцировать в стеке нельзя через alloca().


  1. asya_mozgoff
    07.03.2026 17:56

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


  1. lovvercases
    07.03.2026 17:56

    Если мы объявляем класс который в конструкторе выделяет память через оператор new и присваивает его значение полю класса, освобождение ресурсов происходит в деструкторе считая его последним местом использования переменной? До деструктура в последнем месте использования переменной или где?


    1. willy2312 Автор
      07.03.2026 17:56

      Нет, поля класса нужно вручную чистить в деструкуторе. Пока что, автоудаляемые поля не изобретены, но скоро будут


      1. lovvercases
        07.03.2026 17:56

        Как эксепшены работают в следующих случаях:

        Происходит выделение памяти в конструкторе: operator new выбрасывает исключение(закончилась память для выделения) откат происходит во внешний для конструктора скоуп?Дальше мы будем пытаться по второму разу вызвать тот же конструктор класса? Можем ли мы что то захватить в эксепшен из внешнего скоупа для корректного завершения программы? (Условно освободить всю выделенную память). Или будем выделять память с надеждой что ос предоставит свободное место для выделения?

        Что если идёт создание массива объектов через new в цикле и происходит исключение new? Оператор new в цикле будет заменена на инструкции описанные в exception? Получается массив будет незаполненный до конца и не валиден? Как вообще язык собирается решать эксепшены рантайма?


        1. willy2312 Автор
          07.03.2026 17:56

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


          1. lovvercases
            07.03.2026 17:56

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


            1. willy2312 Автор
              07.03.2026 17:56

              Ну если new() не выделил память, указатель не валидный, и обращаться по нему - сегфолт. new() магическим образом не будет пытаться ещё раз пробовать выделение в памяти.


          1. lovvercases
            07.03.2026 17:56

            Т.е компилятор заранее вычисляет места где могут произойти исключения? Или все держится на том что программист сам опишет все возможные места в которых произойдут эксепшены? Это же нереально, особенно при росте кода. Ну вот у вас рядом с каждым делением будет проверка и ветвление. Оверхед на проверку и ветвление при каждом делении?

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


            1. willy2312 Автор
              07.03.2026 17:56

              Исключения будут описаны заранее в стандартной библеотеке, вручную ничего описывать не пртидётся. Если у вас очень много делений, то да, на каждое действие будет немногие оверхед на несколько примерно 20-35 тактов процессора. Но никто не обязывает пользоваться исключениями если вам нужна максимальная скорость. В теории это может компенсироваться оптимизациями LLVM, но я не проверял. Слышал что с оптимизациями прирост от 50% до 200%, не проверял


          1. lovvercases
            07.03.2026 17:56

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


            1. willy2312 Автор
              07.03.2026 17:56

              Для предотвращения исключения new(), можно целиком заменить инструкцию, примерно так:

              replace {
                $source;
                if (!$2) {
                  out("FATAL");
                }
              }

              Программа при исключении завершится только если там такое прописано. Пока что в стандартной библеотеке нет одной такой функцией, поэтому пока завершать программу вне main() нужно вручную через ASM syscall или ExitProcess.

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


          1. lovvercases
            07.03.2026 17:56

            И собственно, если эксепшен произойдет во вложенной функции ну например main() -> foo() -> bar() {

            Код приводящий к ошибке и ветвлению для ее обработки

            } -> foo() -> main(), как собственно в данной ситуации мы проверим валидность полученных данных? В bar() отработало исключение, но валидны ли данные что вернула bar в foo, а потом foo в main - очевидно нет. Но механизма перевыбросить ошибку во внешний скоуп нет. Или мы будем возвращать магическое число об ошибки в return внутри кода функции? (Спойлер - после каждого использования функции вы должны постоянно проверять не равен ли возвращаемый результат магическому числу ошибки), что если это число об шишибки было получено корректными вычислениями?


            1. willy2312 Автор
              07.03.2026 17:56

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


  1. guamoko995
    07.03.2026 17:56

    Статья + комментарии выше производят впечатление, что простота и отсутствие концепции владения -- это прямое следствие того, что задачи, ради которых концепция живет (там, где она жива), тут "ещё не закрыты". Веет обещаниями, а есть намек, что результат достижим?


    1. willy2312 Автор
      07.03.2026 17:56

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


  1. Veyyr
    07.03.2026 17:56

    Очередная попытка внедрить ООП в системный язык?


    1. willy2312 Автор
      07.03.2026 17:56

      Ну вообще язык делает упор не в ООП. Реализовано это все без оверхеда (по типу vtable), классы это те же структуры, методы это функции которые неявно получают объект как аргумент. new() и delete() - под капотом те же malloc/free, но вызывают конструктор или деструктуор (delete() автоматически выставляет указатель на null)