Uxn — это необычная виртуальная машина, ориентированная на простоту и компактность, подходящая для разработки минималистичных программ и игр. Несмотря на свою лёгкую архитектуру, Uxn обладает достаточно гибкими возможностями для создания сложных структур, таких как замыкания — функция, сохраняющая доступ к переменным своей лексической области видимости. В рамках разработки для Uxn часто используется uxntal — ассемблероподобный низкоуровневый язык, который, однако, может показаться излишне трудным и неповоротливым для повседневного программирования. Именно здесь на сцену выходит niënor — небольшой компилятор и макрорасширитель с Lisp-подобным синтаксисом, который превращает лаконичный код в uxntal для Uxn. В данной статье мы рассмотрим, как реализовать домашнюю систему замыканий в niënor, обеспечивая лексическую область видимости и динамическое создание функций с учётом присущих ограничений Uxn.
По своей природе замыкания — это анонимные функции, которые захватывают переменные из окружающего контекста и могут быть использованы после того, как этот контекст исчезнет. Классический пример — функция создатель "аддера": функция, которая принимает число 'а' и возвращает функцию, прибавляющую 'а' к своему аргументу. При попытке реализовать подобное в Uxn возникают сложности, обусловленные ограничениями среды: отсутствием динамической типизации на уровне вызова и фиксированной моделью передачи управления. В традиционном подходе к компиляции анонимных функций в niënor они просто получают имя, абстрагируя от анонимности. Компилятор генерирует функцию с уникальным идентификатором, а в том месте исходного кода, где встречалась лямбда-выражение, используется ссылка на это имя.
Однако данный метод не работает для замыканий, поскольку внутренняя функция должна иметь доступ к переменным, определённым вне её собственного аргументного списка. Для решения этой проблемы в niënor применяется хитрый приём: во время компиляции функция получает дополнительный аргумент — список всех переменных из внешнего окружения, в которых нуждается замыкание. Проще говоря, если исходная функция имела аргументы (b), а внутри использовала переменную a из внешнего контекста, то результатом трансформации станет функция с сигнатурой (a b). Это обеспечивает, что переменная a становится доступна как формальный параметр, и задействуется стандартный механизм передачи аргументов. Такой подход решает проблему доступности переменных и позволяет компилировать замыкания как обычные функции.
Однако вызов такой функции с удлинённым набором параметров напрямую неудобен для пользователя, ожидающего привычное поведение замыкания с сохранённым окружением. Поэтому на этапе выполнения создаётся дополнительная «портальная» функция — специальный оболочек, который захватывает значения внешних переменных и автоматически подставляет их при вызове внутренней функции. В оперативной памяти выделяется участок с помощью собственной реализации malloc, куда сгенерирован код этого портала — последовательность инструкций, которая при вызове подставляет необходимые аргументы и передаёт управление основной функции с помощью быстрого перехода (JMP2). Такой метод выражения замыканий имеет ряд преимуществ. Во-первых, он сохраняет компактность и простоту вызова для пользователя, который получает привычный интерфейс функции с правильным окружением.
Во-вторых, операции памяти и управления кодом чётко разграничены: сами замыкания хранятся в динамической памяти, которую можно освободить, когда замыкание больше не нужно, благодаря реализации free, предотвращая утечки памяти. Рассмотрим практический пример из мира графики в Uxn. В niënor можно определить функцию make-drawer, которая создаёт замыкание для рисования спрайта на экране. Она принимает аргумент sprite, ссылающийся на текстуру, и возвращает функцию с аргументами x и y, вызывающую sprite! — примитивный вызов рисования в определённой точке. При этом sprite оказывается захваченной переменной, передаваемой в замыкание через механизм, изложенный выше.
Выполняя make-drawer с указанием конкретного спрайта, программа выделяет память под код портала, записывает туда инструкции, которые сначала кладут значение sprite в стек, а затем переходят к основной функции по её адресу. Таким образом вызывающая сторона получает полноценное замыкание, реализованное с учётом ограничений Uxn и uxntal. Особое внимание заслуживает реализация malloc и free в niënor для управления динамической памятью, выделенной после конца ROM. Благодаря им, можно создавать и освобождать замыкания по мере необходимости, что является критически важным в ограниченных средах, подобных Uxn, где нет операционной системы и встроенного менеджера памяти. Пример с созданием нескольких замыканий показывает, что freed память успешно переиспользуется для новых функций, экономя ресурсы.
Рассмотренный подход к созданию замыканий является, безусловно, экспериментальным и требует тщательного тестирования в реальных сценариях, но уже демонстрирует высокую степень гибкости и практической применимости. Niënor, играющий роль мостика между удобным Lisp-подобным синтаксисом и низкоуровневым uxntal, открывает интересные перспективы для разработчиков, стремящихся создавать более выразительные и динамические программы для Uxn. Подводя итоги, можно сказать, что домашняя реализация замыканий в Uxn с помощью niënor — это интересный баланс между простотой, эффективностью и выразительностью. Она позволяет использовать мощные функциональные концепции в крайне ограниченной среде, расширяя границы возможного. Такая система особенно актуальна для тех, кто хочет привнести в проекты Uxn привычные функциональные парадигмы, не жертвуя при этом производительностью и контролем над памятью.
Дальнейшее развитие niënor и связанных с ним инструментов обещает сделать программирование на Uxn ещё более удобным и гибким. Пока же разработчики могут экспериментировать с этим подходом, вдохновляясь идеей, что даже в минималистичных виртуальных машинах находится место для современных концепций программирования.