Выпуск 0.1 моего языка программирования сейчас заблокирован пунктом Н-5 из требований: каждую из 115 внешних функций стандартной библиотеки, то есть каждое место, где код на Unio уходит в среду выполнения на Rust, должны прочитать два человека. Санитайзеры гоняются в CI, фаззер запускается вручную, но ни одна автоматическая проверка за одного из этих двоих не засчитывается. В таблице проверки сейчас 230 клеток, и во всех написано «ожидает».
Начиналось всё как эксперимент «а что если», с идеи на Eurecable: навайбкодить программу или игру уже не проблема, но автоматизированное программирование, похоже, упирается в языки, которые сложились исторически. Почему бы не написать язык с нуля, заточенный под ИИ и при этом понятный людям? Дальше было интересно, что получится, если дать агентам спроектировать и написать его целиком, от концепции до компилятора. Концепцию, спецификацию, компилятор на Rust, стандартную библиотеку и инструменты пишет Claude Opus, а проверяет написанное и выдаёт рекомендации ChatGPT Astra. Две модели проверяют друг друга, но небезопасный код всё равно должен прочитать человек.
Что такое Unio
Статически типизированный язык с синтаксисом в духе Python и нативной компиляцией. Задумывался под одну ситуацию: код пишет модель, а читает и принимает его человек, причём не обязательно тот, кто ставил задачу. Принцип записан в концепции тремя словами: понятный код, явное поведение, контролируемые ресурсы. Из него выведены четыре правила, по которым проверяется каждое решение по языку:
-
Одна задача, один способ записи. Если способов два, один объявляется стандартным, и
unio fmtприводит к нему. -
Ничего не происходит неявно: нет неявных преобразований типов, нет состояния, тайно общего у разных имён, нет скрытых исключений.
-
Сигнатура функции описывает весь контракт: вход, результат и ошибки.
-
Всё, что может пойти не так, либо выражено типом, либо считается аварией с заранее определённым поведением.
Дальше про то, как эти правила выглядят в коде.
Ошибки видны в сигнатуре
error IoError: path as Path reason as Textfunction read_config(path as Path) returns Config fails with IoError, ParseError: let text = try read_text(path) # IoError уходит вызывающему let parsed = try parse_toml(text) # ParseError тоже return Config.from(parsed)
Ошибка в Unio это значение, структура с полями, а не исключение. Функция, которая может завершиться ошибкой, перечисляет её после fails with. Вызвать такую функцию и ничего не сделать с ошибкой нельзя, это ошибка компиляции. Вариантов два: написать try и пропустить ошибку выше, тогда она должна быть и в вашем fails with, или обработать её в блоке try: с ветками on IoError as e:. Подсветка в примерах питоновская, своей для Unio у Хабра, понятно, нет.
Отсутствие значения выражается Option: find_user(7) or default_user подставит значение по умолчанию, find_user(7)?.name пробросит none дальше по цепочке. null в языке нет.
Дефекты программы отделены от ошибок предметной области. Индекс за границей списка, деление на ноль, переполнение Int, нарушенный assert это аварии: их нельзя поймать через on, программа печатает трассу, закрывает открытые ресурсы и выходит с кодом 101. Для каждой такой операции есть безопасная форма, которая возвращает Option или ошибку: items.get(i), a.checked_divide(b), a.checked_add(b). Нехватка памяти авария не считается: среда выполнения печатает заранее подготовленное сообщение и выходит с кодом 102, не пытаясь выполнить пользовательский код.
Данные это значения, а не общие ссылки
Структуры, списки, словари и тексты в Unio ведут себя как значения. Передали список в функцию, сохранили в поле, вернули из функции, и изменение через одно имя никогда не видно через другое. Менять можно только то, что объявлено через var: переменную, поле или параметр. Если функция меняет переданный ей список, это видно в сигнатуре (var items as List[Int]), и одну переменную нельзя передать в два таких параметра одного вызова. Замыкания захватывают копии и присваивать захваченным переменным не могут.
Копий при этом почти не бывает: значения разделяются, пока их не меняют, а при изменении копируется только меняющийся уровень. Поэтому для памяти в 0.1 хватает подсчёта ссылок. Значения не делят изменяемое состояние, значит, не образуют циклов, с которыми подсчёт ссылок не справляется. Счётчик всё равно нужен, чтобы понять, можно ли менять значение на месте. Память освобождается сразу, как исчезает последнее имя, без пауз на просмотр всей живой кучи. В концепции изначально стоял трассирующий сборщик мусора, от него в 0.1 отказались в RFC 0007, и цену там измерили: структура в 100 МБ освобождается разом примерно за 50 мс, а разбор JSON в тесте производительности идёт 5,47 с против 0,49 с у Go, потому что парсер копирует и отпускает значение на каждый символ.
Ресурсы живут в блоке
Файлы, сокеты и процессы устроены иначе. Это ресурсы: тип реализует интерфейс Resource с методом release, и такое значение создаётся в блоке using:
function count_lines(path as Path) returns Int fails with IoError: using file = try open_file(path): var count = 0 for line in file.lines(): count = count + 1 return count
Файл закрывается на выходе из блока, в том числе при ошибке и при аварии. Сохранить ресурс в структуру или список, который переживёт блок, нельзя, это ошибка компиляции. Если ресурс нужно хранить, владелец сам объявляется ресурсом и освобождает его в своём release. Сравнить два ресурса через == тоже нельзя, для тождества есть is_same(a, b), чтобы никто не перепутал сравнение содержимого файлов со сравнением дескрипторов.
Запись файла через files.write_text атомарна: данные пишутся во временный файл, попадают на носитель и только потом подменяют целевой переименованием. Читатель видит либо старое содержимое, либо новое целиком, даже после отключения питания.
Интерфейсы без наследования
struct это запись с именованными полями, enum тип-сумма, у вариантов которой могут быть поля, interface набор методов. Реализация объявляется явно, блоком extend User with Display. Наследования реализаций нет, переиспользование идёт через композицию, обобщения (function first[T](items as List[T]) returns Option[T]) и методы интерфейса по умолчанию. Поля и методы приватны для модуля, пока не помечены public, поэтому структура может защищать свои инварианты.
Эффекты выводятся сами
Писать в сигнатуре, что функция трогает файлы или запускает процессы, в 0.1 не нужно. Компилятор выводит эффекты из графа вызовов (files.read, files.write, env, process, network, allocates) и показывает их в unio symbol и в документации. Тот же вывод сверяется с полномочиями пакета: использовать модуль стандартной библиотеки сверх того, что объявлено в манифесте, компилятор не даст. Так ответ на вопрос «что эта программа может сделать» есть ещё до запуска.
Тесты раньше кода
Блоки test лежат в том же файле, что и код. Тест, который пока не компилируется, например потому что вызывает ещё не написанную функцию, считается просто непройденным, а остальные тесты файла всё равно выполняются. Так можно писать тест раньше функции, и для человека, и для агента это рекомендуемый цикл: намерение в комментарии, красный тест, минимальный код, unio fmt.
Один язык от утилит до ядра
Стандартная библиотека разделена на три слоя. core не нужны ни аллокатор, ни операционная система, alloc нужна куча, std нужна ОС. Модуль может пользоваться только своим слоем и нижними, и это проверяется. Профиль сборки выбирает слои: application даёт всё, а будущий профиль system оставит core и alloc с явным аллокатором, без автоматического управления памятью и без std. Синтаксис и типы в обоих профилях одинаковые. Идея в том, чтобы горячую часть программы можно было постепенно спустить вниз, а со временем писать на том же языке ядро. Для этого в репозитории уже описана UniOS: небольшое ядро со службами в отдельных процессах, первые эксперименты в виртуальных машинах.
Тензоры, вычисления на GPU и квантовые схемы в первый выпуск не входят, но ядро под них проектировалось. Тензор будет ресурсом с бюджетом памяти, эффект uses gpu добавится в сигнатуры без изменения существующего синтаксиса, квантовая схема будет значением, которое проверяется до запуска. Ускорения от квантового модуля концепция прямо не обещает.
И последнее: язык пишется на самом себе везде, где это уже возможно. Стандартная библиотека и инструменты проекта написаны на Unio, компилятор на Rust временный и должен перейти на Unio к версии 1.0. Каждый пробел, найденный по ходу, закрывается в языке.
Вот как это выглядит вместе, кусок утилиты wc из эталонных программ репозитория:
struct Counts: lines as Int words as Int bytes as Intfunction count(text as Text) returns Counts: return Counts( lines = text.count("n"), words = text.split_whitespace().length(), bytes = text.byte_length(), )public function main() returns Int: var status = 0 for name in args.list(): let text = try files.read_text(Path(name)) on IoError as e: print_error("wc: {name}: {e.reason}") status = 1 continue print("{count(text).lines} {name}") return statustest "bytes are counted in UTF-8": assert count("ёж").bytes == 4
try ... on IoError as e: здесь обрабатывает ошибку прямо в выражении: ветка обязана закончиться return, fail, break или continue, поэтому text всегда получает успешный результат.
Программа не может сама записать файл
Полномочия, которые сверяет компилятор, при запуске ещё раз проверяет операционная система. Пакет описывает в unio.toml, что ему можно:
[permissions]files.read = ["$CWD/**"]files.write = ["$CWD/**"]env = ["HOME", "PATH", "LANG"]process = ["git", "python3"]network = false
На Linux программа запускается под no_new_privs, Landlock и seccomp, на macOS под профилем Seatbelt, где запрещено всё, что не разрешено, на Windows в AppContainer с Job Object.
На Linux и macOS у самой программы и у её дочерних процессов права на запись в файлы нет вообще. Каждая запись идёт запросом к отдельному процессу-писателю. Он стартует из того же исполняемого файла до того, как включится песочница программы, один раз получает разрешённые области записи и дальше принимает только запросы через сокет. У писателя своя песочница: на Linux Landlock с записью только в эти области, seccomp без exec, без новых процессов и без сокетов.
Дочерние процессы запускаются только из списка, без оболочки, в своём дереве процессов с пределами по времени, памяти и числу процессов. Если ядру не хватает нужного, например нет Landlock ABI 3 или seccomp, программа не запускается и выходит с кодом 103. Ослабить песочницу может только оператор, флагом --allow-weaker-sandbox или своей политикой; проект через unio.toml этого сделать не может.
Компилятор живёт по тем же правилам. unio check, fmt и build работают в изолированных рабочих процессах, потому что исходники и пакеты считаются чужими данными, и ошибка в компиляторе не должна дать им выйти за пределы проекта. Даже компоновщик при unio build запускается в своей песочнице.
Что ломалось
Пока готовили кандидата на выпуск, нашлось несколько мест, где проверки были дырявыми.
На macOS предел числа процессов держался на том, что среда выполнения каждые 5 мс искала потомков программы. Между двумя поисками дерево могло на короткое время выйти за предел. Теперь, по RFC 0011, каждый потомок до запуска своей программы получает RLIMIT_NPROC, равный нулю, и любая попытка создать процесс заканчивается EAGAIN. Ядро не применяет этот предел к пользователю с идентификатором 0, поэтому программа от root с заданным пределом просто не запускается.
Фаззер мог отчитаться «находок нет», если его рабочий процесс умер раньше, чем записал свой вход. Теперь такая смерть проваливает запуск.
Тесты песочницы, которые без Python или компилятора C завершались раньше, считались пройденными. CI теперь выставляет UNIO_REQUIRE_PREREQUISITES=1, и тест без нужного окружения падает, а не зеленеет.
Всё это исправлено и ждёт зелёного CI на новом кандидате, а фаззер ещё и суточного прогона.
ИИ пишет, компилятор проверяет
Диагностики компилятора выдаются в JSON вместе с объяснением и, если исправление однозначно, готовой заменой:
{"code": "E0056", "severity": "error", "message": "unknown name 'summ'", "fixes": [{"file": "total.unio", "new_text": "sum", "description": "replace with 'sum'"}]}
Пример сокращён, в полном есть позиции и связанные места. Есть ещё unio explain, unio symbol и unio outline, чтобы агент мог посмотреть на код по кускам, а не читать файл целиком.
Проверяли это на контрольном наборе из 200 программ с ошибками. Каждую программу и каждую попытку получал свежий агент Claude, а эталонные исправления на время испытания убирались из рабочего дерева. Исправленной программа считалась, только если unio check чист и проходят скрытые поведенческие тесты. После одной попытки исправились 161 программа из 200, за три попытки 192. Самое полезное в испытании нашлось среди неудач: шесть из восьми неисправленных программ проходили unio check, но падали на скрытых тестах, так что чистая проверка ещё не исправление. Заодно нашёлся пробел в компиляторе: он принимал files.read = true в манифесте, теперь это ошибка E0088. И 80% здесь критерий этого контрольного набора, а не обещание такого же успеха на любых программах.
Сам Unio, как я написал в начале, пишет Claude Opus, а проверяет ChatGPT Astra. Правила для агентов лежат в репозитории в AGENTS.md, и несколько сессий иногда пушат в main за один час. Первый коммит сделан 2 ноября, так что всё описанное выше собрано меньше чем за две недели.
Что уже есть
Что
Состояние
Компилятор на Rust
check, fmt, outline, explain, symbol, run, test, интерпретатор
Нативная сборка
unio build через Cranelift: Linux x86-64 и AArch64, macOS AArch64, Windows x86-64
Стандартная библиотека
написана на Unio, слои core (без аллокатора и ОС), alloc и std, границы слоёв проверяются
Инструменты проекта
написаны на Unio и сверяются с эталонными проверками на Python
Пакеты
каталог, архив, реестр из статических файлов, файл блокировки, подписи ML-DSA-65 с ротацией ключей, отзывом и защитой от отката
Остальное
языковой сервер, REPL, api-diff, fmt --migrate, тесты документации
Спецификация
грамматика EBNF с корпусом верных и неверных примеров, статическая и динамическая семантика, процесс RFC
Чего у языка нет
Выпуска: 0.1 не опубликован.
Трассирующего сборщика мусора, о нём выше.
TLS в 0.1 тоже нет. Пакеты проверяются по подписям, но сами загрузки не приватны: что именно вы скачиваете, видно по дороге. И ни один компонент не заявляет соответствия постквантовой политике, пока не проведены независимый аудит, фаззинг и проверка времени выполнения.
Чем можно помочь
Главное это та самая проверка. Список лежит в docs/ru/extern-review.md, английская версия в docs/extern-review.md. У каждой функции указаны объявление в stdlib/, связывание в compiler/unio-runtime/src/externs/, полномочия и буква вида проверки. Рецензент смотрит, что паники нет ни на каком входе, что выделения памяти по размеру входа учитываются в пределе кучи до того, как сделаны, что связывание не берёт полномочий сверх объявленных и что у каждого unsafe верный комментарий SAFETY. Дальше идут проверки по виду: NaN и переполнения в математике, символические ссылки и владение дескрипторами в файлах, дерево процессов в std.process.run.
Если знаете Rust, начать можно с Int.checked_add или Text.trim. Если работали с Landlock, seccomp, Seatbelt или Job Object в Win32, больше всего пользы будет в строках std.files.* и std.process.run. Можно взять одну функцию или один вид целиком. Нашли дефект, открывайте issue, его номер записывается в строку вместо имени рецензента.
Что ещё пригодится:
-
прогнать тесты на машинах, которых нет в CI: другие дистрибутивы Linux, старые macOS, Windows в режиме разработчика;
-
посмотреть песочницу (runtime.md, раздел 9) и криптографию (cryptography.md), об уязвимостях писать по SECURITY.md;
-
написать на Unio небольшую утилиту и рассказать, где компилятор ответил непонятно. С чего начать, есть в учебнике.
Что именно блокирует выпуск, собрано в release-0.1.md. Что взято у Zig, Odin, Gleam, Mojo и Unison и что отклонено, с причинами, в influences.md.
Репозиторий: github.com/gfazzz/unio · MIT и Apache-2.0
Источник: habr.com