Nova — система эффектов
Это введение в концепцию. Полная развёртка с handler’ами, AI-first обоснованием и стандартным набором эффектов — в revolutionary.md. Вопросы async, panic и эффект-стирания — в D12, D13, D14.
Центральный принцип
Сеть, диск, время, случайность, лог, ошибка, мутация — в Nova это
всё эффекты. Функция объявляет в сигнатуре те эффекты, которые
использует сама; вызовы других функций не тащат свои эффекты вверх
(исключение — Fail, ошибки видны транзитивно). У каждого эффекта
есть handler, который перехватывает его операции.
Если в сигнатуре нет прямых эффектов и функция не вызывает эффектные функции — она детерминирована (с оговоркой про Panic, см. ниже).
effect vs protocol
В Nova два разных способа описать «что-то с операциями»:
- «Как делать что-то» — функция объявляет, что ей нужны
такие-то операции, а какая реализация будет под ними — решает
вызывающий код через
with-блок (например, для прода — Postgres, для теста — in-memory). Это эффект, объявляется черезtype X effect { ... }. - «Что умеет значение» — реализация жёстко привязана к типу:
intхешируется так-то,str— так-то, и менять это нельзя. Это протокол, объявляется черезtype X protocol { ... }.
Когда использовать эффект, а когда протокол в коде: если хочется при тестировании использовать другую реализацию — это эффект. Если при тестировании мы просто работаем со значениями типа, и подменять там нечего — это протокол.
У эффекта нет полей
У эффекта нет полей — только сигнатуры операций. State, если он нужен, живёт в окружении handler’а и попадает к нему через захват (как у обычного замыкания).
Эффект — это интерфейс + неявный параметр
Самый точный способ понять:
Эффект = интерфейс + неявный параметр, проверяемый компилятором.
Три части:
- Интерфейс — набор операций с сигнатурами без реализации
- Неявный параметр — реализация передаётся через
with-скоуп, не через список аргументов - Проверяемый — если функция использует операцию, эффект обязан быть в её сигнатуре
type Db effect {
query(q Sql) -> []DbRow // только сигнатуры, без реализации
exec(q Sql) -> ()
}
// функция декларирует, какой эффект ей нужен
fn process(o Order) Db -> Receipt =>
Db.query(sql`SELECT * FROM orders WHERE id = ${o.id}`)
// вызов операции активного handler'а
// промежуточные функции просто пробрасывают эффект — без with
fn handle_request(o Order) Db Log -> Receipt {
Log.info("processing")
process(o) // handler берётся из вызывающего скоупа
}
// `with` ставится один раз — там, где определяется реализация
fn main() Io -> () =>
with Db = postgres_handler {
handle_request(o) // handler виден через всю цепочку
}
with нужен один раз, в той точке, где выбирается реализация.
Между этой точкой и местом вызова операции может быть сколько угодно
функций — каждая из них объявляет эффект в сигнатуре, но with не
повторяет. Это решает проблему «реализацию приходится передавать
параметром через все промежуточные функции»: установил with один
раз — она видна везде ниже по стеку.
Синтаксис
Эффекты идут между списком параметров и ->:
fn double(x int) -> int // чистая
fn parse(s str) Fail -> int // может бросить
fn save(u User) Fail Db Log -> () // три эффекта
fn fetch(url str) Net Fail -> Response
Граница задана структурой: всё между ) и -> — эффекты.
Имена — обычные именованные типы
Эффекты — это именованные effect-типы (по D61),
в PascalCase. Объявляются через kind-токен effect, отличаются от
структурных контрактов (protocol) семантикой with-substitution
и continuation-capture.
type Logger effect {
log(msg str) -> ()
}
let console = handler Logger {
log(msg) => println(msg)
}
handler Logger { ... } — handler-литерал (D61).
Префикс handler обязателен и однозначно отличает от record-литерала
User { id: 1 }.
Имя эффекта в коде — три позиции
fn process() Db -> () // 1. позиция типа
Db.query(sql`...`) // 2. позиция операции
let captured = Db // 3. позиция выражения = активный handler
Парсер различает по позиции.
Стандартные эффекты
| Эффект | Что описывает |
|---|---|
Fail[E] | Контракт для перехвата и обработки ошибки типа E |
Io | stdin/stdout/stderr |
Fs | Файловая система |
Net | Сетевые запросы |
Db | База данных |
Time | Часы, таймеры, задержки |
Random | RNG |
Log | Структурированный лог |
Trace | Распределённая трассировка |
Ask[T] | Чтение из контекста (как Reader) |
Alloc[R] | Аллокация в регионе R |
Detach | Fire-and-forget задача, переживающая caller’а (D50) |
Blocking | Синхронный C-вызов на blocking-pool потоке (D50) |
Async, Mut, Par не входят в стандартный набор по
D62: Async — ambient capability
(не часть type system’ы), Mut — заменяется специализированными
эффектами, Par — runtime-keyword parallel for / spawn.
Программист может объявлять собственные эффекты — это обычное
объявление типа через effect.
Зачем это нужно
1. Видно по типу, что вызов делает
let x = double(5) // не делает ничего
let y = parse(s)? // может упасть — обязан обработать
let r = http.get(url)? // ходит в сеть — видно в сигнатуре
LLM (и человек), читая сигнатуру, знает все побочные действия. В Python/Java/Go этой информации в типе нет.
2. Чистые функции отделены от грязных
Если в сигнатуре нет эффектов — компилятор знает: можно мемоизировать, вызвать в любом потоке, заменить на константу при равных входах.
3. Невозможно «случайно» добавить побочку
Кто-то добавил Log.info(...) в утилиту форматирования — компиляция
у вызывающих сломается, потому что появился эффект Log. Тихо
протащить нельзя. Это фича.
Async — невидимая инфраструктура (D62)
Suspension в Nova — не эффект, а ambient runtime-инфраструктура.
Без Future<T> в типе. Без await. Цвет функции отсутствует.
Программист не видит «может ли функция приостановиться» в её
сигнатуре.
fn fetch(url str) Net -> Response => ...
fn handler(req Request) Net Db -> Response {
let user = fetch_user(req.id) // никаких .await
let posts = fetch_posts(user.id)
Response.json(posts)
}
Под капотом — fiber-based scheduler (как Go/OCaml 5). Цена — килобайты памяти на fiber, миллион fiber’ов на машину — норма.
Если нужна гарантия «здесь нельзя приостанавливаться» — используется
блок realtime { ... } как
inverse-маркер.
Подробно — decisions/06-concurrency.md#d14, decisions/04-effects.md#d62.
Что НЕ эффект — Panic
Не каждое прерывание — эффект. Аппаратные/математические сбои не указываются в сигнатуре:
- Деление на ноль
- Целочисленное переполнение
- Выход за границы массива
- Переполнение стека
- Out-of-memory
Они образуют категорию Panic. Программист не ловит panic в коде —
это смерть текущего fiber’а, runtime обрабатывает на границе:
fn handle_request(r Request) Db Log -> Response =>
process(r) // если panic — fiber умирает, runtime вернёт 500
fn server() Net Fail -> () =>
supervised {
spawn handle_requests()
} strategy = one_for_one
// supervisor рестартует упавшие fiber'ы
panic — это смерть fiber’а, не процесса. В сервере падает только
текущий запрос, остальное работает. Если нужно гарантированно гасить
процесс — отдельная функция exit(code, msg) (D13).
Иначе Fail[DivByZero] оказался бы в каждой второй сигнатуре —
информативность эффектов исчезла бы. Сознательный компромисс,
подробно — decisions/08-runtime.md#d13.
Роли — throw / Fail[E] / handler
Чтобы не путать слои, три участника обработки ошибок:
-
throw err— синтаксис языка, запускает ошибку. Послеthrowуправление в эту точку не возвращается (тип операцииnever). -
Fail[E]— эффект-контракт для перехвата и обработки ошибки. -
handler
Fail[E]— то, что перехватывает ошибку. У handler’а ровно два исхода:- завершить with-блок значением через
interrupt v, - перебросить ошибку дальше через
throw.
Возобновить вызов в точке
throwнельзя — тип операцииnever, возвращать в эту точку нечего. - завершить with-блок значением через
Операторы ? и !!
Программист выбирает стиль обработки на месте использования (D85):
expr?— return-стиль: «не получилось — обёртка наверх как значение». Внешняя функция должна возвращатьOption/Result.expr!!— throw-стиль: «не получилось — throw черезFail». Внешняя функция должна иметьFail[E]в сигнатуре.
fn pipeline_return(s str) -> Result[int, ParseError] {
let n = parse(s)? // на Err: return Err(e)
validate(n)?
Ok(n)
}
fn pipeline_throw(s str) Fail[ParseError] -> int {
let n = parse(s)!! // на Err: throw e
validate(n)!!
n
}
Оба оператора работают и для Option[T], и для Result[T, E]. Для
Option!! бросается RuntimeNoneError (prelude unit-тип).
Параллельно остаётся ?? — coalesce / кастомный fallback:
let port = config.get("port") ?? 8080 // default
let port = config.get("port") ?? throw MyError // custom throw
let port = config.get("port") ?? panic("no port") // panic (D13)
Альтернатива: явный Result
fn parse(s str) -> Result[int, ParseError] => ...
Два стиля одного и того же. Fail — сахар поверх Result.
Дефолт для прикладного кода — Fail (читаемее), для библиотек
с важным типом ошибки — явный Result.
Главный смысл
Эффекты — это обещание в сигнатуре + точка перехвата. Один
механизм для того, что в других языках разнесено по try/catch,
async/await, dependency injection, моков и unsafe.