Модульная система SOOT: полное руководство
Оглавление
- Модульная система SOOT: полное руководство
Часть 1: Как работают имена в SOOT
В SOOT нужно различать два момента: как компилятор находит определения и как runtime хранит их значения.
1. Определения в файлах
Каждый файл .sc — отдельный модуль. Внутри одного файла нельзя дважды объявить одно и то же имя:
(defun greet () "hello")
(defun greet () "hi") ; ошибка
Чтобы изменить функцию, нужно исправить её определение, а не добавлять второе.
При этом разные файлы могут определять одинаковые имена.
2. Как компилятор находит определения
Когда файл подключает другой через (require "b.sc"), компилятор получает доступ к его определениям.
Если несколько файлов определяют greet, это не ошибка: каждое определение связано со своим файлом. При разрешении имени компилятор сначала проверяет текущий файл, затем подключённые файлы (рекурсивно по графу require).
Таким образом, одинаковые имена в разных файлах могут существовать независимо друг от друга на уровне компиляции. Компилятор не смешивает их в одну таблицу.
3. Что происходит во время выполнения
При загрузке модуля его глобальные имена регистрируются в общей таблице Globals.
В этой таблице каждому имени соответствует одно текущее значение. Если несколько модулей определяют greet, последняя загрузка заменяет предыдущее значение.
Например, bar.sc определяет greet как "hello", а baz.sc — как "hi".
- Сначала загружен
bar, затемbaz— вGlobalsбудет"hi". - Сначала загружен
baz, затемbar— вGlobalsбудет"hello".
Это правило относится к runtime. Оно не означает, что компилятор смешивает определения из разных файлов.

