Effects — Fail, Io, Db, handlers, with-блоки
Решения этой группы определяют центральную абстракцию Nova: алгебраические
эффекты. Любое взаимодействие с внешним миром — эффект; у эффекта есть handler;
handler перехватывается в with-скоупе. Из этой идеи следуют замены
ключевых слов async/throws/unsafe на типы и единый механизм
для тестов, транзакций, undo/redo, capability-режима.
| # | Решение |
|---|---|
| D2 | Эффекты вместо ключевых слов async/throws/unsafe |
| D3 | Синтаксис эффектов: типы между ) и -> |
| D4 | ? для пробрасывания ошибки |
| D11 | Имена эффектов и синтаксис with |
| D12 | Effect erasure и dynamic effects |
| D18 | Эффекты объявляются через protocol, не type |
| D25 | throw и параметризация Fail[E] |
| D28 | Вывод эффектов: private — выводится, public — явно |
| D31 | Handler-лямбда для эффектов с одной операцией |
| D61 | Полная семантика эффектов: effect keyword, handler-литерал, Effect[E], interrupt |
| D62 | Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol |
| D63 | forbid X { body } — capability sandbox |
| D64 | realtime { body } — гарантия не-приостановки |
| D65 | Полная семантика Fail: гибрид Fail[E] / Fail, lookup, prelude RuntimeError и Error |
| D67 | ⚠️ ОТМЕНЕНО → D85: ? оператор (две семантики) |
| D68 | Stateful handlers: через closure capture или @as_handler метод record |
| D85 | Операторы ? и !! — унифицированное поведение для Result и Option, throw-стиль через !! |
| D86 | ?? coalesce-оператор — fallback для Result/Option без Fail |
| D87 | Effect[E, IRT] — параметризация Handler типом interrupt’а |
| D120 | #pure views + axioms + #verify/#trusted handlers |
| D115 | Axiom binder — BinderType enum вместо Option<TypeRef> |
Полное введение в концепцию — ../effects.md. AI-first обоснование — 01-philosophy.md → D10.
D2. Эффекты вместо ключевых слов async/throws/unsafe
⚠️ REVISED → D62.
Async/Mut/Parубраны из стандартного набора.Asyncстал ambient (невидимая инфраструктура fiber-runtime’а, см. D14),Parтоже не эффект — параллелизм черезspawn/parallelбез эффект-метки (D50).Mutудалён целиком (изменяемое состояние черезmut-поля иmut-параметры, не эффект). Для no-suspend гарантии используетсяrealtime { }block (D64) как inverse-маркер.
Что
Единая система эффектов заменяет два разнородных языковых механизма
(throws, unsafe). Эффекты — обычные типы в PascalCase
(Fail[E], Io, Db, Net, Log, Alloc[R]), выводятся
компилятором в private, объявляются явно в public между списком
параметров и ->.
Правило
Стандартный набор эффектов в stdlib (после D62):
| Эффект | Что описывает |
|---|---|
Fail[E] | Контракт для перехвата и обработки ошибки типа E (D25/D65) |
Io | Файлы, stdout/stderr |
Net | Сетевые запросы |
Db | База данных |
Fs | Чтение/запись файлов |
Time | Часы, таймеры, задержки |
Random | RNG |
Alloc[R] | Аллокация в регионе R |
Log | Структурированный лог |
Trace | Распределённая трассировка |
Ask[T] | Чтение из контекста (как Reader) |
Все имена в PascalCase — это типы, не keyword’ы. Никаких специальных правил «эффекты с маленькой».
fn parse(s str) Fail -> int
fn fetch(url str) Net Fail -> Response
fn save(u User) Fail Db Log -> ()
Fail без параметров ≡ Fail[any] (D65) — catch-all для
quick-and-dirty. Для production рекомендуется явный Fail[E].
Программист может объявлять собственные эффекты через keyword
effect (D18 (REVISED), D61):
type Logger effect {
log(msg str) -> ()
}
fn process(o Order) Logger Db -> Receipt {
Logger.log("processing")
Db.query(sql`SELECT receipt FROM orders WHERE id = ${o.id}`)
}
Почему
- Невидимое поведение в Java/Python/JS. Любая функция может бросить
что угодно — это не видно по сигнатуре. Checked exceptions Java
получились плохо: не комбинируются с дженериками и лямбдами.
Go-стиль
if err != nil— много шума, легко забыть. - Async-вирус. В Rust/JS/C#
asyncотравляет всю цепочку вызовов черезFuture<T>и обязательныйawait. В Nova suspension — ambient runtime-инфраструктура (D62, D14), без цвета функции и безawait. - AI-first. LLM, читая сигнатуру, знает все побочные действия. В Python/Java/Go этой информации в типе нет — для AI это восстанавливается чтением десятка вызываемых функций.
- Один механизм для всего. Тестирование без моков, транзакции, undo/redo, детерминированный запуск, трассировка, capability security — всё это handler’ы одного и того же механизма.
Что отвергнуто
async/throws/unsafeкак отдельные keyword’ы. Три разных механизма для трёх случаев — каждый с собственными правилами композиции, перехвата и пропагации.- Lowercase имена эффектов (
throws io async, как было в первых черновиках). Эффекты — типы, к ним применяется единое PascalCase-правило (03-syntax.md → D30). - Effects как ещё одна фича рядом с trait’ами. В Nova это центр языка (01-philosophy.md → D10).
Связь
- D3 — позиция между
)и->. - D11 — three positions имени
эффекта, синтаксис
with. - D18 — эффект
объявляется через
protocol, неtypeи не специальный keyword. - D25 —
Fail[E]параметризация. - D28 — правило вывода private vs public.
- 01-philosophy.md → D10 — «всё эффект» как центральная абстракция языка.
- 06-concurrency.md → D14 — fiber runtime как ambient инфраструктура (suspension не в типах).
Эволюция
В первых черновиках имена эффектов были lowercase (throws io async) —
их пытались выделить визуально из имён типов. Пересмотрено: эффекты —
обычные типы, к ним применяется PascalCase-правило (D11,
03-syntax.md → D30). Fail без параметра теперь
читается как сахар над Fail[Error]. Подробно —
history/evolution.md.
D3. Синтаксис эффектов: типы между ) и ->
Что
Эффекты в сигнатуре функции перечисляются через пробел между закрывающей
скобкой параметров ) и стрелкой возврата ->. Граница задана
структурой, парсер однозначен, никаких маркеров и ограничителей.
Правило
fn save(u User) Fail Io -> ()
fn fetch(url str) Net Fail -> Response
fn process(o Order) Db Log -> Receipt
fn double(x int) -> int // нет эффектов — чистая
Параметры — без двоеточия (u User, не u: User) — единое
правило для всех типов в Nova (02-types.md → D17,
03-syntax.md → D33).
Эффекты с параметрами читаются так же:
fn parse(s str) Fail[ParseError] -> int
fn alloc_in(buf []u8) Alloc[r] -> Buffer
fn read_ctx(key str) Ask[Config] -> str
Если эффектов нет — между ) и -> пусто:
fn add(a int, b int) -> int =>
a + b
Эффекты в сигнатуре методов через @ — после параметров, перед ->:
fn Account mut @deposit(amount money) Fail Log -> () => ...
Почему
- Граница задана структурой.
)слева,->справа — парсер однозначен без маркеров. - Эффекты — это типы (D2, D11). Применяется единое PascalCase-правило (03-syntax.md → D30).
- Читается слева направо как фраза:
«функция
saveотUserбросает, делаетIo, асинхронна, возвращает()».
Что отвергнуто
!throws io async(маркер!слева). Глаз читает!throwsкак «не throws» — противоположный смысл. К тому же!стоит только перед первым эффектом, дальше идут «голые» — границы списка не видно.!throws !io !async(маркер на каждом). Шумно, проблема «!как not» остаётся.!{throws, io, async}(явный блок). Фигурные скобки заняты телом функции — путается.<throws, io, async>(Koka-style). Угловые скобки нужны для дженериков (хотя в Nova используется[T], см. 03-syntax.md → D16), читается тяжелее.- Атрибуты
@throws @io @async. Четыре лишних символа, и@ассоциируется с метаданными, а не с типом. К тому же@уже занят методами инстанса (03-syntax.md → D35). - Без маркера, всё выводить молча. Опасно — эффекты должны быть видны на глаз в публичном API.
- Lowercase имена эффектов (
throws io async). Отвергнуто в D11 — эффекты обычные типы. :в параметрах (u: User). Заменено наu User— единый стиль (02-types.md → D17).
Связь
- D2 — эффекты как альтернатива keyword’ам.
- D11 — имена эффектов как обычные типы, three positions.
- 02-types.md → D17 — параметры без
:. - 03-syntax.md → D30 — PascalCase правило для типов.
Эволюция
В первых черновиках синтаксис был fn save(u: User) !throws io async —
маркер ! + lowercase эффекты + параметры с :. Каждая из трёх
особенностей пересмотрена отдельно:
!отброшен (визуальный конфликт с «not») — этот D3.- Lowercase → PascalCase в D11.
:в параметрах →u Userв 02-types.md → D17.
Главный урок. Символьная пунктуация дёшева на одном месте и
дорожает экспоненциально с количеством мест. Слова и структурные
границы () … ->) масштабируются линейно.
D4. ? для пробрасывания ошибки
Что
Постфиксный оператор ? после выражения — «если ошибка, верни её
выше». Работает только в функциях с эффектом Fail[E] в сигнатуре.
Правило
fn pipeline(s str) Fail[ParseError] -> int {
let n = parse(s)? // если parse бросил — pipeline бросает то же
validate(n)? // если validate бросил — pipeline бросает то же
n
}
Без Fail в сигнатуре ? — ошибка компиляции:
fn pipeline(s str) -> int {
let n = parse(s)? // ОШИБКА: ? requires effect Fail[E]
n
}
Семантика — сахар над match + throw
expr? компилятор разворачивает в:
match expr {
Ok(v) => v
Err(e) => throw e // обычный throw, требует Fail[E]
}
Поэтому ? работает только в функциях с Fail[E] — не специальное
правило компилятора, а следствие того, что throw сам требует эффект
(D25).
Совместимость с Fail[E]
!! пробрасывает ошибку наверх через Fail — тип ошибки в
сигнатуре вызывающего должен быть совместим. Если совпадают — проходит
напрямую; если разные — нужно явное преобразование через .map_err():
fn pipeline(s str) Fail[PipelineError] -> int {
let n = parse(s).map_err(|e| PipelineError.Parse(e))!!
validate(n).map_err(|e| PipelineError.Validate(e))!!
n
}
Почему
- Заимствовано из Rust/Swift, проверено годами использования.
- Дешевле
try { ... } catch { ... }. Безопаснееif err != nil— нельзя забыть проверку. - Не магия. Полностью разворачивается в существующие конструкции
языка (
match,throw) — никаких специальных правил.
Что отвергнуто
try expr(Swift-style). Слово длиннее, а?уже знаком всем, кто видел Rust/Swift.expr!для force-unwrap. Конфликтует с логическим «не», и panic-семантика противоречит 08-runtime.md → D13 (panic не ловится в коде).?безFailв сигнатуре (с автоматическим выводом). Нарушает правило «public-API явный» (D28). В private может работать через вывод, но даже там удобнее видетьFailявно.
Связь
- D25 —
throwкак операция эффектаFail[E],?разворачивается вthrow. - D2,
D11 —
Failкак обычный эффект. - 03-syntax.md → D19 —
matchсо стрелкой=>(используется в desugaring?).
Coalesce
??вынесен в D86. Раньше описывался подразделом D4; в 2026-05-10 выделен в самостоятельное решение для возможности независимой эволюции и явных ссылок.
Эволюция
В первой формулировке D4 в исходниках указано «работает в функциях
с эффектом throws» (lowercase). Отметка устарела: эффекты в Nova —
PascalCase, правильное имя — Fail[E] (раньше Throws[E] —
переименование в D61
ради согласованности convention «имя эффекта — существительное в
единственном числе», Throws был глаголом, остальные эффекты —
существительные).
D11. Имена эффектов и синтаксис with
⚠️ REVISED → D61. Эффект объявляется через keyword
effect(type X effect { ... }), не черезprotocol. Handler-литерал — через keywordhandler(effect X { ops }), а не черезX { ops }. Раздел оставлен для семантикиwith-блока (без изменений). Старая формулировка про «protocol-форму» устарела.
Что
Эффект объявляется через keyword effect (см. D61), а handler-литерал —
через keyword handler. Имена в PascalCase. Синтаксис with принимает
либо имя handler-переменной, либо подмену вида EffectName = expr
через запятую, и ровно один блок тела.
Правило
Объявление эффекта
type Logger effect {
log(msg str) -> ()
}
type Db effect {
query(q Sql) -> []DbRow
exec(q Sql) -> ()
}
Имя эффекта — обычный идентификатор в PascalCase. Объявление через
keyword effect (см. D61, D18 (REVISED)).
Имя эффекта в коде — three positions
Имя эффекта в коде может появляться в трёх позициях, каждая разрешается контекстом:
// 1. ПОЗИЦИЯ ТИПА — между ) и -> (или в generic-параметре)
fn process(o Order) Db -> Receipt => ...
// ^^ Db — имя типа эффекта
// 2. ПОЗИЦИЯ ОПЕРАЦИИ — Db.X(...) — обращение к операции активного handler'а
Db.query(sql`select * from users`)
// 3. ПОЗИЦИЯ ВЫРАЖЕНИЯ — одиночное Db в выражении
let captured_db = Db // активный handler как значение Effect[Db]
some_function(Db)
return Db
Парсер различает по позиции, никакой неоднозначности нет. Никакого
Db.current() или подобного геттера не существует — просто Db в
выражении. Это симметрично тому, как User в выражении не нуждается
в User.current().
Форма 1: подмена через EffectName = expr
Основной случай — тесты, переключение реализации:
with Logger = console_logger, Db = in_memory, Time = fixed(t0) {
process_order(o)
}
После with — список «эффект = handler-выражение» через запятую,
потом один { body }. Парсер однозначен: запятые разделяют
подмены, { открывает тело.
Форма 2: handler как обычное значение
Для сложных или переиспользуемых handler’ов:
let audit = effect Logger {
log(msg) { audit_db.write(msg); return () }
}
with Logger = audit {
critical_operation()
}
EffectName { ... } — выражение-литерал, дающее значение типа
Effect[EffectName]. Параллель с record-литералами: разные keyword’ы,
разные формы литералов:
type User { id u64, name str } // record-тип (data)
let alice = User { id: 1, name: "alice" } // record-литерал
type Logger effect { log(msg str) -> () } // эффект (behavior)
let console = effect Logger { log(msg) => println(msg) } // handler-литерал
Handler-литерал начинается с keyword’а handler (по D61) —
однозначно отличает от record-литерала. Стрелка в handler-операциях —
именно =>, как в match-arms (03-syntax.md → D19)
и теле лямбды (03-syntax.md → D22).
Слово handler — keyword (D61)
В первой редакции D11 использовался синтаксис без префикса —
Logger { log(msg) => ... }, парсер различал record vs handler по
содержимому {...}. После D61 handler стало keyword’ом,
а handler-литерал требует явного префикса. Это улучшает локальную
читаемость: effect X {...} сразу читается как «литерал handler’а».
Почему
- Один блок тела
with— нет визуальной путаницы между телом handler’а и теломwith-блока. - Несколько эффектов в одном
with— естественно и компактно для тестов:with Logger = test_log, Time = fixed_clock, Random = seeded(42) { run_simulation() } - Handler — обычное значение, не специальная синтаксическая
форма, привязанная к
with. Это упрощает композицию — handler’ы можно хранить в переменных, передавать функциям, держать в map. - Симметрия с record-литералами —
Имя { ... }для значений любых типов, без специальных префиксов. withостаётся примитивом языка, а не сахаром над функцией — потому что он структурно влияет на стек handler’ов (continuation capture).
Что отвергнуто
with effect Logger { log(msg) => ... } { body }. Два{...}блока подряд читаются плохо: непонятно, где кончается тело handler’а и начинается телоwith.handler EffectName = ...keyword. Префикс лишний — содержимое блока (name(args) => body) однозначно говорит, что это handler.- Lowercase имена эффектов (
throws,io). Эффекты — обычные типы, применяется единое PascalCase-правило (03-syntax.md → D30). Db.current()геттер для активного handler’а. Лишний синтаксис — имя эффекта в выражении и так даёт активный handler.
Связь
- D2, D3 — эффекты как типы.
- D18 — эффект
объявляется через
protocol; литералы различаются по содержимому. - D25 —
Fail[E]— частный случай этой схемы. - D31 — handler-лямбда (третья форма для эффектов с одной операцией).
- 02-types.md → D42 —
protocolкак структурный контракт; эффекты — этоprotocol, использованный в позиции эффекта. - 03-syntax.md → D19,
03-syntax.md → D22 — стрелка
=>в match-arms и теле лямбды (та же стрелка в handler-операциях). - 03-syntax.md → D30 — PascalCase для типов.
Эволюция
Ранние черновики содержали with effect EffectName { ... } { body } —
два {...} подряд и обязательный префикс handler. Пересмотрено на
форму без префикса с handler-литералом EffectName { op() => ... } и
явную форму подмены EffectName = expr. Lowercase имена эффектов
(throws, io) отброшены в пользу PascalCase.
D12. Effect erasure и dynamic effects
Что
Статическая типизация эффектов — дефолт: очереди, каналы, планировщики типизированы по эффектам функций, которые они принимают. Для разнородных задач, плагинов и сериализации есть явные инструменты стирания эффектов и динамики.
Правило
Уровень 1 — статически типизированный планировщик (дефолт)
let order_queue Queue[fn(OrderId) Db Log Fail -> ()]
order_queue.enqueue(send_order_confirmation) // ок
order_queue.enqueue(cleanup_db_task) // ОШИБКА: лишний эффект Net
Воркер этой очереди статически проверен. Лишний эффект не пройдёт. Это правильный дефолт для типизированных пайплайнов.
Уровень 2 — явное стирание через erase[E]
fn erase[E](task fn() E -> ()) E -> fn() -> () =>
let captured = capture_handlers[E]()
|| with captured { task() }
universal_queue.enqueue(erase(send_email_task))
universal_queue.enqueue(erase(cleanup_db_task))
Эффекты захвачены в момент erase, тип задачи становится fn() -> (),
очередь принимает разнородные задачи. Цена: handler’ы зашиты, если
они стали невалидными к моменту исполнения — это проблема программиста,
не компилятора.
Уровень 3 — динамические эффекты через EffectSet + DynFn
Runtime-структура EffectSet, тип DynFn для случаев, когда
эффекты задачи известны только в рантайме (плагины, сериализация
в БД). Используется редко, помечается явно.
Что НЕ делается
- Стирание не автоматическое — иначе строгая типизация превращается
в видимость (как Java generic erasure). Программист должен явно
попросить
erase[E]. - Все очереди не делаются динамическими по умолчанию — потеряется главное свойство Nova (видимость эффектов в типе).
- Через границу процесса handler’ы не передаются. Эффекты на этой границе становятся протоколом (имена сервисов, типов сообщений) — это паттерн «commands + dispatcher», не часть системы эффектов.
Почему
- Правильный дефолт. 95% случаев — типизированные пайплайны, для них эффекты в типе очереди — гарантия безопасности.
- Эскейп-хатч есть, но виден.
erase[E]илиDynFn— явные маркеры в коде, понятные при ревью. Компилятор не трогает остальные места. - AI-first. LLM, генерируя код, видит явный
erase— понимает, что в этой точке статическая безопасность кончается.
Что отвергнуто
- Автоматическое стирание (Java-style generic erasure). Превращает типизацию в видимость — лишает Nova главного свойства.
- Все очереди динамические. Каждый
enqueueтогда требует runtime- проверки эффектов; типизация в сигнатуре теряет смысл. - Эффекты как часть protocol-message через сеть. Handler’ы — это closures с capture, по проводу не передаются. Через границу процесса — обычный паттерн «команды + диспатчер».
Связь
- D2 — типизация эффектов в сигнатуре.
- D11 — handler как обычное
значение, что позволяет capture через
erase. - 06-concurrency.md → D14 — fiber runtime, планировщик задач.
Открытые вопросы
- Конкретный синтаксис
capture_handlers[E]()(имя, форма параметра). - Семантика
EffectSetв рантайме (теги типов? vtable?). - Граничные случаи: эффект выходит за scope, handler уже невалиден к моменту исполнения — как сигналить ошибку.
D18. Эффекты объявляются через kind-токен, не голый type
⚠️ REVISED → D53, D61, D62. Финальный синтаксис для эффектов:
type X effect { ... }.effect— kind-токен (по D53) И keyword (по D61). Структурные контракты остаются какtype X protocol { ... }(см. D62 правилоeffect/protocol: program-based выбор по двум sniff-вопросам). Различие: effects поддерживаютwith-substitution и continuation-capture, protocols — нет.
Правило
Чёткое разделение type vs type X effect vs type X protocol
// data — голый type (см. D52)
type User { id u64, name str }
type Color | Red | Green | Blue
type UserId u64
// эффекты (with-substitution + continuation-capture) — kind-токен effect
type Db effect {
query(q Sql) -> []DbRow
exec(q Sql) -> ()
}
type Logger effect {
log(msg str) -> ()
}
// структурные контракты (без with-substitution) — kind-токен protocol
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
Выбор effect vs protocol — программистский (D62 правило 4):
- with-substitution нужна (mock в тестах)? —
effect - continuation-capture нужен (throw, interrupt)? —
effect - Оба «нет» —
protocol
type X { методы без полей } запрещено — нужно type X effect { ... }
или type X protocol { ... }. Слова effect и handler зарезервированы
как keyword’ы; protocol — kind-токен (не зарезервирован как keyword
вне type-decl).
Один protocol — две роли по контексту использования
Тот же protocol может работать и как эффект, и как структурный параметр. Различение идёт по позиции в сигнатуре:
type Logger effect { log(msg str) -> () }
// А: позиция эффекта — между ) и ->. Активный handler берётся из скоупа.
fn process_a(o Order) Logger -> () =>
Logger.log("processing")
// Б: позиция типа значения — обычный параметр, передаётся явно.
fn process_b(o Order, logger Logger) -> () =>
logger.log("processing")
Программист выбирает стиль:
- Эффект (А) — для пронизывающих контекстов: БД, лог, аутентификация, трассировка. Не таскается через 10 функций.
- Параметр (Б) — для явных зависимостей одной функции, когда хочется локальной видимости.
Что осталось без изменений
- Имя protocol’а в позиции эффекта (между
)и->) — требование активного handler’а в скоупе. Db.operation(args)— вызов операции активного handler’а.Dbв позиции выражения — активный handler как значение (D11).with Db = expr { body }— подмена handler’а в скоупе.- Литерал handler’а —
effect Db { query(s, a) => ..., exec(s, a) => ... }(через keywordhandler, см. D61).
Handler-литерал начинается с keyword handler — это однозначно
отличает его от record-литерала. До D61 парсер различал по
содержимому {...} (двоеточие vs стрелка); теперь — по prefix’у
keyword’а.
Различение литералов
Type { name: value }→ record-литерал уtype(User { id: 1 })effect Type { name(args) => body }→ handler-литерал у effect’а (effect Db { query(s, a) => ... })
Стрелка handler-операций — =>, та же что в match-arms
(03-syntax.md → D19) и теле лямбды
(03-syntax.md → D22). Не ->.
Почему
typeдля данных,protocolдля поведения — единое правило языка (02-types.md → D42). Эффект — это поведение (набор операций без полей), и логично, чтобы он использовал тот же keyword, что и обычные структурные контракты.- Намерение явно по первому токену. Раньше требовалось смотреть
на содержимое
{...}(поля или методы), чтобы понять, что объявлено. Теперь с keyword видно сразу. - Меньше двусмысленности у LLM. В предыдущей редакции D18 LLM
нужно было запоминать «type с одними методами — это контракт/эффект».
Сейчас правило прямее: «методы →
protocol». - Согласованность с D42. D42 разделил данные и поведение, но
эффекты выпадали из правила (объявлялись через
type). Этот разворот D18 убирает противоречие.
Что отвергнуто
effect X { ... }keyword (как в первоначальной редакции). Не возвращаем — третий keyword рядом сtype/protocolплодит сущности без выгоды.protocolуже описывает «именованный набор операций»; эффект — этоprotocol, использованный в позиции эффекта.handler X = ...keyword. Префикс лишний — содержимоеX { op(args) => body }однозначно говорит, что это handler.- Сохранить
typeдля эффектов (как в предыдущей редакции D18). Конфликтует с D42: D42 говорит «type— данные,protocol— поведение», а эффект — это поведение. Оставлять эффекты подtype— это ровно то противоречие, которое этот разворот D18 устраняет.
Цена
- Breaking change для всех ранее написанных примеров эффектов.
Все
type Db { query, exec }→protocol Db { query, exec }. Поскольку реализации компилятора нет, цена — обновление спецификации и примеров. - Семантическая зависимость в парсинге литералов сохраняется.
Парсер всё ещё смотрит на содержимое
{...}(двоеточие vs стрелка), чтобы различить record-литерал и handler-литерал. Но keywordprotocolявно говорит, что у этого имени литерал — handler-форма. - Anonymous structural type в позиции эффекта —
fn f(x { show() -> str })сейчас валиден как анонимный protocol в позиции параметра (D42:200-203). Допустим ли он в позиции эффекта между)и->? — open question, см. open-questions.md.
Связь
- D2 — эффекты как
protocol’ы, не keyword’ы
async/throws/unsafe. - D11 — three positions имени
эффекта;
with-синтаксис для подмены. - 02-types.md → D17 — единый синтаксис объявления
type(для данных). - 02-types.md → D42 —
protocolkeyword; эффекты — частный случайprotocol, использованного в позиции эффекта. - 03-syntax.md → D19 — стрелка
=>в match-arms, та же что в handler-литералах.
Эволюция
История развода в три шага:
- Первая редакция — два keyword’а:
effect X { ... }для эффектов,type X { ... }для всего остального. - Вторая редакция —
effectотменён, эффекты объявляются черезtype. Различение по контексту использования. Этот шаг убрал лишний keyword, но оставилtypeперегруженным (и данные, и поведение). - Текущая редакция — после D42, который разделил
type(данные) иprotocol(поведение), эффекты переведены наprotocol.typeтеперь только для данных. Это устраняет противоречие между D18 и D42.
В одном из ранних черновиков D18 пример handler-литерала был записан
со стрелкой -> (Db { query(s, a) -> return ... }) — устарело.
Стрелка => — единое правило для всех мест, где «образец/параметры →
тело» (03-syntax.md → D19,
03-syntax.md → D22).
D25. throw и параметризация Fail[E]
Уточнено D65:
Failбез параметра — сахар надFail[any](universal), неFail[Error]. Lookup-правило, re-throw, prelude-типыRuntimeErrorиError(record) формально определены в D65. Этот блок (D25) сохраняется как описание базового механизмаthrowиFail[E]; полная семантика — в D65.
Что
Бросать ошибку — выражение throw expr, прерывающее функцию через
эффект Fail[E]. Параметр E — тип бросаемого значения, обычно
sum-type. Fail без параметра — сахар над Fail[any] (universal,
catch-all). Convention: для public API использовать Fail[E] с
конкретным типом; для quick-and-dirty/scripts/internal helper’ов —
Fail (any) допустим. См. D65.
Правило
Базовое использование
type DepositError | Closed | NotPositive | OverLimit
fn deposit(mut acc Account, amount money) Fail[DepositError] -> () =>
if acc.closed { throw Closed }
if amount <= 0 { throw NotPositive }
acc.balance += amount
throw expr — выражение типа never (никогда не возвращает), прерывает
функцию и передаёт expr через эффект Fail[E]. Тип expr должен
совпадать с E в сигнатуре.
Bootstrap (2026-05-06): throw парсится и как statement, и как
expression. В expression-position (match-arm body, ternary, аргумент
функции) codegen эмитирует Nova_Fail_fail(msg) + dummy ((nova_int)0LL)
— dummy после fail() недостижим. Тесты — nova_tests/effects/throws.nv (stmt),
nova_tests/syntax/throw_in_expression.nv (expr). Известное ограничение
bootstrap’а: if cond { throw } else { val } выражение не работает —
codegen смешивает unit и реальный тип; workaround — if cond { throw msg } val (throw как stmt, fall-through к val).
throw — операция эффекта Fail[E], не магия
Связь между throw и Fail[E] — не специальная проверка
компилятора, а прямое применение модели алгебраических эффектов.
throw expr — это операция эффекта, точно так же как Db.query(...)
или Logger.log(...).
Концептуально prelude объявляет:
type Fail[E] effect {
fail(value E) -> never // операция, никогда не возвращает
}
throw expr — сахар для Fail[E].fail(expr). Компилятор разворачивает
синтаксический throw в обычный вызов операции эффекта. Дальше работает
общее правило для всех эффектов: использовал операцию — задекларируй
эффект в сигнатуре.
fn lookup(id u64) Db -> User => // Db в сигнатуре — ок
Db.query(sql`SELECT * FROM users WHERE id = ${id}`)
fn lookup(id u64) -> User => // Db отсутствует — ошибка
Db.query(sql`SELECT * FROM users WHERE id = ${id}`)
// ^^^^^^^^^^^^^^^^^^^^ operation Db.query requires effect Db
fn parse(s str) Fail[ParseError] -> int => // Fail в сигнатуре — ок
throw ParseError.BadFormat
fn parse(s str) -> int => // Fail отсутствует — ошибка
throw ParseError.BadFormat
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ throw requires effect Fail[ParseError]
Никакой отдельной логики для throw нет. Та же проверка, что для
Db.query, Net.get, Time.now и любой другой операции эффекта.
? — сахар над match + throw
? тоже не магия. expr? разворачивается в:
match expr {
Ok(v) => v
Err(e) => throw e // обычный throw, требует Fail[E]
}
Поэтому ? работает только в функциях с Fail[E] в сигнатуре —
потому что раскрывается в throw, а throw требует эффект.
Отдельного правила «? требует Fail» нет, оно вытекает из
обычной проверки эффектов (D4).
never — почему throw совместим с любым типом
throw expr имеет тип never — тип, означающий «не возвращает
значение в обычном смысле». never — подтип любого типа (как Nothing
в Kotlin/Scala), поэтому throw можно использовать как выражение в
любой позиции:
let x int = if condition { 42 } else { throw NotReady }
// ^^^^^^^^^^^^^^^
// тип never, совместим с int
Это работа never, а не специальное правило для throw. То же
поведение у return и panic — все три имеют тип never. Поэтому
работают и такие выражения:
let user = lookup(id) ?? return Response.error(404)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
// тип never, совместим с типом user
Поймать через ? (проброс) или handler (обработка)
Проброс через ? — сахар «если ошибка, верни её выше». Работает
в функциях с совместимым Fail[E] в сигнатуре:
fn pipeline(s str) Fail[ParseError] -> int =>
let n = parse(s)? // если parse бросил — pipeline тоже бросает
n
Обработка через handler — обычный handler-блок. Для Fail[E]
основная форма — handler-лямбда (D31),
потому что у Fail[E] ровно одна операция (throw):
fn try_deposit(acc Account, amount money) Log -> bool {
// handler-лямбда (D31) — это лямбда (`=> expr`), без блок-формы
// (D22). Когда нужен блок с side-effect'ами — используем полный
// handler-литерал в блок-форме `op(p) { block }`.
with Fail[DepositError] = effect Fail[DepositError] {
fail(err) {
Log.error("deposit failed: ${err}")
interrupt false
}
} {
deposit(acc, amount)
true
}
}
Тип результата with-блока — общий тип всех веток (тело и handler’ы).
Здесь оба возвращают bool. Если разнотипные — обернуть в sum-type/
Result/Option.
Две формы handler’а для Fail[E]
Операция Fail[E].fail имеет тип возврата never — она по
определению не возвращает значение в точку вызова. Из этого
следует, что у handler’а Fail[E] всего два допустимых
исхода:
interrupt v→ прерывание (with-блок возвращаетv). Аналогtry/catchв Java. Continuation отбрасывается.- Новый
throw→ проброс наверх (как другой тип или с обогащением контекста). Управление ищет следующий handler в стеке.
Третья форма — return value / финальное выражение, которая для
других эффектов даёт «продолжение с подменой» (управление
возвращается в точку вызова операции с подменённым значением), —
для Fail запрещена. Тип операции never означает, что в
точке throw некуда возвращать значение; type-checker должен это
запрещать (см. D61 «never-операции», Q-resume).
Один параметр в Fail[E]
Параметр один. Если функция бросает несколько типов — программист делает sum-type:
type TransferError | InsufficientFunds | InvalidAccount | AccountClosed
fn transfer(from Account, to Account, amount money) Fail[TransferError] Db -> Receipt => ...
Fail без параметра — catch-all (D65)
fn process(o Order) Fail Db { // Fail ≡ Fail[any]
validate(o)
save(o)
}
export fn process_pub(o Order) Fail[OrderError] Db -> () => ...
Правило (по D65):
Failбез[E]≡Fail[any](top-type, ловит любую ошибку).- В приватных функциях допустим как quick-and-dirty.
- В публичных функциях допустим, но рекомендуется явный
Fail[E]— это часть контракта. Линтер может предупреждать.
Это AI-first компромисс: внутри модуля программист может писать быстро, не придумывая имя ошибки. На границе модуля LLM (и человек) видят конкретный тип в сигнатуре.
⚠️ Изменение в D65. Раньше
Fail≡Fail[Error](конкретный record-тип). После D65Fail≡Fail[any](top-type). Семантика catch-all сохранилась, тип-обёрткаErrorостался в prelude как удобный record дляthrow(см. D26).
Связь с Result[T, E]
Fail[E] и Result[T, E] — разные инструменты с пересечением
сценариев.
fn parse_a(s str) Fail[ParseError] -> int => ... // эффект-стиль
fn parse_b(s str) -> Result[int, ParseError] => ... // value-стиль
Когда Fail[E]
- Прикладной код, где ошибка пробрасывается до handler’а (через
?). - Effect-композиция: handler
Fail[E]ловится черезwith, retry/log/централизованная обработка. - Несколько функций цепочкой выбрасывают одну и ту же ошибку —
чтение становится линейным (
x()? .map(f)?без обёрток).
Когда Result[T, E]
- Значение, которое нужно проинспектировать прямо у вызова
(
match result { Ok(v) => ..., Err(e) => ... }). - API, где обработка ошибки ВСЕГДА происходит локально (не пробрасывается).
- Возвращаемое значение функции, которая сама по себе не ошибка
(например,
try_parseвозвращаетResultнамеренно).
Конвертация
Из Fail[E] в Result[T, E]:
let r = with Fail[ParseError] = |e| interrupt Err(e) {
Ok(parse(s)?)
}
// r: Result[int, ParseError]
Из Result[T, E] в Fail[E]:
let v = parse(s)? // если Result, ? = match Ok(v) => v / Err(e) => throw e
Оператор ? работает на обоих типах (D26).
Дефолт и AI-first
Дефолт — Fail[E]. Эффект-стиль читается линейно, лучше для
LLM-генерации (нет вложенных match’ей). Result — когда сценарий
«ошибка как значение, всегда обработать локально».
«Два пути для одного» — кажущееся: пути решают разные задачи. Это не нарушает D40, потому что выбор детерминирован сценарием, не вкусом.
throw ≠ panic
throw expr — обычная ошибка через эффект, видна в сигнатуре через
Fail[E]. Перехватывается handler’ом в коде.
panic (08-runtime.md → D13) — аппаратные/
математические сбои (деление на 0, переполнение, OOM, выход за границы
массива) или вызов panic(msg) программистом. Не виден в сигнатуре.
Не ловится в коде — означает смерть текущего fiber’а, ловится
только runtime’ом на границе fiber’а.
Это разные миры:
- «обработать можно и нужно» →
throw+Fail[E] - «обработать никак нельзя, fiber умирает» →
panic
Почему
throw— обычная операция эффекта, не специальная конструкция. Минус один концепт —throwобъясняется через тот же механизм, чтоDb.queryиLogger.log.- Тип ошибки в сигнатуре — AI-first: LLM видит конкретный класс ошибок, не общий «может бросить что-то».
throwизвестно из Java/JS/C#/Swift — AI-friendly без переучивания.- Sum-type для нескольких ошибок — простая композиция handler’ов:
один handler ловит весь sum-type, дальше
matchпо вариантам.
Что отвергнуто
raiseилиerror()вместоthrow.throwизвестно по умолчанию из мейнстримных языков.Failвсегда без параметра (как Java unchecked exceptions или Swiftthrows). Теряется видимость типа ошибки в сигнатуре, ломает AI-first тезис.Fail[E1, E2, E3](множественные параметры). Усложняет композицию handler’ов (handler ловит «один из E1/E2/E3»? все три? один с union-pattern’ом?). Семантически избыточно — sum-type выражает то же чище. Нарушает простое правило «один эффект — один параметр», какAlloc[R],Ask[T].throwбез эффекта в сигнатуре (как Java RuntimeException). Невидимое control flow — главная проблема Java unchecked exceptions.
Связь
- D2 — эффекты
вместо keyword’ов;
Fail[E]— один из эффектов. - D4 —
?как сахар надmatch+throw. - D11 — handler-литералы для
Fail[E]. - D31 —
handler-лямбда для
Fail[E](главный case сахара). - 02-types.md → D15 — sum-types для нескольких типов ошибок.
- 08-runtime.md → D13 —
throw≠panic.
Цена
- Программист обязан явно описывать тип ошибки в публичных API — дополнительная работа, оправданная видимостью контракта.
- Sum-type для нескольких типов ошибок — небольшой синтаксический налог, оправданный простотой композиции handler’ов.
- Граница
throwvspanicтребует понимания — лечится документацией.
Performance: насколько дорогой throw
Bootstrap-runtime реализация throw msg:
- Vtable indirect call:
_nova_handler_Fail->fail(ctx, msg)— один pointer-load + indirect-call. ~1ns на современном CPU. - Handler-method body — пользовательский Nova-код. Зависит.
longjmpна nearest fail-frame: restore callee-saved regs, sp, pc. ~10-20ns. Без RAII-unwind (D6 GC — нет destructor’ов).- Cross-mco-boundary (если throw в fiber, handler снаружи): запись pending в scope-state, longjmp на fiber-local fail-frame, потом scope-runner re-issue на main. Дополнительно ~10-20ns.
Итого: ~50-200ns на throw без stack-trace. Дёшево.
Сравнение:
| Язык | Cost throw |
|---|---|
| Java exceptions | 10000-50000ns (stack-trace fill-in + class lookup) |
| C++ exceptions | 1000-10000ns (zero-cost happy path, expensive throw) |
| Rust panic | 1000-10000ns (similar to C++) |
| Go panic | 100-500ns (similar approach to Nova) |
| Nova throw | ~50-200ns (без stack-trace, без RAII) |
Когда throw становится узким местом:
Hot loop с throw на каждой итерации (парсер где throw для каждого
invalid char) — даже 100ns × 10⁶ итераций = 100ms. В таком случае
использовать Result-стиль через D77 try_from/try_into:
match на Result в hot path вообще не использует longjmp.
Throw — для business-level errors, где он редок и acceptable.
Result — для парсинга / валидации / hot path. Это рекомендация
из D73 (from/into для use-cases, try_from/try_into для
implementation хотя оба доступны вызывающему).
и сообщениями компилятора.
Эволюция
В первых черновиках допускалось Throws[E1, E2] (множественные
параметры) — пересмотрено в пользу sum-type. Также раньше Throws без
параметра был всегда допустим, теперь — только в приватных функциях
(D28).
Эффект переименован Throws → Fail в D61.
D28. Вывод эффектов: private — выводится, public — обязательно явно
⚠️ REVISED → D62. Изначально D28 объявлял «полный транзитивный вывод эффектов = compile error при missing в public». После D62 вывод прямых эффектов остался обязательным (compile error если не объявлены), но транзитивные эффекты теперь дают warning (suppressable через
#allow_transit(...)илиNova.toml). «Чистая функция = проверенный факт» теперь работает как «прямой эффект отсутствует» — функция может транзитивно делатьDb.exec, но если она сама не вызываетDb.X, она формально безDbв сигнатуре. Гарантия чистоты ослаблена; для жёсткой санитизации использоватьforbid X { }.
Что
Эффекты в сигнатуре private-функций (без export) выводятся
компилятором для прямых вызовов; транзитивные — warning. Программист
может опустить прямые эффекты в private, компилятор проанализирует
тело и добавит. В public API (export fn) прямые эффекты
обязательны явно — это контракт.
Функция без прямых эффектов — это проверенный факт об отсутствии прямых обращений к эффект-операциям. Транзитивные обращения через вложенные вызовы возможны (но виден warning по D62).
Правило
Базовое использование
// private — эффекты выводятся
fn helper(x int) =>
Logger.log("processing ${x}") // компилятор добавит Logger в сигнатуру
x * 2
// то же явно — программист тоже может писать
fn helper(x int) Logger -> int =>
Logger.log("processing ${x}")
x * 2
// public — должно быть явно
export fn process(x int) Logger -> int =>
Logger.log("...")
x * 2
// public БЕЗ эффектов — компилятор проверяет, что их и правда нет
export fn double(x int) -> int =>
x * 2 // ок, чистая
export fn bad(x int) -> int =>
Logger.log("...") // ОШИБКА: эффект Logger не объявлен
x * 2
Гарантия отсутствия прямых эффектов
Функция без эффектов в сигнатуре (после D62) = компилятор доказал, что она сама не использует эффект-операции:
- Не вызывает
Io.read/print(но может вызывать функцию, которая внутри это делает — warning) - Не делает прямых вызовов на сеть/БД/файлы
- Не делает
throwили?(Fail strict — транзитивный, всегда виден) - Не аллоцирует в region’ах с эффектом
Это слабее «полной чистоты» (которая была в D28 до D62). Для
жёсткой гарантии «вызовы суда не доходят» — использовать
forbid X { ... } capability sandbox.
Что осталось strict:
Fail[E]— всегда транзитивный, обязан быть в сигнатуре если callee может бросить (D65).- Прямые вызовы — compile error если эффект не объявлен.
Правило вывода (после D62)
Компилятор анализирует тело функции:
- Использование операции эффекта (
Db.query,Logger.log) прямо в теле → этот эффект добавляется (обязательный для public). - Каждый
throwилиexpr?→Fail[E]добавляется (всегда транзитивный, см. D65). - Каждый вызов функции с эффектами в чужой сигнатуре →
Failтранзитивно добавляется (strict).- Другие эффекты — warning «не объявленный транзитивный X»,
suppressable через
#allow_transit(X).
- Мутация
@fieldвmut @method(03-syntax.md → D35) — этоmut-метод, не эффект (D62 убралMut).
Public API — почему обязательно явно
- Контракт модуля. Сигнатура — это интерфейс, который другие модули видят. Изменение эффектов = breaking change. Должно быть видно в коде, не выводиться невидимо.
- AI-first. LLM, читая сигнатуру публичной функции, должна видеть все побочные действия. Public API — точка, где «сигнатура = полное описание» работает.
- Документация. Public — это то, что попадает в
nova doc. Эффекты — часть документации, не runtime-деталь. - Случайное расширение. Если private-функция получила лишний
эффект (программист добавил
Logger.logв утилиту), это не должно автоматически попадать в public — public видит ошибку компиляции, программист принимает осознанное решение.
Случайное расширение в private — после D62
Программист добавил вызов Logger.log(...) в утилиту → у функции
автоматически появился Logger (прямой) → вызывающие private-функции
получают warning «транзитивный Logger не объявлен», но
компилируются. До public API доходит warning, не ошибка.
Это ослабление D28 в пользу удобства. Если нужна жёсткая
проверка «функция не должна косвенно делать X» — использовать
forbid X { ... }:
fn pure_view(u User) -> str =>
forbid Db, Net, Io {
format_user(u) // compile error если внутри есть Db/Net/Io
}
Тулинг:
nova check --show-effects— режим, показывающий выведенные эффекты для всех private-функций.@no_effectsатрибут на private-функцию — компилятор обязан подтвердить, что функция чистая. Если нет — ошибка.@effects(Logger, Db)атрибут — закрепить ожидаемые эффекты для private. Расширение → ошибка.
В release-сборке тулинг не нужен, в dev — стандартный механизм проверки.
Историческая заметка про Async
В первой редакции D28 здесь был раздел «Async — особенно важно»,
обсуждавший «сделать ли Async дефолтным эффектом». После
D62 Async вообще не эффект (ambient runtime-инфраструктура,
см. D14), поэтому дилемма не актуальна.
Возникал вопрос «сделать Async дефолтным для всех функций, чтобы
не писать его в каждой backend-сигнатуре». Отвергнуто в пользу
полного удаления Async из системы типов:
Чистая функция double(x int) -> int гарантированно не
приостанавливается. Можно использовать в hot loop без yield-pauses.
Если бы Async был дефолтом — этой гарантии бы не было.
D28 решает «шум Async» иначе: в private он выводится, программист
не пишет. В public — пишет один раз. Гарантии чистоты сохраняются.
Почему
- AI-first компромисс. Внутри модуля программист пишет быстро, на границе модуля LLM (и человек) видит явный контракт.
- Гарантия чистоты сохраняется. Public-функция без эффектов — проверенный факт, можно мемоизировать.
- Шум
Asyncуходит. В private его не пишут, в public — один раз для каждой границы.
Что отвергнуто
- Везде явно (как Java checked exceptions). Шум в private-утилитах без выгоды.
- Везде выведено (как Haskell для типов). Public API теряет явный контракт.
Asyncкак эффект (любой формы — дефолт или явно). Отвергнуто в D62: suspension — runtime-факт, не type-fact.- Опт-ин для вывода (
@infer_effectsили подобный). Программист выбирает каждый раз — лишний шум.
Связь
- D2 — эффекты вместо keyword’ов; D28 уточняет правило вывода.
- D25 — то же правило для
Fail: выводится в private (можно опустить параметр), обязателен в public. - 01-philosophy.md → D10 — AI-first: видимость в public API сохраняется, шум в private убирается.
- 07-modules.md → D5 — два уровня видимости (
export/ приватно), эффект-видимость следует видимости функции.
Цена
- Качество сообщений компилятора при ошибке «private-функция приобрела эффект, public-вызывающий не объявлен» — критично. Программист должен видеть где эффект пришёл, через какую цепочку вызовов.
- Цепные изменения в private — диф не показывает явно, что
эффекты расширились. Тулинг (
--show-effects,@no_effects) компенсирует. - Compile-time стоимость — анализ эффектов транзитивный, увеличивает время компиляции на несколько процентов. Приемлемо.
D31. Handler-лямбда для эффектов с одной операцией
Обновлено D61 и D22-rev (2026-05-10): синтаксис
Fail[E]ушёл вFail[E],protocolдля эффектов ушёл вeffect, handler-литерал получил keywordhandler. Тело handler-method’а завершается черезreturn v/ финальное выражение илиinterrupt v(см. D61). Handler-лямбда мигрирована с(args) => exprна|args| exprсимметрично closure-rev — единый «pipe-маркер» для всех безымянных функций.
Что
Если эффект имеет ровно одну операцию, handler можно записать как
handler-лямбду в форме |args| body — параметры соответствуют
параметрам единственной операции эффекта. Для эффектов с двумя и
более операциями — handler-литерал effect EffectName { ... }
обязателен.
Handler-литерал effect EffectName { op(p) ... } содержит handler-методы,
у которых две взаимоисключающие формы тела, как у fn (D40):
op(p) => expr (одно выражение) или op(p) { block } (блок-форма
без =>). Сочетание => и {} в handler-method запрещено —
правило симметрично D40.
Правило
Базовое использование
type Fail[E] effect {
fail(value E) -> never
}
// сокращённо — handler-лямбда (одна операция → |args| body)
// Тело должно содержать `interrupt v` (поскольку fail возвращает
// never, нормальное завершение через return невозможно):
with Fail[Error] = |err| interrupt log_and_default(err) {
Db.exec(sql`UPDATE accounts SET balance = balance - 1`)
}
// полная форма — эквивалентна
with Fail[Error] = effect Fail[Error] {
fail(err) => interrupt log_and_default(err)
} {
Db.exec(sql`UPDATE accounts SET balance = balance - 1`)
}
Тело handler-лямбды — bare expression или block (как у closure-light, D22):
// expression-body — типичный случай
with Fail[Error] = |err| interrupt default_value { ... }
// block-body — несколько statement'ов
with Fail[Error] = |err| {
Log.error("got error: ${err}")
interrupt default_value
} { ... }
|err| -1 без interrupt — невалидно для Fail[E]-handler’а:
операция fail(value E) -> never запрещает return/финальное выражение
(нет значения типа never), требуется явный interrupt. Старая форма
без interrupt соответствовала pre-D61 implicit-interrupt семантике —
она отвергнута в D61 как AI-unfriendly.
Для эффекта с несколькими операциями — handler-литерал обязателен.
Handler-method может быть как => expr, так и блок-формы { block }:
type Db effect {
query(q Sql) -> []DbRow
exec(q Sql) -> ()
}
with Db = effect Db {
// короткая форма — одно выражение
query(q) => real.query(q)
// блок-форма — несколько statement'ов
exec(q) {
staged.push(q)
return ()
}
} {
transfer(alice, bob, 100)
}
Запрещено (нарушает D40 «=> и {} не сочетаются»):
with Db = effect Db {
exec(q) => { // ← запрет: => { block }
staged.push(q)
return ()
}
}
Какие эффекты попадают под сахар
Из стандартного набора:
| Эффект | Операции | Сахар работает |
|---|---|---|
Fail[E] | fail(value) | да — главный win |
Random | next() | да (если одна операция) |
Logger (минимальный) | log(msg) | да |
Time | now(), sleep(d) | нет |
Db | query, exec | нет |
Net | get, post, … | нет |
Fs | read, write, … | нет |
| Пользовательские | зависит | если одна |
Fail[E] — самый частый случай, ради него сахар главным образом
вводится. В backend-коде with Fail[E] = |err| ... { ... } будет
основной формой обработки ошибок через handler.
Грамматика
В позиции значения после with EffectName =:
- Handler-лямбда
|params| body(где body — expression или block, по D22-rev) → сахар, разворачивается в handler-литерал с одной операцией. Компилятор проверяет, что у эффекта ровно одна операция, и параметры лямбды совместимы с её сигнатурой. - No-arg handler-лямбда
|| body— для операций без параметров (напримерRandom.next() -> int). - Handler-литерал
effect EffectName { op(p) => expr, op(p) { block }, ... }→ используется как есть. Работает для любого числа операций. Каждый handler-method —=> exprили{ block }, никогда не вместе (D40). - Переменная или выражение типа
Effect[EffectName]илиEffect[EffectName, IRT](D87) → используется как есть.
Парсер однозначен по первому токену после =:
|(pipe) → handler-лямбда (по closure-light grammar D22)||→ handler-лямбда без параметровhandler(keyword) → handler-литерал- идентификатор → переменная/выражение
В отличие от обычной closure-light, закрытие в этой позиции
интерпретируется как handler-лямбда — компилятор смотрит на
ожидаемый тип Effect[EffectName] и:
- проверяет что эффект имеет ровно одну операцию,
- сопоставляет параметры лямбды с параметрами этой операции,
- разворачивает в полный handler-литерал.
Что компилятор проверяет
// ОК — эффект с одной операцией
type Logger effect { log(msg str) -> () }
with Logger = |msg| println(msg) { ... }
// ОШИБКА — у Db две операции, лямбда неоднозначна
with Db = |sql| ... { ... }
// ^^^^^^^^^^^
// error: handler-lambda requires effect with exactly one operation
// (Db has 2: query, exec)
// suggestion: use handler literal — effect Db { query(...) => ..., exec(...) => ... }
// ОШИБКА — параметры лямбды не совпадают с операцией
with Fail[Error] = || { ... } { ... }
// ^^^^^^^^^^^^
// error: handler-lambda parameter count mismatch
// expected one parameter (value Error), got zero
Почему
- Главный win —
Fail[E]обработка. В backend-кодеwith Fail[E] = |err| ... { ... }повторяется в каждой обработке ошибок. Сахар сокращает в 2-3 раза без потери семантики. - «Минимум строк на выходе» — один из центральных принципов Nova (01-philosophy.md → D10).
- Граница сахара чёткая — только в позиции
with EffectName =, только для эффектов с одной операцией. Не превращается в общую SAM-conversion. - Симметрия с closure-rev. После D22-rev
|x|— единый «pipe-маркер» для всех безымянных функций (closure как value, closure как arg, handler-лямбда). Программист учит одну грамматику.
Что отвергнуто
Полная SAM-conversion (любой type с одной операцией → лямбда). Это разрешало бы лямбды и для не-эффектных типов:
type Comparator { compare(a int, b int) -> int }
let c Comparator = |a, b| a - b // ← отвергнуто, не делаем
Причина: для эффектов сахар сильно окупается (Fail частый,
минимизация строк критична). Для обычных типов дублирует
функциональный тип fn(int, int) -> int без выгоды. Граница чёткая —
сахар работает только в позиции with EffectName =.
Handler-лямбда через (params) => (форма до 2026-05-10) —
заменена на |params| body ради симметрии с closure-rev. =>
освобождён от роли «лямбда-стрелки» и остаётся маркером тела
named fn / handler-method / match-arm.
Связь
- D11 — добавляет третью форму handler’а (помимо литерала и переменной).
- D25 —
throwкак операция эффектаFail[E], лямбда естественно её перехватывает. - 03-syntax.md → D22 —
closure-light
|x| body. Handler-лямбда — специализация в позицииwith EffectName =для эффектов с одной операцией. - 03-syntax.md → D40 — handler-method подчиняется
тому же правилу
=>↔{}, что иfn: или=> expr, или{ block }, никогда не вместе. - 03-syntax.md → D43 — trailing-block с
обязательными
()(сюда не применяется — здесь handler-выражение после=, не trailing-block).
Цена
- Парсер чуть сложнее — после
with X =нужно различить handler-лямбду (|...|), handler-литерал (handler-keyword) и переменную. Каждый случай распознаётся по первому токену. - Breaking change при добавлении операции — если эффект расширили, все handler-лямбды для него ломаются с compile error. Это корректное поведение (видимое нарушение контракта), но программисту нужно обновить код в нескольких местах.
- Два способа делать одно и то же. Сахар (
|params| body) и полная форма (effect EffectName { op() => ... }). Линт может предлагать сахар где он короче.
Эволюция
Ранее в open-questions.md была отрицательная запись «SAM-conversion
отвергнут». Сейчас пересмотрена: SAM принят с ограничением — только
для эффектов в with, только при одной операции. Главный аргумент
пересмотра: «минимум строк на выходе» — with Fail[E] = effect Fail[E] { fail(err) => ... } повторяется в каждой обработке ошибок.
Ревизия (2026-05-10): handler-лямбда мигрирована с (params) => expr
на |params| body симметрично closure-rev D22.
Тело теперь может быть expression ИЛИ block (раньше — только expression).
Семантика не изменилась. Migration: ~15 примеров в spec.
D61. Полная семантика эффектов: effect keyword, handler-литерал, Effect[E], interrupt
Что
Закрывающий блок системы эффектов. Фиксирует:
type Foo effect { ... }— отдельный keyword для объявления типа эффекта (вместо ранее использовавшегосяprotocol).effect Foo { ... }— keyword для handler-литерала (значения, реализующего эффект).Effect[E]— тип значения handler-литерала, first-class.- Effect-row — неупорядоченное множество, дубликаты запрещены.
return v/ финальное выражение в handler-method — нормальное завершение, значение идёт в caller операции (continuation возобновляется).interrupt v— досрочное завершение всегоwith-блока, новый keyword.- tail-position для
return/interrupt— код после запрещён. Effect[E].op(args)— прямой вызов операции на handler-значении, минуя with-стек.- Тип
with-блока — единый типT, который дают и финальное выражение body, и все handler-method’ы (когда они не делаютinterrupt). - Алгоритм компиляции/интерпретации — пошаговое тех-задание для имплементатора (раздел ниже).
Этот блок закрывает Q-resume-semantics и Q-handler-method-param-inference.
Правило
1. type Foo effect { ops } — объявление эффекта
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
exec(q Sql) Fail[DbError] -> int
in_transaction[T](body fn() Db Fail -> T) Fail -> T
}
Generic-методы в effect-объявлении (например, in_transaction[T])
требуют rank-2 polymorphism — один handler работает с любым T для
каждого вызова. Точная семантика type-checker’а для rank-2 в effect-
методах — открытый вопрос (Q6). Bootstrap-
интерпретатор поддерживает через runtime erasure (T мономорфизуется
как any на уровне dispatch’а); production-компилятор должен дать
формальное правило.
type Logger effect {
log(msg str) -> ()
}
type Fail[E] effect {
fail(value E) -> never
}
Раньше эффекты объявлялись через type X protocol { ... } (D18,
D53). Теперь — отдельный keyword effect. Причина:
эффект и protocol — семантически разные контракты:
protocol— структурный интерфейс, проверяется на типе значения параметра (fn sort[T Hashable](xs []T), D72). Статический dispatch.effect— контракт на наличие активного handler’а в скоупе (fn save() Db -> ()). Lookup в with-стеке, динамический dispatch.
Смешение запрещено:
fn f[T Db](x T)— compile error:Dbэто эффект, не protocol.fn f() Hashable -> ()— compile error:Hashableэто protocol, не effect.
2. effect Foo { ops } — handler-литерал
Значение, реализующее эффект Foo. Появляется в let-биндинге, в
with X = ..., в return-position функций, в аргументах:
// Место 1 — let-биндинг
let postgres_db = effect Db {
query(q) => real_query(q)
exec(q) => real_exec(q)
in_transaction(body) => real_transaction(body)
}
// Место 2 — внутри with
with Db = effect Db {
query(q) => []
exec(q) => 0
in_transaction(body) => body()
} {
process()
}
// Место 3 — return из функции (декоратор)
fn with_audit(real Effect[Db]) -> Effect[Db] => effect Db {
query(q) => real.query(q)
exec(q) {
spawn write_audit(q)
real.exec(q)
}
in_transaction(b) => real.in_transaction(b)
}
// Место 4 — аргумент функции
fn run_with(h Effect[Db], body fn() Db -> ()) -> () {
with Db = h { body() }
}
Handler-литерал содержит handler-method’ы — по одному на каждую
операцию эффекта. Тело handler-method’а — => expr или { block },
как у fn (D40).
3. Effect[E, IRT] — тип значения
Effect[E, IRT] — встроенный generic-тип, не объявляется в
пользовательском коде. Параметризован эффектом E и типом
interrupt’а (IRT — interrupt-return type), полностью описан в
D87.
Effect[E] ≡ Effect[E, never] — sugar (через
D88 default generic)
для handler’а, который не делает interrupt.
Источники значений:
- handler-литерал
effect EffectName { ops }— выражение типаEffect[E, IRT](IRT inferred из interrupt’ов в теле; если их нет —never) - handler-лямбда
|args| bodyдля одно-операционных эффектов (D31)
Effect[E, IRT] — first-class:
let h = effect Db { ... } // h: Effect[Db, never] (нет interrupt)
let arr = [h, h2, h3] // в массив
let pair = (h, "label") // в кортеж
fn make() -> Effect[Db] => h // вернуть из fn (never по default)
fn use(h Effect[Db]) { ... } // принять как параметр
// Handler с interrupt типа int:
fn make_fatal() -> Effect[Logger, int] => effect Logger {
log(msg) {
if msg.starts_with("FATAL") { interrupt -1 }
println(msg)
}
}
Effect-type vs Effect[E] — где какой использовать
Это два разных типа, и компилятор различает их по позиции:
| Тип | Где допустим | Что значит |
|---|---|---|
Foo (effect-тип сам по себе) | effect-position сигнатуры (между ) и ->), позиция эффекта в with X = ... | контракт «нужен handler в скоупе» |
Effect[Foo] (тип значения) | позиция типа значения: тип переменной, тип параметра, тип return | конкретное handler-значение, которое можно передавать |
Конкретные правила:
let h Fail[Error] = ...— compile error:Fail[Error]не тип значения. Должно бытьlet h Effect[Fail[Error]] = ....fn f() Fail[Error] -> ()— OK:Fail[Error]в effect-position.fn make() -> Effect[Fail[Error]] => effect Fail[Error] { ... }— OK: возвращаемый тип =Effect[Fail[Error]], литерал даёт это значение.fn run(h Effect[Fail[Error]]) -> () { with Fail[Error] = h { ... } }— OK: параметр-handler в позиции типа значения, вwith— effect-тип.
Эта строгая разделённость позиций — не ради «чистоты», а ради
disambiguation при чтении. Один и тот же синтаксический токен Foo
парсится в effect-row или в обычной type-position, и эти позиции
грамматически различимы. Правило «compile error при попытке
смешать» — gatekeeper, чтобы случайные ошибки ловились на type-check.
4. Effect-row неупорядочен, дубликаты запрещены
fn process(o Order) Db Logger Fail[E] -> ()
fn process(o Order) Logger Db Fail[E] -> () // та же сигнатура
Effect-row — множество, не список. Порядок не определяет сигнатуру. Lookup в with-стеке индексирует по имени типа эффекта.
Дубликаты одного и того же эффекта — compile error:
fn bad() Db Db -> () // ОШИБКА: duplicate effect `Db`
Разные параметры одного generic-эффекта — разрешены (D65):
fn process(s str) Fail[ParseError] Fail[RuntimeError] -> int { ... }
// ОК: multi-Fail в row,
// см. D65
Это применимо ТОЛЬКО к параметризованным эффектам, у которых разные
type-аргументы дают разные effect-роли. Для Fail[E] — это canonical
паттерн composition’а (см. D65).
Convention для записи: алфавитный порядок или по «частоте использования» (программистский выбор), но это convention, не grammar.
5. Завершение handler-method’а — return / финальное выражение
Handler-method ведёт себя как обычная функция. Возвращает значение в caller операции (continuation возобновляется с этим значением):
effect Db {
query(q) => real_query(q) // финальное выражение = return
exec(q) {
let r = real_exec(q)
return r // явный return
}
}
С точки зрения caller’а операции — это обычный возврат:
let rows = Db.query(q) // получает результат query
println(rows.len()) // программа продолжается
6. interrupt v — досрочное завершение with-блока
Когда handler-method хочет прервать continuation и сделать так, чтобы
вместо вызова операции из Db.query(...) весь with-блок сразу
вернул v — используется interrupt v:
effect Fail[E] {
fail(err) => interrupt -1 // throw перехвачен; with-блок отдаёт -1
}
effect Db {
query(q) => real_query(q) // обычное завершение
exec(q) {
if dangerous(q) {
interrupt 0 // прервать с 0, не выполнять SQL
}
real_exec(q)
}
}
Семантика:
interrupt vвалиден только внутри handler-method’а. Вне — compile error.- После
interrupt vcontinuation не возобновляется. Значениеvстановится результатом всегоwith-блока. - Handler-method, в котором сработал
interrupt, считается завершённым. Code послеinterruptв той же ветке — compile error (мёртвый код).
Тип аргумента interrupt v — это тип with-блока (W), не
return-тип операции. Компилятор знает W через type inference сверху
вниз для всего with-блока (см. раздел «Тип with-блока» ниже).
Для каждого handler-method’а:
| Путь завершения | Тип v должен быть |
|---|---|
return v или финальное выражение | return-тип операции (R из декларации) |
interrupt v | тип with-блока (W) |
Это разные типы: R определяется effect-декларацией статически,
W — контекстом where the with appears. Один handler-method может
смешивать оба завершения в разных ветвях:
type Db effect {
query(q Sql) -> []DbRow // R = []DbRow
}
let result = with Db = effect Db {
query(q) {
if q.template == "" {
interrupt 42 // здесь v: int (W = int — см. body ниже)
}
real_query(q) // здесь финальное выражение: []DbRow (R)
}
} {
let rows = Db.query(some_q)
rows.len() // body даёт int → W = int
}
// result: int
Чтобы это валидно проходило type-check:
- В ветке
interrupt 42—42: int, совместимо сW = int. ✅ - В ветке
real_query(q)—[]DbRow, совместимо сR = []DbRow. ✅ - Body даёт
rows.len: int, совместимо сW = int. ✅
7. Tail-position для return и interrupt
После return v или interrupt v в той же ветке — код запрещён
(аналогично D23 для return в обычной функции):
exec(q) {
return real_exec(q)
println("dead") // ОШИБКА: код после return недостижим
}
exec(q) {
if dangerous(q) {
interrupt 0 // OK — последняя инструкция в ветке
} else {
return real_exec(q) // OK — последняя инструкция в ветке
}
// OK — код после if/else возможен, если хотя бы одна ветка не выходит
}
В match каждая arm — отдельная tail-position. То же что для обычных
функций.
8. never-операции и interrupt
Операция типа never (классический пример — Fail.throw) не имеет
валидных значений возврата. Поэтому в её handler-method’е:
return vзапрещён (нет значения типаnever).- Финальное выражение запрещено.
- Единственный валидный путь —
interrupt v, гдеvимеет тип результата with-блока.
effect Fail[Error] {
fail(err) => interrupt log_and_default(err) // OK
}
effect Fail[Error] {
fail(err) => err.message // ОШИБКА: return запрещён для never
}
throw expr — keyword-сахар над Fail[E].fail(expr)
Keyword throw expr — синтаксический сахар над операцией fail
эффекта Fail[E]:
throw expr
// разворачивается в
Fail[E].fail(expr)
Семантика:
- Тип
throw expr—never(как и операцияfail). - Требует активный handler для
Fail[E]где-то выше по стеку. Без него runtime panic «no handler for effect Fail[E]» (либо compile error, если static-анализ доказал что handler никогда не активен). - Type checker проверяет, что в эффект-row enclosing-функции есть
Fail[E](или эффект может быть выведен через D28 для private-функций). - Тип
Eдляthrow exprопределяется типомexpr(или явной параметризацией еслиFail[E]указан в сигнатуре с конкретнымE).
Связь с ? (D4):
expr?
// разворачивается в
match expr {
Ok(v) => v
Err(e) => throw e // throw e ≡ Fail[E].fail(e)
}
То есть ? это сахар над match + throw, а throw — сахар
над Fail[E].fail(...). Никаких специальных правил компилятора
для throw или ? — всё через стандартный effect-механизм.
never как тип значения, совместимый с любым
never — bottom-тип (02-types.md → D26). Значений
типа never не существует, но позиция типа never совместима с
любым другим типом при type-check. Это нужно потому что:
throw expr(типnever) может стоять в любой позиции:let x int = throw e,if cond { throw e } else { 42 }, и т.д.- Body
with-блока, который всегда заканчивается throw’ом, имеет типnever. Тогда типwith-блока = типinterrupt-веток handler’а (потому что body не возвращает значение нормально).
Пример:
let i = with Fail[Error] = effect Fail[Error] {
fail(err) => interrupt -1
} {
throw Error.new("bad") // тип throw — never
}
// type of i = int
// body's type = never (всегда throw)
// handler interrupt's type = int
// объединение: int (never совместим с int)
Алгоритм типизации with-блока:
T_body— тип финального выражения тела (может бытьnever).T_handler[i]— тип каждогоinterrupt vпути в каждом handler-method’е.W(тип всего with-блока) = наименьший общий тип всехT_bodyи всехT_handler[i].neverпоглощается любым типом (то естьlub(never, T) = Tдля любогоT).
9. Прямой вызов h.op(args) на handler-значении
Handler-значение поддерживает прямой member-call к своим операциям:
let real = effect Db { query(q) => real_query(q), ... }
let rows = real.query(sql`SELECT 1`) // прямой вызов на handler-значении
Семантика:
h.op(args)исполняет handler-methodopна значенииhнапрямую.- Минует with-стек — runtime не ищет handler по имени, использует
именно
h. - Handler-method’ы внутри
hмогут использовать другие эффекты (через свой собственный with-скоуп, или через прямой вызов на ещё одном handler-значении). interrupt vвнутриh.op(args)прерывает этот вызов, не enclosing-with-блок. Значениеvстановится результатомh.op(args).
Это нужно для handler-декораторов:
fn with_audit(real Effect[Db]) -> Effect[Db] => effect Db {
query(q) => real.query(q) // вызываем real напрямую
exec(q) {
spawn write_audit(q)
real.exec(q) // вызываем real напрямую
}
}
h.op(args) — не сахар для with E = h { E.op(args) }, это разные
механизмы с разной семантикой при вложенных вызовах:
real.exec(q)—realне попадает в with-стек. Если внутриrealестьDb.exec(...), он найдёт handler из внешнего скоупа.with Db = real { Db.exec(q) }—realкладётся в стек как активныйDb. ВложенныйDb.exec(...)внутриrealрекурсивно снова попадёт вreal.
Для handler-декораторов это критично: если бы with_soft_delete использовал
with Db = real { ... } вместо real.exec(q), любой вложенный вызов
Db.exec(...) внутри real снова проходил бы через with_soft_delete —
бесконечная рекурсия. Прямой вызов явно говорит «вызови именно этот
handler-объект, не ищи в стеке».
Без прямого вызова декоратору пришлось бы оборачивать в with:
exec(q) {
spawn write_audit(q)
with Db = real { Db.exec(q) } // длиннее, и семантика другая
}
Канонический пример: handler через переменную
Тип-разграничение «Foo в effect-position vs Effect[Foo] в
value-position» лучше всего видно на примере, где handler сначала
кладётся в переменную, а потом передаётся в with:
fn make_recovery() -> Effect[Fail[Error]] => effect Fail[Error] {
fail(err) => interrupt -1
}
let h = make_recovery() // тип h: Effect[Fail[Error]]
let i = with Fail[Error] = h { // Fail[Error] здесь — effect-position
throw Error.new("not good")
}
// тип i = int (через never-совместимость + interrupt -1)
По строкам:
Effect[Fail[Error]]— return-тип фабрики (позиция типа значения).effect Fail[Error] { ... }— handler-литерал, выражение типаEffect[Fail[Error]].let h = make_recovery()— биндинг handler-значения. Тип переменной выводится:Effect[Fail[Error]].with Fail[Error] = h { ... }—Fail[Error]в effect-position (контракт),h— конкретное handler-значение.throw Error.new("not good")— keywordthrowраскрывается вFail[Error].fail(Error.new("not good")). Тип throw-выражения =never.interrupt -1в handler-method’е — даётintкак результат всего with-блока.iимеет типint(never из body совместим с int из interrupt).
Невалидные альтернативы:
let h Fail[Error] = ...— compile error:Fail[Error]не type-position. Нужноlet h Effect[Fail[Error]] = ....with Effect[Fail[Error]] = h { ... }— compile error:Effect[Fail[Error]]не effect-position. Нужноwith Fail[Error] = h { ... }.
10. Тип with-блока
let r = with Db = h { body }
Тип r определяется так:
T_body— тип финального выражения body.- Для каждого handler-method’а, который может завершиться без
interrupt(т.е. черезreturn vили финальное выражение): типvдолжен быть совместим с типом, ожидаемым caller’ом операции (т.е. с return-типом операции в decl). - Для каждого handler-method’а, который может завершиться с
interrupt v: типvдолжен быть совместим сT_body. - Тип
r=T_body.
Несовпадения — compile error:
let r = with Fail[E] = effect Fail[E] {
fail(err) => interrupt "fail" // handler даёт str
} {
fetch_user_id()? // body даёт int
}
// COMPILE ERROR: handler interrupt type str != body type int
11. Параметры handler-method’а
Имена параметров handler-method’а биндят аргументы операции по позиции. Типы выводятся из effect-декларации — писать их в handler-литерале не обязательно (закрывает Q-handler-method-param-inference):
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
}
effect Db {
query(q) => real_query(q) // q: Sql выводится из decl
}
// Явные типы тоже разрешены (для документации):
effect Db {
query(q Sql) => real_query(q) // OK, но избыточно
}
Алгоритм компиляции/интерпретации эффектов
Это тех-задание для имплементатора. Пошагово описывает что делает компилятор и runtime для каждой конструкции эффекта. Без этого раздела любая независимая имплементация выберет своё поведение и сломает совместимость.
При парсинге type Foo effect { ops }
- Парсер регистрирует тип
Fooкак effect-тип. - Каждая
op(params) effects? -> Rв теле — сигнатура операции. Сохраняется в symbol-table эффектаFoo: имя, типы параметров, row эффектов внутри (опц.), return-тип.
При парсинге effect Foo { handler-methods }
- Парсер ищет
Fooв symbol-table — должен быть effect-тип. Если protocol или другой тип — compile error. - Каждый handler-method
name(params) bodyсопоставляется с операциейFoo.name. Имена операций должны точно совпадать. - Каждая операция эффекта обязана иметь handler-method
(full coverage). Иначе compile error «handler missing operation
name». - Параметры handler-method’а биндятся по позиции к параметрам декларации операции; типы инферируются.
- Возвращается значение типа
Effect[Foo].
При парсинге with EffectName = handler-expr { body }
EffectNameищется в symbol-table — должен быть effect-тип.handler-exprдолжен иметь типEffect[EffectName]. Иначе compile error.- Тип
bodyопределяется по правилам выше (раздел «Тип with-блока»). with X = h1, Y = h2 { body }равно вложенным with’ам:with X = h1 { with Y = h2 { body } }.
При вызове операции EffectName.op(args)
- Type checker:
EffectNameсуществует и это effect.EffectNameприсутствует в effect-row enclosing-функции (или активен в текущем with-скоупе через inference, D28).- Типы
argsсовместимы с декларацией операции.
- Runtime (interpreter / codegen):
- Ищет в handler-стеке handler с тегом
EffectName. Стек просматривается сверху вниз, берётся первый найденный. - Если не найден — runtime panic «no handler for effect
EffectName». - Найденный handler — значение типа
Effect[EffectName]. Из него извлекается handler-methodop. - Управление передаётся в handler-method с биндингом параметров.
- Continuation сохраняется (или, в (II) tail-only, не сохраняется — см. ниже).
- Ищет в handler-стеке handler с тегом
При завершении handler-method’а
В семантике (II) tail-only — текущая принятая семантика Nova:
- Handler-method это обычный блок. Finalize:
return vили финальное выражение — handler-method заканчивается нормально. Значениеvпередаётся в caller операции через стандартный return-механизм (тот же что для обычных функций). Continuation = «остаток caller’а после операции» — продолжается обычным flow-of-execution.interrupt v— handler-method заканчивается аномально. Значениеvстановится результатом всегоwith-блока. Continuation не запускается (никакой возврат в caller операции).
Технически в (II):
- Continuation не нужно сохранять как объект — она это просто «продолжение текущего call-stack’а после операции».
return v⇒ обычный возврат из handler-method-fn, значение становится результатом операции для caller’а.interrupt v⇒ исключение-подобный escape: runtime разворачивает стек до границы текущегоwith-блока, делаетvего результатом.
Это позволяет реализовать эффекты без специального fiber-runtime’а:
обычный stack, обычные вызовы функций, исключение-подобное interrupt.
Цена — нельзя писать код после возврата continuation в handler-method’е
(нет resume в полной семантике).
В полной семантике (Koka, OCaml 5) continuation сохраняется как first-class объект, может вызываться явно. Это требует stack-снимка (corosensei в OCaml 5) или CPS-преобразования (Koka). Nova не идёт по этому пути — выбираем (II) ради простоты понимания и реализации. Если когда-нибудь потребуется multi-step или multi-shot resumption — это будет отдельный D-блок, отложен под Q-multishot-resume.
При прямом вызове h.op(args)
h— значение типаEffect[E].opищется среди handler-method’овh. Если нет — compile error.- Handler-method вызывается без push’а handler’а в with-стек.
- Continuation для этого вызова — обычный return (не возобновляет
что-то снаружи
h.op).interrupt vвнутри прерываетh.op, возвращаяvкак результат именно этого вызова.
Lifetime handler-стека
- При входе в
with X = h { body }— push(X, h)на стек. - При выходе из body (любым способом — нормально, через interrupt, через panic) — pop стека.
- Handler-стек локален текущему fiber’у/потоку. В bootstrap’е fiber один — стек глобальный.
Почему
-
Закрытие зияющего пробела в спеке. До D61 семантика resume, тип
Effect[E], поведение «без resume», запрет для never-операций — фактически использовались в коде, но не были формализованы. Любой имплементатор должен был догадываться. Теперь — пошаговый алгоритм, не требующий гипотез. -
Семантика «как обычный return» снижает порог входа. Программист, видящий handler-литерал впервые, должен понимать его за 30 секунд.
query(q) => real_query(q)— «возвращает значение для query», как обычная функция.interrupt— единственный новый keyword, используется редко, его легко выучить отдельно. -
(II) tail-only достаточна для backend-кода. Реальные handler’ы (Fail, Db, Logger, Time, Random, Cache) укладываются в две формы —
return vилиinterrupt vв tail-position. Полная resume-семантика с кодом-после-resume используется в backtracking и sampling-задачах, которые в Nova-целевой нише редкость. -
Раздельные
effect/protocol— семантически разные контракты (статический dispatch vs lookup в with-стеке). Один keyword для обоих создавал ложное ощущение взаимозаменяемости. -
Effect[E]first-class — нужен для handler-декораторов (orm_decorators.nv), которые выражают audit / soft-delete / replica-routing как обычные функции. Без first-class handler’ов это невозможно сделать без AOP/reflection. -
Прямой
h.op(args)— sugar для частого паттерна, без него декораторы пишутся в 2 раза длиннее через вложенныйwith. -
interruptотдельный keyword — однозначно сигнализирует «прервать continuation», не требует понимания что финальное выражение делает в зависимости от типа операции.
Что отвергнуто
-
Слово
resumeдля нормального завершения — литературное, но пользователь без опыта алгебраических эффектов не поймёт. В (II) tail-only это обычное возвращение значения, поэтому словоreturn(или финальное выражение, как у обычной функции) передаёт смысл точнее. -
returnв handler-method перегружен (значит «вернуть в caller операции», а в обычной функции «вернуть из самой функции»). Это технически правда, но семантика идентична для пользователя: «handler возвращает значение». Перегрузка минимальна. -
Полная continuation-семантика (multi-step resume) — отложено. Цена реализации высока (stack-снимки или CPS), польза в backend-коде низка. Если потребуется — отдельный D-блок и keyword.
-
Multi-shot resume — отложено как Q-multishot-resume. Backend Nova не нуждается в backtracking-эффектах.
-
Effect[E]какHandler[E]илиImpl[E]—Effect[E]это стандарт литературы (Eff, Koka, Effekt) и наш choice после Plan 97 Ф.3 / D142 (см. amendment ниже + D87 amendment). Раньше использовалсяHandler[E]— снят clean-break’ом для симметрии с keyword’ом литерала. -
Сохранение
protocolдля эффектов — раздельный keywordeffectснимает двусмысленность со structural-protocol’ами. -
Финальное выражение без keyword’а как «implicit interrupt» для never-операций — implicit поведение зависит от типа операции, AI-unfriendly. Явный
interruptдля never иreturn/финальное выражение для остальных — однозначно.
Связь
- D2 — концепция эффектов вместо keyword’ов.
- D10 — «всё — эффект» как центральная ставка.
- D11 — синтаксис
with X = h { body }. - D18 — отменено в части
«через protocol»; эффекты теперь через
effect. - D25 —
Fail[E]как эффект. D61 формализует, что Fail-handler используетinterrupt(не resume). - D31 — handler-лямбда
для одно-операционных эффектов. Сохраняется как сахар над
effect X { ... }. - D40 — handler-method body имеет две формы (
=> exprили{ block }), какfn. - D53 —
protocolостаётся для structural-интерфейсов. D61 расщепляет:protocolдля типов значений,effectдля эффектов. - 02-types.md → D55 — literal coercion применяется к параметрам операций как обычно.
- 03-syntax.md → D23 — tail-position для
return. D61 расширяет правило наreturn/interruptв handler-method’ах. - 06-concurrency.md → D80 — handler scoping
per-fiber. Семантика
with X = h { body }локальна для текущего fiber’а; черезspawnнаследуется snapshot. D80 — runtime invariant поверх D61.
Цена
-
Sweep по spec и examples. ~30+ файлов содержат
protocolдля эффектов (type Db effect { ... }) — переписать наeffect. Handler-литералы (Db { query(q) => ... }) →effect Db { ... }. Fail-handler’ы и другие, которые не делали resume — добавитьinterruptявно. -
Bootstrap-компилятор требует доработки. Сейчас (на момент D61):
effectkeyword не парсится — пока используетсяprotocol.handlerkeyword не парсится — handler-литерал распознаётся эвристикой поIdent (после{.interruptkeyword не парсится — нет в lexer’е.Effect[E]тип не понимается type checker’ом — это просто dynamic-typed value.- Прямой
h.op(args)не реализован.
-
Линтер
interruptдля Fail-handler’ов — нужен, иначе старые(err) => -1без interrupt’а будут проходить парсер, но семантически ломаются.
Эволюция
D61 — закрывающий блок системы эффектов. Закрывает Q-resume-semantics
(в варианте (II) tail-only) и Q-handler-method-param-inference (в
варианте (A) inference из protocol-сигнатуры). Также явно фиксирует
расщепление protocol/effect (раньше намеренно объединённое в D53,
но опыт показал — разные контракты, нужны разные keyword’ы).
Альтернативы которые рассматривались:
resume v(Koka-стандарт) — отвергнут, перегружает понятие для пользователя без опыта алгебраических эффектов.effect Db { ... }для handler-литерала (двойное использованиеeffect) — отвергнуто, путаница «тип/значение» через одно слово. REVERTED 2026-05-22, см. D142 — в Plan 97 принято обратное решение: keywordhandlerотменён, литерал записывается черезeffect X { ops }(clean break). Симметрия с объявлениемtype X effect { sigs }оказалась важнее изоляции «тип/значение» через отдельное слово; декларация vs литерал теперь различаются позицией (type ...префикс / выражение или let-инициализатор).Handler[E]→Effect[E]— отвергнуто, тавтология. REVERTED 2026-05-22, см. D142 — переименован вEffect[E, IRT]для симметрии с keyword’ом литералаeffect. Тавтология не подтвердилась практикой:Effect[Db]читается как «значение-effect для эффекта Db» — то же отношение «тип/контекст», что[]T(массив элементов типа T) илиOption[T]. См. D87 для обновлённого определения.- (I) полная resume-семантика — отложена до Q-multishot-resume, backend-фокус Nova не требует.
Plan 97 amendment (2026-05-22) — handler keyword retired
Pre-D142 status: keyword handler парсился для handler-литерала
(handler Db { query(q) => ... }), тип значения Handler[E, IRT].
D142 (post-Plan 97 Ф.3): keyword handler снят. Литерал
записывается через тот же keyword effect, что и объявление,
с дисамбигуацией по позиции:
// Объявление (как было)
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
}
// Литерал (изменилось: handler → effect)
let pg = effect Db {
query(q) => real_query(q)
}
// Тип значения (переименован Handler → Effect)
fn run(h Effect[Db]) -> () => with Db = h { ... }
Парсер различает декларацию и литерал по leading-keyword’у type:
type X effect { ... } — declaration, effect X { ... } (без
type) — literal. То же правило, что для protocol (см. D53 + D142
- D143).
Clean break — миграция через sweep одной CL’ой (nova_tests/**,
std/**, examples/**, spec/**). Backwards-compat не сохраняется.
D62. Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol
Что
Финальная ревизия философии эффектов после большой дискуссии о
транзитивности, Async, Mut, и правиле выбора effect/protocol.
Закрывающий блок этой темы.
Четыре связанных решения:
- Прямые эффекты в сигнатуре, не транзитивные. Функция объявляет только те эффекты, чьи операции она использует сама, не через вложенные вызовы.
Failstrict. ЭффектFail[E]обязателен в сигнатуре везде, где может произойти throw — прямойthrow eилиexpr?(который desugar’ится в throw). Транзитивный throw через границы вызовов тоже требуетFailв сигнатуре caller’а. Это исключение из правила «прямые эффекты».Async— ambient capability. Не пишется в сигнатурах, не является частью type system’ы. Fiber-runtime — реализационный механизм под капотом.- Правило выбора
effect/protocolдля программиста — два вопроса. Сознательный выбор; compile-time enforcement = последствие. Mut[T]убран из стандартного набора эффектов. Реальные use-case’ы покрываются специализированными эффектами или локальнымиlet mut.
Это большая ревизия философии. Ослабляется R5.2 «сигнатура = полное описание»: теперь сигнатура показывает только прямые эффекты
- Fail транзитивно. Транзитивные эффекты других типов — лишь warning’ом подсвечиваются. R6 capability-режим ослабляется аналогично.
Правило 1. Прямые эффекты в сигнатуре
Что считается «прямым» использованием
Функция использует эффект прямо (и обязана его декларировать), если в её собственном теле:
- Вызывается операция эффекта:
Db.exec(...),Log.info(...). - Используется keyword-сахар, разворачивающийся в операцию эффекта:
throw e⇒Fail[E].fail(e),expr?⇒ throw на ошибке.
Функция использует эффект транзитивно (НЕ обязана декларировать, но есть warning), если:
- Вызывает другую функцию, в чьей сигнатуре эффект объявлен.
- Транзитивный throw через границы — исключение, см. правило 2.
fn save(u User) Db -> () {
Db.exec(...) // прямое использование Db — Db в сигнатуре
}
fn helper(u User) -> () {
save(u) // транзитивное Db — warning, можно подавить
}
Семантика проверки
Type checker:
- Прямой эффект не объявлен в сигнатуре → compile error
- Транзитивный эффект не объявлен → warning (suppressable)
- Активный handler в runtime отсутствует на момент операции → runtime fail (panic)
Подавление warning’а
Программист может явно подавить warning:
#allow_transit(Db, Log)
fn helper(u User) -> () {
save(u) // save имеет Db Log, но helper не объявляет — без warning
}
Или через настройку Nova.toml для проекта:
[lints]
transit_effects = "off" # disable warnings for whole project
# или
transit_effects = "error" # treat as compile error (strict mode)
Программист контролирует уровень дисциплины для своего кода.
Правило 2. Fail strict — исключение из «прямых»
Fail[E] обязателен в сигнатуре функции всегда, когда внутри
неё может произойти throw — прямой или транзитивный:
- Прямой
throw eилиexpr?в теле →Fail[E]в сигнатуре. Иначе compile error. - Транзитивный throw через вызов функции с
Fail[E']в её сигнатуре →Fail[E'](или совместимая) в сигнатуре caller’а. Иначе compile error, не warning.
fn parse(s str) Fail[ParseError] -> int {
if invalid(s) { throw ParseError.Bad } // прямой throw — Fail обязан
...
}
fn pipeline(s str) Fail[ParseError] -> int {
let n = parse(s)? // ? = throw — Fail обязан
n
}
fn caller(s str) Fail[ParseError] -> int { // ОБЯЗАН Fail (transit)
pipeline(s) + 1
}
fn caller(s str) -> int { // COMPILE ERROR
pipeline(s) + 1 // pipeline может throw, не объявлено
}
Почему Fail — исключение
Throw это изменение control-flow, не side-effect. Программист
обязан знать что вызов может «не вернуться нормально», иначе
происходят bugs типа Java RuntimeException — невидимые crash’и.
Это центральный аргумент checked exceptions из Java и Result<T,E>
из Rust.
В Nova Fail[E] — типизированная версия checked-throw. Транзитивность
сохраняется, чтобы caller всегда знал «может бросить — обработай
или объяви».
Остальные эффекты (Db, Log, Time, …) — не control-flow, а side-effects. Они меняют мир, но не ломают возврат значения. Программист может позволить себе их не отслеживать транзитивно.
Альтернатива через with
Если caller хочет обработать throw локально и не объявлять Fail:
fn caller(s str) -> int {
with Fail[ParseError] = |e| interrupt 0 {
pipeline(s) + 1
}
}
Handler ловит throw, дает дефолт. caller возвращает int, без Fail в сигнатуре.
Правило 3. Async — ambient capability
Async не является эффектом в Nova. Не пишется в сигнатурах, не
является частью type system’ы.
fn fetch(url str) Net -> Response { // НЕТ Async
Net.get(url)
}
fn double(x int) -> int => x * 2 // тоже без Async
Под капотом — fiber-based scheduler. Функции могут suspend на yield-point’ах (network, sleep, channel.recv, async-Db). Это деталь реализации, не контракт типа.
Цвета функции нет — нет деления sync/async. Программист пишет код, fiber-runtime сам решает где можно вытесняться.
spawn, parallel for, supervised, with_timeout, race —
остаются как runtime-конструкции (keyword’и или библиотечные
функции), не как эффекты:
// Гомогенный fan-out — массив результатов через parallel for.
fn fetch_dashboard(uid int) Net Fail -> Dashboard {
let users_and_posts = parallel for kind in ["users", "posts"] {
fetch_section(uid, kind)
}
Dashboard.ok(users_and_posts)
}
// Гетерогенная параллельность — mut-захваты в supervised.
fn handle_request(req Request) Net Db -> Response {
let mut users = []
let mut posts = []
supervised {
spawn { users = fetch_users() } // spawn — fire-and-forget statement
spawn { posts = fetch_posts() }
}
Response.ok(users, posts)
}
spawn body сам по себе возвращает unit — не результат body.
Результат — только через прямой вызов (async прозрачный), parallel for (массив) или mut-захваты (см. D50 п. 2).
Почему так
В backend-коде Nova почти каждая нетривиальная функция async —
ходит в Db, Net, sleep’ит. Если Async в сигнатуре — он там
везде. Информативность нулевая. Это шум.
Решение: убрать Async из типов. Программист не пишет, не выводит,
не помнит. Fiber-runtime просто работает.
Прецедент: Go (горутины могут вытесняться где угодно, нет async-
keyword’а), Erlang/Elixir (то же). Async в типах остаётся в Rust
(где async важен из-за no-runtime), C# (где async из-за callbacks),
Koka (academic effects). В Nova не нужен.
Правило 4. effect vs protocol — критерий resource-capability
Формулировка
Эффект описывает resource-capability — нечто, что можно подменить handler’ом в скоупе. Suspension и runtime-механизмы — не resource, а ambient mechanic, общая для всех асинхронных операций; они НЕ эффекты.
Resource-capability — концептуальная единица, к которой имеет смысл question «может ли это быть подменено в тесте?». Если да — это effect (handler-substitution). Если нет — это либо runtime-mechanic (не существует в типах), либо обычный protocol на значении.
Применение к стандартным эффектам:
| Эффект | Resource-capability? | Подменяется в тесте? |
|---|---|---|
Time | clock | fixed_ms(ms) ✓ — фиксированный момент; mut_clock(start_ms) ✓ — sleep продвигает виртуальное время |
Random | RNG | seeded(seed) ✓ — xoshiro256++ deterministic PRNG |
Db/Net/Fs | соединение/socket/fd | in-memory handler ✓ |
Mem | alloc counter | mock-counter (для leak-тестов) ✓ |
Detach | background supervisor | SyncDetach ✓ |
Blocking | OS-thread pool | mock ✓ |
Async | fiber scheduler | не подменяется (runtime mechanic) — НЕ effect |
Источник test-handler’ов:
std/testing/handlers.nvэкспортируетseeded(seed u64) -> Effect[Random](xoshiro256++ — tier с Go math/rand v2 PCG и RustrandChaCha8),fixed_ms(ms u64) -> Effect[Time],mut_clock(start_ms u64) -> Effect[Time]. Production-handler’ы (secure()CSPRNG,system_clock()realtime) — отдельный план (требуют runtime hooks: BCryptGenRandom/getrandom + libuv).
Decision flow для программиста
В Nova два разных способа описать «что-то с операциями»:
- «Как делать что-то» — функция объявляет, что ей нужны
такие-то операции, а какая реализация будет под ними — решает
вызывающий код через
with-блок (например, для прода — Postgres, для теста — in-memory). Это эффект, объявляется черезtype X effect { ... }. - «Что умеет значение» — реализация жёстко привязана к типу:
intхешируется так-то,str— так-то, и менять это нельзя. Это протокол, объявляется черезtype X protocol { ... }.
Когда использовать эффект, а когда протокол в коде: если хочется при тестировании использовать другую реализацию — это эффект. Если при тестировании мы просто работаем со значениями типа, и подменять там нечего — это протокол.
Особый случай — runtime mechanic (Async/fiber scheduler,
GC/region) — в типах не объявляется ни как effect, ни как
protocol. См. Async, Mem/Trace instrumental эффекты в
D26.
Decision matrix — канонические случаи
| Тип / контракт | Resource? | Continuation? | Решение | Why |
|---|---|---|---|---|
| Структурные protocols (значения) | ||||
Hashable | нет (у каждого значения свой hash) | нет | protocol | bound на T в HashMap[K Hashable, V] |
Ord | нет | нет | protocol | bound в priority queue, сортировке |
Eq | нет | нет | protocol | bound в множествах |
Iter[T] | нет (конкретный итератор) | нет | protocol | for-in / collect через D58 |
From[T] / Into[T] | нет | нет | protocol | conversion (D73) |
TryFrom[T,E] | нет | нет | protocol | fallible conversion (D77) |
| Resource-capabilities (effects) | ||||
Db | соединение к БД | нет | effect | mock в тестах через with Db = ... |
Net | сокет/HTTP-клиент | нет | effect | recorded responses |
Fs | файловая система | нет | effect | virtual-fs handler |
Time | clock | нет | effect | fixed_ms(...) ✓ (uuid v7, jwt); mut_clock(...) ✓ (rate_limiter, retry, cron — advance via sleep) |
Random | RNG | нет | effect | seeded(...) ✓ — xoshiro256++ (uuid v4, ulid, snowflake, bcrypt) |
Log | logger sink | нет | effect | capture-log в тестах |
Trace | distributed tracer | нет | effect | в-memory trace |
Io | stdout/stderr | нет | effect | mock-stdout |
Cache[K,V] | кэш-провайдер | нет | effect | in-memory mock |
Authn/Authz | identity / capability | нет | effect | fixed-user в тестах |
Idempotency | dedup-store | нет | effect | in-memory mock |
| Continuation-effects | ||||
Fail[E] | error reporter | да (throw → never) | effect | один на язык, особый |
| Resource + instrumental | ||||
Mem | alloc counter | нет | effect (instrumental) | observability, ambient (D26) |
| Не существует в типах | ||||
Async | fiber scheduler | — | runtime mechanic | suspension ambient (D14/D62) |
| GC / region | memory allocator | — | runtime mechanic | implicit (D6) |
Кейсы где границы нечёткие
-
Loggerкак protocol: возможно, если используется черезfn f(log Logger)parameter passing без mock. Но 99% случаев — effect (тесты подменяют). Default —effect. -
ComparablevsOrdeffect:Ordвсегда protocol (bound). Если нужно «глобальный compare-handler в тесте» — это очень редкий use-case, лучше через named-fn-параметр. -
Cache[K,V]: effect, потому что нужен mock в тестах (бесплатныйwith Cache = noop_cache). Если cache — value-handle (как Channel), то protocol; но обычно — handler-driven.
Аналогия со статическим классом
effect — это как статический класс с методами в C#/Java
(Math.sqrt, Math.abs): нет инстансов, методы вызываются через имя
(Db.query(...)). В отличие от обычного статического класса, у
effect:
- Реализация подменяется через
with(статический класс не подменяется без рефлексии). - Operations могут захватывать continuation через
throw/interrupt.
Если эти два свойства не нужны — это просто protocol на инстансе,
не effect.
Compile-time enforcement = последствие
Type checker ловит несоответствие:
- Тип объявлен через
effect— используется в effect-position сигнатуры (fn f() Db -> ...), operations через имяX.op(...). - Тип объявлен через
protocol— используется в позиции значения (параметр, поле, generic-bound), operations через инстансx.op(...).
Смешение — compile error. Качество ошибок:
error: `Db` is an effect, not a protocol-bound
in fn f[T Db](x T)
^^ effect cannot appear as type-bound
hint: use effect-position instead:
fn f(x T) Db -> ...
Это gatekeeper, ловит ошибки выбора; не диктует, какой выбор делать.
Правило 5. Mut[T] убран из стандартного набора
Mut[T] как generic эффект не существует в стандартной библиотеке
Nova. Реальные сценарии mut-state покрываются:
- Локальные
let mut x— обычная mutable переменная, без эффекта. - Глобальное мутабельное состояние — через специализированные
effect’ы (
Counter,Cache,IdGen, etc.) с понятными именами и operations. - Атомарные счётчики, mutex’ы —
Atomic[T],Mutex[T]как тип-значения, не эффекты.
Каждый раз когда возникает соблазн «нужен Mut[T]», есть лучшая альтернатива: дать состоянию имя через специализированный эффект.
Если когда-то понадобится истинно generic Mut[T] — добавится отдельным D-блоком. На данный момент — не нужен.
Что меняется в R-главах
R5.2 «Сигнатура = полное описание»
Было: «по сигнатуре функции LLM/человек знает все побочные действия».
Стало: «сигнатура показывает прямые эффекты функции + Fail транзитивно. Side-effects через вложенные вызовы транзитивно warning’ом подсвечиваются — программист обязан знать, но не обязан писать».
Это сознательное ослабление ради компактности сигнатур в реальном backend-коде. Полная карта эффектов — расчётный артефакт, не часть spec’а.
R5.6 «Self-describing API»
Было: «по сигнатурам модуля видна полная карта эффектов».
Стало: «по сигнатурам видна карта прямых эффектов + полный throw-граф через Fail». IDE/линтер дают полную транзитивную карту по запросу.
R6 «Capability-режим»
Было: «функция без Net в сигнатуре физически не может ходить
в сеть».
Стало: «функция без Net в сигнатуре прямо ходить в сеть не
может; через вложенные вызовы — может, если их сигнатуры это
допускают». Реальная capability-sandbox реализуется на closure-
границах (декларация fn() -> T для callback’а гарантирует что
callback ничего не делает) или через явный whitelist эффектов
проекта (Nova.toml). Compile-time гарантия не транзитивная.
R7 «Async — эффект, не вирус»
Было: «Async — обычный эффект в сигнатуре. Без Future
Стало: «Async — невидимая инфраструктура. Не часть типа. Цвета функции нет. Fiber-runtime под капотом. Программист не пишет, не видит, не помнит». Глава переименована в «Fiber runtime — прозрачный async».
R3 «Детерминированный режим тестирования»
Было: «любую программу можно запустить полностью детерминированно, если все эффекты заменены».
Стало: «программу можно запустить детерминированно, заменив все используемые эффекты. IDE подсказывает какие эффекты вовлечены по транзитивному графу. Compile-time гарантия только для прямых».
Что НЕ меняется
- Грамматика effect-row в сигнатуре — без изменений.
- D11 with-синтаксис — без изменений.
- D25 Fail/throw/? — без изменений семантики, только подтверждается что Fail транзитивен.
- D31 handler-литералы — без изменений.
- D61 effect/handler keywords, interrupt, Effect[E] — без изменений. D62 это философское уточнение, не синтаксическое.
Стандартный набор эффектов (после D62)
| Эффект | Что описывает |
|------------|---------------------------------------|
| Fail[E] | Контракт для перехвата и обработки ошибки типа E |
| Io | stdin/stdout/stderr |
| Fs | Файловая система |
| Net | Сетевые запросы |
| Db | Базы данных |
| Time | Часы, таймеры, задержки |
| Random | RNG |
| Log | Структурированный лог |
| Trace | Распределённая трассировка |
| Ask[T] | Чтение из контекста (Reader) |
| Alloc[R] | Аллокация в регионе R |
Убраны: Async (ambient), Mut (специализированные эффекты вместо),
Par (runtime-keyword, не эффект).
Почему
-
Прагматизм vs дидактика. Полная транзитивность даёт максимально честные сигнатуры, но в реальном backend-коде эффект-row растёт до 8-10 имён, что тяжело читать. Прямые эффекты + Fail strict — баланс.
-
AI-first сохраняется частично. LLM по сигнатуре всё ещё знает прямое использование функции и полную throw-картину. Транзитивные side-effects через помощь IDE — не трагедия для AI, который и так читает несколько уровней.
-
Async как ambient — единственный разумный выбор. В backend-коде он везде. Если он эффект — он шум. Если ambient — программисту не надо думать. Прецедент: Go.
-
Mut[T] не нужен. Каждый раз когда возникает идея «mut-cell» — правильнее дать ей имя. Generic Mut[T] провоцирует анти-паттерн «безымянное shared state».
-
effect/protocolправило через подмену. Sniff-test «подменяю ли через with в тестах» — практически проверяемый критерий, не философская абстракция. -
R5.2 ослабление обоснованно. Чистая транзитивность в эффектах не существует ни в одном мейнстрим-языке. Nova остаётся впереди других языков (Java, Go, Python) в плане видимости throw + прямых эффектов, но не пытается решить «полную карту через типы», что неподъёмно для production-кода.
Что отвергнуто
- Полная транзитивность всех эффектов — обоснованно для революционной заявки, но громоздко в реальном коде. Принят компромисс «прямые + Fail strict».
..Erow-tail polymorphism — не нужен с прямыми эффектами. Closure-параметры не пробрасывают эффекты caller’у.Asyncкак явный эффект — везде в backend, шум.Mut[T]как generic эффект — анти-паттерн «безымянное shared state», предпочтительны специализированные.- Полное удаление эффектов из сигнатур (Java/Python style) — теряется проверка throw, теряется handler-substitution-видимость. Не идём так далеко.
- Compile-time гарантия capability через все границы — только на closure-границах с явной декларацией. Полная транзитивная capability-sandbox не дается типами.
Связь
- D2, D3 — синтаксис effect-row, без изменений.
- D11 —
withсинтаксис. - D25 — Fail/throw/?, теперь явно strict-транзитивный.
- D28 — effect inference, теперь только для прямых эффектов в private. Транзитивных нет.
- D61 — effect/handler keywords, Effect[E], interrupt. D62 это философское уточнение D61.
- 01-philosophy.md → D10 — AI-first пересмотрен в R-главах.
- revolutionary.md — R2/R3/R5.2/R5.6/R6/R7 обновлены.
Цена
- Sweep по spec и examples — убрать
Asyncиз всех сигнатур (~30+ мест). Перепроверить что в сигнатурах только прямые эффекты (большинство уже так — реальные функции используют свои эффекты напрямую). - Bootstrap-компилятор: warning для транзитивных эффектов,
strict для Fail. Атрибут
#allow_transitв парсере (опционально). - R-главы переписать — революционная заявка ослабляется. Это важно для маркетинга/документации, README.
Эволюция
D62 финализирует длительную дискуссию о транзитивности эффектов.
Изначально (до D62) Nova была транзитивной по всем эффектам — что
обоснованно для «AI-first язык где сигнатура говорит правду».
Опыт с реальными примерами (effect-density/) показал что сигнатуры
накапливают 8-10 эффектов, нечитаемые. Обсуждалось row polymorphism
(..E), но это сложно в type checker’е. Финальное решение:
прямые + Fail strict — баланс компактности и проверки control-flow.
Async всегда был спорным эффектом — везде в backend-коде, шум.
D62 переводит его в ambient capability. Глава R7 переписывается:
«не эффект-не-вирус», а «вообще не часть типа».
Mut[T] упоминался в R2 списке эффектов, но не имел реальных
use-case’ов. Каждый раз когда возникал — оказывался лучше через
специализированный эффект. D62 убирает его.
D65. Полная семантика Fail: гибрид Fail[E] / Fail, lookup, prelude RuntimeError и Error
Что
Закрывающий блок по теме обработки ошибок. Объединяет четыре связанных решения:
- Гибридная параметризация
Fail—Fail[E]типизированный (рекомендуется для public API) иFailбез параметра как сахар дляFail[any](catch-all, quick-and-dirty). - Subtype-aware lookup при throw: точный тип E →
Fail(any) → runtime panic. Match по конкретным вариантам sum — внутри handler’а через обычныйmatch. - Re-throw внутри handler’а через
throw expr— ищется outer handler в стеке. - Prelude-типы для runtime-ошибок: sum-тип
RuntimeErrorс фиксированным набором вариантов + recordError { msg }для пользовательских ошибок с сообщением.
D65 заменяет ранее существовавший unit-маркер type Error в prelude
(D26) на полноценный record. Также формализует
лукап-правило handler’ов для Fail, которое раньше было implicit.
Правило 1. Гибридная параметризация: Fail[E] или Fail ≡ Fail[any]
// Типизированный — рекомендуется для public API
fn parse(s str) Fail[ParseError] -> int {
if invalid(s) { throw ParseError.Bad }
}
// Сахар — Fail ≡ Fail[any], catch-all
fn quick_helper(s str) Fail -> int {
if bad { throw "raw string error" }
}
// Generic с явным [E] параметром — типизация через generics
fn retry[T, E](attempts int, body fn() Fail[E] -> T) Time Fail[E] -> T
// Caller:
retry(3, || parse("..."))
// ↑ возвращает Fail[ParseError] → E = ParseError
// retry имеет signature Fail[ParseError]
Семантика — две формы:
| Форма | Семантика | Use-case |
|---|---|---|
Fail[E] | typed — точный тип ошибки | public API, библиотечный код |
Fail ≡ Fail[any] | catch-all — сахар над erasure-формой; ловит throw любого типа | private fn, quick scripts, top-level supervisors |
Fail без параметра — синтаксический сахар над Fail[any]. Одна
форма с одной семантикой; никакой placeholder-инференс E не делается.
Если программисту нужна типизация — пишет Fail[E] явно (или
использует generic-параметр [E] как в retry выше).
Convention (рекомендация, частично enforce’ится линтером):
| Контекст | Форма | Линт |
|---|---|---|
export (public API) | Fail[E] с конкретным типом | warning если Fail без E |
| Library, переиспользуемый код | Fail[E] | warning |
| Internal/private helper | Fail[E] или Fail | ok |
| Quick-and-dirty / scripts / тесты | Fail | ok |
Generic в retry, transaction | Fail[E] через [E] | ok |
| Catch-all logger / supervisor | Fail или Fail[any] | ok (намеренный паттерн) |
Линтер может предупреждать «public-fn использует Fail без параметра» —
suppressable через настройку проекта.
Зачем Fail без параметра — catch-all use-case
Fail (sugar над Fail[any]) — не косметика. Это отдельная семантика
catch-all handler’а, без которой не выражаются три canonical паттерна:
1. Top-level supervisor:
fn main() Io -> () {
with Fail = |e| Log.error("uncaught: ${e}") {
run_app()
}
}
run_app() может бросать любые Fail[E1], Fail[E2], … — все
ловятся одним handler’ом. Без Fail (any) пришлось бы перечислять
все типы ошибок, что невозможно для composable systems.
2. Untrusted plugin / user code:
fn run_plugin(p Plugin) -> Result[(), str] {
with Fail = |e| interrupt Err(str.from(e)) {
Ok(p.execute())
}
}
Plugin может бросать что угодно (типы из его собственного кода, неизвестные caller’у). Catch-all позволяет sandboxить.
3. Quick scripts / REPL:
fn quick_check() Fail -> int {
let n = parse(input)? // Fail[ParseError]
let v = lookup(n)? // Fail[LookupError]
v + 1
}
В quick-and-dirty коде программист не хочет писать
Fail[ParseError | LookupError] — Fail достаточно.
Safety сохранена
Эффект Fail остаётся видимым в сигнатуре — главное свойство
системы эффектов не нарушено: caller знает, что функция может
бросить. Тип ошибки не указан — это compile-time рекомендация
(линт export-fail-untyped), не нарушение effect-safety.
Trade-off
| Форма | Use-case | Compile-time check |
|---|---|---|
Fail[E] | typed business errors | exhaustive match по E |
Fail ≡ Fail[any] | catch-all / supervisor / scripts | runtime is-check на handler-стороне |
Сознательный trade-off: catch-all теряет exhaustiveness в match
(handler получает значение типа any, программист использует
is-проверки или str.from(e)), взамен покрывает три use-case’а
выше.
Прецеденты
- Java unchecked exceptions (
RuntimeException) — catch-all без typed checked exceptions. Известная проблема: catch-all невидим в сигнатуре. Nova решает: видим, но не типизирован. - Go
errorinterface — единственный тип ошибки, runtime-typed. Прямой аналог NovaFail[any]. - Rust
Box<dyn Error>— explicit erasure для top-level error handling. Тоже прямой аналог.
Правило 2. Lookup при throw expr
throw expr это keyword-сахар над операцией эффекта Fail[E].fail(expr),
где E = type-of(expr). Runtime ищет handler в стеке:
- Точное совпадение — handler
Fail[E]где E совпадает с типом значения. Если найден — вызывается. - Catch-all — handler
Fail(≡Fail[any]). Если найден — вызывается. - Runtime panic «no handler for Fail» — если ни один не найден.
Lookup идёт сверху вниз стека (свежие handler’ы первыми, как для любого эффекта).
Match по sum-вариантам — внутри handler’а
Для перехвата конкретного варианта sum-типа использоваться handler один на тип + match внутри:
type RuntimeError | DivByZero | Overflow | IndexOutOfBounds
fn risky() Fail[RuntimeError] -> int {
throw RuntimeError.DivByZero // тип значения: RuntimeError
}
with Fail[RuntimeError] = |err| match err {
DivByZero => interrupt 0
Overflow => interrupt MAX_INT
_ => interrupt -1
} {
risky()
}
Тип брошенного значения — RuntimeError, не DivByZero (DivByZero это
sum-вариант, не отдельный тип). Поэтому Fail[DivByZero] не существует
для этого случая. Один handler Fail[RuntimeError], разбор внутри.
Subtype-aware lookup НЕ делается
Lookup проверяет точное совпадение типа, не subtype-relations.
Fail[RuntimeError] не ловит автоматически Fail[DivByZero] (если
DivByZero отдельный тип) и наоборот. Если нужна гибкость — программист
явно использует Fail (any) как catch-all.
Это сохраняет локальное reasoning: программист видит handler
Fail[X] и знает что он перехватывает только throw expr где
type-of(expr) == X.
Правило 3. Re-throw для частичной обработки
throw expr внутри handler-method’а — это обычная операция эффекта
Fail. Runtime ищет handler в стеке, минуя текущий handler-frame
(текущий обрабатывает throw, не может ловить сам себя). Если outer
есть — он перехватит. Если нет — runtime panic.
with Fail[RuntimeError] = |err| interrupt log_and_default(err) {
with Fail[RuntimeError] = |err| match err {
DivByZero => interrupt 0 // обработали локально
other => throw other // пробросили дальше — найдёт outer
} {
risky()
}
}
Это позволяет:
- Обрабатывать подмножество sum-вариантов локально.
- Пропускать остальные дальше по стеку.
- Композиция handler’ов через nested-with.
Правило 4. Prelude-типы для ошибок
RuntimeError — sum-тип runtime-сбоев
// в prelude (D26)
type RuntimeError
| DivByZero
| Overflow
| IndexOutOfBounds { index int, length int }
| TypeMismatch(str)
| AssertFailed(str)
| NoHandler(str)
Встроенные runtime-операции бросают конкретные варианты:
| Операция | Бросает |
|---|---|
a / b (b == 0) | RuntimeError.DivByZero |
arr[i] (i out of bounds) | RuntimeError.IndexOutOfBounds { index: i, length: arr.len } |
(x as Type) (cast fail) | RuntimeError.TypeMismatch("expected ..., got ...") |
assert(cond) (false) | RuntimeError.AssertFailed("...") |
Db.query(...) (no handler) | RuntimeError.NoHandler("Db") |
Переполнение знаковой целочисленной арифметики int (a + b,
a - b, a * b за границами int.MIN..int.MAX) — panic, не
Fail (Plan 33.8 Ф.1.1, решение 2026-05-21). Не ловится в коде; как
StackOverflow/OutOfMemory. Причина: переполнение — баг программы, а
не ожидаемая ошибка; делать каждую арифметическую операцию эффектной
(Fail в сигнатуре) недопустимо эргономически. Sized-типы
(u8/u16/u32/u64/i8/i16/i32) — иная семантика: wrap-around
по модулю 2^N (см. Plan 33.7). Вариант RuntimeError.Overflow
сохранён в типе для явных checked-арифметических API stdlib, но
оператор + его НЕ бросает.
StackOverflow и OutOfMemory не входят в RuntimeError — они
panic’и, не Fail. Не ловятся в коде. См. D13.
Error — record для пользовательских ошибок с сообщением
// в prelude (D26)
type Error {
readonly msg str
}
fn Error.new(msg str) -> Error => { msg }
Quick-and-dirty замена throw "string":
fn validate(x int) Fail[Error] -> () {
if x < 0 { throw Error.new("negative not allowed") }
}
Используется когда:
- Программист не хочет придумывать typed sum.
- Сообщение для лога/UI достаточно (не разбор по вариантам).
Альтернатива — типизированный sum для domain-логики:
type ValidationError | NegativeNotAllowed | TooLarge(int)
fn validate(x int) Fail[ValidationError] -> () {
if x < 0 { throw ValidationError.NegativeNotAllowed }
}
Для production-API typed sum предпочтительнее (compile-time exhaustiveness в match).
Замена ранее существовавшего unit-маркера Error
В D26 (08-runtime.md) ранее был type Error как unit-тип-маркер для
Fail без параметра. D65 заменяет его на record Error { msg str },
полезный для quick-and-dirty.
Правило 5. Транзитивность с гибридом — уточнение D62
D62 фиксирует «Fail strict транзитивен». С гибридом это уточняется:
Совместимость по подтипу
| Caller declared | Callee может бросать |
|---|---|
Fail[E] | только Fail[E] (тот же тип) или ничего |
Fail (any) | Fail[E] любого E, Fail (any), throw любого значения |
То есть Fail (any) поглощает любой Fail[E] — это естественно,
any это top-type.
В обратную сторону — Fail[E] не покрывает Fail (any). Caller с
Fail[E] не может вызывать функцию с Fail (any) без явной обёртки.
Несовместимость
Если callee имеет Fail[E'], а caller декларировал Fail[E] (E ≠ E’):
- Compile error, не warning.
- Программист обязан выбрать:
- Объявить
Fail[E']как дополнительный эффект (multi-Fail в row). - Использовать
Fail(any) — поглощает оба. - Обернуть через
.map_err(...)?для конверсии E’ → E. - Локально поймать через
with Fail[E'] = ... { ... }и не пробрасывать.
- Объявить
Multi-Fail в row синтаксически валиден:
fn process(s str) Fail[ParseError] Fail[RuntimeError] -> int {
parse(s)? // throws ParseError
safe_div(n, 2)? // throws RuntimeError
}
Две раздельные Fail-записи. Caller обязан установить два handler’а
или один Fail (any).
Coercion через sum-variant — отложено
«Если E имеет однозначный конструктор для типа источника E',
? автоматически coerce’ит» — отложено как Q-fail-coercion. Сейчас
требуется явный .map_err(...) или multi-Fail.
Что меняется по сравнению с D25
D25 (throw и параметризация Fail[E]) остаётся валиден в основной
части. D65 уточняет:
Failбез параметра — теперь явно сахар надFail[any], не unit-маркер.- Lookup-правило (точный тип → catch-all → panic) явно зафиксирован.
- Re-throw через
throw errв handler’е явно описан. - Prelude-типы
RuntimeErrorиError— новые, заменяют unit-маркер.
Раздел «Эволюция» D25 апдейтится с указанием на D65.
Почему
-
Гибрид удобства и точности.
Fail[E]для production даёт compile-time exhaustiveness и точный caller-knows-what-to-catch.Fail(any) для quick-and-dirty не заставляет придумывать тип. Один способ был бы крайностью. -
Простой lookup без subtype-magic. Точное совпадение типа — локально проверяемо. Match внутри handler’а покрывает sum-варианты. Не нужно расширять type system’у на subtype-aware lookup.
-
Re-throw позволяет композицию handler’ов. Локальная обработка подмножества + проброс остальных — стандартный pattern, работает через standard effect mechanics.
-
RuntimeErrorsum даёт типизированный set встроенных ошибок. Caller match’ит варианты, добавление новой ветки вRuntimeErrorломает существующие caller’ы (через non-exhaustive match warning). Это фича — программист обновляется консистентно. -
Errorrecord — низкоуровневый escape hatch. Не sum-тип (нечего match’ить, кромеmsg), но удобный для логов и UI.
Что отвергнуто
throws Ekeyword (Java-style) — не нужен, единая записьFail[E]единообразна с другими эффектами. Прецедент вводить второе имя для одного концепта (throws≡Fail) нарушает D40-style «один способ для одного случая».- Subtype-aware lookup (
Fail[RuntimeError]ловитFail[DivByZero]если DivByZero ⊆ RuntimeError) — отвергнуто. Match внутри handler’а достаточно. Subtype-aware расширил бы type system на sum-subtype-relations, цена/польза неудачное. - Auto-coercion
?через однозначный sum-variant — отложено как Q-fail-coercion. Сейчас явный.map_err(...). Failбез параметра как отдельный эффект, не алиас наFail[any]— отвергнуто. Лишняя сущность; алиас даёт ту же семантику.- Auto-inference
Fail[RuntimeError]для функций использующих встроенные операции — отвергнуто. Программист пишет руками (для public — D62 strict; для private — D28 inference, который выводит на основе тела). Если в теле естьarr[i]илиa/b, D28-inference добавляетFail[RuntimeError]в инферированную сигнатуру, но программист может явно написатьFail(any) илиFail[CompositeError]если делает map_err. throws SomeError | throw SomeError— путаница keyword’ов. В Novathrowэто keyword (какreturn),Fail[E]это эффект-тип. Они на разных уровнях:throw— control-flow в теле,Fail[E]— декларация в сигнатуре.
Связь
- D2, D3 — синтаксис effect-row.
- D4 —
?пробрасывание ошибки. - D86 —
??coalesce / fallback. - D11 —
withсинтаксис. - D25 —
throwиFail[E]. D65 уточняетFailбез параметра, lookup, re-throw. - D26 — prelude.
ErrorиRuntimeErrorдобавлены/обновлены. - D31 — handler-лямбда
для одно-операционных эффектов. Работает для
Fail(одна операцияfail). - D53 —
anyкак top-type через пустой protocol; основа дляFail≡Fail[any]. - D54 —
isдля runtime-проверок типа в catch-all handler’еFail(any). - D61 — effect/handler keywords, interrupt. D65 не меняет.
- D62 — Fail strict. D65 уточняет совместимость типов при транзитивности.
Цена
- Sweep по spec и examples — заменить
Fail(там где quick-and-dirty) на корректные формы:transaction[T](body fn() Db Fail -> T) Fail -> T— generic параметр[T, E]:transaction[T, E](body fn() Db Fail[E] -> T) Db Fail[E] -> T- Конкретные функции (
parse(s) Fail) —Fail[ParseError]или оставитьFail(any) для скрипт-кода. - Эталоны в spec (
fn parse(s str) Fail -> int) — переписать с явнымFail[ParseError]для clarity.
- Bootstrap-компилятор:
- Парсер уже принимает
Failбез параметра (как имя эффекта). - Type checker нужно расширить на subtype-aware «Fail (any) поглощает Fail[E]».
- Re-throw в handler’е работает через стандартную effect mechanics.
- Парсер уже принимает
- Prelude в bootstrap’е: добавить
RuntimeErrorsum иErrorrecord. Заменить старый unit-маркерError.
Эволюция
Fail без параметра существовал в D25 как сахар над Fail[Error],
где Error был unit-маркером. Это работало, но Error без полей был
бесполезен. D65 переопределяет:
Errorтеперь record{ msg str }— полезный.Failбез параметра теперь сахар надFail[any](universal).- Lookup с приоритетом «точный тип → catch-all → panic».
Дискуссия привела через несколько итераций:
- Сначала рассматривался
Failstrict-only (всегда явный тип). Отвергнуто — quick-and-dirty неудобно. - Потом
Fail=Fail[RuntimeError](фиксированный тип). Отвергнуто — ограничивает универсальность. - Финал: гибрид
Fail(any) +Fail[E]typed.
RuntimeError как sum-тип был очевидным решением — встроенные
операции имеют конечный набор runtime-сбоев, sum покрывает.
Error как record (не sum) — для случаев когда программист не хочет
типизированный domain-sum, но хочет message. Это replacement старого
unit-маркера.
Откат «трёх форм» (2026-05-07)
В одной из итераций рассматривалась трёхформенная семантика
(Fail placeholder ≠ Fail[any] erasure), где Fail без параметра
означал бы «inference placeholder — компилятор выводит конкретный E».
Откатано к простой Fail ≡ Fail[any] по двум причинам:
-
Различие наблюдаемо только при полном type-inference, которого bootstrap не реализует. В runtime/codegen «голый Fail» эрейзится через lookup как catch-all (Правило 2), что эквивалентно
Fail[any]. Production-компилятор может реализовать placeholder-семантику через точную D28-инференс E, но это отдельное расширение, не часть базового D65. -
Catch-all use-case требует erasure-семантики. Программист пишет
with Fail = |e| Log.error(e) { ... }чтобы поймать любой throw независимо от типа. Если быFailбыл placeholder (ждёт inference), уwith Fail = handlerне было бы контекста для inference — паттерн терял бы чёткость. СFail ≡ Fail[any]семантика однозначна: handler принимает значение типаany, в теле —is-проверки илиstr.from(e)для message.
Реализация bootstrap’а (commit 284b2074) уже соответствует откатанной
формулировке — добавляет голый Fail без E через D28-inference;
дальше lookup эрейзит его как catch-all.
D63. forbid X { body } — capability sandbox
Что
Keyword-блок, запрещающий использование операций перечисленных эффектов внутри body. Реализуется на двух уровнях:
- Compile-time: для каждой функции, вызываемой в body, type checker проверяет, что её прямые эффекты не пересекаются с forbid-set. Иначе compile error.
- Runtime: при операции forbid-эффекта runtime ловит и fail’ится — даже если функция была пропущена compile-time проверкой (через D62 transit warning или handler-substitution).
forbid непреодолим: код в body не может выйти из sandbox через
with X = .... Установка нового handler’а для forbid-эффекта внутри —
compile error.
R6 (revolutionary.md) ссылается на D63 как на формализацию capability mode.
Capability sandbox: три механизма, разные цели
В Nova есть три инструмента для ограничения «что код может делать», часто их путают. Разница важна:
| Механизм | Что ограничивает | Где задаётся | Что нарушение даёт |
|---|---|---|---|
forbid X { body } (D63) | использование эффектов из set | вокруг блока кода | compile error + runtime fail |
realtime { body } (D64) | suspension (приостановка fiber’а) | вокруг блока кода | runtime panic |
| closure границы (D62 capture rules) | какие handler’ы захватываются | при создании handler’а | type error если handler’а нет |
Когда какой использовать:
forbid— когда нужно гарантировать «эта подсистема НЕ обращается к Net/Db/Fs» (sandbox для plugins, contract-функций, pure_view).realtime— когда нужно гарантировать «здесь нельзя приостанавливаться» (real-time loops, ISR-like обработчики, hot paths). Async — runtime-факт, не эффект, поэтомуforbid Asyncневозможен;realtime— отдельный inverse-маркер.- closure границы — автоматически: при создании handler-литерала компилятор проверяет, что захваченные handler’ы валидны в момент использования. Не вмешательство программиста.
Они не пересекаются по семантике — каждый закрывает свою категорию проверок:
fn pure_view(u User) -> str =>
forbid Net, Db, Fs { // нельзя side effects
realtime nogc { // нельзя suspension и аллокации
format(u)
}
}
Композиция работает: forbid запрещает effect-вызовы, realtime
дополнительно запрещает suspend-точки и (при nogc) аллокации.
Программист выбирает один или оба в зависимости от того, что
гарантировать.
Правило
forbid Net, Fs, Db { body }
Внутри body:
- Прямой вызов операции
Net.op(...),Fs.op(...),Db.op(...)— compile error. - Вызов функции с
Net/Fs/Dbв прямой сигнатуре — compile error. with Net = h { ... }(илиFs,Db) — compile error: «cannot install handler for forbid-effect».- Транзитивный вызов через функцию которая не объявила forbid-эффект, но вызывает что-то с ним — compile-time warning (по D62), runtime fail на момент операции.
Runtime барьер
Реализуется через специальный sentinel-frame в handler-стеке:
handler-стек (lookup сверху вниз):
┌────────────────────────┐
│ FORBID(Net, Fs, Db) │ ← sentinel, push'нут при входе в forbid
│ Db = postgres_handler │ ← старый, ниже
│ ... │
└────────────────────────┘
При операции forbid’ed эффекта (Db.query(...)):
- Runtime ищет handler сверху вниз.
- Видит
FORBID(Db)первым — fail с «effect Db is forbidden in current scope».
Установка нового handler’а внутри запрещена
Если бы установка with Db = other { ... } внутри forbid Db { ... }
была разрешена, новый handler оказался бы выше sentinel’а в стеке
и lookup нашёл бы его раньше — sandbox обходится. Запрещаем установку
compile-time:
forbid Db {
with Db = mock_db { // COMPILE ERROR
...
}
}
Это делает sandbox непроницаемым.
Пример: плагин в sandbox
fn run_plugin(plugin Plugin) -> str {
forbid Net, Fs, Db {
plugin.invoke() // compile-time + runtime гарантия
// что plugin не ходит в Net/Fs/Db
}
}
Пример: детерминированное вычисление
fn compute_pure(input []u8) -> []u8 {
forbid Time, Random, Io, Net, Fs, Db {
process(input) // гарантированно детерминировано
}
}
Async нельзя forbid’ить
Async это не type-system эффект (D62),
а ambient capability fiber-runtime’а.
forbid Async { ... } // COMPILE ERROR: «Async is not a type-system
// effect, use `realtime { ... }` block instead»
Для запрета приостановки используется отдельный realtime { ... }
блок (D64) — это
runtime-конструкция, не часть type system’ы.
Запретить можно только effect-типы
forbid принимает только effect-типы (D62 правило effect/protocol):
forbid Hashable { ... } // COMPILE ERROR: Hashable это protocol, не effect
forbid Net { ... } // OK: Net это effect
Семантика Fail
Fail[E] — обычный effect, можно forbid:
forbid Fail[ParseError] { ... } // запрет throw'а ParseError
forbid Fail { ... } // запрет любого throw (Fail any)
Если внутри есть throw expr который соответствует forbid’ed Fail —
compile error. Runtime fail если транзитивно через несовместимую
функцию.
Грамматика
forbid-block = 'forbid' effect-list block
effect-list = type-ref { ',' type-ref }
type-ref это полная ссылка на effect-тип, включая generic-параметры:
Fail[ParseError], Fail[E] (из generic-контекста).
Почему
- Capability sandbox без runtime-only решений. Java SecurityManager — runtime, не compile-time. Compile-time даёт feedback при разработке.
- Симметрия с
with:with X = h { ... }устанавливает handler;forbid X { ... }запрещает. Pair-of-opposites. - Прецедент Effekt language — capability tracking через тип, forbid через ограничение row.
- Использования: плагины, песочницы для AI-сгенерированного кода, детерминированные вычисления, тестирование «функция не делает X».
Что отвергнуто
- Только compile-time forbid (без runtime барьера) — D62 ослабил R5.2 для прямых эффектов, поэтому compile-time не ловит транзитивные вызовы. Runtime барьер нужен для полной гарантии.
- Только runtime forbid (без compile-time) — теряется immediate feedback в IDE при написании кода.
- Soft forbid (warning вместо error) — sandbox должен быть гарантирован, не «вежливое предупреждение».
- Forbid Async — Async не существует в типах (D62);
realtime { ... }для запрета приостановки (D64). - Forbid non-effect-types (protocol, sum, record) — не имеет смысла; forbid это про эффекты-как-capabilities.
Связь
- D11 —
withсинтаксис. forbid синтаксически близок. - D62 — effect/protocol правило, прямые эффекты.
- D64 —
realtimeдля async-запрета (отдельный механизм). - revolutionary.md → R6 — capability mode описан, D63 формализует.
Цена
- Bootstrap-компилятор:
- Lexer: keyword
forbid. - Parser:
forbid effect-list { body }блок. - AST:
ExprKind::Forbid { effects, body }. - Interp: sentinel-frame в handler-стеке; runtime-проверка операций.
- Type checker (опционально для bootstrap): compile-time валидация прямых эффектов callee’ев.
- Lexer: keyword
- Спека: D63 + R6 ссылка на D63.
Реализация в bootstrap (2026-05-09, Plan 16 Ф.1-Ф.6)
Compile-time enforcement реализован в compiler-codegen/src/types/mod.rs
через CapabilityCtx. Walk модуля проходит fn-bodies + test-bodies со
state’ом forbidden_stack: Vec<HashSet<String>>. На входе/выходе из
ExprKind::Forbid { effects, body } push/pop. На каждом call-site
union forbidden-стека пересекается с callee.effects → R5.3 error.
Forbid-handler-ban (D63 §3473): ExprKind::With { bindings, body }
проверяет, что устанавливаемые handler’ы не пересекаются с
forbidden_union. Иначе error «cannot install handler for X inside
forbid X block».
Pure-fn (callee.effects пустой) — всегда OK.
Транзитивные эффекты (callee → callee → effect) пока не trace’ятся (D62 говорит — warning). Закроется после полного effect-row inference (отдельный план).
D64. realtime { body } / blocking { body } — гарантия не-приостановки (RETRACTED by Plan 113)
⚠️ RETRACTED (Plan 113, 2026-05-29). Block-forms
realtime { }иblocking { }удалены из языка. Логика и семантика переехали в D172 attribute-only model:
realtime { body }→ extract в#realtime fn(callee guarantee)blocking { body }→ extract в#blocking fn(fn-level threadpool offload)Атрибут
#realtimeна функции — сохранён (callee guarantee модель, D172). D64 retract’ирован как block-form spec.История ниже сохранена для понимания эволюции.
Что (историческое, retracted)
Runtime-блок, гарантирующий что код внутри не приостанавливается на yield-point’ах fiber-runtime’а. Применяется для real-time-зон, hot loops, lock-критичного кода.
realtime это не эффект (Async убран из type system по D62),
а runtime-конструкция. Семантика — fiber-runtime отказывается
выполнять suspend-операции внутри realtime-блока, fail’ится при
попытке.
Правило
realtime { body }
Внутри body:
- Synchronous вычисления — OK (математика, локальные mut, локальные структуры на стеке).
- Доступ к ambient handler’ам без suspend — OK (например,
Log.infoесли log-handler не блокирующий). - Suspend-операции — runtime panic «cannot suspend in realtime block».
Включает:
Net.get(...),Fs.read(...),Db.query(...),Time.sleep(...),Channel.recv(...), любая операция, которая в fiber-runtime приводит к yield’у.
Compile-time
Type checker (production-компилятор) может частично ловить нарушения:
- Вызовы функций с эффектами
Net,Fs,Db,Time(известно что они suspend) — compile error. - Это не полная гарантия — пользовательский effect может suspend через свой handler. Runtime барьер всё равно нужен.
Runtime
Fiber-runtime устанавливает флаг при входе в realtime-блок. Каждая suspend-точка проверяет флаг — если активен, runtime panic.
Пример: hot loop без suspend
fn checksum(data []u8) -> int {
realtime {
let mut sum = 0
for b in data { sum += b as int }
sum
}
}
Пример: lock-критичная секция
fn update_counter(counter mut Counter) {
counter.lock()
realtime {
counter.value += 1 // не должно yield'нуть с захваченным lock'ом
}
counter.unlock()
}
Атрибут #realtime на функции
Sugar для функции целиком (атрибут-префикс # — см.
D96):
#realtime
fn checksum(data []u8) -> int {
let mut sum = 0
for b in data { sum += b as int }
sum
}
// эквивалентно:
fn checksum(data []u8) -> int {
realtime {
let mut sum = 0
for b in data { sum += b as int }
sum
}
}
Что внутри запрещено
| Операция | Запрет | Почему |
|---|---|---|
Net.get(...) | да | network roundtrip → suspend |
Fs.read(...) | да | disk I/O → suspend |
Db.query(...) | да | network → suspend |
Time.sleep(d) | да | явный sleep → suspend |
Time.now() | нет | обычно sync (timer read) |
Random.next() | нет | sync RNG |
Log.info(...) | зависит | если handler не blocking — OK |
Channel.recv() | да | блокирующий wait → suspend |
Channel.send_nonblocking() | нет | non-blocking — OK |
spawn ... | да | создаёт fiber, нарушает «нет suspend» |
| Аллокация в managed heap | зависит | если GC может paus’ить — да |
Точный список — задача production-компилятора и runtime’а; D64 фиксирует принцип: «всё что может yield — запрещено».
Опционально — запрет аллокации
Для жёсткого real-time-mode’а можно запретить аллокацию в managed heap (GC pause-free):
realtime nogc { body }
Внутри realtime nogc — никаких аллокаций, кроме как в region’е
(05-memory.md → D6).
Это расширение realtime, опциональное. Базовый realtime запрещает
suspend, не аллокацию.
Грамматика
realtime-block = 'realtime' [ 'nogc' ] block
nogc — опциональный модификатор для жёсткого режима.
Async концепт полностью удалён из языка
Это окончательно фиксирует:
Asyncне существует как тип эффекта.- Не пишется в сигнатурах.
- Не упоминается в effect-row.
- Программист про него не знает.
Если нужна гарантия не-приостановки — realtime { body }. Это
inverse-маркер: дефолт «может suspend», realtime — «гарантированно
нет».
Почему
- Inverse-семантика лучше для AI-first. В большинстве кода suspend разрешён (это дефолт). Программист пишет специальный маркер только когда отличается от дефолта. Меньше cognitive load.
- Реальные use-cases: real-time системы, hot loops в backend, lock-критичный код. Не везде, но достаточно часто.
- Прецедент: Erlang has
:hibernatefor non-yielding paths, Rust has#[no_std]for no-allocation, Java has@RealTimeannotations. Nova consolidates через один keyword. - Симметрия с
forbid: оба — runtime-ограничения. forbid для эффектов в типах, realtime для невидимой приостановки.
Что отвергнуто
Asyncкак явный эффект в сигнатурах (D62) — везде в backend-коде шум.@no_suspendатрибут только —realtimeblock более гибкий (зона внутри функции), атрибут это sugar.synckeyword —syncимеет другие коннотации (синхронизация, thread-sync) в других языках.pinnedkeyword — слишком узкое значение (real-time terminology), не покрывает hot loops.forbid Async— Async не в типах, нечего forbid’ить через type-system механизм.
Связь
- D62 — Async ambient, не пишется в сигнатурах. D64 — inverse-механизм.
- D63 — capability sandbox для type-system эффектов; параллельный механизм для type-effects, тогда как D64 для async-runtime.
- 05-memory.md → D6 —
region { ... }для GC-free аллокации;realtime nogcиспользует region семантику. - 06-concurrency.md → D14 — fiber runtime, yield-points; D64 запрещает yield внутри блока.
- revolutionary.md → R7 — «Async — невидимая инфраструктура»; D64 формализует противоположное направление.
Цена
- Bootstrap-компилятор (опционально):
- Lexer: keyword
realtime. - Parser:
realtime [nogc] { body }. - AST:
ExprKind::Realtime { nogc bool, body }. - Interp: runtime флаг + проверка на suspend-операциях.
- Можно отложить — bootstrap не имеет полноценного fiber-runtime’а
(синхронное исполнение);
realtimeno-op в bootstrap.
- Lexer: keyword
- Production-компилятор: type-level проверки + runtime барьер + оптимизация (LLVM может удалить safepoint’ы внутри realtime).
Эволюция
Изначально Async был эффектом в типах, как у Koka. Опыт показал
что в реальном backend-коде он везде, что обесценивает его как
информативный маркер. D62
сделал Async ambient capability — не пишется в типах, но «существует»
концептуально.
Дискуссия про forbid Async показала: если Async не в типах, его
нельзя forbid’ить через type-system механизм. Нужен отдельный
runtime-маркер. Так появился realtime { body }.
Окончательно: Async концепт удалён из языка целиком. Программист
не знает про него; есть только realtime как inverse-маркер. Это
приближает Nova к Go/Erlang модели «горутины могут suspend, нет
async-keyword’а».
D67. ? оператор: семантика для Result через Fail, для Option через ранний return
⚠️ ОТМЕНЕНО 2026-05-10, см. D85. D85 унифицирует семантику
?: для обоихResultиOptionделает ранний return обёртки. Throw-стиль черезFailтеперь выражается отдельным оператором!!или явным?? throw E.?больше не задействует эффектFail.Текст ниже сохранён для исторической справки. Актуальная семантика — D85.
Что
Постфиксный оператор ? имеет две разные семантики в зависимости
от типа выражения:
Result[T, E]—?desugar’ится вmatch+throwчерез эффектFail[E](D4).Option[T]—?desugar’ится вmatch+ раннийreturn Noneиз текущей функции, без эффекта Fail.
На любом другом типе ? — синтаксическая ошибка.
Правило
? на Result[T, E]
Требует Fail[E] в сигнатуре функции. Точная семантика — D4:
expr? ≡ match expr {
Ok(v) => v
Err(e) => throw e // через эффект Fail[E]
}
? на Option[T]
Не требует эффекта в сигнатуре. Превращается в ранний return:
expr? ≡ match expr {
Some(v) => v
None => return None // ранний выход из текущей fn
}
Возвращаемый тип функции должен быть Option[U] — иначе compile error
(return None несовместим с return type’ом).
fn first_pos(xs []int) -> Option[int] {
let head = xs.first()? // Option[int]; на None: return None
if head > 0 { Some(head) } else { None }
}
? НЕ работает на Fail[E] -> T
Если выражение бросает через эффект Fail (а не возвращает Result-
значение), ? после него — синтаксическая ошибка:
fn save(u User) Fail[DbError] -> () => Db.exec(...)
fn caller(u User) Fail[DbError] -> () =>
save(u)? // ОШИБКА: save возвращает (), не Result/Option
? ожидает значение типа Result или Option, а не throw’а — throw
от save сам собой пробрасывается через Fail[DbError] в caller’е,
без ?.
Семантика на handler-методах
Внутри handler-method’а ? подчиняется тем же правилам:
type Db effect {
in_transaction[T](body fn() Db Fail -> T) Fail -> T
}
effect Db {
in_transaction(b) => real.in_transaction(b)? // ОШИБКА: in_transaction
// возвращает T, не Result/Option
in_transaction(b) => real.in_transaction(b) // правильно: throw сам
// пробрасывается через Fail
}
Это частая ошибка при написании middleware-handler’ов: программист
думает «обернуть и вернуть» через ?, но ? нужен только когда
callee возвращает Result/Option как значение.
Почему две семантики
- Result — про обработку ошибок: нужен механизм propagation
через стек, единый с
throw. ЭффектFail[E]даёт это. - Option — про отсутствие значения: семантически отдельная
категория, не «ошибка». Использовать
Failдля каждогоNone— шум:lookup,find,parse_intбросали бы Fail везде. Ранний returnNoneиз функции с-> Option[T]— естественнее.
Признанное напряжение с D10 «всё — handler»
? на Option — второй механизм control-flow в Nova: ранний
return из функции, не перехватываемый через with-handler. Это
признанное исключение из «всё взаимодействие с внешним миром —
эффект»:
Optionэто значение, не эффект.Noneэто валидный результат, не ошибка. Поэтому propagation через эффект-stack здесь неприменимо — нет «вверх» куда передавать.- Альтернатива
Fail[NoneError]создавала бы фантомные эффекты в сигнатурах для каждой функции сlookup/find/parse_int, что значительно хуже по AI-first критерию (R5.2). - Compile-time правило тривиально:
?наOption[T]валиден только в функции с return typeOption[U]для какого-то U.
Это прагматичный компромисс — в духе D62 (Fail strict, остальное ослаблено). Полная унификация через эффект-stack теряет больше чем выигрывает.
Альтернативы рассмотрены:
?на Option черезFail[NoneError]— отвергнуто: засоряет сигнатуры неинформативным типом ошибки.?только на Result — отвергнуто: Option-чтение черезmatchбойлерплейт;?для unwrap-or-return — естественный pattern.- Отдельный оператор
??!для Option — отвергнуто:??уже используется как coalesce (D86), цена ещё одного символа выше пользы.
Что отвергнуто
?на произвольном sum-type’е с двумя вариантами — слишком магично; программист может назвать варианты как угодно, парсер не знает «какой Ok какой Err».?наResult[T, E]безFail[E]в сигнатуре — нарушает D4 правило (?черезthrow).- Auto-coercion
Result↔Optionчерез?— отвергнуто (Q-fail-coercion). Программист явно конвертирует через.ok()/.into().
Связь
- D4 —
?для Result + Fail[E]. - D25 —
throwкак операция Fail[E]. - D26 — Option/Result в prelude.
- D62 — Fail strict, транзитивность.
- D65 —
Fail[any]для catch-all.
Эволюция
В D4 (изначально) ? был определён только для Result. Семантика для
Option работала де-факто в bootstrap-интерпретаторе через ранний
return, но не была зафиксирована — это был open question
(D26 открытые вопросы).
D67 формализует обе семантики и явно отделяет от случая «callee
бросает через Fail» (где ? не нужен и является ошибкой).
Что НЕ меняется
D4 продолжает определять ? для Result. D67 — расширение, не пересмотр.
D68. Stateful handlers: через closure capture или @as_handler метод record
Что
Handler-литерал (D61) содержит только методы операций — поля внутрь добавлять нельзя. Stateful handlers (handler’ы со своим состоянием) делаются одним из двух способов:
- Closure capture — state живёт в локальной переменной (или параметре функции-фабрики), handler-method’ы захватывают её через closure. Каноничный «лёгкий» способ.
@as_handlerметод record’а — state живёт в полях обычного record’а, метод record’а возвращаетEffect[E], который через@fieldобращается к полям. Канонично когда state нужно проинспектировать снаружи послеwith-блока.
Правило
Способ 1: closure capture (легковесный)
State — локальная переменная или параметр свободной функции:
// State прямо в `with` — captured by closure
let mut counter = 0
with Counter = effect Counter {
next() {
counter += 1
return counter
}
} {
do_work()
}
// counter здесь = число вызовов Counter.next() в do_work
Или через handler-фабрику с параметром:
fn make_counter(initial int) -> Effect[Counter] {
let mut state = initial
effect Counter {
next() {
state += 1
return state
}
}
}
with Counter = make_counter(100) {
do_work()
}
// state здесь недоступен — он внутри closure
Когда применять: state используется только во время handler-life’а,
после with его инспектировать не нужно (или достаточно with-сnopa).
Способ 2: @as_handler метод record’а
State — поля обычного record’а с mut. Метод записи возвращает
Effect[E]:
type CounterState { mut value int }
fn CounterState @as_handler() -> Effect[Counter] => effect Counter {
next() {
@value += 1 // обращение к полю receiver'а
return @value
}
}
let s = CounterState { value: 0 }
with Counter = s.as_handler() {
do_work()
}
println(s.value) // публичное состояние, инспектируется снаружи
Когда применять:
- State нужно проверить после
with-блока (типичный testing-сценарий:assert s.value == expected). - Один state используется несколькими handler-инстансами (один handler на запись, другой на чтение).
- State имеет смысл сам по себе как доменный объект (не deal-с-handler-detail).
Семантика @field внутри handler-литерала
Handler-литерал effect E { ... } внутри @-метода типа T —
это обычное выражение, и @field указывает на receiver метода
(инстанс типа T). То есть:
fn CounterState @as_handler() -> Effect[Counter] =>
effect Counter {
next() {
@value += 1 // @value — это поле receiver'а CounterState,
// не «self» handler'а (handler не имеет полей)
return @value
}
}
Внутри handler-method’а нет своего @self — handler не имеет полей.
@ в теле handler-method’а ссылается на receiver внешнего метода,
если handler-литерал создан внутри метода.
Почему два способа
- Closure capture — простой, локальный, без объявления отдельного типа. Хорош для одноразовых handler’ов и тестов с in-flight state.
@as_handler— даёт state имя и публичный API. Хорош когда state — это часть домена (счётчик ID, кэш-стат, in-memory БД).
Это не два инструмента для одного — выбор детерминирован сценарием (нужен ли state наружу). D40 «один способ» не нарушается.
Что отвергнуто
- Handler-литерал с полями (
effect Counter { state int = 0; next() {...} }). Отвергнуто: путает handler с record’ом, парсер не однозначен, смысл «инстанс с полями + методами» не нужен — это обычный record с методом-фабрикой. - Скрытые «handler trait» objects (как Java: класс реализует interface). Отвергнуто: handler — обычное значение, fabriqué из closure’а или метода. Никаких неявных классов.
selfkeyword внутри handler-method’а. Отвергнуто:@уже определён как «field/method receiver’а» в D35, использование внутри handler-литерала естественно ссылается на внешний receiver.
Связь
- D11 — handler-литерал, основной синтаксис.
- D31 — handler-лямбда для одно-операционных эффектов.
- D35 —
@-методы и@field. - D61 —
handlerkeyword. - D66 —
Selfuniversal (можно использовать в return type’е@as_handler).
Thread safety
D68 stateful handlers работают на одном fiber’е по умолчанию.
Если handler передаётся между fiber’ами через spawn/detach/
parallel for — программист обязан использовать thread-safe
state:
// ❌ Race: shared между fiber'ами без атомика
let mut counter = 0
parallel for url in urls {
with Counter = effect Counter {
next() {
counter += 1 // race condition
return counter
}
} { ... }
}
// ✅ Atomic для shared counter:
let counter = Atomic[int].new(0)
parallel for url in urls {
with Counter = effect Counter {
next() => counter.fetch_add(1) + 1
} { ... }
}
// ✅ Или per-fiber state:
parallel for url in urls {
let mut local = 0
with Counter = effect Counter {
next() {
local += 1
return local
}
} { ... }
}
Правило: state, захваченный handler-method’ом, должен быть либо
fiber-local (новый let на каждый fiber), либо thread-safe
(Atomic[T], Mutex[T]). Compile-time enforcement — открытый
вопрос (Q12 concurrency model). Bootstrap
не проверяет.
Эволюция
D68 формализует два устоявшихся паттерна. Closure-capture
использовался во всех nova_tests/ и в большинстве examples/*.nv
(make_counter, in_memory_db_handler и т.д.). Паттерн через
@as_handler явно ещё не использовался — D68 рекомендует его как
канонический способ для stateful handler’ов с публичным state.
D85. Операторы ? и !! — унифицированное поведение для Result и Option, throw-стиль через !!
Закрывает D67 (отменён 2026-05-10). Унифицирует семантику
?: для обоихResultиOption— ранний return обёртки. Throw-стиль черезFailтеперь выражается новым оператором!!или явным?? throw E.
Что
В Nova два постфиксных оператора для работы с Option[T] и
Result[T, E], выбираемых программистом по стилю обработки:
expr?— return-стиль: «не получилось — обёртка наверх как значение». Локальное продолжение цепочки, без эффектов.expr!!— throw-стиль: «не получилось — throw через эффектFail». Эффект попадает в сигнатуру, ловится handler’ом.
Программист на месте использования выбирает, какой стиль обработки
ему нужен. Один и тот же тип (Option[T] или Result[T, E])
поддерживает оба оператора.
? больше не задействует Fail — это унификация дизайна.
Правило
expr? для Result[T, E] — return-стиль
expr? ≡ match expr {
Ok(v) => v
Err(e) => return Err(e)
}
Внешняя функция должна возвращать Result[U, E'] где E' совместим
с E (тот же тип или supertype через sum-расширение). Иначе compile
error.
fn pipeline(s str) -> Result[int, ParseError] {
let n = parse(s)? // на Err: return Err(e)
let v = validate(n)?
Ok(v)
}
expr? для Option[T] — return-стиль
expr? ≡ match expr {
Some(v) => v
None => return None
}
Внешняя функция должна возвращать Option[U]. Иначе compile error.
fn first_pos(xs []int) -> Option[int] {
let head = xs.first()? // на None: return None
if head > 0 { Some(head) } else { None }
}
expr!! для Result[T, E] — throw-стиль
expr!! ≡ match expr {
Ok(v) => v
Err(e) => throw e
}
Внешняя функция должна иметь Fail[E'] в сигнатуре, где E'
совместим с E. Иначе compile error.
fn pipeline(s str) Fail[ParseError] -> int {
let n = parse(s)!! // на Err: throw e
let v = validate(n)!!
v
}
expr!! для Option[T] — throw-стиль
expr!! ≡ match expr {
Some(v) => v
None => throw RuntimeNoneError
}
Внешняя функция должна иметь Fail[RuntimeNoneError] в сигнатуре.
Иначе compile error.
fn extract(json Json) Fail[RuntimeNoneError] -> str {
let user = json.get("user")!! // None → throw RuntimeNoneError
let email = user.get("email")!!
email.as_str()!!
}
RuntimeNoneError — unit-тип в prelude (D26),
введён специально для expr!! на Option. Это отдельный тип,
не вариант RuntimeError — разные категории (отсутствие значения vs
аппаратные сбои).
expr?? — coalesce / кастомный fallback
Параллельно с ? и !! работает ?? (D86) — coalesce
для default или явного custom-throw’а:
let port = config.get("port") ?? 8080 // default
let port = config.get("port") ?? throw ConfigError.MissingPort // custom throw
let port = config.get("port") ?? panic("config must have port") // panic (D13)
let port = config.get("port") ?? exit(1, "no port in config") // exit (D13)
?? — для случаев, когда программисту нужен конкретный fallback:
конкретное значение, конкретный тип ошибки, panic, exit. !!
оптимизировано под дефолтный шаблон throw’а; ?? throw E —
расширенная форма для кастомизации типа. Полная семантика ?? — в
D86.
Смешение ?, !!, ?? в одном выражении
Все три оператора валидны параллельно и могут сочетаться по типу вмещающей функции:
// Функция возвращает Option — используем ?
fn first_word_pos(s str) -> Option[int] =>
s.find(' ')?
// Функция бросает Fail — используем !!
fn first_word(s str) Fail[RuntimeNoneError] -> str =>
s.split(' ')!!.first()!!.into()
// Mix: разные операнды, разные стили
fn process(s str) Fail[ParseError] -> int {
let raw = config.get("raw") ?? "default"
let n = parse(raw)!!
n
}
Парсер
!! — постфиксный оператор, имеет высший приоритет (тот же
уровень, что ?). Грамматически: expr!! всегда парсится как
постфикс, независимо от пробелов вокруг.
Префиксное !! (двойной boolean not) формально валидно (!!cond =
cond), но семантически бессмысленно — линтер может предупреждать.
Конфликт с постфиксом разрешается позицией: префикс не следует
за выражением, постфикс следует.
Edge-case b!!c — парсится как (b!!) c, что синтаксически
бессмысленно (два выражения подряд) → compile error. Программист
пишет с пробелом, оператором или скобками: b!! - c, (b!!) c_call().
Одиночный ! остаётся только префиксным (boolean not, D46
@not). Постфиксный ! не используется — оставлен под будущие
расширения.
? НЕ работает на Fail[E] -> T
Если выражение бросает через эффект Fail (а не возвращает Result-
значение), ? после него — синтаксическая ошибка:
fn save(u User) Fail[DbError] -> () => Db.exec(...)
fn caller(u User) Fail[DbError] -> () =>
save(u)? // ОШИБКА: save возвращает (), не Result/Option
? ожидает значение типа Result или Option. Throw от save сам
пробрасывается через Fail[DbError] в caller’е, без ?.
То же для !!:
fn caller(u User) Fail[DbError] -> () =>
save(u)!! // ОШИБКА: save возвращает (), не Result/Option
Семантика на handler-методах
Внутри handler-method’а ? и !! подчиняются тем же правилам, что
снаружи. Тип возврата handler-method’а определяет, какой оператор
валиден.
type Db effect {
in_transaction[T](body fn() Db Fail -> T) Fail -> T
}
effect Db {
in_transaction(b) => real.in_transaction(b) // правильно: throw сам пробрасывается
in_transaction(b) => real.in_transaction(b)? // ОШИБКА: in_transaction возвращает T
in_transaction(b) => real.in_transaction(b)!! // ОШИБКА: то же
}
Почему
Зачем унификация ?
В D67 ? имел две разные семантики:
?наResult→ throw черезFail(engaged эффект, требовалFail[E]в сигнатуре).?наOption→ ранний return None (без эффекта).
Это создавало категориальную неоднородность: один оператор
выражал две разные операции. D67 признавал это «исключением из
принципа эффектов» в секции «Признанное напряжение». На деле никакого
«нарушения принципа» не было — Option-форма это match + return,
обычные конструкции, не имеющие отношения к handler’ам. Просто
дизайн D67 пытался впихнуть две разные операции в один символ ради
краткости.
D85 разводит две операции на два символа — ? для return-стиля,
!! для throw-стиля. Каждый оператор делает одно и делает это
консистентно для обоих типов (Option и Result).
Зачем throw-стиль вообще
Throw-стиль через Fail — центральный механизм обработки ошибок в
Nova (R1 в revolutionary.md). Handler
перехватывает throw в with-блоке, реализует transaction, retry,
log, тестовый mock. Без короткого синтаксиса для throw’а программисты
вынуждены писать длинные match’ы или ?? throw e_from_result,
что замусоривает hot-path.
!! — короткий шаблон дефолтного throw’а (E как есть для
Result, RuntimeNoneError для Option). ?? throw E остаётся для
кастомизации типа ошибки.
Почему !!, не !
Одиночный ! занят под boolean not (!cond → cond.@not()). Чтобы
использовать его как постфикс, потребовалось бы правило про
обязательный пробел перед префиксным !, без пробела — постфикс.
Это работает, но создаёт хрупкость: !cond vs ! cond vs cond !
становятся принципиально разными.
!! решает это естественно: префикс/постфикс однозначно
различаются позицией в грамматике. Никаких пробельных правил.
Дополнительные плюсы !!:
- Визуально тяжелее
?— сигналит «здесь throw, серьёзная операция», тогда как?визуально лёгкий. - Параллель с Kotlin —
!!там «настаиваю, иначе крах». Семантически близко к нашему «настаиваю, иначе throw». - Одиночный
!остаётся свободен под будущие расширения — оператор как у нас «один раз решённое не возвращаем».
Почему ? работает только на Option/Result, не на Fail
? это сахар над match + return. match работает только над
значением. Функция Fail[E] -> T возвращает T, не Result[T, E] —
сделать match на её результате нельзя.
Если программист хочет сконвертировать throw в Result, он использует
обычный with-блок:
let r Result[int, ParseError] = with Fail[ParseError] = handler {
fail(e) { interrupt Err(e) }
} {
Ok(parse(s)) // parse без !! — throw сам ловится handler'ом
}
Цена миграции
D85 ломает текущий идиоматический Nova-стиль:
- Все
parse(s)?в коде сFail[E] -> Tсигнатурой перестают работать как раньше. Нужно либо переписать наparse(s)!!(если хотим оставить throw-стиль), либо изменить сигнатуру наResult[T, E](если хотим return-стиль).
В stdlib и тестах это десятки-сотни мест. Миграция запланирована как отдельная задача (см. Plan-task post-D85).
Что отвергнуто
- Оставить D67 как был. Категориальная неоднородность сохранилась бы.
- Унификация через throw для обоих типов. Каждый
lookup/find/parse_intобязан был бы иметьFail[NoneError]в сигнатуре — засоряет сигнатуры частных функций неинформативным эффектом (R5.2). - Унификация через ранний return для обоих + полное удаление
Fail-стиля.
Failостаётся центральным механизмом языка черезthrow,with, handler’ы —?это просто перестаёт быть его сахаром. Полное удаление сломало бы R1. - Одиночный
!под throw. Конфликт с префиксным!, требует правил про пробелы. См. «Почему!!, не!». expr try(Swift-style префикс). Длиннее, не симметрично с?(постфиксом).!?или?!как throw.?уже занят, добавление к нему суффикса визуально путаниец.- Force-unwrap (Rust
.unwrap()/Swift!) как краткий оператор. В Nova нет force-unwrap-оператора — для краша используется panic через?? panic(...), для throw —!!или?? throw. Никаких скрытых panic’ов через короткий синтаксис.
Связь
- D67 — отменено D85.
- D4 —
?через Fail (отменено вместе с D67). - D25, D65 —
Fail[E]остаётся центральным механизмом throw’а;!!— её краткий синтаксис. - D26 — prelude:
RuntimeNoneErrorдобавлен как unit-тип дляexpr!!наOption. - D13 —
panic/exitкак fallback в?? panic(...)/?? exit(...). - D46 —
!как boolean not, остаётся только префиксом. - D86 —
??coalesce / fallback. Параллельный механизм: D85 (?/!!) для канонического return-/throw-стиля, D86 (??) для кастомного fallback’а с любым выражением. - R1 в revolutionary.md — обновляется:
?для Fail-стиля заменён на!!в примерах. - Plan 19 — план атомарной реализации (closure-rev + D85 в одном PR).
Эволюция
- D67 (2026-04-XX) — две семантики
?, секция «Признанное напряжение» признавала кривизну дизайна. - D85 (2026-05-10) — унификация:
?всегда return,!!всегда throw. Обе работают для обоихOptionиResult.?отвязан отFail. Признанное напряжение снято — это были две операции, теперь у каждой свой символ.
Миграция кода
| Было (D67) | Стало (D85) |
|---|---|
parse(s)? в Fail[E] -> T функции | parse(s)!! (если parse возвращает Result) или сменить сигнатуру на -> Result |
xs.first()? в -> Option[T] функции | без изменений |
xs.first()? в Fail[E] -> T функции | xs.first()!! (бросает RuntimeNoneError, требует Fail[RuntimeNoneError]) |
lookup(k) ?? throw E | без изменений (или lookup(k)!! если устраивает RuntimeNoneError) |
Полный план миграции stdlib — отдельная задача.
D86. ?? coalesce-оператор — fallback для Result/Option
Что
expr ?? fallback — coalesce-оператор: если expr это Some(v)
или Ok(v), возвращает v; иначе возвращает значение fallback.
В отличие от ? и
!!
— ?? не требует Fail[E] в сигнатуре. Это локальная чистая
операция, поглощающая ошибку/None, заменяя на fallback.
Правило
let v = lookup(id) ?? 0 // None → 0
let r = parse(s) ?? -1 // Err(_) → -1
let port = config.get("port") ?? 8080 // default
Fallback может быть:
- значением того же типа, что внутри
Some/Ok:let port = config.get("port") ?? 8080 throw err(custom ошибка):let port = config.get("port") ?? throw MissingPortErrorpanic("...")(D13):let port = config.get("port") ?? panic("port required")return ...для раннего выхода из enclosing fn.- произвольным выражением, чей тип совместим с
T(внутриSome(T)/Ok(T)) или имеет типnever(throw/panic/return).
Семантически — сахар над match:
expr ?? fallback
// разворачивается в:
match expr {
Some(v) => v
None => fallback
Ok(v) => v
Err(_) => fallback
}
Err-значение не доступно в fallback — ?? его отбрасывает.
Если нужен доступ — использовать match явно или expr ?? throw
(пробрасывает новую ошибку, не оригинальную).
Сравнение ?, !!, ??
| Оператор | На Some(v) / Ok(v) | На None / Err | Эффект |
|---|---|---|---|
expr? | разворачивает в v | early-return из enclosing fn (D67) | требует Fail[E] если expr это Result |
expr!! | разворачивает в v | throw err (для Option — RuntimeNoneError) (D85) | требует Fail[E] |
expr ?? fb | разворачивает в v | возвращает fb (или throw/panic/return если fb это они) | без эффекта для default-value fallback |
Почему
- Локальная замена ошибки на default — частый паттерн (config,
lookup в map’е, parse с fallback’ом).
?/!!для таких случаев слишком тяжёлы — заставляют завестиFail[E]в сигнатуре только ради того, чтобы тут же его catch’ить. - Пуристая операция — coalesce-оператор не требует эффектной системы. Чистая функция, видна на уровне выражения.
- Fallback может быть любым выражением — включая
throwдля замены типа ошибки, илиreturnдля раннего выхода. Гибкость без накручивания grammar’а. - Прецедент. Swift
??, JS/TS??, RustOption::unwrap_or— узнаваемая convention.
Что отвергнуто
??=null-coalescing assignment (rejected.md) — десахарa ??= e ≡ a = a ?? eломается типами: LHSOption[T], RHST(потому что??разворачивает Option), type mismatch.- Семантика «set-if-None» для
??=(if a is None { a = e }) — отличается от других compound-assignment операторов; вводит исключение в правило десахара. ?? else { ... }— лишний синтаксис;??уже принимает любое выражение справа, включая block-{ ... }.- Доступ к
Err-значению через?? |e| ...— это ужеmatch, не coalesce.??для случая «ошибка не важна, default достаточен».
Связь
- D4 —
?early-return оператор, парный к??(распространение vs поглощение). - D85
—
?и!!унифицированы дляResult/Option;??— третья форма обработки. - D67
— старая семантика
?, поглощена D85; ссылки?? для Optionиз D67 теперь указывают сюда. - D13 —
panic(...)как fallback. - history/rejected.md — отклонённый
??=.
Эволюция
В первых ревизиях ?? описан как подсекция D4
без собственного D-номера. 2026-05-10: выделен в отдельное решение
D86 для:
- возможности независимой эволюции (
??это про fallback,?про пробрасывание — разные роли); - явных ссылок из spec / docs / runtime errors;
- симметрии с D85 (
?и!!) — каждый постфиксный оператор имеет свой D.
Семантика не изменилась — это формальное выделение, не содержательный пересмотр.
D87. Effect[E, IRT] — параметризация Handler типом interrupt’а
Что
Тип Effect[E] параметризован двумя generic-параметрами:
эффектом E и типом interrupt’а IRT (interrupt-return type).
Полная форма — Effect[E, IRT]. Default IRT = never через
D88 — то есть
Effect[E] ≡ Effect[E, never].
Effect[E, never] — handler, который не делает interrupt
(только return/финальное выражение в handler-method’ах). Если
такой handler пытается сделать interrupt v — compile error.
Effect[E, T] (для T ≠ never) — handler, который может
сделать interrupt v где v: T. При использовании в with-блоке
type-checker унифицирует T с типом with-выражения (W) по правилам
D61 секция 10.
Зачем
Без D87 тип Effect[E], возвращаемый из named функции, не сообщает
о том, делает ли handler interrupt. Программист, использующий такой
handler в with-блоке, не может локально (без чтения тела) понять,
какой тип получит with-выражение и совместим ли он с body. Это
противоречит принципу Nova R1 «эффекты и связанные контракты всегда
видны в сигнатуре».
Правило
Базовая форма
type Logger effect {
log(msg str) -> ()
}
// Handler без interrupt'а — sugar `Effect[Logger]` ≡ `Effect[Logger, never]`
fn console_logger() -> Effect[Logger] => effect Logger {
log(msg) => println(msg)
}
// Handler с interrupt'ом типа int
fn fatal_logger() -> Effect[Logger, int] => effect Logger {
log(msg) {
if msg.starts_with("FATAL") { interrupt -1 }
println(msg)
}
}
Использование в with-блоке
// Effect[Logger, never] — interrupt запрещён, with-блок даёт T_body:
let r = with Logger = console_logger() {
Logger.log("hello")
"ok" // T_body = str
}
// r: str
// Effect[Logger, int] — IRT = int должен быть совместим с T_body:
let r = with Logger = fatal_logger() {
Logger.log("FATAL: oom")
"ok" // ❌ T_body = str, IRT = int → несовместимы
}
// COMPILE ERROR: cannot unify with-block type
// handler interrupt type: int
// body type: str
Чтобы пример работал — нужно привести типы:
let r = with Logger = fatal_logger() {
Logger.log("FATAL: oom")
-1 // T_body = int, совпадает с IRT
}
// r: int
или явно указать общий supertype:
let r any = with Logger = fatal_logger() {
Logger.log("FATAL: oom")
"ok"
}
// r: any (programmer opted into dynamic typing)
Compile-time проверки
Компилятор enforce’ит:
| Проверка | Когда |
|---|---|
Effect[E, never] не содержит interrupt в handler-method’ах | при compilation handler-литерала |
interrupt v где typeof(v) ⊑ IRT | при compilation handler-литерала |
IRT ⊑ W (где W — тип with-выражения) | при compilation with |
Если условие не выполнено — compile error со ссылкой на конкретное место.
Inference IRT
IRT чаще всего выводится из тела handler-литерала:
// IRT выводится из interrupt-выражений:
fn make_handler() -> Effect[Logger, _] => effect Logger {
log(msg) {
if msg.starts_with("FATAL") { interrupt -1 } // IRT = int
println(msg)
}
}
// эквивалентно:
fn make_handler() -> Effect[Logger, int] => effect Logger { ... }
Для return-position parent fn’а компилятор смотрит на тип return и проверяет совместимость с inferred IRT.
Несколько interrupt с разными типами
Если handler-method содержит несколько interrupt v_1, interrupt v_2,
… — IRT выводится как наименьший общий supertype их типов:
fn make_handler() -> Effect[Logger, Result[(), str]] => effect Logger {
log(msg) {
if msg.starts_with("ERROR") { interrupt Err("logged error") }
if msg.starts_with("FATAL") { interrupt Ok(()) }
println(msg)
}
}
// IRT = Result[(), str] — supertype Err(str) и Ok(())
Если supertype’а нет — compile error «handler has incompatible interrupt types».
Inline handler в with-блоке
Когда handler-литерал стоит прямо в with-блоке (не передаётся
как value через named fn), IRT определяется по правилам D61 секция 10
(unify body ↔ interrupt). Параметризация Effect[E, IRT] тут
неявная — компилятор знает контекст и не требует явных аннотаций:
let r = with Fail[E] = effect Fail[E] {
fail(err) => interrupt -1
} {
fetch_count()
}
// IRT inferred = int (из interrupt -1)
// W = int (из body fetch_count())
// совместимо → r: int
Migration: handler-лямбда
Handler-лямбда D31 автоматически работает с D87:
// Inline в with — IRT inferred из контекста:
with Fail[E] = |err| interrupt Err(err) { // IRT = Result[T, E]
Ok(work())
}
// Returned from named fn — нужен явный IRT:
fn make_fail_handler() -> Effect[Fail[E], Result[T, E]] =>
|err| interrupt Err(err)
Что отвергнуто
Effect[E]без второго параметра разрешаетinterrupt— отвергнуто. Тогда тип не сообщает о возможности interrupt’а, и программист не может локально понять что будет вwith-блоке.- Effect-row для interrupt’ов (
fn make() -> Effect[E] interrupts T) — отвергнуто. Сложнее парсить, нет прецедентов, не композируется с generic’ами так же чисто как второй параметр. - Implicit IRT через inference во всех случаях — отвергнуто. Inference работает для inline и для local fn (Plan 19 first-use), но для public API функций IRT должен быть явно в сигнатуре — иначе программист (или LLM) не увидит контракт без чтения тела.
Связь
- D61 —
семантика
interrupt, тип with-блока, базовое определениеEffect[E]. D87 расширяет доEffect[E, IRT]. - D31 —
handler-лямбда
|x| body. Совместима с D87: IRT inferred из тела. - D88 —
default-значения generic’ов;
IRT = neverиспользует этот механизм. - D26 —
neverкак bottom-type. - revolutionary.md → R1 — принцип «контракты видны в сигнатуре».
Эволюция
Зафиксировано 2026-05-10. Закрывает gap, выявленный при обсуждении
closure-rev и handler-лямбды на |x|: тип Effect[E] не сообщал о
способности handler’а делать interrupt, что нарушало R1 «контракты
в сигнатуре».
Migration: ~10 примеров Effect[E] в spec/, где handler делает
interrupt, перевести на Effect[E, IRT]. Inline handler-литералы
в with-блоках не требуют миграции (IRT inferred неявно по D61).
Plan 97 amendment (2026-05-22) — Handler → Effect rename
С D142 (Plan 97 Ф.3) builtin переименован:
| Pre-D142 | Post-D142 |
|---|---|
Handler[E] | Effect[E] |
Handler[E, IRT] | Effect[E, IRT] |
Handler[E, never] | Effect[E, never] |
Семантика полностью идентична — это renaming, не пересмотр.
Effect[E, IRT] остаётся встроенным generic-типом с двумя
параметрами (E — эффект, IRT — interrupt-return-type),
default-значение IRT = never через D88.
Имя Effect выбрано для симметрии с keyword’ом литерала effect:
// declaration ─ keyword `effect` после имени
type Logger effect { log(msg str) -> () }
// literal ─ тот же keyword `effect` префиксом (без `type`)
fn console_logger() -> Effect[Logger] => effect Logger {
log(msg) => println(msg)
}
// type-position ─ builtin `Effect[...]`
fn run(h Effect[Logger]) -> () => with Logger = h { ... }
Три use-site’а (declaration / literal / type-position) пишутся
через одну вариативность keyword’а: effect — для синтаксиса,
Effect[...] — для типа значения. Тавтологии «Handler для Effect»
больше нет — Effect[Logger] читается как «значение-effect для
протокола Logger» (то же, что Result[int, str] — «значение-result
для int и str»).
Миграция (clean break, без backwards-compat) — sweep одной CL’ой по prelude / std / nova_tests / examples / spec.
D120. #pure views + axioms + #verify/#trusted handlers
Статус: Принято (Plan 33.3 Ф.9, реализовано 2026-05-14)
Решение
Эффект или протокол может объявлять #pure операции (чистые
проекции состояния) и axiom — утверждения об их поведении,
используемые SMT-движком при верификации контрактов.
effect Db {
setBalance(id AccountId, x money) -> ()
#pure balance(id AccountId) -> money
axiom non_negative(id) =>
balance(id) >= 0
axiom balance_after_set(id, x) =>
post(setBalance(id, x))(balance(id)) == x
}
with-binding для эффекта, у которого объявлены axioms, обязан
явно указывать #verify или #trusted:
#verify— компилятор символически проверяет handler’а против axioms.#trusted— axioms принимаются без proof (handler содержит FFI, IO или ветвление, не поддерживаемое V1 symbolic execution).
with #trusted Db = ffi_handler { ... } // контракт принят на доверие
with #verify Db = pure_handler { ... } // V1: gate принят, Ф.9.7 — pending
Без явного атрибута использование такого handler — compile error.
Обоснование
Эффекты с axioms приобретают формальный контракт, проверяемый SMT.
Это позволяет ensures Db.balance(to) == old(Db.balance(to)) + amount
доказываться без знания тела handler’а — достаточно axiom’ов эффекта.
Nova — единственный mainstream-язык с effect-aware contracts в сигнатуре.
Реализация
compiler-codegen/src/ast/mod.rs—OpKind::PureView,EffectAxiom, полеaxioms: Vec<EffectAxiom>вTypeDecl.compiler-codegen/src/parser/mod.rs— синтаксис#pure op,axiom name(binders) => formula.compiler-codegen/src/types/mod.rs— type-check axiom-body, gate#verify/#trusted.compiler-codegen/src/verify/encode.rs— кодировка#pureview → UF, axiom →Z3_mk_forall_const; inconsistency check pre-flight.
Ограничения V1
#verify(Ф.9.6) принимает атрибут и применяет gate, но symbolic handler verification (Ф.9.7) — placeholder, реализация в Plan 33.4 Ф.1.- Поддерживаются только static axioms (
balance(id) >= 0); axioms про state transition (post(action)(view) == X) — V2.
D115. Axiom binder — BinderType enum вместо Option<TypeRef>
Статус: Принято (Plan 33.4 P1-5, реализовано)
Решение
Параметры axiom-формулы ранее представлялись как Vec<(String, Option<TypeRef>)>,
где None означало «без типа». Семантически существуют три различных
состояния:
| Состояние | Смысл |
|---|---|
Untyped | Binder без аннотации (axiom foo(x)) |
Typed(TypeRef) | Binder с конкретным типом (axiom foo(x AccountId)) |
Generic(String) | Binder через generic-параметр эффекта (axiom foo(x T)) |
Введён enum:
pub enum BinderType {
Untyped,
Typed(TypeRef),
Generic(String),
}
pub struct BinderDef {
pub name: String,
pub kind: BinderType,
pub span: Span,
}
EffectAxiom.binders теперь Vec<BinderDef>.
Обоснование
Option<TypeRef> не различал Untyped и Generic — оба давали None.
Enum устраняет двусмысленность и позволяет SMT-encoder правильно
выводить sort для каждого binder’а (Generic → sort из параметра эффекта).
Реализация
compiler-codegen/src/ast/mod.rs, parser/mod.rs, types/mod.rs,
verify/pipeline.rs — механический рефактор (4 файла).
D118. Typed Fail[E] codegen — payload preservation via fail-frame
Status: active (spec). Реализация — Plan 61. Расширяет D25/D65/D85.
Что
throw expr где expr: T (T ≠ nova_str, e.g. record/sum variant) —
payload передаётся через NovaFailFrame.error_user_payload +
NovaFailFrame.error_user_type_id (NovaTypeId). Handler-arm |e: E|
читает payload как (E*)payload через transparent C cast.
До Plan 61: codegen делал Nova_Fail_fail(nova_int_to_str((nova_int)val)) —
silent pointer-to-int pun. Handler получал garbage string. Silent UB #1
закрыт Plan 61 Ф.2/Ф.3.
Правило
-
Throw lowering (Stmt + Expr):
expr: nova_str→ legacyNova_Fail_fail(msg).expr: T*или value →nova_throw_typed(msg_repr, payload, NOVA_TID_<T>). Value-types heap-boxed inline (nova_alloc(sizeof(T))+ copy).
-
Handler-arm typed binding:
with Fail[E] = |e| body— compiler infer’итe: Eиз effect-type (Plan 61 Ф.3 inference вdesugar_handler_lambda).- В body
Ident(e)resolves через(E*)_nova_fail_top->error_user_payload(pointer) или*(E*)_nova_fail_top->error_user_payload(value). - Pattern-match
match e { ... }работает natural — field-access проходит через typed cast.
-
Dispatch precedence (
nova_throw_typedв effects.h):_nova_handler_Fail_any— erased typed slot (Fail ≡ Fail[any] catch-all D65 правило 1). Если установлен, вызывается с(payload, tid)._nova_handler_Fail— legacy string slot. Вызывается сmsg_repr(typeid name). Handler arm может typed (читает payload через frame) или string (читает msg).- Unwind через fail-frame с typed payload preserved
(
error_user_payloadset’нут на step 0 — до dispatch).
-
D65 правило 3 (re-throw):
_nova_handler_Fail = current->prevswap во время handler-body invocation — корректно работает с typed throws потому чтоnova_throw_typedreuses тот же swap pattern. -
expr!!: codegen эмититNova_Fail_fail(err_payload)для bootstrap-stage Result (hardcoded наErr = nova_str). После Plan 14/56 generic Result mono’d — Err получит real type, codegen перейдёт наnova_throw_typed. Plan 61 Ф.4 removednova_throw_valueplaceholder macro — был Silent UB #2 (silently замещал payload на строку"Result::Err").
Codegen representation
/* NovaFailFrame extended (Plan 61 Ф.2): */
typedef struct NovaFailFrame {
jmp_buf jmp;
nova_str error_msg;
NovaThrowKind error_kind; /* USER / CANCEL / USER_TYPED */
void* error_reason_ptr; /* Plan 49 typed cancel */
void* error_user_payload; /* Plan 61 typed user payload */
NovaTypeId error_user_type_id; /* Plan 61 type tag */
struct NovaFailFrame* prev;
} NovaFailFrame;
/* NovaTypeId (Plan 61 Ф.1, typeid.h): */
typedef uint32_t NovaTypeId;
/* Reserved 1..16 для primitives. User types — IDs from USER_BASE = 17
* через compile-time auto-register в codegen (splice'тся в preamble как
* #define NOVA_TID_USER_<X> N). */
/* Erased typed slot (Plan 61 Ф.2): */
typedef struct NovaVtable_Fail_any {
void* ctx;
nova_unit (*fail)(void* _ctx, void* err, NovaTypeId tid);
struct NovaVtable_Fail_any* prev;
} NovaVtable_Fail_any;
extern __thread NovaVtable_Fail_any* _nova_handler_Fail_any;
Plan 61 followup (production-grade closure 2026-05-17)
Все 4 ранее-deferred items закрыты production-grade в followup session:
-
Cross-effect throw в handler-arm (
with Fail[A] = |e| throw B {...}) — закрыто через owner_iframe поле в NovaVtable_Fail / NovaVtable_Fail_any- новый TLS slot
_nova_current_handler_iframe(set/restored dispatcher’ом в Nova_Fail_fail / nova_throw_typed / per-E throw entries).nova_interrupt/nova_interrupt_ptrсначала смотрят этот slot — handler-arminterrupt vjump’тся в OUR with-block, не в _nova_interrupt_top (который может быть inner nested). Это architectural fix interrupt-frame routing для cross-effect dispatch.
- новый TLS slot
-
Stdlib migration —
semver_range.parse_versionмигрирован на idiomatic D65 правило 3 form (with Fail[A] = |_e| throw NewErr {...}) после Plan 61 fu#1. Other stdlib usages (retry.nvResult-wrap для last_error capture,http.nv/audit.nvconvert-to-Response patterns) — legitimate patterns, не workaround; задокументировано. -
Generic Result typed Err — (история: Plan 61 fu#3) hybrid через extended
Nova_Resultstruct (err_typed_payload+err_typed_type_id)nova_make_Result_Err_typed(payload, tid). ✅ Заменено полной мономорфизацией (Plan 59 Ф.7.5 increment 2, 2026-05-21):Result[T,E]→ per-(T,E) типNovaRes_<ok>_<err>*, гдеpayload.Err._0несёт реальное typed-значение Err напрямую.Err(custom_value)строит mono-инстанс (hybridnova_make_Result_Err_typedearly-return удалён);expr!!для non-str Err —nova_throw_typedс реальным payload’ом. typed-Err поля (err_typed_payload/err_typed_type_id) сохранены в схеме mono-типа дляResult[T, str]-кейсов.
-
Per-E TLS slots + per-E vtable — реализовано через preamble splice
/*__PER_E_FAIL_DECLS__*/. Для each E type registered in per_e_fail_types — эмиттится typedefNovaVtable_Fail_<E>(typed(void* ctx, E* err)signature), TLS slot_nova_handler_Fail_<E>, fast-path_nova_throw_typed_<E>(E* payload)dispatcher. Dual-install в emit_with: дляwith Fail[E] = ...install legacy_nova_handler_Fail(current) AND per-E slot через adapter wrapper (sets typed payload в fail-frame, delegates к legacy handler).Stmt::Throw/ExprKind::Throwдля concrete E emit per-E throw entry (fallback к erased path preserves payload via fail-frame).
Что отвергнуто
- String-only
Fail— ломает D65 правило 1. nova_throw_valueplaceholder — УДАЛЁН в Plan 61 Ф.4 (Silent UB #2).Full per-(T, E) Nova_Result mono struct — требует extension Plan 48/59 mono на sum types. Hybrid через extended Nova_Result (typed slot) даёт equivalent semantics для bootstrap; full mono — future polish.✅ РЕАЛИЗОВАНО (Plan 59 Ф.7.5 increment 2, 2026-05-21): full per-(T,E) Result mono —NovaRes_<ok>_<err>*. Hybrid через extendedNova_Resultснят, остался лишь как back-compat#define-алиас.
Связь
- D25/D65 — Fail семантика, правила 1-5.
- D85 —
expr!!semantics. - Plan 11 — закрыт cross-effect throw bug в Plan 61 followup #1 (owner_iframe routing).
- Plan 59 Ф.7.5 —
full per-(T,E) Result mono struct
NovaRes_<ok>_<err>✅ реализован (increment 2, 2026-05-21); заменил hybrid extendedNova_Resultиз Plan 61 fu#3. - Plan 49 — симметричная typed-payload infra для CANCEL kanal. Plan 61 — для USER kanal. Две оси параллельны.
- D158 — failable defer/errdefer body. Расширяет
NovaFailFrameполемerror_suppressed(singly-linkedNovaErrorChain) для multi-error composition при cleanup-fail во время propagation. Plan 100.4.1 (2026-05-23 proposed; runtime impl extends этот D118 fail-frame layout).