flang язык, в котором спецификация исполняется

Модульность и распространение кода в flang

Этот документ отвечает на один вопрос: как flang будет раздавать и подключать чужой код. Разбирается гипотеза «библиотеки Борхеса» — имя функции есть хеш её собственного текста, установка пакета есть взятие пути в общем хранилище. Ровно так устроен язык Unison, поэтому Unison здесь не пересказан по документации, а установлен, запущен и измерен: все числа ниже получены на этой машине, а не взяты из чужих статей. Как именно — в разделе 10.

Документ ничего не меняет в компиляторе. Это решение с ценой, а не обзор.


1. Вывод, на который можно опереться

Взять из идеи ровно половину: хеш содержимого как идентичность внутри, имена и версии как интерфейс снаружи. Базу данных вместо файлов не делать.

Коротко, почему именно так.

Половина, которую надо взять, — самая ценная часть Unison, и она стоит дёшево. Хеш нормализованного определения даёт то, чего у flang нет и что ему нужнее, чем кому-либо: ключ, по которому кешируется проверка тотальности и доказательство теоремы. Unison кеширует по хешу результаты тестов — приятно, но терпимо и без этого. flang по тому же хешу кеширует доказательства, а доказательство стоит на три порядка дороже теста. Это единственный довод в пользу адресации по содержимому, который для flang сильнее, чем для Unison.

Половина, которую брать нельзя, — та, ради которой Unison всё это затевал. Главный выигрыш Unison — «конфликта версий не существует»: две библиотеки тянут разные версии третьей, обе живут рядом, программа собирается. В flang этот выигрыш недостижим при любой схеме адресации, потому что импорт в flang — это слияние объявлений в одно плоское пространство имён, а совпадение имён — ошибка FLANG_DUPLICATE_NAME ([flang/src/link.mjs:256](../flang/src/link.mjs)). Две версии одного модуля не слинкуются, как их ни адресуй. Чтобы они ужились, нужны квалифицированные имена, а это правка парсера, связывания и восьми печатников (14 287 строк в [flang/src/emit/](../flang/src/emit/)), каждый из которых сегодня печатает программу в одно плоское пространство имён целевого языка. Библиотека Борхеса требует сначала пространств имён, и стоят они дороже самой библиотеки.

Что делать — три шага, из них обязателен один.

шагчтоценачто даёт
1хеш нормализованного определения (digest)~250 строк JS + ~350 на flang + ~400 тестов, ~1 неделякеш проверки и доказательств; устойчивый ключ сертификата; фундамент для 2 и 3
2манифест + замок с хешами + хранилище ~/.flang/store~600 строк JS + ~250 на flang + ~500 тестов, ~2 неделираспространение кода, воспроизводимая сборка, «поставить библиотеку»
3публичный индекс имён и версийне сейчаснужен, когда появится третья сторона, публикующая код

Шаг 1 полезен сам по себе и не меняет ни синтаксиса, ни раскладки файлов, ни одного байта печати. Шаг 2 добавляет одно новое слово в шапку модуля и один файл в корень проекта. Шаг 3 — организационная задача, а не языковая, и до неё далеко.

От чего отказываемся, называя вещи своими именами.