4. Что происходит в REPL
REPL — интерактивная консоль. Введённый в ней код компилятор рассматривает как отдельный виртуальный файл, например <repl> или <repl-N>.
Поэтому определение greet в REPL не удаляет определение из bar.sc в таблицах компилятора. Но после загрузки нового определения его значение может заменить прежнее в общей таблице Globals.
5. Кратко
- Файл: содержит собственные определения; повторное объявление имени внутри файла запрещено.
- Компилятор: различает определения из разных файлов и разрешает имена по правилам видимости.
- Runtime: хранит одно текущее значение для каждого глобального имени; последняя загрузка может его заменить.
- REPL: создаёт отдельные определения, которые при загрузке могут изменить значения в runtime.
Часть 2: Использование командной строки
Компиляция одного файла
sootc --no-repl --target build soot_src/tests/bar.sc
Что происходит:
- Компилятор читает
soot_src/tests/bar.sc. - Строит для него AST (
FileNodeс именемbar). - Генерирует
build/bar.binиbuild/bar.h. - Выходит, потому что
--no-repl.
--target build — папка, куда писать результаты. Если её нет, создаётся. Если не указать — по умолчанию build.
Что внутри bar.bin:
bar-greet— скомпилированная функция.top-level— если в файле есть выражения верхнего уровня (например,(bar-greet)без обёртки вdefun). Если файл содержит только объявления,top-levelнет — модуль ничего не делает при загрузке, только регистрирует имена.
Компиляция файла с зависимостями
sootc --no-repl --target build soot_src/tests/foo.sc
foo.sc содержит:
(require "bar.sc")
(defun foo-greet () (bar-greet))
(foo-greet)
Что происходит:
- Компилятор читает
foo.sc. - Видит
(require "bar.sc"). - Ищет
bar.sc— сначала рядом сfoo.sc(soot_src/tests/bar.sc), потом вPROJECT, потом вsearch_paths. - Находит, компилирует
bar.scкак отдельный модуль в памяти и регистрирует его в runtime. - Продолжает компиляцию
foo.sc:foo-greetтеперь видитbar-greetчерез граф импортов. - Генерирует
build/foo.bin, в котором записано: «этот модуль требуетbar».
build/bar.bin при этом на диск не пишется: require компилирует bar.sc в памяти, но .bin создаёт только отдельный запуск sootc bar.sc или (save-module "bar.sc") из REPL.
Компиляция с SIDBase
sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/foo.sc
SIDBase — это файл, который сопоставляет 64-битные хеши (SID) обратно строкам. Внутри .bin все имена хранятся как SID (0xF9E6E6EF197C2B25), а не как строки — это компактнее и быстрее. Но без SIDBase дизассемблер и инспектор показывают <unknown:0x...> вместо bar-greet.
Зачем --sidbase:
- Загружается в начале компиляции — чтобы все строки, встреченные в
lib.sc,lib.st, и в твоих файлах, попали в SIDBase. - Сохраняется в конце — чтобы дизассемблер мог вернуть имена.
Если --sidbase не указан:
- SIDBase не загружается (пустая).
- SIDBase не сохраняется.
- Это нормально для одноразовых компиляций, но
list-symbols,inspectиsc-runв REPL будут показывать<unknown:...>.
Рекомендация: всегда передавай --sidbase build/sidbase.bin. Один файл, накапливает имена между запусками.
Запуск REPL с runtime
sootc --repl --runtime local --sidbase build/sidbase.bin
--repl— открыть интерактивную сессию.--runtime local— использовать in-process VM (Carbon). Альтернативы:--runtime null(ничего не выполнять) или--runtime host:port(удалённый хост).--sidbase— загрузить SIDBase, чтобы имена резолвились.
В REPL ты можешь:
- Компилировать формы (
(defun f () ...)). - Загружать модули (
(load-module "./build/foo")). - Вызывать функции (
(sc-run foo-greet)). - Сохранять сессию (
(save-session "mysession")). - Сохранять модули (
(save-module "soot_src/tests/bar.sc")).
Часть 3: Использование интерактивной сессии
Компилятор может быть запущен с использованием интерактивной сессии REPL.
$ sootc --repl
Открывшееся окно может быть использовано для инкрементальной компиляции. Для этого необходимо ввести выражение и нажать enter.
Что происходит при вводе формы
Например, пользователь вводит в REPL:
sci> (defun bar-greet () (print "hello"))
Происходит следующее:
- Загрузчик парсит строку в SOOT-объект.
- REPL-диспетчер проверяет: это REPL-команда (из
repl_commands()) или обычная форма?defun— не команда, идёт дальше. - Компилятор компилирует форму как отдельный модуль с именем
<repl-N>(N растёт с каждым вводом). - Runtime загружает модуль
<repl-N>:bar-greetпопадает вGlobals. - Runtime выполняет секцию
top-levelмодуля<repl-N>(если есть). - Форма запоминается в
m_session_forms— истории сессии.
Главное: каждый REPL-ввод — отдельный модуль <repl-0>, <repl-1>, и т.д. Это сделано, чтобы повторное определение одного имени в разных вводах не вытесняло старое в компиляторе. В runtime Globals — одна, там побеждает последний.
Проблема ошибочного ввода
Если ты введёшь неправильный defun или любое другое неверное выражение, компиляция упадёт, а состояние компилятора откатится к предыдущему состоянию:
sci> (defun bar-greet (lambda () "wrong"))
; ERROR: FunctionCompiler::parse_arguments ...
Эта форма не попадёт в m_session_forms — она не часть сессии. Только успешно скомпилированные формы записываются. Поэтому (save-session ...) не упадёт на старых ошибках.
Компиляция запроса зависимости
sci> (require "bar.sc")
Это языковая форма, не REPL-команда. Она обрабатывается во время компиляции:
- Компилятор ищет
bar.sc(рядом с текущим файлом, потомPROJECT, потомsearch_paths). - Компилирует
bar.scкак отдельный модуль (FileNode("bar")). - Регистрирует модуль в runtime.
- Добавляет
bar.scв граф импортов текущего файла (<repl-N>).
Результат: bar-greet виден компилятору в текущей форме через FileNode::lookup, который рекурсивно обходит граф импортов.
Загрузка модуля в среде исполнения
sci> (load-module "./build/foo")
load-module — обычный вызов функции, которая выполняет загрузку во время работы программы. Это не команда компилятора и не команда диспетчера REPL. Она:
- Ищет
foo.bin(сначала как есть, потом вPROJECT, потом вsearch_paths). - Загружает его в
Globals. - Рекурсивно читает
requires: ["bar"]и загружаетbar.bin. - Выполняет секцию
top-levelмодуляfoo.bin(если есть).
Главное различие между require и load-module:
require "bar.sc"— компилируетbar.sc, регистрирует в runtime, добавляет импорт в граф.load-module "./build/bar"— загружает уже скомпилированныйbar.binв runtime, не компилирует, не добавляет импорт.
Что из этого следует: после (load-module "bar") можно вызвать (sc-run bar-greet), но нельзя написать (bar-greet) — компилятор не знает о bar-greet, потому что bar.bin не проходил через require.
Сохранение истории консоли
sci> (save-session "mysession")
Что сохраняется:
- Все формы, которые ты ввёл в консоли и которые успешно скомпилировались.
- Не сохраняются: неудачные формы, runtime-команды (
(e),(reload),(gc)), формы изrequire.
Что не сохраняется:
- Модули, загруженные через
load-module. Они не вm_session_forms. requiresиз консоли, если они были добавлены только черезload-module.
Итак, mysession.bin сохраняет формы, введённые в консоли, а не копии всех модулей, которые консоль загружала. Если сохранённая сессия должна иметь компиляционную зависимость от исходного модуля foo, используй require. load-module загружает готовый бинарный модуль во время выполнения, но не создаёт такую зависимость компилятора.
Self-dependency: если ты сохраняешь сессию как "bar" и в консоли был (require "bar.sc"), bar будет удалён из requires — иначе bar.bin требовал бы сам себя, и загрузка была бы невозможна.
Сохранение модуля
sci> (save-module "soot_src/tests/bar.sc")
Что сохраняется:
- Содержимое файла
bar.sc: егоdefun, егоrequire, егоtop-level. - Но не то, что ты вводил в консоли.
Когда это нужно: когда ты отредактировал .sc и хочешь скомпилировать именно его, не выходя из REPL. Или когда ты работаешь с несколькими файлами и хочешь пересобрать один из них.
Есть также (save-module-recursive "src/main") — сохраняет модуль и все его require в правильном порядке (зависимости первыми). Один вызов — весь граф зависимостей на диске.
Self-dependency: save-module "bar.sc" удаляет bar из requires — если bar.sc содержит (require "bar.sc"), это отбрасывается.
Разница между save-session и save-module
save-session "x" |
save-module "x.sc" |
|
|---|---|---|
| Что сохраняется | формы, введённые в консоли | содержимое файла x.sc |
requires |
из require в консоли |
из require в файле |
top-level |
все формы консоли | top-level из файла |
| Self-dependency | удаляется | удаляется |
| Когда использовать | «сохранить, что я наработал» | «пересобрать файл, не выходя из REPL» |
Часть 4: Загрузка модуля
sci> (load-module "./build/foo")
hello from bar
; => null
Что происходит:
load-moduleищетfoo.bin(сначала как есть, потомPROJECT/foo.bin, потомPROJECT/search_path/foo.bin).- Загружает
foo.binвGlobals:foo-greet— регистрируется.top-level— не регистрируется (это entry point модуля, не символ).requires— не регистрируется (это метаданные).
- Читает
requires: ["bar"]. - Рекурсивно загружает
bar.bin:bar-greet— регистрируется.
- Выполняет секцию
top-levelмодуляfoo.bin:(foo-greet)→(bar-greet)→print "hello from bar". - Печатает
hello from bar.
; => null — top-level ничего не возвращает, это нормально.
Часть 5: Вызов функции
sci> (sc-run foo-greet)
hello from bar
; foo-greet => 1
sc-run — REPL-команда. Она:
- Смотрит имя
foo-greetвGlobals. - Находит
ScriptLambda*. - Вызывает через VM.
- Печатает результат (
1— потому чтоprintвозвращает1).
Разница между (sc-run foo-greet) и (foo-greet):
(sc-run foo-greet)— runtime, работает, потому чтоfoo-greetвGlobals.(foo-greet)— компилятор, упадёт с'foo-greet' not visible from the current file, потому чтоfoo.scне в графе импортов текущего<repl-N>(еслиfoo.scне был required из консоли).
Если ты хочешь, чтобы (foo-greet) работал:
sci> (require "foo.sc") ; ← не load-module!
require компилирует foo.sc и добавляет foo.sc в граф импортов текущего <repl-N>. После этого (foo-greet) компилируется и выполняется.
Часть 6: Поиск файлов проекта
Корень проекта
PROJECT — корень проекта. Определяется в следующем порядке:
--project-root <dir>(или алиас--project <dir>);- переменная окружения
SOOT_ROOT; - поле
project_rootвsoot.json(путь относительно каталога самогоsoot.json); - каталог, где лежит найденный
soot.json; - текущая рабочая директория.
Поле project_root в soot.json позволяет положить конфигурационный файл в любое место и указать настоящий корень относительно него. Без поля корнем считается каталог самого soot.json. Пример:
{
"project_root": "..",
"runtime": "local",
"sidbase": "build/sidbase.bin",
"search_paths": ["lib", "src", "examples"]
}
Здесь PROJECT = каталог на уровень выше soot.json, а sidbase и search_paths отсчитываются от этого PROJECT.
Папки
PROJECT— корень проекта (см. выше).build/— папка результатов по умолчанию (--target build).soot_src/— исходникиlib.st,lib.sc(prelude).
Файлы
*.sc— исходник.*.bin— скомпилированный модуль.*.h— C-заголовок с описанием типов (для внешних инструментов).sidbase.bin— SIDBase.
Search paths
При поиске require и load-module проверяются:
- Рядом с текущим файлом (для
require; дляload-module— рядом с.bin). CWD— текущая директория.EXE— каталог исполняемого файла.PROJECT— корень проекта.PROJECT / search_path— каждый из путейsearch_paths(по умолчанию., scripts, src, examples, soot_src, либо изsoot.json).CONFIG— системный каталог конфигурации.SHARE— системный каталог общих файлов.
Часть 7: Типичные сценарии
Скомпилировать один файл
sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/bar.sc
Скомпилировать файл с зависимостями
sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/foo.sc
foo.bin будет содержать requires: ["bar"]. bar.bin на диск не запишется: require компилирует bar.sc только в памяти. Чтобы получить bar.bin, скомпилируй bar.sc отдельно (sootc --no-repl bar.sc) или используй (save-module "bar.sc") / (save-module-recursive ...) из REPL.
Собрать несколько модулей одним вызовом
Создай скрипт do.st:
; soot_src/tests/do.st
(require "./tests/bar.sc")
(require "./tests/foo.sc")
(save-module "tests/bar")
(save-module "tests/foo")
Запусти:
sootc --run soot_src/tests/do.st
--run <script.st> выполняет скрипт в новой сессии — эквивалент интерактивного ввода. --run не требует --no-repl: по умолчанию sootc выходит после выполнения. REPL открывается только если явно передан --repl или "repl": true в soot.json.
Открыть REPL и загрузить модуль
sootc --repl --runtime local --sidbase build/sidbase.bin
sci> (load-module "./build/foo")
hello from bar
sci> (sc-run foo-greet)
hello from bar
Работа в REPL, сохранение сессии
sci> (require "bar.sc")
sci> (defun my-fn () (bar-greet))
sci> (save-session "mysession")
; saved mysession.bin, requires: ["bar"]
sci> (e)
Загрузка сохранённой сессии
sootc --repl --runtime local --sidbase build/sidbase.bin
sci> (load-module "./mysession")
hello from bar
sci> (sc-run my-fn)
hello from bar
Перекомпиляция файла из REPL
sci> (save-module "soot_src/tests/bar.sc")
; saved bar.bin
sci> (load-module "./build/bar")
sci> (sc-run bar-greet)
hello from bar
Часть 8: Правила и подводные камни
Следующие примечания поясняют, как работает компилятор.
-
Каждое определение принадлежит файлу. Внутри файла повторное имя — ошибка. В разных файлах — норма.
-
В глобальной области среды исполнения побеждает последний. Не важно, сколько файлов определяют
greet. ВGlobals— одинgreet, из последнего загруженного модуля. require— компиляция,load-module— загрузка.require→ компилирует.sc, регистрирует в runtime, добавляет импорт в граф.load-module→ загружает.bin, регистрирует в runtime, не добавляет импорт. Из этого следует: послеrequire "foo.sc"можно писать(foo-greet). После(load-module "foo")— только(sc-run foo-greet).
-
requireищет рядом с файлом. Еслиfoo.scвsoot_src/tests/требуетbar.sc,bar.scнайдётся вsoot_src/tests/, даже еслиsoot_src/tests/нет вsearch_paths. -
REPL — отдельный файл. Всё, что вводится в консоли, принадлежит
<repl-N>. Не переопределяет файлы. В runtime замещает. save-sessionотличается отsave-module.save-session— сохраняет консоль.save-module— сохраняет файл.
- Модуль не может зависеть на себе.
save-module "bar.sc"удаляетbarизrequires.save-session "bar"— тоже.
Следующие примечания демонстрируют потенциальные проблемы.
-
Подводный камень: система не проверяет циклические зависимости. Использование зависимости
a -> b -> aне приведёт к ошибке компиляции и не приведёт к ошибке исполнения, но может быть утрачен детерминизм. -
Подводный камень:
<unknown:0x...>вlist-symbols. Если запустил REPL без--sidbase, имена показываются как<unknown:0x...>. Это не баг, это отсутствие SIDBase. Всегда передавай--sidbase. -
Подводный камень:
foo.binв корне проекта. Если ты скомпилировалfoo.scбез--target build,foo.binокажется в корнеPROJECT.load_module_recursiveищетfoo.binсначала рядом сfoo.bin, потом вPROJECT, потом вsearch_paths. Если старыйfoo.binв корне — он может выиграть у новогоbuild/foo.bin. Всегда используй--target build.
Заключение
Чтобы ориентироваться в системе, достаточно помнить три основных различия: определения внутри исходных файлов и таблица имён runtime — не одно и то же; require работает с исходным кодом на этапе компиляции, а load-module загружает готовый бинарный модуль во время выполнения; save-session сохраняет историю консоли, а save-module — конкретный исходный файл.
- Файл — единица компиляции.
defun,deftype,require. - Компилятор — знает все файлы, но не смешивает.
- Runtime — одна
Globals, последний побеждает.
require — компилирует и регистрирует. load-module — загружает и регистрирует. save-session — сохраняет консоль. save-module — сохраняет файл.
Всё остальное — детали.