HWW Portfolio

Software and Hardware Developer

Инкрементальная интерактивная компиляция: как совместить строгий компилятор и REPL

Оглавление


Введение

Компилятор SOOT проектировался для игровой индустрии. В геймдеве интерактивная и инкрементальная разработка дают существенный выигрыш: цикл «изменил — запустил — увидел» сокращается с минут до секунд, а в больших проектах — с часов до минут. Чем короче цикл, тем больше экспериментов успевает сделать команда за день.

Однако в геймдеве важна не только скорость, но и разделение труда. В крупных студиях код ядра (движок, физика, рендер, сеть) пишут инженеры, которым нужен строгий типизированный компилятор: типы ловят ошибки до запуска, оптимизации работают по известным типам, бинарник собирается AOT (Ahead-Of-Time, компиляция заранее) и не требует интерпретатора в проде. А геймдизайнеры, скриптеры и контент-дизайнеры пишут логику поверх ядра: события, состояния, диалоги, поведение NPC. Им нужен интерпретатор или REPL, где можно ввести выражение, сразу увидеть результат, поменять правило и не пересобирать проект.

Обычно языки выбирают одно из двух: либо строгий компилятор (и тогда геймдизайнеры мучаются), либо динамический интерпретатор (и тогда ядро теряет в скорости и надёжности). SOOT пытается совместить оба режима в одном языке: строгая типизация и AOT-компиляция для ядра, REPL и интерактивная разработка для логики. Эта статья о том, как сделано это совмещение.


Строгий компилятор с интерактивным REPL

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

Слова «как-то синхронизируются» лишь описывают проблему, решения в них нет. Синхронизацию между интерпретатором и компилятором как раз и нужно сделать, и именно в ней живёт вся сложность. У очевидного подхода три конкретные проблемы: две семантики (интерпретатор и компилятор понимают if, when, not по-разному), две таблицы имён (интерпретатор держит имена в своём окружении, компилятор — в Globals VM) и две типизации (компилятор требует явных типов, REPL — нет). Каждую из них нужно решать отдельно, и каждая тянет за собой ещё несколько.

В архитектуре SOOT REPL не интерпретирует код. Он вызывает компилятор — тот же самый, который собирает .sc в .bin. Каждый ввод превращается в новый модуль: парсится, компилируется, загружается в runtime. С точки зрения компилятора, разницы между файлом game.sc и строкой, введённой в терминале, нет: оба проходят один и тот же путь AST → FileNode → BinaryFile → load_module.

REPL оказывается тонкой обёрткой: вся сложность (типы, макросы, кодогенерация) находится внутри компилятора, а REPL только вызывает его и загружает результат. Три проблемы выше здесь не возникают: второй семантики, второй таблицы имён и второго набора типов просто нет. Есть один компилятор, один Globals, один TypeSystem. REPL использует тот же путь, только вход у него короче.

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


Каждый ввод — это новый модуль

Работа в REPL — это сессия. Программист запускает её, вводит выражения одно за другим и ожидает, что всё введённое сохраняется: определения, переменные, функции доступны в следующих вводах до конца сессии. Это базовое свойство интерактивной среды, без него REPL бесполезен.

Самое простое решение, которое приходит в голову: компилировать всё накопленное в один модуль и при каждом вводе заменять старый модуль новым. Но тогда весь код выполняется заново при каждом вводе. Ввели (print "hello") — напечаталось hello. Ввели (print "world") — первый print выполнился снова, и в консоли получилось hello, hello, world. REPL превращается в «перезапуск с начала», и сессия теряет смысл.

Идеальный вариант — компилировать каждое выражение в отдельный модуль и загружать его в runtime, не трогая предыдущие. Тогда (print "hello") выполняется один раз, (print "world") тоже один раз, и в консоли появляются hello и world.

Можно было бы пойти дальше и сделать сложную систему конкатенации: новый модуль «дописывается» к старому, и оба сливаются в один. Но это требует отслеживать, что изменилось, что осталось и что переопределено. Такая сложность для REPL не оправдана.

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


Как новый модуль видит всё, что было раньше

Каждый ввод — отдельный модуль, но с точки зрения программиста это одна сессия и одна область видимости: если я ввёл (defun f ...), а через строку — (f 5), я ожидаю, что f видна. Модули — внутренняя механика компилятора, программист о них не думает. Для него есть один REPL, один набор определений, одна сессия.

Компилятор видит другую картину. Для него каждый модуль — изолированная единица со своими именами, своей таблицей и своими определениями. По умолчанию модуль не экспортирует ничего: всё, что в нём определено, остаётся внутренним. Так устроены обычные .sc-файлы: (defun f ...) в a.sc не виден в b.sc, пока a.sc явно не экспортирует f.

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

Решение простое: в REPL экспортируется всё. Каждый модуль, созданный из ввода, компилируется с флагом export_all = true. Этот флаг велит компилятору экспортировать всё, что определено в модуле. Определения регистрируются в глобальной таблице имён (Globals), как если бы это был обычный модуль, явно экспортировавший каждое своё определение.

