Нормативный текст спецификации. Английская страница — informative translation.

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’а и попадает к нему через захват (как у обычного замыкания).

Эффект — это интерфейс + неявный параметр

Самый точный способ понять:

Эффект = интерфейс + неявный параметр, проверяемый компилятором.

Три части:

  1. Интерфейс — набор операций с сигнатурами без реализации
  2. Неявный параметр — реализация передаётся через with-скоуп, не через список аргументов
  3. Проверяемый — если функция использует операцию, эффект обязан быть в её сигнатуре
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
Iostdin/stdout/stderr
FsФайловая система
NetСетевые запросы
DbБаза данных
TimeЧасы, таймеры, задержки
RandomRNG
LogСтруктурированный лог
TraceРаспределённая трассировка
Ask[T]Чтение из контекста (как Reader)
Alloc[R]Аллокация в регионе R
DetachFire-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, где DecisionEscalate или 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, возвращать в эту точку нечего.

Операторы ? и !!

Программист выбирает стиль обработки на месте использования (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.