Привет, Хабр! Меня зовут mixitosik, я разрабатываю открытый язык программирования общего назначения RayQuiro.
Когда берешься за создание собственного языка, большинство гайдов и туториалов быстро упираются в тупик: они учат писать либо «игрушечные» древовидные интерпретаторы (Tree-walk AST), либо простые трансляторы в C. На практике же любой язык общего назначения требует решения жестких системных задач: проектирования компонуемой модульной системы, многоуровневого пайплайна исполнения (для мгновенных итераций и для продакшена), кроссплатформенного рантайма и интеграции легковесного GUI без раздувания зависимостей до размеров Electron.
В этой статье я по шагам разберу внутреннее устройство RayQuiro: структуру парсера и AST, архитектуру стековой виртуальной машины, генерацию IR для LLVM-бэкенда, а также инженерные грабли, на которые пришлось наступить при реализации динамического языка со строгим управлением памятью.
1. Концепция и Зачем: архитектурные уровни RayQuiro
Главная задача RayQuiro — дать выразительный синтаксис с динамической типизацией во время выполнения, но при этом сохранить нулевой порог входа и высокую производительность.
Поэтому среда исполнения разделена на три независимых уровня (Tiered Execution Pipeline):
┌───────────────────────┐ │ RayQuiro Source │ │ (*.rq) │ └──────────┬────────────┘ │ Lexer / Parser ▼ ┌───────────────────────┐ │ AST Program │ └──────────┬────────────┘ │ ┌────────────────────────┼────────────────────────┐ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ Tier 1: AST │ │ Tier 2: VM │ │ Tier 3: Native │ │ Interpreter │ │ Bytecode Engine │ │ LLVM / TCC AOT │ ├──────────────────┤ ├──────────────────┤ ├──────────────────┤ │ Zero-startup │ │ High-throughput │ │ LLVM IR opt -O3 │ │ Tree-walk │ │ Stack Machine │ │ Bare-metal EXE │ │ Embedded scripts │ │ Hot Reloading │ │ Standalone dist │ └──────────────────┘ └──────────────────┘ └──────────────────┘
Tier 1 (AST Interpreter): прямое исполнение узлов AST обходом дерева. Дает минимальный запуск (< 2 мс), используется во встраиваемых сценариях (
rqio_core) и как эталонная реализация семантики.Tier 2 (Bytecode VM): трансляция AST в компактный линейный байт-код с исполнением на регистрово-стековой виртуальной машине. Оптимален для быстрой разработки, поддерживает бинарные контейнеры (
.rqb) и hot-reloading (rqio run --watch).Tier 3 (AOT Native Compiler): трансляция семантического графа в оптимизированный C++ / LLVM IR. Сборка идет либо через встроенный TinyCC (сверхбыстрые билды за ~10 мс), либо через Clang/LLVM с проходами
-O3(векторизация, инлайнинг, устранение мертвого кода) для продакшена.
2. Устройство Лексера и Парсера: от потока символов к AST
Компилятор RayQuiro написан на C++20. Пайплайн обработки текста начинается с лексического анализатора (Lexer.h), который переводит поток UTF-8 символов в типизированные токены Token.
Лексер: контекстные токены и операторы
Лексер распознает стандартный набор ключевых слов (fn, var, let, struct, match, try, catch, finally, throw, async, await), составные присваивания (+=, -=, *=, /=) и операторы безопасной навигации (?. — optional chaining, ?? — null coalescing, ... — variadic parameter):
C++
enum class TokenType { IDENTIFIER, NUMBER, STRING, VAR, LET, CONST, FN, STRUCT, ENUM, INTERFACE, IF, ELSE, WHILE, FOR, IN, RETURN, BREAK, CONTINUE, TRY, CATCH, FINALLY, THROW, MATCH, QUESTION_DOT, // ?. QUESTION_QUESTION, // ?? DOT_DOT_DOT, // ... ARROW, // => // ... };
Для операторов ?, ?. и ?? в лексере задействован предиктивный просмотр на один символ вперед (peekNext()):
C++
if (c == '?' && peekNext() == '?') { advance(); advance(); addToken(tokens, TokenType::QUESTION_QUESTION, "??", startLine, startCol); continue; } if (c == '?' && peekNext() == '.') { advance(); advance(); addToken(tokens, TokenType::QUESTION_DOT, "?.", startLine, startCol); continue; }
Парсер: рекурсивный спуск и приоритеты операций
Парсер (Parser.h) строит абстрактное синтаксическое дерево (AST) методом рекурсивного спуска (Recursive Descent) с учетом приоритетов бинарных операций (Precedence Climbing):
Assignment (=, +=, -=) └── Null Coalescing (??) └── Logical OR (||) └── Logical AND (&&) └── Equality (==, !=) └── Comparison (<, <=, >, >=) └── Term (+, -) └── Factor (*, /, %) └── Unary (!, -) └── Postfix (.field, [index], ?., (args)) └── Primary (literal, ident, grouping)
Каждый узел выражения наследуется от базовой структуры Expr, а оператор — от Stmt:
C++
struct Expr { virtual ~Expr() = default; }; struct Stmt { virtual ~Stmt() = default; }; struct NullCoalesceExpr : Expr { std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; }; struct OptionalChainExpr : Expr { std::unique_ptr<Expr> object; std::string field; std::unique_ptr<Expr> index; }; struct ReturnStmt : Stmt { std::vector<std::unique_ptr<Expr>> values; };
Парсер из коробки поддерживает множественные возвращаемые значения и деструктуризацию. При вызове return a, b, c; все выражения упаковываются в вектор values структуры ReturnStmt:
C++
std::unique_ptr<Stmt> parseReturn() { auto node = std::make_unique<ReturnStmt>(); if (!check(TokenType::SEMICOLON) && !check(TokenType::RBRACE) && !isAtEnd()) { node->values.push_back(parseAssignment()); while (match({TokenType::COMMA})) { node->values.push_back(parseAssignment()); } } match({TokenType::SEMICOLON}); return node; }
3. Виртуальная Машина (Bytecode VM): модель данных, стек и опкоды
Виртуальная машина RayQuiro спроектирована по классической стековой модели с плоским представлением инструкций и локальных фреймов.
Универсальное значение: VMValue
Для представления динамических данных используется структура VMValue поверх std::variant:
C++
struct VMValue { using Array = std::vector<VMValue>; using Object = std::map<std::string, VMValue>; std::variant<std::monostate, double, std::string, bool, Array, Object> data; VMValue() : data(std::monostate{}) {} VMValue(double value) : data(value) {} VMValue(const std::string& value) : data(value) {} VMValue(bool value) : data(value) {} VMValue(const Array& value) : data(value) {} VMValue(const Object& value) : data(value) {} };
std::monostate заменяет null. Числовые значения хранятся как IEEE 754 64-bit float (double), что избавляет от накладных издержек на жесткое разделение целых и вещественных типов в критическом цикле ВМ.
Набор инструкций (Instruction Set Architecture)
Каждая инструкция фиксирована по размеру и содержит опкод плюс два 32-битных аргумента:
C++
struct Instruction { OpCode op = OpCode::Return; std::int32_t a = 0; std::int32_t b = 0; };
Система команд разбита на группы:
Категория | Опкоды | Описание |
Стек и литералы |
| Загрузка констант, дублирование и сброс верхушки стека. |
Память |
| Чтение и запись в локальные слоты стекового кадра или глобальный хэш-мап. |
Коллекции |
| Создание контейнеров и доступ по индексу. |
Арифметика |
| Базовые математические операции с приведением типов. |
Сравнение и логика |
| Предикаты и логические инверсии. |
Поток управления |
| Переходы со смещением указателя инструкций ( |
Исключения |
| Регистрация обработчиков и раскрутка стека. |
Байт-код компилятор: разрешение слотов переменных
BytecodeCompiler транслирует AST в BytecodeProgram. Локальные переменные разрешаются на этапе компиляции в фиксированные индексы слотов стека (GetLocal a, SetLocal a), полностью исключая поиск имен по строкам во время исполнения функций:
C++
int resolveLocal(const std::string& name) { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto found = it->locals.find(name); if (found != it->locals.end()) return found->second; } return -1; // Не локальная переменная -> поиск в глобалях }
Цикл диспетчеризации (Dispatch Loop)
Исполнение функций инкапсулировано в VM::executeFunction. Стек вызовов управляется через CallFrame:
C++
struct CallFrame { const BytecodeFunction* function = nullptr; std::size_t ip = 0; std::size_t stackOffset = 0; };
Основной цикл интерпретатора использует классический switch-case по опкодам:
C++
while (frame->ip < frame->function->code.size()) { const Instruction& inst = frame->function->code[frame->ip++]; switch (inst.op) { case OpCode::Constant: push(frame->function->constants[inst.a]); break; case OpCode::GetLocal: push(stack_[frame->stackOffset + inst.a]); break; case OpCode::SetLocal: stack_[frame->stackOffset + inst.a] = peek(0); break; case OpCode::Add: { VMValue b = pop(); VMValue a = pop(); if (std::holds_alternative<std::string>(a.data) || std::holds_alternative<std::string>(b.data)) { push(VMValue(toString(a) + toString(b))); } else { push(VMValue(toNumber(a) + toNumber(b))); } break; } case OpCode::JumpIfFalse: { if (!isTruthy(pop())) { frame->ip += inst.a; } break; } // ... } }
4. LLVM и TCC AOT-бэкенд: компиляция в машинный код
Для генерации максимального перформанса RayQuiro использует транслятор Generator.h и систему компиляции Compiler.h.
Стратегия кодогенерации
Вместо динамической диспетчеризации через тяжелый интерпретатор, генератор преобразует AST в семантически эквивалентный код на C++ с прямыми типами rq::Value и оптимизированным выделением памяти под структуры. Далее полученный код компилируется:
TinyCC (TCC): В состав RayQuiro встроен легкий тулчейн TCC (
tools/tcc/на Windows или системный пакет на Linux). Это дает сборку в нативный PE/ELF-бинарник за 10–15 миллисекунд без необходимости тащить тяжелые компиляторы вроде MSVC или GCC.Clang / LLVM Backend (
--release): При флаге--releaseвключается тяжелый оптимизирующий пайплайн LLVM:-O3оптимизации.Автоматическая векторизация циклов (Loop Vectorization).
Инлайнинг горячих функций и Dead Code Elimination (DCE).
Проход
mem2regдля выталкивания локальных переменных из стека памяти прямо на регистры процессора.
Кросс-компиляция
За счет поддержки LLVM Target Triples, кросс-сборка под разные ОС делается одной командой:
Bash
rqio build app.rq --target linux-x64 -o bin/app_linux rqio build app.rq --target win-x64 -o bin/app.exe rqio build app.rq --target macos-arm64 -o bin/app_macos
При линковке на Linux компилятор автоматом цепляет системные библиотеки (-lGL -lm -lpthread -ldl -lrt -lX11), позволяя собирать десктопные GUI-приложения «из коробки».
5. Примеры кода на RayQuiro
Синтаксис языка проектировался лаконичным, строгим и безопасным в плане работы с null.
Объявление структур, функций и деструктуризация
Фрагмент кода
struct Vector2D { x: 0, y: 0 } fn divmod(dividend, divisor) { if (divisor == 0) { throw "Division by zero!"; } var q = floor(dividend / divisor); var r = dividend % divisor; return q, r; } var [quotient, remainder] = divmod(17, 5); print("Quotient: " + str(quotient) + ", Remainder: " + str(remainder)); var vec = Vector2D { x: 10, y: 25 }; print("Vector point: " + str(vec["x"]) + ", " + str(vec["y"]));
Безопасная навигация и сопоставление с образцом (Pattern Matching)
Фрагмент кода
var config = { "network": { "port": 8080 } }; // Безопасное чтение через optional chaining и null coalescing: var host = config?.["network"]?.["host"] ?? "127.0.0.1"; var port = config?.["network"]?.["port"] ?? 80; print("Connecting to: " + host + ":" + str(port)); // Матчинг без проваливания (no fallthrough): var statusCode = 404; var statusMessage = ""; match statusCode { 200 => statusMessage = "OK", 404 => statusMessage = "Not Found", 500 => statusMessage = "Internal Server Error", _ => statusMessage = "Unknown Status" } print("HTTP Status: " + statusMessage);
Разделение app и ui: оконный интерфейс
В RayQuiro разделена логика и стилизация: модуль app отвечает за структуру окна и виджеты (DOM), а модуль ui — за CSS-подобное оформление:
Фрагмент кода
import "rayquiro.app"; import "rayquiro.ui"; app.init("Системный монитор", 480, 320); ui.style("background: #18181b; color: #fafafa; font-size: 14;"); var label = app.text("Статус демона: активен"); var check = app.checkbox("Автозапуск при старте", true); var btn = app.button("Перезагрузить сервис"); btn = ui.apply(btn, { "background": "#2563eb", "color": "#ffffff", "radius": 6, "padding": "6px 12px" }); app.run();
6. Архитектурные вызовы и их решения
При реализации ядра языка пришлось столкнуться с несколькими интересными инженерными задачами.
Вызов 1: Исключения и раскрутка стека в виртуальной машине
Проблема: В AST-интерпретаторе исключения легко заворачиваются в стандартный C++ try/catch. Но в байт-код машине нативный C++ механизм дает огромный оверхед, портит локальность кэша инструкций и не дает нормально восстанавливать ip (указатель инструкций) вместе с глубиной стека операндов.
Решение: В ВМ был добавлен отдельный стек контекстов для перехвата исключений:
C++
struct TryHandler { std::size_t catchIp; std::size_t stackDepth; std::string errorParam; }; std::vector<TryHandler> tryStack_;
Опкод TryBegin сохраняет точку возврата catch и текущую глубину стека данных. При срабатывании Throw машина мгновенно откатывает стек операндов до stackDepth, извлекает значение ошибки, прокидывает его в переменную errorParam и делает переход на catchIp. Если стек tryStack_ пуст, ошибка подымается на уровень хост-приложения.
Вызов 2: Lvalue-присваивание по вычисляемому индексу
Проблема: В ряде простых интерпретаторов выражение table[key] = value парсится как бинарный оператор =, у которого левая часть — IndexExpr. Но IndexExpr при вычислении возвращает обычное значение (rvalue), полностью теряя ссылку на исходный контейнер и ключ.
Решение: В AST был выделен специализированный узел SetIndexExpr, а в байт-код добавлены точечные инструкции:
SetIndexдля универсальных случаев.SetLocalIndexиSetGlobalIndexдля прямого изменения полей объектов и массивов, лежащих в слотах локальных или глобальных переменных, без лишнего копирования.
Вызов 3: Кроссплатформенная графика без тяжелых зависимостей
Проблема: Попытка прикрутить к языку десктопный GUI обычно тащит за собой монструозные либы вроде Qt, Electron или GTK, что превращает развертывание под чистой ОС в ад.
Решение: Для модуля rayquiro.app и графической подсистемы мы упаковали кастомизированный Raylib прямо в ядро рантайма. На Linux графический бэкенд общается с X11 и Wayland (через XWayland) по OpenGL, используя встроенные векторные шрифты для рендеринга текста без зависимости от системных шрифтовых демонов.
Заключение
RayQuiro развивается как полностью самостоятельный инструмент, сочетающий удобство скриптового синтаксиса с предсказуемостью нативной компиляции.
Проект открытый, код доступен сообществу:
Репозиторий ядра: GitHub — RayQuiro
Документация: rayquiro-docs
Плагин для VS Code: rayquiro-vscode
Буду рад конструктивной критике в комментариях, обсуждению деталей реализации ВМ и пулл-реквестам!