Почему не полный Unison. Полная модель — это не схема именования, а вторая инфраструктура: база данных вместо файлов, менеджер кодовой базы вместо редактора, собственный хостинг (Unison Share появился именно потому, что git не подошёл), собственная история ревью (задача «Story for pull requests», unison#867, открыта с 2019 года). Оценка снизу — 15–25 тысяч строк и постоянное сопровождение второго инструментария. При этом главный выигрыш в flang недостижим (см. выше), а побочный — кеш по хешу — берётся шагом 1 за неделю. Соотношение цены и пользы не сходится ни при каком порядке величин.

Что в гипотезе владельца оказалось верно. Довод «flang чистый, у функции нет скрытого состояния, поэтому содержимое действительно её определяет» — верен, и он строже, чем у Unison, но не там, где ожидалось. Он не про пакеты. Он про то, что у flang есть что кешировать по хешу, кроме результатов тестов: меры, тотальность, постусловия, теоремы. Подробно — в разделе 7, включая место, где довод ломается: поверхностей языка четыре (ru/en/eo/zh), и один и тот же алгоритм, записанный русскими и китайскими словами, обязан давать один хеш, иначе экосистема расколется на четыре несовместимые.


2. Что в flang есть сегодня

Не «модульной системы нет» — она есть, но настолько узкая, что распространять код на ней нельзя. Факты, а не впечатления.

2.1 Язык знает про модули пять слов

В таблице поверхностей лексера ([flang/src/lexer.mjs:158](../flang/src/lexer.mjs)) на модульность работают ровно пять идентификаторов, каждый на четырёх поверхностях:

смыслrueneozh
модульмодульmodule模块
экспортэкспортируетexportseksportas导出
импортиспользуетusesuzas使用
сузитьтолькоonlynur
откудаизfromel

Форма шапки:

модуль «Каталог»
  экспортирует «Найти», «Добавить»
  использует «Списки» из "../stdlib/lists.flang" только «Сумма», «Длина»

Слов import, package, namespace, version в языке нет. Разбирает шапку parseModuleHeader ([flang/src/parser.mjs:840](../flang/src/parser.mjs)).

2.2 Импорт — это слияние, а не пространство имён

Это записано прямым текстом в шапке [flang/src/link.mjs](../flang/src/link.mjs) и определяет всё остальное. Связывание берёт объявления импортированного файла и кладёт их в тот же плоский список, что и объявления своего файла. Квалифицированных имён нет: написать Списки.Сумма невозможно. Совпадение имён из двух модулей — диагностика FLANG_DUPLICATE_NAME (строки 260, 280, 318, 343), а не перекрытие и не выбор.

Отсюда три следствия, которые придётся держать в голове весь остаток документа:

  1. Двух версий одной библиотеки в программе быть не может. Не «неудобно», а не компилируется.
  2. У библиотеки проекта один входной модуль. Это уже записано как правило в [docs/rukovodstvo/project-layout.ru.md](rukovodstvo/project-layout.ru.md), раздел 6, и выведено из того же ограничения.
  3. Печать в цель тоже плоская. Печатник C заводит один именователь на всю программу и различает роли приставками, потому что «одно пространство имён C не различает конструктор варианта и функцию» ([flang/src/emit/c.mjs:441](../flang/src/emit/c.mjs)). Так же устроены остальные семь.

2.3 Разрешение пути — только относительный путь по файловой системе

использует «М» из "..." — это resolve(dirname(текущийФайл), путь) ([link.mjs:429](../flang/src/link.mjs)). Никакого поиска по имени, реестра, версий, кеша. Ошибки: файла нет — FLANG_IMPORT_NOT_FOUND, цикл — FLANG_IMPORT_CYCLE, имя в использует не совпало с шапкой файла — FLANG_IMPORT_NAME.

Командная строка компилирует ровно один файл: options.file = positional[1] ?? "-" ([flang/bin/flang.mjs:992](../flang/bin/flang.mjs)). Всё остальное подтягивает связывание. Манифеста проекта нет: ни flang.json, ни .flang-project, ничего.

2.4 Слой связывания есть в двух экземплярах, и оба надо править

Утверждение «link есть на JavaScript, а на flang его нет» фактически неверно — это стоит знать до планирования работ, потому что удваивает цену любой правки.

гдефайлстрок
эталон на JS[flang/src/link.mjs](../flang/src/link.mjs)570
на самом flang[flang/self/bootstrap/compiler.flang](../flang/self/bootstrap/compiler.flang)685 (70 функций)
сторож[flang/test/link.test.mjs](../flang/test/link.test.mjs)1242
сторож самораскрутки[flang/test/self-bootstrap.test.mjs](../flang/test/self-bootstrap.test.mjs)1253

Версия на flang повторяет JS-версию функция в функцию: «Разрешить путь» (:83), «Загрузить» (:382), «Пройти импорты» (:404), «Сузить видимость» (:128), «Второй проход» (:529). Тест «связывание на flang повторяет src/link.mjs» сверяет JSON связанной программы побайтово и диагностики по коду и тексту. Значит, цена любой правки модульности — это тройка «JS, flang, сторож», и перепечатка порождённого C (bootstrap/kompilyator_flang.c, 121 023 строки).

Важное следствие для будущего хранилища: чтения файлов в языке нет вовсе. Исходники приезжают в компилятор на flang списком объектов «Исходник» {путь, текст} (комментарий compiler.flang:40). Это хорошая новость: хранилище по хешу — работа хозяина (JS или C), а язык остаётся чистым и не узнаёт, откуда взялся текст.

2.5 Стандартная библиотека подключается тем же относительным путём

[flang/stdlib/](../flang/stdlib/) — 12 файлов, 4301 строка, 185 функций. Прелюдии нет: автоматически не подключается ничего. Пишут использует «Списки» из "../stdlib/lists.flang", то есть привязываются к раскладке каталогов на диске. Мёртвый код не отбрасывается, отбор делает рука автора словом только: замер в [flang/stdlib/README.md](../flang/stdlib/README.md) показывает 26 260 байт печати при полном импорте против 2 880 при только «Сумма» — разница в девять раз. При этом только не досчитывает транзитивные зависимости: возьмёшь функцию, зовущую соседку, — получишь FLANG_UNKNOWN_NAME.

2.6 Хешей содержимого в сборке нет, но идея в репозитории уже живёт

Кеша сборки и адресации по содержимому нет: поиск по content.address, кеш сборк, хеш содерж не даёт ничего. Зато есть три готовых куска, на которые новая работа ляжет без спора:

  1. Сертификат доказательстваsha256 канонического JSON, [src/certificate.ts](../src/certificate.ts): поля document_digest, context_digest, certificate_digest. Это ровно «доказательство, привязанное к содержимому», только для документа FTS, а не для функции.
  2. Рукопожатие распределённых узловsha256 напечатанного модуля, [flang/conc/bin/node.mjs:113](../flang/conc/bin/node.mjs). Там прямо сказано: хеш печати, а не исходника, «два разных исходника, дающих один и тот же модуль, — одна и та же программа». Это уже адресация по содержимому, и выбрана нормализация — печать. Ровно тот выбор, который придётся сделать заново для определений.
  3. **Отвергнутая идея манифеста с sha256 входных .flang** — [bootstrap/README.md:96](../bootstrap/README.md). Отвергнута в пользу сверки байтов, но помечена как возможная.

2.7 Теоремы не переезжают через импорт

Отдельно, потому что это прямо относится к разделу 7. Связывание сливает типы, функции и категорные списки, а theorems, processes, plans берутся только у входного файла ([link.mjs:566](../flang/src/link.mjs)). Импортировал модуль — доказанные о нём теоремы не приехали. Сегодня это почти незаметно, потому что многофайловых проектов на flang мало (из 163 файлов .flang слово использует есть в 17). Как только код начнут раздавать, это станет главной дырой: раздаётся код, не раздаются доказательства о нём.


3. Как Unison устроен на самом деле

Ниже — не пересказ обещаний. Стоял ucm release/1.3.0 (сборка 2026-05-13), скачанный с GitHub; всё, что помечено «замер» или «опыт», выполнено на этой машине 2026-08-15.

3.1 Что именно хешируется

Определение опознаётся по 512-битному SHA3 от его синтаксического дерева. Документация говорит прямо: хеш есть «дайджест внутренней структуры терма или типа, исключая все имена» (Hashes). Перед хешированием дерево нормализуют: имена аргументов заменяются позиционными ссылками, каждая зависимость — своим хешем. Пример из документации: increment n = n + 1 хешируется как (#arg1 -> #a8s6df921a8 #arg1 1).

Опыт: объявленный тип входит в хеш наравне с телом. Добавил три определения с одним и тем же телом x -> x:

f : Nat -> Nat        h : Nat -> Nat        g : Text -> Text
f x = x               h x = x               g x = x

Результат names:

#0h3hsuemei   Term   f, h
#61d8apf76h   Term   g

f и h — это один объект с двумя именами: одинаковые определения не дублируются, они склеиваются. А g с тем же телом, но другой сигнатурой — уже другой хеш. Значит, объявленный тип входит в хеш наравне с телом.

Опыт: документация и тесты в хеш не входят. Дописал к f документацию, а затем тест — хеш f остался #0h3hsuemei, а тест получил собственный адрес #08qoqaps3v. В Unison f.doc и f.tests.ex1 — не поля функции, а отдельные определения, которые на неё ссылаются. Это важнейшая деталь всей конструкции, и в разделе 7 она решает вопрос про теоремы flang.

Взаимная рекурсия. Цикл хешируется целиком: адрес имеет вид #x.n, где x — хеш цикла, а n — номер члена внутри. Порядок членов канонический: они сортируются по своим индивидуальным хешам, посчитанным с вырезанным циклом. То есть перестановка функций в файле ничего не меняет, а вот состав цикла — меняет всё: добавил в клубок взаимно рекурсивных функций ещё одну, и все получили новые адреса.

Типы: две дисциплины. structural type хешируется чисто по структуре, и два типа с одинаковой формой — один тип: Suit из четырёх конструкторов и Direction из четырёх конструкторов взаимозаменяемы, что почти всегда не то, чего хотел автор. Поэтому по умолчанию тип unique: при создании ему выдаётся GUID, который и участвует в хешировании (Unique types). Иначе говоря, чистая адресация по содержимому оказалась негодной для типов, и в неё пришлось подмешать случайное число. Это стоит запомнить: даже в языке, построенном вокруг хеша содержимого, не всё определяется содержимым.

3.2 Имена и кодовая база

Имена — метаданные, лежащие рядом и на хеш не влияющие. Имя — указатель на адрес; поменять, куда указывает имя, можно, содержимое адреса — никогда.

Кодовая база — не каталог с текстами, а база SQLite: <проект>/.unison/v2/unison.sqlite3 (замер: этот файл и есть вся кодовая база, рядом только пустой lock-файл). Внутри — определения, полный граф зависимостей, индексы по типам и кеш компиляции, который «никогда не инвалидируется». Исходники .u — это черновики: пишешь в scratch.u, менеджер кодовой базы следит за файлом, проверяет типы при сохранении, а команда add или update переносит определения в базу. После этого текст черновика не нужен.

Зависимости лежат в пространстве имён lib внутри проекта. Ставятся командой lib.install @unison/base; конкретная версия — lib.install @unison/base/releases/7.0.0, и она попадает в lib.unison_base_7_0_0 (замер, вывод ls lib ниже в разделе 5). Публикуются проекты на Unison Share — собственный хостинг, появившийся потому, что git для нетекстового формата подошёл плохо.

3.3 Обновление зависимости и переименование

Обновление — это lib.install новой версии и затем update. Дальше работает распространение: у изменённого определения новый хеш, поэтому все зависящие от него определения тоже перехешируются, автоматически и рекурсивно. Незакрытые места собирает команда todo; пока они не закрыты, кодовая база не считается сломанной — старые адреса продолжают существовать и работать.

Переименованиеmove старое новое. Ни один хеш не меняется, потому что имена не в хеше. Проверка после переименования не перезапускается: тесты остаются в кеше (Cached test results), потому что кеш ключуется адресом, а не именем. Это действительно приятно и в текстовых языках так не бывает.

Правка тела — противоположность: меняется хеш, и по цепочке зависимостей перехешируется всё, что от функции зависит. Замер этой цены — в разделе 5.

3.4 Проверка главного обещания: «конфликтов версий не существует»

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

Завёл тип и две функции над ним, сохранил копию в lib.staraya (это «старая версия библиотеки»), затем изменил тип, как будто вышла новая версия:

было:  unique type Money = Money Nat
стало: unique type Money = Money Nat Text

Обе версии спокойно живут рядом: старая в lib.staraya, новая в корне, ничего не сломалось, перекомпилировать старую не понадобилось. Затем попробовал передать значение новой версии в функцию старой:

proba = lib.staraya.vzyat (sdelat 5)

Ответ компилятора (дословно):

The 1st argument to `staraya.vzyat`
          has type:  Money
    but I expected:  staraya.Money

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

3.5 Шероховатости, которые видно сразу

Одна попалась в первом же опыте. После fork . lib.staraya имя конструктора Money стало неоднозначным, и компилятор отказался разбирать файл:

I couldn't resolve any of these names:
    vzyat = cases Money n _ -> n
Name    Type          Suggestions
Money   constructor   Money.Money
                      lib.staraya.Money.Money

Пришлось писать Money.Money. Ничего страшного, но иллюстрирует общее: когда имена — просто метки на адресах, разрешение имени перестаёт быть однозначным и становится диалогом с инструментом. К этому привыкают, но это не бесплатно.


4. Один сценарий, три мира

Приложение «Заказы» подключает две библиотеки: «Оплата» и «Отчёты». Обе подключают третью — «Деньги». Дальше два больных случая.

4.1 Случай (а): разные версии третьей библиотеки

«Оплата» требует «Деньги» 1.x, «Отчёты» успели перейти на «Деньги» 2.x, где у типа Деньги появилось поле валюты.

npm. Ставятся обе: одна в node_modules/деньги, вторая вложенно в node_modules/отчёты/node_modules/деньги. Сборка проходит. Дальше — как повезёт. Пока значения не пересекают границу, всё работает. Как только «Оплата» передаёт объект в «Отчёты», начинается класс ошибок, который в JS ловится только на исполнении: instanceof возвращает false для объекта «из другой копии», глобальные реестры расходятся, а знаменитое «two copies of React» — это ровно он. Диагноза нет: типов нет, ошибка вылезает в другом месте и позже.

cargo. Ставятся обе, версии несовместимые по semver уживаются штатно. Но Rust типизирован, поэтому ошибка приезжает при сборке — правда, в самом недружелюбном из возможных видов:

expected struct `money::Money`, found struct `money::Money`
note: perhaps two different versions of crate `money` are being used?

Одинаковый текст, разные типы. Спасает только приписка в конце и cargo tree -d, который покажет дубли, — но первый раз это стоит человеку полдня.

Unison. Обе версии живут в lib рядом, каждая под своим именем (lib.base и lib.unison_base_7_0_0 — замер в разделе 5). Ничего не пересобирается, потому что пересобирать нечего: у старых определений прежние адреса и прежний кеш. На границе — ошибка типов, которую я получил в опыте 3.4:

The 1st argument to `staraya.vzyat`
          has type:  Money
    but I expected:  staraya.Money

Это лучше, чем у cargo, ровно на одно: имена в сообщении разные, и по ним сразу видно, кто из какой версии. И это то же самое, что у cargo, по сути: сосуществование двух версий, обмен значениями невозможен. Стоит проговорить прямо, потому что обещание «конфликта версий не существует» звучит громче, чем то, что реально происходит: конфликт не исчез, у него стало понятное сообщение и не стало этапа разрешения версий.

flang сегодня. Не собирается вовсе:

FLANG_DUPLICATE_NAME: тип «Деньги» объявлен в двух модулях: ...

Импорт сливает объявления, двух Деньги в одной программе быть не может. Единственный выход — поднять всех до одной версии.

flang после предлагаемых шагов. Ровно то же ограничение, но выясняется оно не на связывании, а на разрешении зависимостей, и сообщением по делу: «„Оплата“ просит „Деньги“ 1.4, „Отчёты“ просят 2.1, выбрана 2.1; „Оплата“ с ней не сходится — поднимите или закрепите». Это модель Elm и Go, а не Unison. Взамен исчезает целый класс проблем: никогда не бывает двух копий одного определения, а значит, не бывает и ошибок «одинаковый тип, но не тот».

4.2 Случай (б): в библиотеке нашли ошибку, её починили

В «Деньгах» неверно округлялась копейка. Автор починил одну функцию из сорока.

npm. Выходит 1.4.3. Вы делаете npm update, замок обновляется. Что именно изменилось — вопрос к changelog автора: технически вы получили новый архив целиком, и различить «поправлена одна функция» и «переписано полбиблиотеки» можно только диффом руками. Дополнительно: транзитивные зависимости с диапазоном ^1.4.0 получат новую версию сами, у себя, без вашего решения. Ваши тесты перезапустятся полностью: кеш прогона, если он вообще есть, ключуется файлами, а не отдельными определениями, и после подмены одного файла библиотеки считается недействительным весь.

Unison. У починенной функции новый хеш; у остальных тридцати девяти — прежние. Вы ставите новый релиз и говорите update. Дальше распространение: перехешируется починенная функция и всё, что от неё зависит, — по графу, без вашего участия. Незакрытое собирает todo. Ключевое: результаты тестов для всех незатронутых определений остаются в кеше и не перезапускаются, потому что кеш ключуется адресом. Вы физически видите объём изменения: столько-то определений сменило адрес, остальные — те же.

flang сегодня. Копируете новый файл поверх старого. Компилятор ничего не знает про «что изменилось» — он перепроверяет типы, тотальность, меры и постусловия всей программы заново, потому что кеша нет вовсе. На маленьком проекте это незаметно; на самораскрутке (flang/self/, 56 474 строк на flang) это уже цена, которую платят на каждом прогоне.

flang после шага 1. Компилятор считает хеш каждого определения и знает точно: сменили адрес одна функция и её зависимые. Проверка тотальности и доказательства для остальных берутся из кеша и не могут протухнуть неверно — адрес сменился бы, если бы содержимое изменилось. Это и есть та половина Unison, которую надо взять: не «пакеты по хешу», а «работа по хешу не переделывается».


5. Сколько это занимает места

Вопрос был задан прямо, поэтому отвечаю замерами, а не ощущениями. Всё ниже получено на этой машине 2026-08-15: ucm release/1.3.0, @unison/base версии 7.19.2, node v26.7.0, npm 11.19.0. Размеры — в байтах, командой du -sb. Воспроизведение — в разделе 10.

5.1 Кодовая база Unison

чтобайтчеловеческим языком
только встроенные, без библиотек413 696404 КиБ
пустой проект491 520480 КиБ
проект со стандартной библиотекой @unison/base 7.19.217 518 59216,7 МиБ
он же плюс base 7.0.0 второй версией19 206 14418,3 МиБ
он же плюс версии 7.0.0, 7.5.0, 7.10.0, 7.16.0 (пять версий сразу)19 476 48018,6 МиБ

base 7.19.2 — это 9095 термов и 213 типов, всего 9308 определений; в них входят документация и тесты, потому что в Unison они тоже определения. Выходит 1882 байта на определение, включая индексы имён, граф зависимостей и кеш компиляции.

Сам инструмент — отдельно: ucm-linux-x64.tar.gz весит 38 191 876 байт (36 МиБ), и это разово на машину, а не на проект.

5.2 Во сколько раз это больше или меньше node_modules

Три проекта, поставленные тут же настоящим npm install:

проектпакетовфайловбайтна диске
бэкенд: express, pg, zod, dotenv, jsonwebtoken, pino10819007 970 77213 МБ
инструментарий TypeScript: typescript, vitest, eslint, @types/node, prettier153256173 166 70177 МБ
фронт: react, react-dom, vite, typescript38234759 941 56464 МБ
глобальный кеш ~/.npm (был на машине до опытов)254 МБ

Сопоставление честное настолько, насколько оно вообще возможно, — сравниваются разные вещи:

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

5.3 Что растёт со временем

Старые версии действительно не удаляются. Два замера отвечают на «сколько за год» с двух сторон.

Сторона первая: чужие библиотеки. Четыре дополнительные версии base, покрывающие развитие библиотеки с ноября 2025 по июнь 2026, добавили к базе 1 957 888 байт — 1,87 МиБ на четыре версии, примерно 478 КиБ на версию, то есть 2,8 % от размера первой копии. Это и есть цена адресации по содержимому: вторая версия библиотеки хранится не целиком, а только тем, чем отличается от первой.

Для сравнения масштаба: за 12 месяцев (с 2025-08-15) у @unison/base вышло 53 релиза — число из API Unison Share, не оценка. Если бы кто-то держал в кодовой базе каждый из них, линейная экстраполяция замера даёт около 25 МБ в год. Это оценка, а не измерение, и, скорее всего, завышенная: мои четыре образца отстоят друг от друга на пять релизов, а соседние релизы отличаются меньше. Реальная практика — держать одну версию и иногда вторую, то есть единицы мегабайт.

Сторона вторая: свой код. Здесь растёт быстрее, и вот почему. Правка одной функции меняет её хеш, а значит, и хеши всех зависящих от неё определений, — и старые никуда не деваются. Замер: цепочка из 50 определений (каждое зависит от предыдущего), затем 20 правок самой первой функции.

чтобайтприрост
база (только base)17 518 592
плюс 50 определений цепочки17 764 352+245 760 (4,9 КБ на определение)
плюс 20 правок корня цепочки18 710 528+946 176

Двадцать правок породили 1000 новых версий определений и стоили 946 176 байт: 47 КБ на правку, 946 байт на новую версию определения. Определения в опыте крошечные (f n = g n + 3), так что почти весь этот вес — служебный: записи истории, индексы имён, гранулярность страниц SQLite.

Прикидка на команду — снова оценка, а не замер: если разработчик делает 30 содержательных правок в день, и каждая задевает в среднем десяток зависимых определений, выходит 300 новых версий в день, около 284 КБ в день, порядка 70 МБ на человека в год. Пятеро — треть гигабайта в год.

Это много или мало? Ровно столько же уже платит любой проект на git и никто не жалуется: .git этого репозитория весит 49 МБ и хранит всю историю всех версий всех файлов. Git — тоже хранилище с адресацией по содержимому, и вопрос «старое не удаляется, сколько за год» решён в нём тем же способом: никак, и это оказалось приемлемо. Для масштаба, весь исходный код на flang в этом репозитории — 163 файла, 3 556 450 байт, 3,4 МиБ.


6. Удобно ли этим пользоваться

Честно с обеих сторон, потому что решение принимается не по списку достоинств.

6.1 Что действительно проще

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

Разрешения версий нет как этапа. Не нужен решатель ограничений (задача, к слову, NP-полная — именно её решают npm, cargo, pip), потому что выбирать не из чего: адрес либо есть в хранилище, либо его дотягивают.

Откат не может не собраться. Старое определение никуда не делось, откат — это перевести имя обратно на прежний адрес. Ни пересборки, ни «а вдруг старая версия уже не встаёт с новым компилятором».

Кеш проверок ключуется содержимым, а не временем файла. После переименования тесты не перезапускаются (Cached test results) — просто потому, что адрес не изменился. Ни один инструмент на текстовых файлах так не умеет: make смотрит на дату, npm test перезапускает всё.

Одинаковый код существует в одном экземпляре. Замер из 3.1: две функции с одинаковым определением получили один адрес и два имени. Дублирование кода перестаёт быть вопросом дисциплины и становится физически невозможным.

«Работает у меня» ломается по-другому. Адрес фиксирует не версию, а именно тот код, включая всю транзитивную глубину. Подменить содержимое под тем же адресом нельзя.

6.2 Что хуже, и это надолго

Диффа нет. Изменение — это список определений, сменивших адрес, а не построчный дифф. Привычное «посмотреть, что поменялось в этой функции» превращается в «сравнить два дерева» — инструмент это умеет, но глазами так не читают. Отсюда же и главная незакрытая дыра: задача про рабочий процесс pull request'ов (unison#867) открыта с 2019 года.

Git перестаёт быть местом, где живёт код. Не «неудобно с git», а код лежит в другом месте: git может хранить файл базы, но git diff, git blame, git bisect, git log -p по нему бессмысленны. Unison Share появился именно поэтому. Для этого репозитория это приговор отдельно: вся дисциплина качества здесь построена на сверке байтов в git — самораскрутка сверяет побайтово порождённый C, журнал печатается из тем коммитов, тесты сравнивают JSON побайтово. Перевод кода в базу данных обнуляет не «удобство», а метод проверки.

Ломается всё, что читает текстовые файлы. grep, sed, патч из письма, покрытие, сторонние анализаторы, любой инструмент, который умеет .flang и не умеет вашу базу. Инструментов вокруг текста накоплено больше, чем кажется, пока их не отняли.

Имена перестают разрешаться однозначно. Живой пример из моего первого же опыта — после копирования пространства имён конструктор Money стал неоднозначным, и компилятор потребовал уточнения. Это не редкость и не ошибка инструмента, это следствие того, что имя — метка, а меток может быть много.

Порог входа. Прежде чем написать первую функцию, надо усвоить модель: черновик, кодовая база, add, update, пространства имён, распространение. Для языка, который сам по себе непривычен (чистота, тотальность, доказательства), это второй непривычный слой поверх первого.

6.3 Что просто непривычно, но не хуже


7. Что адресация по содержимому даёт именно flang — и чего не даёт

Довод владельца звучал так: flang чистый, функции тотальные, скрытого состояния нет — значит, содержимое действительно определяет функцию. Довод надо проверять по частям, потому что верна в нём одна половина, а полезна другая.

7.1 Проверка довода

Чистота — верно, и это сильнее, чем у Unison. Ввода-вывода в языке нет вовсе: среди встроенных форм нет ни одной, которая читает файл, ходит в сеть или спрашивает время. У Unison иначе — там есть эффекты (abilities), и функция может их совершать; чтобы хеш оставался честным, эффекты пришлось загнать в тип, а тип — в хеш. flang этой сложности не несёт. Но выигрыша сверх Unison тут нет: Unison уже решил задачу типом, просто дороже. Чистота даёт право считать хеш идентичностью, и на этом её роль заканчивается.

Тотальность — верно наполовину. В языке два класса, тотальная функция и обычная ([flang/SPEC.md](../flang/SPEC.md), таблица в начале); только тотальный класс годится для встраиваемого факт-чекинга. Так что «функции тотальные» — это про часть корпуса, а не про язык. Для адресации это, впрочем, неважно: хешируется определение, а не его поведение.

Настоящая прибыль в другом месте, и она одна. У flang есть то, чего нет ни у Unison, ни у npm: дорогая проверка. Тотальность, убывание меры, постусловия, теоремы — всё это компилятор считает заново на каждом прогоне, потому что кеша нет вовсе (см. 2.6). Unison кеширует по хешу результаты тестов; это приятно. flang по тому же хешу кеширует доказательства, а доказательство дороже теста на порядки. Вот единственный довод в пользу адресации по содержимому, который для flang весит больше, чем для Unison. Он не про пакеты вообще.

7.2 Входят ли в хеш постусловия, меры, теоремы

Вопрос поставлен точно, и ответ у него не одинаковый для всех трёх.

Постусловие и мера — входят, и обязаны. обеспечивает лежит полем функции (postconditions в узле функции), убывает стоит между возвращает и телом и разбирается в области видимости параметров ([flang/SPEC.md](../flang/SPEC.md), «Объявленная мера», «Постусловия функции»). Это не примечания — это то, что компилятор обязан доказать и на что вызывающая сторона имеет право опереться. Функция с постусловием «результат не меньше нуля» и такая же функция без него — разные обязательства, и путать их адресом нельзя. Если бы постусловие в хеш не входило, кеш доказательств стал бы неверным: сохранили бы доказательство для одного контракта, а применили к другому.

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

Теорема — не входит, и не должна. Тут решение подсказал сам Unison, и я его измерил (опыт в 3.1): документация f.doc и тест f.tests.ex1не поля функции, а отдельные определения, которые на функцию ссылаются; хеш f от их появления не меняется. Теорема flang устроена ровно так же: theorems — это список верхнего уровня, а не поле функции ([flang/SPEC.md](../flang/SPEC.md), «Теорема»). Значит, у теоремы должен быть свой адрес, а внутри — ссылка на функцию по хешу.

Отсюда прямой ответ на вопрос «доказательство привязано к телу навсегда — это хорошо или плохо». Оно привязано не к телу, а к паре: адрес функции плюс утверждение. И это именно то, чего хочется:

И это закрывает сегодняшнюю дыру. Связывание берёт theorems только у входного файла ([link.mjs:566](../flang/src/link.mjs)): импортировал модуль — теоремы о нём не приехали. Пока проектов на flang мало, это незаметно; как только код начнут раздавать, окажется, что раздаётся код без доказательств — то есть ровно без того, ради чего язык существует. Теорема с собственным адресом и ссылкой на хеш функции — единственный известный мне способ починить это так, чтобы теоремы ездили вместе с библиотекой и проверялись на месте.

Третий ключ, о котором легко забыть. Доказательство верно относительно проверяющего. Кеш, ключом которого служит только хеш функции, однажды сохранит доказательство, сделанное сломанной версией компилятора, и будет отдавать его годами. Ключ обязан быть тройкой: хеш функции, хеш утверждения, хеш проверяющего. В репозитории эта мысль уже есть: у сертификата доказательства рядом с document_digest лежит context_digest ([src/certificate.ts](../src/certificate.ts)).

7.3 Что тогда значит «та же функция»

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

Чего это не значит: одинакового поведения. Две разные реализации сортировки дают два разных адреса, и никакая адресация по содержимому этого не изменит — внешнее равенство функций неразрешимо. Адрес даёт тождество, а не эквивалентность, и путать их нельзя.

Но именно здесь у flang есть ход, которого нет ни у кого: эквивалентность двух разных адресов можно доказать и записать теоремой — отдельным объектом, связывающим два хеша. У Unison такого объекта быть не может, потому что нечем доказывать. Это не работа на завтра, но направление, в котором «библиотека Борхеса» для flang становится чем-то большим, чем склад.

7.4 Где довод ломается: четыре поверхности

Самое опасное место — не теоремы, а то, о чём легко не подумать. У flang четыре поверхности: модуль / module / — / 模块. Один и тот же алгоритм, набранный русскими и китайскими словами, — одна функция, и хеш обязан быть один. Иначе экосистема расколется на четыре несовместимые, и это не гипотеза, а арифметика: одна и та же библиотека будет лежать в хранилище четырежды под разными адресами.

Хорошая новость: в репозитории эта задача уже решена и проверена тестом. В [flang/test/surface-pair.mjs](../flang/test/surface-pair.mjs) (208 строк) и [flang/test/surfaces.test.mjs](../flang/test/surfaces.test.mjs) (253 строки) две поверхности одной программы сверяются деревьями «с точностью до переименования, позиции сняты» — там же лежит готовая таблица, какой слот дерева считается именем, а какой сверяется побуквенно (литерал.value — данные, builtin.name — канонический идентификатор, kind и op — сам разбор). Это и есть половина работы по нормализации, уже написанная и защищённая тестом.

Плохая новость — два места, где придётся принять неприятные решения.

Имена вызываемых функций. Сегодня вызов записан именем, и имя же служит ключом при слиянии модулей. Чтобы хеш не зависел от имени, нормализатор обязан построить граф зависимостей и заменить вызов по имени вызовом по адресу, а для взаимно рекурсивных клубков — сделать то же, что Unison: хешировать клубок целиком, а членов нумеровать в порядке, полученном сортировкой по их собственным хешам с вырезанным клубком. Это самая содержательная часть работы.

Типы придётся испортить нарочно. Урок Unison, который стоит принять сразу: чисто структурный хеш типов негоден. Запись { значение: число } под именем «Метры» и такая же под именем «Секунды» — один адрес и, стало быть, один тип. Unison прошёл этот путь до конца и вернулся: по умолчанию тип unique, ему при создании выдаётся случайный GUID, и GUID участвует в хешировании. То есть даже в языке, построенном вокруг содержимого, не всё определяется содержимым, и это не недоделка Unison, а свойство задачи. flang придётся сделать так же — либо мириться с тем, что «Метры» и «Секунды» неразличимы.


8. Альтернативы, и какая из них даёт 80 % пользы дешевле

8.1 Nix: адресация по содержимому на уровне сборки

В Nix каждый результат сборки лежит в /nix/store/<хеш>-<имя>, каталог неизменяем, а версии сосуществуют без всяких усилий — у них просто разные пути. Внешне это ровно «библиотека Борхеса», но хеш там не от содержимого, а от рецепта: от входов, флагов и всей замкнутой цепочки зависимостей. То есть отвечает он на вопрос «это та же сборка?», а не «это та же функция?».

Самое поучительное в Nix — то, что случилось с настоящей адресацией по содержимому. Её добавили (ca-derivations) в версии 2.4 в ноябре 2021 года ради дедупликации и «раннего отсечения» пересборок — и по сей день, пять лет спустя, она остаётся экспериментальной, за флагом (wiki.nixos.org). А неизменяемое хранилище с точной замкнутой цепочкой зависимостей работает в проде двадцать лет и приносит почти всю пользу.

Урок для flang, и он главный во всём разделе. Для раздачи кода полезная часть — не хеш содержимого, а неизменяемое хранилище плюс точная фиксация того, что взято. Хеш содержимого добавляет к этому дедупликацию и раннее отсечение пересборок, и в Nix эта добавка за пять лет так и не окупилась настолько, чтобы выйти из-под флага.

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

8.2 Go: имена и версии снаружи, хеши внутри

Go выбирает версии минимальным выбором версий (MVS): в графе берётся максимум из минимально требуемых. Решателя нет, перебора нет, задача сводится к достижимости в графе и решается линейно — в отличие от общей задачи разрешения версий, которая NP-полна и которую решают npm, cargo и pip. Расплата признана автором честно: «немного меньше автоматической свежести» в обмен на «огромное упрощение». Сборка воспроизводима без замка, потому что результат выбора зависит только от графа.

Целостность при этом обеспечена ровно хешами: go.sum хранит хеш содержимого каждого модуля, а рядом стоит публичный журнал контрольных сумм. Подменить содержимое под тем же именем и версией нельзя.

Это и есть форма, которую я предлагаю для flang. Снаружи — имена и версии, понятные человеку и годные для разговора. Внутри — хеши, отвечающие за тождество и целостность. Ромб решается подъёмом до одной версии, а не сосуществованием, и это честно объявлено ограничением, а не выдаётся за отсутствие проблемы.

8.3 Elm: семантику версий проверяет компилятор

В Elm номер версии не назначают, его вычисляют: elm diff сравнивает публичный интерфейс двух версий, elm bump по этому сравнению сам решает, что поднимать — мажор, минор или патч. Соврать нельзя: реестр не примет пакет с номером, который не соответствует изменению интерфейса. Двух версий одного пакета в сборке не бывает — ромб обязан быть разрешён подъёмом.

Здесь у flang редкая возможность обойти образец. Elm сравнивает сигнатуры типов — это всё, что он умеет назвать интерфейсом. У flang интерфейс богаче: типы, постусловия, объявленная мера, тотальность. Как только есть хеш нормализованного определения (шаг 1), «дифф интерфейса» считается механически, и можно запретить выпуск с номером патча, если изменилось постусловие. Это дёшево и это ровно та проверка, которой ни у кого нет.

8.4 Сводка

подходчто даётчего стоитберём?
Unison целикомромб не мешает; кеш по хешу; бесплатное переименованиебаза данных вместо файлов, свой хостинг, ревью без диффа; 15–25 тыс. строкнет
Nixнеизменяемое хранилище, точная цепочка зависимостей, версии рядомсвоя модель сборки; адресация по содержимому пять лет как экспериментидею хранилища — да
Go (MVS)простой и предсказуемый выбор версий, целостность по хешамромб решается подъёмом, свежесть не автоматическаяда, это основа
Elmномер версии не врёт, его считает компилятородна версия пакета на сборкуда, вторым заходом
хеш определения (из Unison)кеш дорогих проверок и доказательствнормализация дерева, GUID у типовда, это шаг 1

Дешевле всего и с наибольшей отдачей: хранилище Nix + выбор версий Go + хеш определения из Unison, без кодовой базы Unison.


9. Что делать: план, цена в файлах и строках

Сначала одно различение, без которого план читается неправильно.

Хранилище адресует модули, кеш адресует определения. Это разные задачи и разная гранулярность, и путать их дорого:

Unison делает второе и получает первое даром. Нам выгоднее сделать их по отдельности — и шаги независимы: если раздача кода понадобится раньше, шаг 2 можно делать первым, хеш определения ему не нужен. Порядок ниже отражает ценность, а не зависимость.

Шаг 1. Хеш нормализованного определения

Что появляется:

файлстрокчто делает
flang/src/digest.mjs (новый)~250нормализация узла (снять span, стереть имена, заменить ссылки хешами, свернуть взаимно рекурсивные клубки) и sha256 канонического JSON
flang/test/digest.test.mjs (новый)~400устойчивость и чувствительность (см. ниже)
flang/bin/flang.mjs (правка)~40команда flang digest <файл> — печать адресов определений
flang/src/failures.mjs или новый кеш (правка)~80ключ кеша проверок: тройка «адрес определения, адрес утверждения, версия проверяющего»

Итого около 770 строк, из них 400 — тесты. Оценка недели работы.

Что обязан проверять тест, иначе шаг бесполезен:

Чего в шаге 1 нет: изменений синтаксиса, изменений печати, изменений в link.mjs, двойника на flang. Двойник понадобится только когда компилятор на flang начнёт сам решать, что перепроверять; сегодня хеш считает хозяин, а язык о нём не знает — и это согласуется с тем, что в языке нет ввода-вывода.

Шаг 2. Манифест, замок, хранилище

Синтаксис языка не меняется вовсе — и это осознанный выбор, потому что новое ключевое слово стоит таблицы на четырёх поверхностях. Меняется только правило чтения строки в из "...":

использует «Списки» из "../stdlib/lists.flang"   как сегодня: путь по файлам
использует «Списки» из "списки"                  новое: имя библиотеки из замка

Разделяются они однозначно: есть / или расширение .flang/.fl — путь; нет — имя библиотеки. Всё, что собирается сегодня, собирается и завтра.

файлстрокчто делает
flang/src/manifest.mjs (новый)~200чтение flang.json (имя, версия, зависимости) и flang.lock (имя → версия → хеш)
flang/src/store.mjs (новый)~180~/.flang/store/sha256/<хеш>: положить, взять, проверить
flang/src/versions.mjs (новый)~120минимальный выбор версий по графу зависимостей (алгоритм Go)
flang/src/link.mjs (правка из 570)~150разрешение имени библиотеки через замок рядом с нынешним разрешением пути
flang/bin/flang.mjs (правка)~120корень проекта, команды flang deps, flang lock
flang/self/bootstrap/compiler.flang (правка из 650)~100то же разрешение имени по списку источников (файлов язык по-прежнему не читает)
flang/test/manifest.test.mjs, store.test.mjs, дополнения к link.test.mjs~500ромб, отсутствующий хеш, испорченный хеш, обратная совместимость путей

Итого около 1370 строк, из них 500 — тесты, плюс перепечатка bootstrap/kompilyator_flang.c (порождённый файл, но сторож сверяет байты — перепечатать обязательно). Оценка двух недель.

Шаг 3. Публикация и индекс — не сейчас

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

Что сделать заодно, потому что дальше будет дороже

Теоремы обязаны переезжать через импорт. Сегодня связывание берёт theorems только у входного файла ([link.mjs:566](../flang/src/link.mjs)). Как только появятся библиотеки, это превратится в «код раздаётся, доказательства — нет». Правка невелика (в связывании — десятки строк), но требует решить, как теорема ссылается на функцию: по имени, как сейчас, или по адресу. Правильный ответ — по адресу, и потому этот вопрос закрывается вместе с шагом 1, а не после него.

Цена отказа: во что обошёлся бы полный Unison

Не оценка «на глаз», а перечисление того, что придётся написать заново: менеджер кодовой базы вместо файловой раскладки; хранилище определений с индексами вместо link.mjs (570); переписанный REPL (1104) и языковой сервер (900), которые сегодня работают с буферами файлов; собственный протокол обмена вместо git; собственный показ изменений вместо git diff; и — главное — замена всей сегодняшней дисциплины сверок, где качество держится на побайтовом сравнении порождённых артефактов. Пятнадцать-двадцать пять тысяч строк и второй инструментарий на постоянном содержании. За это покупается ромб зависимостей, который в flang всё равно не работает без квалифицированных имён, и бесплатное переименование.

Как понять, что это решение было неверным

Одно наблюдение опровергнет весь план: если появятся две независимые библиотеки от разных авторов, обе нужные в одной программе и обе тянущие несовместимые версии третьей, — подъём до одной версии перестанет быть выходом. Тогда придётся делать квалифицированные имена (парсер, связывание, восемь печатников), и разговор про кодовую базу Unison вернётся уже на других основаниях. Пока весь корпус — 163 файла, из которых использует встречается в 17, этого не случится ещё долго.


10. Откуда числа

Всё измеренное получено 2026-08-15 на одной машине: Linux x86-64, ucm release/1.3.0 (сборка 2026-05-13, ucm-linux-x64.tar.gz, 38 191 876 байт), node v26.7.0, npm 11.19.0.

Как мерились кодовые базы. UCM умеет прогонять сценарии из markdown-файла (ucm transcript файл.md), а ключ -S каталог сохраняет получившуюся кодовую базу вместо удаления. Размер — du -sb по каталогу .unison; внутри лежит один файл v2/unison.sqlite3 и пустой lock-файл, так что мерился именно он. Последовательность: пустая база → project.create test (ставит @unison/base) → lib.install @unison/base/releases/7.0.0 и далее. Сценарий с правками (50 определений цепочкой, 20 правок корня) порождался скриптом, чтобы правки были одинаковыми.

**Как мерились node_modules.** Три каталога с рукописным package.json, обычный npm install, размер — тем же du -sb, число файлов — find -type f.

Что важно знать о самих числах.

Мелочь, которая стоила времени и может стоить его снова. В пустом проекте (project.create-empty) встроенные имена вроде Nat не разрешаются, поэтому первый вариант опыта с правками не собрался; всё меряется поверх проекта с установленной base. И ucm в интерактивном режиме падает, если в системе исчерпаны экземпляры inotify (initINotify: resource exhausted), — режим сценариев этим не страдает.

Источники, на которые опирается пересказ устройства Unison (всё остальное — собственные опыты):