Nova — система эффектов
Это введение в концепцию. Полная развёртка с handler’ами, AI-first обоснованием и стандартным набором эффектов — в revolutionary.md. Вопросы async, panic и эффект-стирания — в D12, D13, D14.
Центральный принцип
Сеть, диск, время, случайность, лог, ошибка, мутация, запуск процесса
(std.os, Command.new(...).run() — Plan 265 Ф.1, D453) — в Nova это
всё эффекты. Функция объявляет в сигнатуре те эффекты, которые
использует сама; вызовы других функций не тащат свои эффекты вверх
(исключение — Fail, ошибки видны транзитивно). У каждого эффекта
есть handler, который перехватывает его операции.
Если в сигнатуре нет прямых эффектов и функция не вызывает эффектные функции — она детерминирована (с оговоркой про Panic, см. ниже).
effect vs protocol
В Nova два разных способа описать «что-то с операциями»:
- «Как делать что-то» — функция объявляет, что ей нужны
такие-то операции, а какая реализация будет под ними — решает
вызывающий код через
with-блок (например, для прода — Postgres, для теста — in-memory). Это эффект, объявляется черезtype X effect { ... }. - «Что умеет значение» — реализация жёстко привязана к типу:
intхешируется так-то,str— так-то, и менять это нельзя. Это протокол, объявляется черезtype X protocol { ... }.
Когда использовать эффект, а когда протокол в коде: если хочется при тестировании использовать другую реализацию — это эффект. Если при тестировании мы просто работаем со значениями типа, и подменять там нечего — это протокол.
У эффекта нет полей
У эффекта нет полей — только сигнатуры операций. State, если он нужен, живёт в окружении handler’а и попадает к нему через захват (как у обычного замыкания).
Эффект — это интерфейс + неявный параметр
Самый точный способ понять:
Эффект = интерфейс + неявный параметр, проверяемый компилятором.
Три части:
- Интерфейс — набор операций с сигнатурами без реализации
- Неявный параметр — реализация передаётся через
with-скоуп, не через список аргументов - Проверяемый — если функция использует операцию, эффект обязан быть в её сигнатуре
type Db effect {
query(q Sql) -> []DbRow // только сигнатуры, без реализации
exec(q Sql) -> ()
}
// функция декларирует, какой эффект ей нужен
fn process(o Order) Db -> Receipt =>
Db.query(sql`SELECT * FROM orders WHERE id = ${o.id}`)
// вызов операции активного handler'а
// промежуточные функции просто пробрасывают эффект — без with
fn handle_request(o Order) Db Log -> Receipt {
Log.info("processing")
process(o) // handler берётся из вызывающего скоупа
}
// `with` ставится один раз — там, где определяется реализация
fn main() Io -> () =>
with Db = postgres_handler {
handle_request(o) // handler виден через всю цепочку
}
with нужен один раз, в той точке, где выбирается реализация.
Между этой точкой и местом вызова операции может быть сколько угодно
функций — каждая из них объявляет эффект в сигнатуре, но with не
повторяет. Это решает проблему «реализацию приходится передавать
параметром через все промежуточные функции»: установил with один
раз — она видна везде ниже по стеку.
Синтаксис
Эффекты идут между списком параметров и ->:
fn double(x int) -> int // чистая
fn parse(s str) Fail -> int // может бросить
fn save(u User) Fail Db Log -> () // три эффекта
fn fetch(url str) Net Fail -> Response
Граница задана структурой: всё между ) и -> — эффекты.
Имена — обычные именованные типы
Эффекты — это именованные effect-типы (по D61),
в PascalCase. Объявляются через kind-токен effect, отличаются от
структурных контрактов (protocol) семантикой with-substitution
и continuation-capture.
type Logger effect {
log(msg str) -> ()
}
ro console = effect Logger {
log(msg) -> () => println(msg)
}
effect Logger { ... } — handler-литерал: то же ключевое слово effect,
что и в декларации типа (D61/D142
— symmetry между декларацией и литералом для effect/protocol).
Старое ключевое слово handler для литерала retracted без
deprecated-алиаса (2026-05-23, clean break) — компилятор на встрече
handler X { ... } выдаёт diagnostic «handler keyword removed; use
effect (D142)». Однозначность с record-литералом (User { id: 1 })
обеспечивает сам keyword effect/protocol, а не отдельный префикс.
Каждая операция handler-литерала обязана указывать -> Тип явно
(D434, 2026-07-22) — единственное
место в языке, где раньше была синтаксическая опциональность
return-типа. Пропуск даёт E_INCOMPLETE_HANDLER_OP_DECL, несовпадение
с типом операции в декларации эффекта — E_HANDLER_OP_RETURN_TYPE_MISMATCH.
Типы параметров при этом по-прежнему можно не повторять (log(msg) -> () => ...) — обязателен только возврат.
Имя эффекта в коде — три позиции
fn process() Db -> () // 1. позиция типа
Db.query(sql`...`) // 2. позиция операции
ro captured = Db // 3. позиция выражения = активный handler
Парсер различает по позиции.
Стандартные эффекты
| Эффект | Что описывает |
|---|---|
Fail[E] | Контракт для перехвата и обработки ошибки типа E |
Io | stdin/stdout/stderr |
Fs | Файловая система |
Net | Сетевые запросы |
Db | База данных |
Time | Часы, таймеры, задержки |
Random | RNG |
Log | Структурированный лог |
Trace | Распределённая трассировка |
Ask[T] | Чтение из контекста (как Reader) |
Alloc[R] | Аллокация в регионе R |
Detach | Fire-and-forget задача, переживающая caller’а (D50) |
Blocking | Синхронный C-вызов на blocking-pool потоке (D50) |
Async, Mut, Par не входят в стандартный набор по
D62: Async — ambient capability
(не часть type system’ы), Mut — заменяется специализированными
эффектами, Par — runtime-keyword parallel for / spawn.
Программист может объявлять собственные эффекты — это обычное
объявление типа через effect.
Перечень эффектов — требование-минимум, а не точное описание
Эффекты в объявлении функции читаются как «умеет как минимум это». Отсюда правило подстановки: функцию можно передать всюду, где требуется не больше эффектов, чем она объявляет. Лишние эффекты сверх требования препятствием не являются.
fn run_handler[T, E](body fn() Fail[E] -> T) Fail[E] -> T => body()
fn user_handler() Time Fail[FwErr] -> int => 42
ro v = run_handler(user_handler)
Частный случай: fn() -> T не предъявляет требований и потому принимает функцию
с любыми эффектами.
Зачем так. Библиотека, принимающая пользовательский код — роутер HTTP, планировщик, тест-раннер, повтор с политикой, итератор с обратным вызовом, — не может знать его эффекты: обработчик волен ходить в базу, в файлы, в сеть, спать и писать логи в любом сочетании. При трактовке «ровно это и ничего сверх» библиотека отвергала бы пользовательский код за то, что тот делает больше, чем предугадал её автор. Предугадать нельзя — значит фреймворки на таком правиле невыразимы.
Гарантии от этого не слабеют. «Ничего сверх» выражается отдельно —
конструкцией forbid E1, E2 { … }. Две стороны разведены:
| конструкция | смысл | сторона |
|---|---|---|
| эффекты в сигнатуре | «умеет как минимум это» | нижняя граница |
forbid E1, E2 { … } | «здесь нельзя это» | верхняя граница |
Режим --strict-effects проверяет, что объявлено не меньше, чем
используется, — то есть ту же нижнюю границу, только со стороны определения
функции.
Норма — D448.
Зачем это нужно
1. Видно по типу, что вызов делает
ro x = double(5) // не делает ничего
ro y = parse(s)? // может упасть — обязан обработать
ro r = http.get(url)? // ходит в сеть — видно в сигнатуре
LLM (и человек), читая сигнатуру, знает все побочные действия. В Python/Java/Go этой информации в типе нет.
2. Чистые функции отделены от грязных
Если в сигнатуре нет эффектов — компилятор знает: можно мемоизировать, вызвать в любом потоке, заменить на константу при равных входах.
3. Невозможно «случайно» добавить побочку
Кто-то добавил Log.info(...) в утилиту форматирования — компиляция
у вызывающих сломается, потому что появился эффект Log. Тихо
протащить нельзя. Это фича.
Прямые эффекты, не транзитивные (D28)
Сигнатура объявляет только эффекты, чьи операции функция вызывает сама — не эффекты вложенных вызовов:
type Db effect {
exec(stmt str) -> ()
}
fn save(name str) Db -> () {
Db.exec(name) // прямое использование — Db в сигнатуре обязателен
}
fn helper(name str) -> () {
save(name) // транзитивное Db — по умолчанию только warning
}
- Прямой эффект не объявлен → compile error, всегда — включая
СЫРОЙ вызов операции эффекта (
Db.exec(...)без промежуточной именованной fn): до №131 этот call-shape был энфорс-дырой (E_RAW_EFFECT_OP_UNDECLAREDтеперь закрывает её на export-границе, той же границе, что у!!/№113 ниже — приватным доставляет D28-инференс). - Транзитивный эффект не объявлен → warning, подавляемый через
#allow_transit(Db, Log)на функции илиtransit_effects = "off"вNova.toml. - Флаг
--strict-effects(nova check/build/test, Plan 197) переводит это предупреждение в жёсткую ошибкуE_UNDECLARED_TRANSITIVE_EFFECT— проектная конвенция требует собиратьstd/**иexamples/**именно с этим флагом (см.CLAUDE.md). Тот же флаг ловитE_EFFECT_ERASED_IN_FN_TYPE— присваивание/передачу функции в более узкий по эффектамfn(...) Row -> T. Fail[E]— исключение из «прямого»: throw остаётся строго транзитивным и обязателен в сигнатуре везде, где может произойти (см. «Операторы?и!!» ниже) — компилятор здесь не ослабляет проверку никаким флагом.- Авто-очистка (
@cleanup, D432) считается ПРЯМЫМ эффектом той функции, в чьём скоупе компилятор вставил вызов: вызов физически генерируется в её теле. ДержитеFile— в сигнатуре появляетсяFs; очистка может отказать — появляетсяFail[E]. Без этого уточнения правило читалось бы как «транзитивный», то есть выродилось бы в предупреждение (амендмент D432 от 2026-08-04). - Приватная (без
export) функция может не писать прямые эффекты вручную вообще — компилятор выводит их из тела автоматически (в т.ч. добавляетFail[E], если приватная функция где-то использует!!/throw). Вexport fnпрямые эффекты обязаны быть явными — это публичный контракт.
Безопасность между файберами — свойство типа, требование на границе (D446)
Правило одной фразой: значение может быть достижимо более чем из одного файбера, только если оно неизменяемо, либо единолично принадлежит одному владельцу, либо его тип объявлен безопасным для одновременного использования — и это объявление проверено компилятором.
Проверяется тремя локальными шагами, без анализа достижимости:
- у типа есть проверяемое свойство «безопасен для одновременного использования»;
- замыкание безопасно, если безопасно всё, что оно захватило;
- функция, принимающая значение, которое ПОЗЖЕ будет исполняться параллельно (установка middleware, регистрация обработчика), объявляет это требование в своей подписи.
Компилятор не выясняет, кто и откуда позовёт: каждый вход в параллельность сам требует свойства от того, что принимает. Подробности и обоснование — D446.
Async — невидимая инфраструктура (D62)
Suspension в Nova — не эффект, а ambient runtime-инфраструктура.
Без Future<T> в типе. Без await. Цвет функции отсутствует.
Программист не видит «может ли функция приостановиться» в её
сигнатуре.
fn fetch(url str) Net -> Response => ...
fn handler(req Request) Net Db -> Response {
ro user = fetch_user(req.id) // никаких .await
ro posts = fetch_posts(user.id)
Response.json(posts)
}
Под капотом — fiber-based scheduler (как Go/OCaml 5). Цена — килобайты памяти на fiber, миллион fiber’ов на машину — норма.
Если нужна гарантия «здесь нельзя приостанавливаться» — используется
блок realtime { ... } как
inverse-маркер.
Подробно — decisions/06-concurrency.md#d14, decisions/04-effects.md#d62.
Дефолтный handler без with (D431)
Некоторые эффекты (Time — эталонный пример) работают без явного
with, если программист не подставил свой handler:
fn log_uptime() Time Io -> () =>
println("${Time.now()}") // handler не установлен — используется дефолтный (real-clock)
Это не «эффект без handler’а» — компилятор синтезирует ленивый,
once-per-thread дефолт-конструктор через атрибут
#default_handler(EffectName) на обычной handler-литерал-фабрике в
.nv-исходнике (не хардкод в Rust). with Effect = ... по-прежнему
полностью переопределяет дефолт — механизм не теряет мокабельность,
просто снимает необходимость писать with для типового bootstrap-случая.
Что НЕ эффект — Panic
Не каждое прерывание — эффект. Аппаратные/математические сбои не указываются в сигнатуре:
- Деление на ноль
- Целочисленное переполнение
- Выход за границы массива
- Переполнение стека
- Out-of-memory
Они образуют категорию Panic. Программист не ловит panic в коде —
это смерть текущего fiber’а, runtime обрабатывает на границе:
import std.concurrency.supervisor as sup
fn handle_request(id int) -> int =>
id / id // если panic (напр. id == 0 → div/0) — fiber умирает
fn server(ids []int) -> () =>
with Supervisor = sup.stop() { // упавший ребёнок НЕ отменяет siblings
supervised {
for id in ids { spawn { handle_request(id) } }
}
}
Исход отмены виден в ЗНАЧЕНИИ области (D455):
область с cancel: возвращает не T, а Outcome[T] = Done(T) | Cancelled, и вызывающий
разбирает исход match’ем. Без cancel: тип прежний — платит только тот, кто
отмену заказал. Причина: отмена ничего не бросает, и без исхода в значении «область
завершилась» и «область отменена» неразличимы — значит корректное завершение
нечем проверить.
Стратегия супервизии — обычный эффект-handler (Supervisor,
D416), а не именованный параметр:
готовые политики — sup.stop() (упавший «выкинут», остальные продолжают,
его ошибка не теряется — retained) и sup.escalate() (эквивалент дефолта:
ошибка становится primary, siblings кооперативно отменяются). Пользовательская
политика — обычный handler-литерал: on_child_fail(idx int, err any) -> Decision,
где Decision — Escalate или Stop. Restart-семейство в словаре
отсутствует (ретрактировано 2026-07-10) — рестарт-идиома для fiber’ов
инородна structured concurrency; повтор попытки живёт внутри тела ребёнка
(std.concurrency.retry), не в супервизоре.
panic — это смерть fiber’а, не процесса. В сервере падает только
текущий запрос, остальное работает. Если нужно гарантированно гасить
процесс — отдельная функция exit(code, msg) (D13).
Иначе Fail[DivByZero] оказался бы в каждой второй сигнатуре —
информативность эффектов исчезла бы. Сознательный компромисс,
подробно — decisions/08-runtime.md#d13.
Что печатается при отказе (D462)
Неперехваченный отказ — Fail, которого никто не поймал, panic,
типизированный Fail — это ОДНА запись: вид, сообщение, точка броска,
?-цепочка, которая его несла, и ошибки cleanup’а, которые он подавил. Форму
выбирает тот, кто ЗАПУСКАЕТ программу; тому, кто её собирал, решать заранее не
приходится:
$ ./app # nobody is parsing — a person reads it
nova: unhandled Fail: leaf-error
at app.nv:29 (throw site)
propagation trace (`?`-chain, oldest first):
via app.nv:19 (?)
$ NOVA_PANIC_FORMAT=json ./app # a tool is parsing — one line, stable keys
{"nova_failure":1,"kind":"fail","message":"leaf-error",
"site":{"file":"app.nv","line":29},"trace":[{"file":"app.nv","line":19}],
"trace_dropped":0,"suppressed":[]}
По умолчанию — человеческий вывод: машинный формат инструмент просит явно, а человек не должен читать JSON, не попросив. Переменная окружения, а не флаг сборки — пересобирать программу ради машинного лога абсурдно.
Прочитать отказ из кода (D463)
Та же запись, которую печатает рантайм, доступна программе, которая отказ обрабатывает — три свободных аксессора, без новых типов и без нового слова:
with Supervisor = effect Supervisor {
on_child_fail(idx, err) -> Decision {
Log.error(report()) // the whole record
for s in suppressed() { Log.warn(s) } // cleanup failures
if Some(c) = cause() { Log.error("caused by: ", c) } // one step back
return if err is Panic { Decision.Stop } else { Decision.Restart }
}
}
report() рендерится ТЕМ ЖЕ рендерером, что печатает терминальный отказ, поэтому
NOVA_PANIC_FORMAT управляет обоими — второму формату неоткуда разойтись.
cause() — один необязательный шаг назад, форма, которую Rust пишет source(),
Java getCause(), а Go errors.Unwrap; цепочка получается ходьбой по ней.
suppressed() — карман D158, он не меняется.
Обработчик, бросающий ВМЕСТО пойманной ошибки, связывает её причиной сам — слово
from не нужно, потому что место замены это ровно рука обработчика, где
пойманная ошибка и есть параметр. Cleanup, бросивший РЯДОМ с ещё летящей
ошибкой, уходит в suppressed(). Развод структурный, а не эвристический.
Роли — throw / Fail[E] / handler
Чтобы не путать слои, три участника обработки ошибок:
-
throw err— синтаксис языка, запускает ошибку. Послеthrowуправление в эту точку не возвращается (тип операцииnever). -
Fail[E]— эффект-контракт для перехвата и обработки ошибки. -
handler
Fail[E]— то, что перехватывает ошибку. У handler’а ровно два исхода:- завершить with-блок значением через
interrupt v, - перебросить ошибку дальше через
throw.
Возобновить вызов в точке
throwнельзя — тип операцииnever, возвращать в эту точку нечего. - завершить with-блок значением через
Операторы ? и !!
Программист выбирает стиль обработки на месте использования (D85):
expr?— return-стиль: «не получилось — обёртка наверх как значение». Внешняя функция должна возвращатьOption/Result.expr!!— throw-стиль: «не получилось — throw черезFail». Внешняя функция должна иметьFail[E]в сигнатуре.
fn pipeline_return(s str) -> Result[int, ParseError] {
ro n = parse(s)? // на Err: return Err(e)
validate(n)?
Ok(n)
}
fn pipeline_throw(s str) Fail[ParseError] -> int {
ro n = parse(s)!! // на Err: throw e
validate(n)!!
n
}
Оба оператора работают и для Option[T], и для Result[T, E]. Для
Option!! бросается RuntimeNoneError (prelude unit-тип). Методы-близнецы
.unwrap() / .unwrap_or(v) / .unwrap_or_else(f) ретрактированы
(2026-07-07) — единственный канонический путь операторный (!!, ??),
методов на Option/Result с тем же смыслом в prelude нет.
Энфорс на границе экспорта (№113,
2026-07-25). expr!! в export fn, чья сигнатура не несёт совместимый
Fail[E] (и throw не пойман локальным with Fail = ... {}) — compile
error E_BANG_REQUIRES_FAIL. Для приватных функций это не действует —
там работает D28 auto-inference (см. выше): Fail молча подставляется в
эффект-строку, если тело использует throw/!!. Если !! защищает
программный инвариант, а не реальную fallibility (типовой пример — сеттер
над compile-time-известным литералом), выход — ?? panic("...") вместо
протаскивания Fail в публичную сигнатуру.
Параллельно остаётся ?? — coalesce / кастомный fallback:
ro port = config.get("port") ?? 8080 // default
ro port = config.get("port") ?? throw MyError // custom throw
ro port = config.get("port") ?? panic("no port") // panic (D13)
Форма ?? return ... (ранний выход из объемлющей функции) —
ретрактирована (D86 amend, 2026-07-23,
E_COALESCE_RETURN_FALLBACK): она была второй дверью к ?, а не
независимой нишей. Канон вместо неё — X? (та же обёртка наружу),
.ok()? (Result → функция отдаёт Option), .map_err(...)? (меняется тип
ошибки), .ok_or(err)? (Option → функция отдаёт Result), либо явный
match, если обёртки для проброса нет вовсе.
Альтернатива: явный Result
fn parse(s str) -> Result[int, ParseError] => ...
Два стиля одного и того же. Fail — сахар поверх Result.
Дефолт для прикладного кода — Fail (читаемее), для библиотек
с важным типом ошибки — явный Result.
Как выглядит операция эффекта (D456)
Операция эффекта — обычная функция Nova, у которой отнят ровно один элемент:
получатель @, потому что у эффекта нет инстанса. Всё остальное, что умеет
язык, ей доступно и от неё ожидается — генерики, Result/Option, записи и
суммы, коллекции, параметры-функции, именованные типы вместо голых чисел.
Обратное — тоже правило: на границе эффекта не бывает отрицательного errno
вместо ошибки, пустой строки как признака «нет», обхода по индексу
(_len + _at), out-параметров, сырых ручек-int, счётчиков рядом с данными и
str, в котором лежат не-UTF-8 байты. C-формы живут в extern "C"-слое ffi.nv
и внутри real_*-обработчика: обработчик — переводчик, а не сквозной канал.
Причина не в красоте. Граница эффекта — это ровно то, что видит автор мока, а подменяемость мы называем отличием языка: если в декларации видна C-форма, перевод просто не написан, и писать его придётся каждому, кто пишет тест.
Одно исключение, и владелец решением 2026-08-12 сделал его именованным
отказом, а не молчаливым провалом: генерики в эффектах НЕ поддержаны на
обеих осях обобщения — ни на операции (type Wrap effect { around[T](body fn() -> T) -> T }, реестр 221.1 №570), ни на самом эффекте (type Store[T] effect { ... }, реестр 221.1 №614). До этого решения обе формы принимались
nova check зелёным и падали уже в Си-компиляторе (Nova_T*/unknown type name 'NovaVtable_Store') — теперь чекер отвергает обе именованной ошибкой
(E_EFFECT_OP_GENERIC_UNSUPPORTED / E_EFFECT_GENERIC_UNSUPPORTED) прямо на
объявлении. Rank-2 полиморфизм через vtable-слот с одной подписью — открытый
вопрос Q6; поддерживал его только ретрактированный bootstrap-интерпретатор.
Компилятор-интринсик Fail[E] — исключение из отказа: это sugar-цель
throw/!! со своей рантайм-машинерией, не пользовательский эффект через
общий vtable-путь. Для сравнения: generic-ПРОТОКОЛ работает и как bound, и
непосредственно как тип (бокс с vtable) — проверено сборкой и запуском. Значит
дело не в генериках как таковых, а в vtable эффекта.
Главный смысл
Эффекты — это обещание в сигнатуре + точка перехвата. Один
механизм для того, что в других языках разнесено по try/catch,
async/await, dependency injection, моков и unsafe.