HWW Portfolio

Software and Hardware Developer

Модульная система 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".

Это правило относится к runtime. Оно не означает, что компилятор смешивает определения из разных файлов.

Modules

4. Что происходит в REPL

REPL — интерактивная консоль. Введённый в ней код компилятор рассматривает как отдельный виртуальный файл, например <repl> или <repl-N>.

Поэтому определение greet в REPL не удаляет определение из bar.sc в таблицах компилятора. Но после загрузки нового определения его значение может заменить прежнее в общей таблице Globals.

5. Кратко


Часть 2: Использование командной строки

Компиляция одного файла

sootc --no-repl --target build soot_src/tests/bar.sc

Что происходит:

  1. Компилятор читает soot_src/tests/bar.sc.
  2. Строит для него AST (FileNode с именем bar).
  3. Генерирует build/bar.bin и build/bar.h.
  4. Выходит, потому что --no-repl.

--target build — папка, куда писать результаты. Если её нет, создаётся. Если не указать — по умолчанию build.

Что внутри bar.bin:

Компиляция файла с зависимостями

sootc --no-repl --target build soot_src/tests/foo.sc

foo.sc содержит:

(require "bar.sc")

(defun foo-greet () (bar-greet))

(foo-greet)

Что происходит:

  1. Компилятор читает foo.sc.
  2. Видит (require "bar.sc").
  3. Ищет bar.sc — сначала рядом с foo.sc (soot_src/tests/bar.sc), потом в PROJECT, потом в search_paths.
  4. Находит, компилирует bar.sc как отдельный модуль в памяти и регистрирует его в runtime.
  5. Продолжает компиляцию foo.sc: foo-greet теперь видит bar-greet через граф импортов.
  6. Генерирует 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:

Если --sidbase не указан:

Рекомендация: всегда передавай --sidbase build/sidbase.bin. Один файл, накапливает имена между запусками.

Запуск REPL с runtime

sootc --repl --runtime local --sidbase build/sidbase.bin

В REPL ты можешь:


Часть 3: Использование интерактивной сессии

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

$ sootc --repl

Открывшееся окно может быть использовано для инкрементальной компиляции. Для этого необходимо ввести выражение и нажать enter.

Что происходит при вводе формы

Например, пользователь вводит в REPL:

sci> (defun bar-greet () (print "hello"))

Происходит следующее:

  1. Загрузчик парсит строку в SOOT-объект.
  2. REPL-диспетчер проверяет: это REPL-команда (из repl_commands()) или обычная форма? defun — не команда, идёт дальше.
  3. Компилятор компилирует форму как отдельный модуль с именем <repl-N> (N растёт с каждым вводом).
  4. Runtime загружает модуль <repl-N>: bar-greet попадает в Globals.
  5. Runtime выполняет секцию top-level модуля <repl-N> (если есть).
  6. Форма запоминается в 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-команда. Она обрабатывается во время компиляции:

  1. Компилятор ищет bar.sc (рядом с текущим файлом, потом PROJECT, потом search_paths).
  2. Компилирует bar.sc как отдельный модуль (FileNode("bar")).
  3. Регистрирует модуль в runtime.
  4. Добавляет bar.sc в граф импортов текущего файла (<repl-N>).

Результат: bar-greet виден компилятору в текущей форме через FileNode::lookup, который рекурсивно обходит граф импортов.

Загрузка модуля в среде исполнения

sci> (load-module "./build/foo")

load-module — обычный вызов функции, которая выполняет загрузку во время работы программы. Это не команда компилятора и не команда диспетчера REPL. Она:

  1. Ищет foo.bin (сначала как есть, потом в PROJECT, потом в search_paths).
  2. Загружает его в Globals.
  3. Рекурсивно читает requires: ["bar"] и загружает bar.bin.
  4. Выполняет секцию top-level модуля foo.bin (если есть).

Главное различие между require и load-module:

Что из этого следует: после (load-module "bar") можно вызвать (sc-run bar-greet), но нельзя написать (bar-greet) — компилятор не знает о bar-greet, потому что bar.bin не проходил через require.

Сохранение истории консоли

sci> (save-session "mysession")

Что сохраняется:

Что не сохраняется:

Итак, mysession.bin сохраняет формы, введённые в консоли, а не копии всех модулей, которые консоль загружала. Если сохранённая сессия должна иметь компиляционную зависимость от исходного модуля foo, используй require. load-module загружает готовый бинарный модуль во время выполнения, но не создаёт такую зависимость компилятора.

Self-dependency: если ты сохраняешь сессию как "bar" и в консоли был (require "bar.sc"), bar будет удалён из requires — иначе bar.bin требовал бы сам себя, и загрузка была бы невозможна.

Сохранение модуля

sci> (save-module "soot_src/tests/bar.sc")

Что сохраняется:

Когда это нужно: когда ты отредактировал .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

Что происходит:

  1. load-module ищет foo.bin (сначала как есть, потом PROJECT/foo.bin, потом PROJECT/search_path/foo.bin).
  2. Загружает foo.bin в Globals:
    • foo-greet — регистрируется.
    • top-level — не регистрируется (это entry point модуля, не символ).
    • requires — не регистрируется (это метаданные).
  3. Читает requires: ["bar"].
  4. Рекурсивно загружает bar.bin:
    • bar-greet — регистрируется.
  5. Выполняет секцию top-level модуля foo.bin: (foo-greet) → (bar-greet) → print "hello from bar".
  6. Печатает hello from bar.

; => null — top-level ничего не возвращает, это нормально.

Часть 5: Вызов функции

sci> (sc-run foo-greet)
hello from bar
; foo-greet => 1

sc-run — REPL-команда. Она:

  1. Смотрит имя foo-greet в Globals.
  2. Находит ScriptLambda*.
  3. Вызывает через VM.
  4. Печатает результат (1 — потому что print возвращает 1).

Разница между (sc-run foo-greet) и (foo-greet):

Если ты хочешь, чтобы (foo-greet) работал:

sci> (require "foo.sc")   ; ← не load-module!

require компилирует foo.sc и добавляет foo.sc в граф импортов текущего <repl-N>. После этого (foo-greet) компилируется и выполняется.


Часть 6: Поиск файлов проекта

Корень проекта

PROJECT — корень проекта. Определяется в следующем порядке:

  1. --project-root <dir> (или алиас --project <dir>);
  2. переменная окружения SOOT_ROOT;
  3. поле project_root в soot.json (путь относительно каталога самого soot.json);
  4. каталог, где лежит найденный soot.json;
  5. текущая рабочая директория.

Поле 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.

Папки

Файлы

Search paths

При поиске require и load-module проверяются:

  1. Рядом с текущим файлом (для require; для load-module — рядом с .bin).
  2. CWD — текущая директория.
  3. EXE — каталог исполняемого файла.
  4. PROJECT — корень проекта.
  5. PROJECT / search_path — каждый из путей search_paths (по умолчанию ., scripts, src, examples, soot_src, либо из soot.json).
  6. CONFIG — системный каталог конфигурации.
  7. 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: Правила и подводные камни

Следующие примечания поясняют, как работает компилятор.

Следующие примечания демонстрируют потенциальные проблемы.


Заключение

Чтобы ориентироваться в системе, достаточно помнить три основных различия: определения внутри исходных файлов и таблица имён runtime — не одно и то же; require работает с исходным кодом на этапе компиляции, а load-module загружает готовый бинарный модуль во время выполнения; save-session сохраняет историю консоли, а save-module — конкретный исходный файл.

require — компилирует и регистрирует. load-module — загружает и регистрирует. save-session — сохраняет консоль. save-module — сохраняет файл.

Всё остальное — детали.


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