Дальше работает механика загрузки модулей. Когда новый модуль загружается в runtime, Globals дополняется его экспортами. Следующий ввод компилируется уже после этого, и NodeBuilder при поиске имени находит его в Globals — наравне с именами из lib.sc, из (require ...) и из предыдущих вводов. Специальная синхронизация между модулями не нужна: Globals — одна таблица, и она уже видит всё, что было загружено.

Благодаря этому REPL прозрачен. Программист вводит (defun f ...), потом (f 5), и f находится. Причина в том, что f лежит в Globals, как любой другой экспорт, а вовсе не в том, что REPL её «помнит». С точки зрения компилятора второго ввода f — внешняя сущность из другого модуля, с точки зрения программиста — своя, из этой же сессии. Правы оба, потому что Globals не различает источники.

Что происходит при переопределении

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

Допустим, X определён в модуле A, а затем загружен модуль B, где тоже есть X. С этого момента Globals указывает на X из B. Имена, которые B не переопределяет, продолжают указывать на A. Побеждает определение из модуля, загруженного позже, то есть порядок загрузки и есть порядок приоритета.

Поэтому в любой момент Globals — это актуальный срез всей сессии: все загруженные модули действуют, а для каждого имени выбрано самое свежее определение.


Именование модулей в REPL

Каждый модуль загружается с уникальным именем: <repl-0>, <repl-1>, <repl-2>. Это условие корректности. Если бы все модули назывались одинаково, load_module при загрузке нового выгружал бы предыдущий, и имена из Globals пропадали бы. Уникальное имя гарантирует, что новый модуль дополняет старые и не заменяет их.

