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

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

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

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

Три части:

  1. Интерфейс — набор операций с сигнатурами без реализации
  2. Неявный параметр — реализация передаётся через with-скоуп, не через список аргументов
  3. Проверяемый — если функция использует операцию, эффект обязан быть в её сигнатуре
type Db effect {
    query(q Sql) -> []DbRow                // только сигнатуры, без реализации
    exec(q Sql)  -> ()
}

// функция декларирует, какой эффект ей нужен
fn process(o Order) Db -> Receipt =>
    Db.query(sql`SELECT * FROM orders WHERE id = ${o.id}`)
                                           // вызов операции активного handler'а

// промежуточные функции просто пробрасывают эффект — без with
fn handle_request(o Order) Db Log -> Receipt {
    Log.info("processing")
    process(o)                              // handler берётся из вызывающего скоупа
}

// `with` ставится один раз — там, где определяется реализация
fn main() Io -> () =>
    with Db = postgres_handler {
        handle_request(o)                   // handler виден через всю цепочку
    }

with нужен один раз, в той точке, где выбирается реализация. Между этой точкой и местом вызова операции может быть сколько угодно функций — каждая из них объявляет эффект в сигнатуре, но with не повторяет. Это решает проблему «реализацию приходится передавать параметром через все промежуточные функции»: установил with один раз — она видна везде ниже по стеку.

Синтаксис

Эффекты идут между списком параметров и ->:

fn double(x int) -> int                       // чистая
fn parse(s str) Fail -> int                 // может бросить
fn save(u User) Fail Db Log -> ()           // три эффекта
fn fetch(url str) Net Fail -> Response

Граница задана структурой: всё между ) и -> — эффекты.

Имена — обычные именованные типы

Эффекты — это именованные effect-типы (по D61), в PascalCase. Объявляются через kind-токен effect, отличаются от структурных контрактов (protocol) семантикой with-substitution и continuation-capture.

type Logger effect {
    log(msg str) -> ()
}

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
Iostdin/stdout/stderr
FsФайловая система
NetСетевые запросы
DbБаза данных
TimeЧасы, таймеры, задержки
RandomRNG
LogСтруктурированный лог
TraceРаспределённая трассировка
Ask[T]Чтение из контекста (как Reader)
Alloc[R]Аллокация в регионе R
DetachFire-and-forget задача, переживающая caller’а (D50)
BlockingСинхронный C-вызов на blocking-pool потоке (D50)

Async, Mut, Par не входят в стандартный набор по D62: Async — ambient capability (не часть type system’ы), Mut — заменяется специализированными эффектами, Par — runtime-keyword parallel for / spawn.

Программист может объявлять собственные эффекты — это обычное объявление типа через effect.

Зачем это нужно

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, возвращать в эту точку нечего.

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

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