Есть и вторая причина, связанная с heap-переменными. В модуле могут быть статические поля, ссылающиеся на динамические значения, например (define x (new 'heap ...)). top-level создаёт такое значение в куче и записывает ссылку на него в статическое поле модуля. Если выгрузить модуль, поле исчезнет, и сборщик мусора заберёт все объекты, на которые больше никто не ссылается. Пока модуль остаётся в памяти, сохраняется весь контекст, который он создал.

Цена такого решения — рост памяти. Модули не выгружаются до конца сессии: каждый ввод добавляет новый, и через сотню вводов в памяти лежит сотня модулей. Действующими при этом остаются все они, но для каждого имени Globals указывает на самое свежее определение. Старые определения, которые были переопределены, никто уже не использует, хотя занимают память.

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

Почему оно оправдано? Модули в REPL маленькие. Один ввод — это несколько строк кода, несколько определений, несколько килобайт бинарника. Даже тысяча вводов — это единицы мегабайт. Для человека за терминалом это незаметно. Для сервера, который работает месяцами, было бы заметно, но REPL живёт ровно столько, сколько длится сессия, а сессия — это работа программиста за один день.

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

Поэтому модули не удаляются. Каждый ввод дополняет предыдущие, Globals указывает на самые свежие определения, а старые модули лежат в памяти до конца сессии. Простота этого решения делает REPL надёжным: не нужно отслеживать граф ссылок, бояться выгрузить лишнее и объяснять программисту, почему его функция «внезапно исчезла».


Сохранение сессии

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

Команда save-session делает именно это: берёт все формы, введённые за сессию, строит из них один FileNode, компилирует его в один BinaryFile и сохраняет на диск как .bin и .h, то есть как обычный скомпилированный модуль. С точки зрения компилятора, разницы между сессией из ста вводов и файлом game.sc из ста форм нет: и то и другое — набор форм, из которых строится модуль.

Это удобно. Сессия, в которой вы отлаживали код, переопределяли функции и экспериментировали, превращается в воспроизводимый бинарник. Это пересборка из исходных форм, без дампа памяти и снимков состояния. Если завтра запустить этот .bin, он выполнит тот же код, который вы вводили, в том же порядке. Для отладки это полезно: вот сессия, в которой найден баг, вот бинарник, который его воспроизводит.

Способом программирования save-session при этом не является, это вспомогательный инструмент. Писать код в REPL, сохранять в .bin и считать результат «исходником» — плохая идея: .bin нечитаем, не дифается, не ревьюится и не мержится. Лучше всего писать код в файлах .sc, которые компилируются обычным путём, попадают в систему контроля версий и проходят ревью. REPL предназначен для экспериментов, файлы — для кода. save-session служит мостом между ними: «я поэкспериментировал, получилось, теперь перенесу в файл».

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


Инициализация модуля в runtime

В модуле есть два вида кода. Первый — декларации: defun, deftype, defenum, defmethod. Они не выполняются при загрузке: они регистрируют имена и типы, чтобы код, который будет выполняться, мог их найти. Второй — выражения: (print "hello"), (set! x 5), (define a (new 'heap 'acc)). Они должны выполниться ровно один раз, в том порядке, в котором написаны, — при загрузке модуля.

Компилятор собирает все выражения верхнего уровня в одну функцию и называет её top-level. Эта функция синтетическая, программист её не писал: её тело — begin из всех выражений, которые не попали в декларации. Если выражений нет (файл содержит только deftype и defun), top-level не создаётся, потому что выполнять нечего.

Пример. Пусть есть код:

(define a (new 'heap 'acc))
(print "started")
(set! a.total 10)

(define a (new 'heap 'acc)) — это одновременно декларация и runtime-действие. В декларации a получает тип acc и слот в файле. В runtime-действии создаётся heap-объект и записывается в слот. Первая часть идёт в декларации модуля, вторая — в top-level. (print "started") и (set! a.total 10) тоже идут в top-level. В итоге top-level выглядит примерно так:

top-level:
    a = new 'heap 'acc
    print "started"
    a.total = 10

Этот код сгенерирован компилятором и выполняет то, что программист написал вне функций.

Зачем top-level нужен отдельно

Загрузка и выполнение — разные вещи. Загрузчик модуля не должен выполнять код: он должен зарегистрировать модуль в runtime (имена, типы, бинарник). Выполнение top-level — отдельный шаг, и он делается явно.

Это важно, потому что бывают модули-библиотеки. lib.sc содержит defmacro, deftype, defmethod и не содержит выражений, которые надо выполнить при загрузке. У lib.sc нет top-level, и загрузчик его не запускает. Если бы загрузка и выполнение были одним шагом, lib.sc выполнялся бы при каждой загрузке, а это бессмысленно и потенциально опасно (макросы не должны «срабатывать» только потому, что библиотека загружена).

Поэтому load_module регистрирует, а run_top_level выполняет. Два явных шага для двух операций.


Как загружается модуль с зависимостями

Модуль может зависеть от других модулей: через (require "x.sc") в исходнике, через .dci-манифест с импортами или через явную загрузку из хоста. Загрузчик действует рекурсивно: сначала загружает зависимости, потом сам модуль.

Для каждого модуля порядок такой:

  1. Загрузить его зависимости (каждую по тем же правилам).
  2. Загрузить сам модуль.
  3. Выполнить его top-level, если он есть.

Для модуля A, который зависит от B и C, где B зависит от D, это выглядит так:

  1. Загрузить D, выполнить D.top-level.
  2. Загрузить B, выполнить B.top-level.
  3. Загрузить C, выполнить C.top-level.
  4. Загрузить A, выполнить A.top-level.
  5. Вернуться к тому, кто вызвал загрузку A.

Ключевой момент: top-level выполняется сразу после загрузки своего модуля, до загрузки следующего. К моменту, когда загружается A, модули B и C уже загружены и их top-level уже выполнен. Если A использует B.some_function, эта функция уже определена и инициализирована.

Порядок здесь перемешанный: загрузка, top-level, следующая загрузка, top-level. Такой порядок гарантирует, что к моменту выполнения A.top-level все зависимости прошли и загрузку, и инициализацию. Если бы все top-level выполнялись после всех загрузок, A.top-level мог бы обратиться к B, который загружен, но ещё не инициализирован, и получить неверные данные.

Что это даёт

Поэтому top-level и сделан отдельной функцией, а не частью загрузки. Он служит точкой входа модуля, как main в C, только создаётся автоматически из выражений верхнего уровня.

Этим же объясняется, как работает REPL. Каждый ввод — новый модуль со своим top-level: выражения, которые программист ввёл и которые не попали в декларации. (print "hello") в терминале — выражение верхнего уровня, оно идёт в top-level модуля <repl-N>. (defun f ...) — декларация, она идёт в декларации модуля. (define a (new 'heap 'acc)) — и то и другое: декларация a и runtime-действие в top-level.

Когда ввод загружается, run_top_level запускает top-level этого модуля, и print выполняется ровно один раз. Следующий ввод — новый модуль и новый top-level, старый остаётся нетронутым. Поэтому (print "hello") и (print "world") в двух вводах дают hello и world, а не hello, hello, world: у каждого ввода свой top-level, и он выполняется один раз.

Это тот же механизм, что и для обычных модулей. REPL идёт по тому же пути «загрузка → run_top_level», только вход короче, а имя модуля уникально.


Заключение

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

Совместить их удалось за счёт архитектуры. REPL не интерпретирует код, а вызывает тот же компилятор, который собирает .sc в .bin. Каждый ввод становится новым модулем, Globals видит всё загруженное, top-level выполняется один раз после загрузки. Каждое из этих решений объясняется одним предложением.

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

У такого подхода есть цена: память в REPL растёт с числом вводов, а инкрементальной перекомпиляции нет. Эта цена предсказуема и ограничена, и для задач, ради которых создавался язык, она оправдана. SOOT не претендует на роль универсального языка: это язык для конкретной ниши, где ядро написано на строгой типизации, логика на простом синтаксисе, а разработка идёт в интерактивном режиме.


Статус проекта