Nova — синтаксис
Минимальные примеры
// Hello world — никаких main, package, import для stdlib
print("hello")
// Чистая функция: нет эффектов, нет ошибок, детерминирована
fn double(x int) -> int => x * 2
Tagged template literals — tag\…“
Литерал с префиксом-тегом обрабатывается функцией tag. Возвращает
тип, который выбирает функция (не обязательно str):
let j = json`{"name": "alice"}` // -> Json
let q = sql`SELECT * FROM users WHERE id = ${user_id}` // -> Sql, безопасно
let r = regex`\d+\.\d+` // -> Regex, raw
let b = bytes`deadbeef` // -> Bytes
Интерполяция через ${expr} — tag-функция получает части и
аргументы раздельно, что обеспечивает безопасность (защита от SQL
injection):
sql`SELECT * FROM users WHERE name = ${name}`
// → sql(["SELECT * FROM users WHERE name = ", ""], [name])
// функция передаёт name как параметр, не склеивает в строку
Multiline работает естественно. Escape: \`, \\, \${ —
буквальные. Остальные символы — raw (удобно для regex и SQL).
Стандартные теги в stdlib: json, sql, regex, bytes.
Свой тег — обычная функция:
export fn url(parts []str, args []str) -> Url => ...
let u = url`https://api.example.com/users/${user_id}`
Подробно — D48.
Интерполяция строк — "... ${expr} ..."
В обычном строковом литерале "..." (без tag-префикса) разрешена
интерполяция выражений через ${expr}. Это sugar над конкатенацией
с str.from(...):
let name = "alice"
let age = 30
let s = "Hello, ${name}, you are ${age}"
// = "Hello, " + str.from(name) + ", you are " + str.from(age)
Каждое ${expr} должно иметь тип, удовлетворяющий Into[str]
(D73) — для всех примитивов и
prelude-типов это автоматически. Буквальное ${ в строке — через
escape: "\${name}".
Подробно — D44 → «Строковые литералы и интерполяция».
Statement separator: newline или ;
Перенос строки разделяет statement’ы. ; опционален —
нужен только для нескольких statement’ов на одной строке:
let x = 1 // newline разделяет
let y = 2
foo(x, y)
let a = 1; let b = 2; foo(a, b) // ; для одной строки
Newline игнорируется в позициях, где statement продолжается:
// 1. После висящего бинарного оператора
let total = a +
b +
c
// 2. Внутри открытых () [] {}
let user = User {
name: "alice",
age: 30,
}
// 3. Перед .method() (chain)
let result = list
.filter(|x| x > 0)
.sum()
// 4. Перед ? (error propagation)
let user = find_user(id)
?
// 5. Перед else / else if (продолжение if-выражения)
let label =
if s is Origin { "at-origin" }
else if s is Circle { "circle" }
else { "square" }
Бинарные операторы — в конец строки (Go-стиль), не в начало:
let total = a + ✅
b
let total = a
+ b ❌ парсится как унарный +b
Подробно — D49.
Числовые литералы
// Целые
1
1_000_000_000 // разделитель `_` между цифрами
0xFF_FF_FF_FF // hex (любой регистр)
0b1010_0001 // binary
0o755 // octal
// Float
1.5
1_234.567_89
1e10 // научная нотация
1.5e-3
Default-типы без контекста: int для целых, f64 для float. С
аннотацией/контекстом — берётся тип контекста:
let x u8 = 200 // 200 это u8
let arr []f32 = [1.0, 2.0]
Type-suffixes (100u32, 1.5f32) не вводятся. Для редких случаев
дисамбигуации — as-cast: 100 as u32, 0xFF as u8.
Разделитель _ разрешён только между цифрами, не подряд, не в
начале/конце, не сразу после префикса (0x_FF ❌), не вокруг точки
или e. Подробно — D44.
Аннотации типа — без двоеточия
В позициях, где компилятор однозначно знает «дальше идёт тип», двоеточие опускается:
fn save(u User, amount money) Fail Db -> () // параметры
let users []User = [] // let
type User { id u64, name str } // поля типа
for id u64 in ids { ... } // for-loop
: остаётся там, где это разделитель ключ-значение:
let alice = User { id: 1, name: "alice" } // record-литерал
let cfg = { "host": "localhost", "port": 8080 } // dict-литерал
Возврат: -> обязателен, () опционален
fn compute(x int) -> int => x * 2 // явный тип возврата
fn log_event(e Event) Log // -> () можно опускать
fn save(u User) Fail Db // эффекты + dropped -> ()
Closure: light |...| и full fn(...)
В Nova две формы closure (D22):
closure-light — компактная untyped форма, тело bare expr или block:
let inc = |x| x + 1
let zero = || 0
let block = |x| { let y = x*2; y + 1 }
let any = |_| 0 // wildcard
list.filter(|x| x > 0)
list.fold(0, |acc, x| acc + x)
m.get_or_insert("k", || 0)
spawn(|| compute())
|...| валиден только когда контекст однозначно задаёт сигнатуру
(параметр fn-call’а, annotated let, return-position, first-use
inference). Без контекста — переключайся на fn(...).
closure-full — типизированная форма, идентична named fn без имени.
Тело => expr или { block }:
let typed = fn(x int) -> int => x * 2
let block = fn(x int, y int) -> int { let z = x+y; z * 2 }
let with_eff = fn(req Request) Db Log -> Response { process(req) }
Эффекты в closure-light не пишутся — они наследуются из ambient
effect-set (= эффекты enclosing-функции ∪ активные with-блоки).
Если тело closure’а использует эффект, недоступный в parent’е —
compile error. Closure-full объявляет эффекты явно, как named fn.
Trailing — блок/функция-аргумент за скобками вызова
Если последний параметр функции — функционального типа, аргумент можно
вынести за () вызова в одну из двух форм:
trailing-block — для callback’ов без параметров (DSL):
with_timeout(2.seconds) {
Db.exec(sql`UPDATE counters SET v = v + 1`)
}
retry(3) {
Net.get(url)
}
trailing-fn — для callback’ов с параметрами, синтаксис идентичен closure-full без имени:
list.filter() fn(x) => x > 0
list.fold(0) fn(acc, x) { acc + x }
list.map() fn(s str) Fail -> int { parse(s)? }
Правила:
{(для trailing-block) илиfn(для trailing-fn) на той же строке, что). Перенос запрещён.()обязательны (даже пустые).- Тип последнего параметра — функциональный.
- Один trailing на вызов.
|...|(closure-light) в trailing-position запрещён — передавай через args (f(|x| body)) или используйfn(...).
spawn — keyword-конструкция, не функция, поэтому не подчиняется
правилу D43. Его синтаксис описан отдельно ниже.
Когда trailing-fn vs closure-light в args:
f(|x| body)— компактнее для one-liner’ов.f(args) fn(x) { ... }— лучше для длинных тел сlet’ами, визуально маркирует «это блок-аргумент к вызову».
Тело функции: => для выражения, {} для блока
Два взаимоисключающих способа:
// expression-body — ровно одно выражение
fn double(x int) => x * 2 // -> int выведен (D45)
fn classify(n int) -> str => match n { // -> str для ясности
0 => "zero",
n if n > 0 => "positive",
_ => "negative",
}
// block-body — несколько шагов; последнее выражение = значение блока
fn next_pow2(n int) -> int { // -> int обязателен
if n <= 1 { return 1 }
let mut p = 1
while p < n { p *= 2 }
p
}
В expression-body -> T опционален — тип выводится из тела
(D45). В block-body -> T обязателен (если не unit).
Indentation не значим. fn f() => stmt1; stmt2 или multiline без
{} — ошибка. Если шагов больше одного — {} обязательны.
Style: для export-функций (public API) рекомендуется писать -> T
явно — это документация и стабильность. Для приватных и tiny helpers
можно опускать.
Перегрузка операторов
Стандартные операторы автоматически вызывают методы с фиксированными именами:
fn Duration @plus(other Duration) => Duration { nanos: @nanos + other.nanos }
fn Duration @times(n i64) => Duration { nanos: @nanos * n }
let total = 1.hour() + 30.minutes() // вызывает @plus
let triple = 5.seconds() * 3 // вызывает @times
if elapsed > 1.second() { ... } // вызывает @gt
| Оператор | Метод | Оператор | Метод | |
|---|---|---|---|---|
+ | @plus(o) | == | @eq(o) -> bool | |
- (binary) | @minus(o) | < | @lt(o) -> bool | |
- (unary) | @neg() | <= | @le(o) -> bool | |
* | @times(o) | > | @gt(o) -> bool | |
/ | @div(o) | >= | @ge(o) -> bool | |
% | @rem(o) | ! | @not() | |
| | @or(o) | << | @shl(n) | |
& | @and(o) | >> | @shr(n) | |
^ | @xor(o) | |||
a[i] | @get(i) | a[i]=v | @set(i, v) |
!= выводится из @eq. &&/|| не перегружаются (short-circuit
семантика). Custom-операторы (:+, <>) не разрешены. Подробно —
D46.
Математические операции на числовых типах
Стандартные математические функции на f64 / f32 / int объявлены
как instance-методы через @, не как static Math.sin(...).
Это согласовано с D35 (методы — основной механизм для type-bound
функций) и даёт chain-friendly формулы:
let r = (x * x + y * y).sqrt()
let phi = im.atan2(re)
let dist = a.hypot(b)
let s = (theta + offset).sin()
Стандартный набор на f64 (prelude):
| Категория | Методы |
|---|---|
| Корни и степени | @sqrt(), @cbrt(), @sqr(), @pow(exp f64), @powi(n int) |
| Тригонометрия | @sin(), @cos(), @tan(), @asin(), @acos(), @atan() |
atan2 (двух-арг) | @atan2(other f64) -> f64 (y.atan2(x)) |
| Гиперболические | @sinh(), @cosh(), @tanh() |
| Экспонента / лог | @exp(), @ln(), @log10(), @log2(), @log(base f64) |
| Норма / расстояние | @abs(), @hypot(other f64) |
| Округление | @floor(), @ceil(), @round(), @trunc(), @fract() |
| Знак / минимум | @signum(), @min(other f64), @max(other f64) |
| Предикаты | @is_finite(), @is_nan(), @is_infinite() |
Аналогичный набор на int для целочисленных операций
(@abs(), @pow(n int), @signum(), @min, @max).
Имена, на которые стоит обратить внимание:
@sqr()— квадратx*x. Прецедент PascalSqr(x), короче чемsquared. Согласовано сComplex @sqr()и любыми другими типами, где квадрат — частая операция.@hypot(other)/@atan2(other)— двухаргументные функции, второй аргумент идёт как параметр; receiver — первый аргумент по математической конвенции (y.atan2(x),a.hypot(b)).
Static-функции на типе для тех случаев, где нет естественного receiver’а:
f64.PI // константа
f64.E // константа
f64.NAN // константа
f64.INFINITY // константа
f64.try_parse(s str) -> Option[f64]
Конвенции именования
| Что | Стиль | Пример |
|---|---|---|
| Типы, эффекты, протоколы, варианты sum | PascalCase | User, HashMap, Db, Hashable, Some |
| Generic-параметры | PascalCase, односимвольные | T, K, V, E |
Функции, методы (@name), параметры, поля | snake_case | parse_url, @deposit, user_id, created_at |
Константы (const) | SCREAMING_SNAKE_CASE | MAX_PAYLOAD, DEFAULT_TIMEOUT |
| Модули | snake_case через точки | module admin.audit, module std.duration |
Акронимы — PascalCase, не UPPERCASE. Db, не DB. Http, не HTTP.
Json, не JSON. Url, не URL. Правило: акроним = обычное слово.
Зарезервированные имена методов (operator overloading, D46):
@plus, @minus, @times, @div, @rem, @neg, @or, @and,
@xor, @shl, @shr, @eq, @lt, @le, @gt, @ge, @not,
@get, @set. Не использовать для других целей.
Договорные конвенции:
T.new(...)— стандартный конструктор;T.from(v X)— из значения черезFrom[X]protocol (D73);T.from_X(...)— доменный конструктор когдаfrom(v)не передаёт смысл (from_secs,from_polar,from_imag).@into()— конверсия в другой тип черезInto[T](D73); тип цели берётся из контекста (let s str = v.into()). Конверсия в строку —str.from(v)илиv.into()с context =str(раньше было@to_str()через old D70ToStr, отменено в v3).@hash()— хеш,@clone()— копия,@iter()/@next()— iterator.- Имена ошибок (D30) — с типом / доменом:
ParseComplexError,ParseIntError,DbError,OverflowError. Не использовать genericParseError,ValueError,Exception— коллизии импорта, неоднозначность для AI.
Конвенции @to_X(), @as_X(), @is_X() не вводятся — они
дублируют существующие механизмы:
@to_X()дублируетX.from(v)/v.into()(D73).@as_X()дублирует keywordas(D54) для дешёвых cast’ов илиX.fromдля нетривиальных.@is_X()дублируетv is X(D54): для sum-типов иanyоператорisработает напрямую (shape is Circle,arg is intдляarg any). Для извлечения значения варианта с биндингом —if let X(n) = v(D34)._prefix— только для полей (используй методы вместо прямого доступа). Для функций/методов не используется.- Test-имена — строки естественного языка:
test "insert and get", не"test_insert_and_get".
Зарезервированные identifier’ы
Помимо grammar-keyword’ов, Nova имеет identifier’ы со специальной семантикой, известной компилятору. Их можно переопределить локально, но это анти-паттерн (линтер предупреждает).
Special types:
Self— referential type, refers к receiver-типу метода или типу, удовлетворяющему protocol’у (D66). Валиден в любом type-контексте.any— top-type для runtime type-check (D54).never— bottom-type для не-возвращающих функций.
Prelude types:
Option[T],Some(v),None— sum-типResult[T, E],Ok(v),Err(e)— sum-типError— record{ msg str }дляthrow errRuntimeError— sum bottom-уровневых runtime-ошибокRuntimeNoneError— unit-тип, бросается черезexpr!!наOption(D85)Effect[E]— first-class тип handler’а эффектаFrom[T]— protocol со static-методомfrom(v T) -> Self(D73)Into[T]— protocol с instance-методом@into() -> T(D73). Авто-выводится изFrom[T]и наоборот; пишется одна сторона, другая синтезируется компилятором.
Стандартные эффекты:
Fail[E],Fail— failable-эффектIo,Net,Db,Fs,Time,Random,Log,Trace— основныеAsk[T]— Reader-style контекстAlloc[R]— аллокация в regionDetach,Blocking— (D50)
Примитивные типы (lowercase, исключение из PascalCase-правила):
int,i8,i16,i32,i64,u8,u16,u32,u64f32,f64str,bool,byte
Видимость: export для публичных деклараций
export перед декларацией = публичная (видна снаружи модуля).
Без export = приватная (видна только внутри модуля).
Применяется единообразно к типам, функциям, методам, константам и протоколам:
module account
export type Account { // публичный тип
readonly owner str
balance money
_internal_id u64 // convention: `_` = приватное-по-договору
}
type InternalState { ... } // приватный тип
export const ACCOUNT_MIN_BALANCE money = 0
const _INTERNAL_TIMEOUT_MS int = 5_000
export fn Account.new(owner str) -> Account => ... // публичный конструктор
export fn Account @balance() => @balance // публичный метод
fn Account @validate(amount money) => amount > 0 // приватный helper
export type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
Поля record: в MVP все поля export-типа публичны. Convention
_prefix для приватных-по-договору, не enforced компилятором —
обычно инкапсуляция делается через методы (геттеры/сеттеры).
Объявление типов
После type Имя идёт | Что это |
|---|---|
| | sum-type |
( | tuple-структура |
{ | record-структура |
alias | alias |
| идентификатор/тип | newtype |
| ничего | unit-тип |
// newtype — type X Y, новый тип, типизированно отличный от Y
type UserId u64
type Email str
// alias — type X alias Y, для длинных дженериков
type StringMap[V] alias HashMap[str, V]
// record (форма сразу после имени, без `=`)
type User { id u64, name str }
// позиционная структура
type Point(f64, f64)
// unit-тип
type Marker
// sum-type — варианты через leading |
type Color | Red | Green | Blue
type Shape
| Circle { radius f64 }
| Square { side f64 }
| Triangle { a f64, b f64, c f64 }
type Result[T, E] | Ok(T) | Err(E)
type Option[T] | Some(T) | None
Sum-варианты могут иметь числовые discriminants с auto-increment:
type ExitStatus | Ok | Failure | Critical // 0, 1, 2 (auto)
type ErrorCode
| NotFound = 404
| Unauthorized = 401
| InternalError = 500
type Bit u8 | Off = 0 | On = 1 // явный базовый тип
⚠
type X u8 | …(явный базовый тип) пока не реализован — parser drift, см. Plan 105. Работают только формы без базового типа (implicitint).
Подробно — decisions/02-types.md → D52.
Варианты sum-type — те же три формы, что top-level type
Каждый вариант sum-type объявляется по тем же правилам, что top-level объявление:
| После имени варианта | Что это | Пример |
|---|---|---|
( ... ) | позиционный вариант | Some(T), Ok(T), Point(f64, f64) |
{ ... } | record-вариант | Circle { radius f64 } |
| ничего | unit-вариант | None, Red, Origin |
type Option[T]
| Some(T) // позиционный — несёт значение T
| None // unit — без полей, само по себе значение
type Shape
| Circle { radius f64 } // record-вариант
| Point(f64, f64) // позиционный
| Origin // unit
None — это значение типа Option[T], не функция и не конструктор.
Используется без скобок:
let x = Some(42) // позиционный — нужен аргумент
let y = None // unit — без скобок
Подробно — D17.
Создание значений и pattern matching
let p = Point(1.0, 2.0)
let u = User { id: 1, name: "alice" }
let c = Circle { radius: 5.0 }
let s = Active
// доступ к полям (D37)
println(u.name) // record — по имени
println(p.0, p.1) // позиционная — по индексу
let pair = (1, "alice")
println(pair.0, pair.1) // кортеж — то же
// создание массивов (D38)
let xs []int = [] // пустой, тип из annotation
let ys = []int.new() // через static-метод
let buf = []u8.with_capacity(1024) // с pre-allocation
let zeros = []u8.filled(0, 16) // заполненный
// turbofish для дженериков (D38)
let n = parse[int]("42")? // явный T = int
let m = HashMap[str, int].new() // явные K, V
match shape {
Circle { radius } => 3.14159 * radius * radius
Square { side } => side * side
Triangle { a, b, c } => heron(a, b, c)
}
match result {
Ok(value) => value
Err(error) => default
}
Pattern matching
fn classify(x) => match x {
0 => "zero"
1..=9 => "digit"
n if n < 0 => "negative"
_ => "big"
}
Каждая arm имеет форму pattern => result, опционально с guard’ом
pattern if condition => result. Компилятор пробует arm’ы сверху вниз,
берёт первую, где паттерн совпал И guard истинный.
Виды паттернов:
| Форма | Пример | Что делает |
|---|---|---|
| Литерал | 0, "hello", true | сравнение по значению |
| Range | 1..=9, 0..100 | попадание в диапазон |
| Имя (binding) | n, x | ловит любое значение, привязывает к имени |
| Wildcard | _ | ловит любое значение, не привязывает |
| Конструктор | Some(v), Ok(value), None | разбор варианта sum-type |
| Record | User { id, name } | разбор record-полей |
| Tuple | (a, b), (_, value) | разбор кортежа |
| Guard | n if n < 0 | паттерн + дополнительное условие |
Exhaustiveness check. Компилятор проверяет, что match покрывает
все возможные случаи. Если нет — ошибка с указанием непокрытого
варианта. Это работает для sum-type, range’ей, bool. Для общих типов
(int, str) нужен либо _-wildcard, либо явная проверка всех
рассматриваемых значений.
type Color | Red | Green | Blue
fn name(c Color) -> str => match c {
Red => "red"
Green => "green"
// ОШИБКА: missing variant `Blue`
}
match — это выражение, возвращает значение. Все ветви должны
иметь совместимый тип (или общий supertype, либо обёрнутые в sum-type).
Record-литералы и patterns
Shorthand — когда имя поля совпадает с именем переменной в scope:
let key = "alice"
let value = 42
let entry = Entry { key, value } // shorthand обязателен (D52)
let entry = Entry { key, value, extra: "data" } // можно смешивать
// `Entry { key: key }` — ОШИБКА: используйте shorthand `{ key }`.
Partial pattern matching — указывать только нужные поля:
match @buckets[idx] {
Occupied { value } => Some(value) // partial: key игнорируется
Occupied { value, .. } => Some(value) // явный .. — то же самое
_ => None
}
Обе формы валидны (.. или без) — выбор по контексту. .. —
сигнал «у типа есть ещё поля». Без — короче.
Переименование при деструктуризации:
Occupied { key: k, value } // key переименовано в k, value совпадает
Подробно — D17.
Циклы for / while / loop
for x in list { ... } // x — immutable binding на каждой итерации
for mut x in list { ... } // x можно мутировать в теле
for x int in nums { ... } // явный тип элемента
for mut id u64 in ids { ... } // mut + явный тип элемента
for (i, x) in list.enumerate() { ... } // индекс через метод
while cond { ... } // условный цикл
loop { ... } // бесконечный, выход через break/return
Явный тип элемента — for x TYPE in iter — опционален и следует
универсальному правилу «name type» (как let x int, fn(x int),
[T Bound]). Аннотация проверяется компилятором: если TYPE не
совпадает с фактическим типом элемента итератора — compile error. Это
делает её checked assertion (фиксирует ожидание; смена типа источника
→ loud error), а не молчаливым документирующим сахаром. Go/Rust/TS
аннотацию loop-переменной не дают вовсе — Nova получает её как строгий
проверяемый superset.
Переменная в for x in iter — immutable binding (как let без
mut), на каждой итерации получает новое значение. В теле блока
переприсвоить нельзя:
for x in list {
x = 5 // ОШИБКА: x immutable
}
for mut x in list {
x = transform(x) // ок
}
Это согласовано с правилом D32/D33 — все binding’и иммутабельны по
умолчанию, мутация явно через mut. Никакого const или final
маркера в Nova нет — иммутабельность и так дефолт.
break / continue — стандартные. break value выходит из loop
со значением (loop — выражение).
if let и while let
Паттерн-матч прямо в условии — короткая альтернатива match для
одного варианта:
// если в кеше есть — вернуть
if let Some(data) = cache.get(key) {
return data
}
// извлечение из Result
if let Ok(user) = Db.find(id) {
process(user)
} else {
Log.warn("user not found")
}
// while let — итерация пока паттерн совпадает
while let Some(line) = reader.read_line()? {
process(line)
}
// несколько условий через запятую
if let Some(user) = lookup(id), user.is_active {
process(user)
}
⚠ Chain-форма (
if let … , …) пока не реализована — parser drift, см. Plan 106. Реализовано только одиночноеif let pattern = expr.
Локальные binding’и (data, user, line) доступны только в теле
блока. После закрывающей } — недоступны.
Подробно — D34.
Методы инстанса и static-функции
В Nova — два вида функций ассоциированных с типом, различимых по синтаксису декларации:
// конструктор / static — через точку, без @
fn Account.new(owner str) -> Account =>
Account { _balance: 0, owner }
// метод инстанса — через пробел и @, неявный self
fn Account @balance() -> money => @_balance
fn Account @is_solvent() -> bool => @_balance > 0
// мутирующий метод — mut перед @name
fn Account mut @deposit(amount money) {
@_balance += amount
}
Использование:
let acc = Account.new("alice") // вызов constructor через точку
acc.deposit(100) // вызов метода — точка + скобки
let bal = acc.balance() // getter, обязательные скобки
@field для доступа к полям
Внутри метода (@method или mut @method) поля self доступны через
@field — единственная форма:
fn Account @summary() -> str =>
"${@owner}: ${@_balance}" // = self.owner, self._balance
@.field невалидно — точка не используется. @field — единственно
верно.
@ без поля — это значение текущего инстанса:
fn Account @copy() -> Account => @
fn Account @send_to(ch Channel[Account]) => ch.send(@)
Скобки обязательны для вызова
acc.balance() // вызов метода
acc.balance // bound method value (не вызов!), тип: fn() -> money
Account.@balance // unbound method value, тип: fn(Account) -> money
Account.new // static-функция как значение, тип: fn(str) -> Account
Программист и LLM мгновенно различают: вызов = со скобками, значение = без скобок. Никаких property с побочками.
Generic’и
fn HashMap[K, V].new() -> HashMap[K, V] => ... // generic на типе
fn HashMap[K, V] @get(key K) -> Option[V] => ... // тоже
fn []T @map[U](f fn(T) -> U) -> []U => ... // generic на методе [U]
Подробно — D35.
Embed и delegation: use Type и use name Type
Композиция вместо наследования. use — это поле + автопрокси методов:
type Account {
owner str
balance money
}
fn Account mut @deposit(amount money) => @balance += amount
// embed: имя поля обязательно (D39 — alias всегда явный)
type AuditedAccount {
use account Account
audit_log []AuditEntry
}
fn AuditedAccount mut @withdraw(amount money) Fail[AuditError] {
@account.deposit(-amount) // явный вызов "родителя" через имя поля
@audit_log.push(AuditEntry.new(amount))
}
let aa = AuditedAccount { ... }
aa.deposit(100) // авто-прокси: account.deposit
aa.balance // авто-прокси: account.balance
Имя поля обязательно при use (D39)
— согласовано с D30 (поля snake_case):
type Wrapper[K, V] {
use w HashMapIter[K, V] // имя поля = "w"
extra int
}
fn Wrapper[K, V] @next() -> Option[Pair[K, V]] => @w.next()
// конфликт двух embed — псевдонимы обязательны
type Composite {
use a TimerA
use b TimerB // оба определяют tick() — нужны имена
}
Override. Метод того же имени на внешнем типе перекрывает прокси. Доступ к «родительскому» — через имя поля:
fn AuditedAccount mut @deposit(amount money) {
@account.deposit(amount) // вызов оригинала через имя поля
@audit_log.push(AuditEntry.new(amount))
}
use — это не наследование. AuditedAccount не подтип Account.
Функции fn(Account) принимают Account, не AuditedAccount. Структурные
интерфейсы — отдельный механизм (см. ниже).
Подробно — D39.
Передача параметров
Объекты (record, sum-type, массивы) передаются по ссылке в managed
heap. Примитивы (int, bool, f64, …) — по значению.
Префикс mut разрешает мутацию.
type Account { balance money } // обычное поле — мутируется у mut binding'а
// без mut — иммутабельный view, мутация запрещена
fn show(acc Account) Io => println("${acc.balance}")
// с mut — мутации видны вызывающему
fn deposit(mut acc Account, amount money) {
acc.balance += amount
}
let mut my_acc = Account { balance: 100 }
deposit(my_acc, 50)
// my_acc.balance == 150 ← мутация видна
show(my_acc)
// показывает 150, my_acc не изменён
Поля типа: let для never-mut, mut для cache
type Account {
readonly id u64 // никогда не меняется (D36)
readonly owner str // тоже
balance money // мутируется у mut-binding
closed bool // тоже
mut last_cached_total money // мутируется ВСЕГДА (для cache/lazy)
}
// group-syntax — несколько полей одного типа через запятую
type Point { x, y, z f64 }
type Color { r, g, b u8 }
Подробно про правила мутации полей — D36.
| Форма | Передача | Мутация снаружи |
|---|---|---|
x int | by value | нет |
o Order | managed reference | нет (immutable) |
mut o Order | managed reference | да |
Для perf-критичного кода компилятор использует escape analysis:
не утекающие значения остаются на стеке, без аллокаций в managed
heap. Программист не пишет ничего особого. Для real-time — блок
realtime nogc { } (D64), внутри
region { } для arena-allocations (D6).
Подробно — D32.
Опциональные параметры — через record + spread, не через defaults
Default-значений у параметров функции в Nova нет (намеренно — см.
history/rejected.md). Когда у функции
много параметров с разумными дефолтами, используется паттерн
опции-record + spread: комбинация record-типа с константой-дефолтом
(D52), record-coercion в позиции с
известным типом (D55) и spread ...obj
для override отдельных полей (D60).
type ServerOpts {
port int
host str
max_conn int
timeout Duration
}
const SERVER_DEFAULTS ServerOpts = {
port: 8080,
host: "0.0.0.0",
max_conn: 1024,
timeout: 30.seconds(),
}
fn serve(opts ServerOpts) Net -> () => ...
// Все дефолты:
serve({ ...SERVER_DEFAULTS })
// Override одного-двух полей:
serve({ ...SERVER_DEFAULTS, port: 9000 })
serve({ ...SERVER_DEFAULTS, port: 9000, max_conn: 4096 })
// Совсем кастом:
serve({ port: 9000, host: "127.0.0.1", max_conn: 16, timeout: 5.seconds() })
Преимущества над default-значениями:
- Все опции видны на call-site — программист и LLM не гадают что
значит «остальные дефолты».
...SERVER_DEFAULTSявно говорит «возьми всё остальное оттуда». - Дефолты переиспользуются —
SERVER_DEFAULTS,TEST_DEFAULTS,DEV_DEFAULTSдля разных сред. - Refactoring безопасен — добавил поле в record, спред-вызовы подхватывают новое поле; вызовы без спреда — compile error «missing field», программист увидит каждое место.
- Композиция — несколько spread’ов:
{ ...BASE, ...OVERRIDES, port: 9000 }. - Без новой грамматики — работает через существующие D52/D55/D60.
Когда такой паттерн избыточен:
- Функция имеет 2–3 параметра без дефолтов — пишутся напрямую:
fn move(x int, y int). - Дефолты семантически разные («режимы») — лучше отдельные функции
или sum-type:
fn parse_strict(s str),fn parse_lenient(s str).
Подробно: D52 record, D55 coercion, D60 spread.
Эффекты в сигнатуре
Любое взаимодействие с внешним миром — эффект, объявляется между ) и ->:
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 // сеть + async + ошибки
? и !! — два постфиксных оператора для Option/Result
(D85):
expr?— ранний return обёртки (нужен-> Option/Result).expr!!— throw черезFail[E](нуженFail[E]в сигнатуре).
// throw-стиль через !!
fn pipeline(s str) Fail[ParseError] -> int {
let n = parse(s)!!
let doubled = n * 2
validate(doubled)!!
doubled
}
// return-стиль через ?
fn pipeline_r(s str) -> Result[int, ParseError] {
let n = parse(s)?
let doubled = n * 2
validate(doubled)?
Ok(doubled)
}
Подробнее — effects.md, revolutionary.md.
Контракты (опциональны)
fn withdraw(mut acc Account, amount money) Fail -> ()
requires amount > 0
requires acc.balance >= amount
ensures acc.balance == old(acc.balance) - amount
=>
acc.balance -= amount
Без контрактов код работает как обычно. С ними компилятор пытается доказать статически, что не может — превращает в runtime-проверку в debug-режиме.
Handler’ы — литералы у protocol-эффектов
type Logger effect {
log(msg str) -> ()
}
fn process(x int) Logger -> int {
Logger.log("processing ${x}")
x * 2
}
// handler — обычное значение через keyword `handler` (D61)
let console = effect Logger {
log(msg) => println("[LOG] ${msg}")
}
// применение через with
fn main() Io -> () {
with Logger = console {
process(42)
}
}
return value или финальное выражение в handler-method’е продолжает
вычисление с возвращённым значением. Для досрочного выхода из всего
with-блока — interrupt v (D61). resume в Nova не существует.
Имя эффекта в коде — три позиции
fn process() Db -> () // 1. позиция типа
Db.query(sql`...`) // 2. операция активного handler'а
let captured = Db // 3. сам активный handler как значение
Парсер различает по позиции.
With-блок — несколько подмен в одном
test "complex flow" {
with Logger = collect_into(buf),
Db = in_memory,
Time = fixed(t0) {
process_order(o)
}
assert(buf.contains("processed"))
}
После with — список «эффект = handler-выражение» через запятую,
потом один блок тела.
Параллелизм — без async/await
fn fetch_all(ids []u64) Net Fail -> []User =>
parallel for id in ids {
fetch_user(id)
}
Suspension в Nova — ambient runtime-инфраструктура, не эффект и не
специальная конструкция (D62). Тип возврата []User, не
Future<[]User>. Подробно — revolutionary.md R7.
parallel for — structured concurrency: ждёт всех, отменяет хвост
при ошибке.
Capability-режим
fn run_user_script(code str) Fail -> Result =>
forbid Net, Fs, Db {
eval(code)
}
Внутри forbid компилятор не пропустит вызов функции с запрещёнными
эффектами. Sandbox в типах, не в рантайме.
Производительность — escape analysis и regions
Программист пишет обычный код:
fn hot_loop(data []f64) -> f64 =>
data.iter().sum() // SIMD-авто, zero-alloc через escape analysis
Компилятор сам решает: примитивы — в регистрах, не утекающие объекты — на стеке, остальное — в managed heap. Никаких ссылок вручную.
Для real-time hot path — блок realtime nogc { body }
(D64). Внутри блока запрещены
suspend-операции и аллокации в managed heap; region { ... }
используется для arena-allocations (D6).
Структурные «интерфейсы» — protocol
Никаких interface/trait. Структурный контракт — отдельным keyword
protocol:
// именованный
type Printable protocol {
show() -> str
}
fn log_one(x Printable) Log -> () => Log.info(x.show())
// или прямо в сигнатуре, без имени — анонимный структурный тип
fn log_one(x { show() -> str }) Log -> () => Log.info(x.show())
Совместимость автоматическая по структуре — любой тип с
подходящими методами автоматически удовлетворяет protocol’у, никаких
impl-блоков не нужно. Self валиден внутри любого type-контекста
(protocol-блок, effect-блок, instance-метод, static-метод, sum-вариант)
по D66:
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
type Iterator[T] protocol {
next() -> Option[T]
}
type — для данных (record, sum-type, alias). protocol — для
поведения (методы как контракт). Подробно — D42,
D9 / D15.
Дженерики
fn map[T, U](xs []T, f T -> U) -> []U =>
[f(x) for x in xs]
// дженерик по эффектам — функция наследует эффекты `f`
fn map_eff[T, U, E](xs []T, f (T) E -> U) E -> []U =>
[f(x) for x in xs]
Параметры типа — после имени в квадратных скобках Имя[T], не <T>.
Подробно — D16.
Массивы — []T (динамический), [N]T (фиксированный), D27.
Generic bounds — [T Protocol]
Параметр-тип ограничивается protocol’ом через единое правило «name type» (без двоеточия):
fn dedup[T Hashable](xs []T) -> []T => ...
fn map[K Hashable, V](m HashMap[K, V]) -> ...
fn fold[T, Acc](xs Iter[T], init Acc, f fn(Acc, T) -> Acc) -> Acc
Bound — это protocol-тип (D53). Тот же
Hashable стоит и в позиции типа значения (existential), и в bound’е
(universal через мономорфизацию):
fn dump(x Hashable) -> u64 => x.hash() // existential, dynamic dispatch
fn dump2[T Hashable](x T) -> u64 => x.hash() // universal, mono dispatch
Порядок параметров — слева направо. Имя в bound’е должно быть объявлено раньше:
fn func[K, T From[K]](v K) -> T => T.from(v) // ok: K объявлен первым
fn func[T From[K], K](v K) -> T // ОШИБКА: K используется до объявления
Множественные bounds — через анонимный protocol:
fn min[T protocol { @lt(other Self) -> bool, @eq(other Self) -> bool }](xs []T) -> T
Если паттерн повторяется — выносится в именованный protocol (type Ord protocol { ... }).
Подробно — D72.
Конверсии: as и From/Into
Два способа конверсии под разные сценарии:
// 1. as — compile-time, тривиальные cast'ы (D54)
let n = 100 as u32 // numeric
let u = 42 as UserId // newtype ↔ underlying
let code = NotFound as int // sum → int
// 2. From / Into — нетривиальная конверсия с runtime-логикой (D73)
type Celsius f64
type Fahrenheit f64
// Программист пишет ОДНУ сторону пары — компилятор синтезирует парную.
fn Fahrenheit.from(c Celsius) -> Self =>
Self((c as f64) * 9.0 / 5.0 + 32.0)
// Две формы вызова из одной реализации:
let f1 = Fahrenheit.from(Celsius(100.0)) // static, explicit-type
let f2 = Celsius(100.0).into() // instance, тип из контекста (требует let-аннотации
// или return-position)
let f3 Fahrenheit = Celsius(100.0).into() // тип цели — Fahrenheit (из аннотации)
// Конверсия в строку — частный случай:
let s = str.from(42) // "42"
let s2 str = (42).into() // "42"
let msg = "id=${user_id}" // sugar над str.from(user_id)
Когда какая форма:
T.from(v)— целевой тип в начале, читается «build a Fahrenheit from this Celsius». Хорош в выражениях.v.into()— короче в method-chains:c.into().log(). Тип цели — из контекста (let x T = ..., параметр функции, return-type).
Граница as vs From:
as— bit/tag-уровень, без runtime-кода:100 as u32,id as u64.From— арифметика, парсинг, валидация:Fahrenheit.from(c),User.from(json).
Граница D73 vs D55: D55 — automatic coercion для record/sum-литералов
в позиции с известным типом (let u User = { id: 1, name: "x" }).
D73 — explicit method call для произвольных типов.
spawn / supervised / parallel for / detach
spawn expr
spawn — keyword-конструкция (не функция). По спеке D50 — разрешён только внутри
structured-scope (supervised, в т.ч. supervised(cancel:), parallel for,
select; и stdlib race/with_timeout внутри своих тел); вне scope —
compile error. В bootstrap-реализации spawn вне scope временно разрешён в
eager-blocking семантике (D71 legacy).
Внутри scope spawn кладёт fiber в очередь и возвращает unit; результат
работы — через захваченные mut-переменные или каналы. spawn() { body }
с пустыми скобками запрещён (нет смысла; spawn — не функция).
supervised {
spawn fetch_users() // spawn + вызов функции
spawn { compute(x) } // spawn + inline-блок
}
Тип результата
spawn body возвращает unit, всегда (D50/D71, resolution 2026-05-06).
Результат body не доступен caller’у. Чтобы получить значение от
concurrent-выполнения:
// (1) прямой вызов — async прозрачный, suspension сама
let users = fetch_users()
// (2) гомогенный fan-out — массив результатов
let responses = parallel for url in urls { fetch(url) }
// (3) гетерогенная параллельность — mut-захваты
let mut a = 0; let mut b = 0
supervised {
spawn { a = compute_a() }
spawn { b = compute_b() }
}
Bootstrap-исключение: let r = spawn { ... } вне supervised
временно работает (legacy eager-blocking). Удалится вместе с
ужесточением «spawn вне scope = compile error».
supervised { body }
Structured-concurrency scope. Все spawn внутри ждут scope-exit перед запуском;
scheduler крутит resume по очереди (round-robin) пока все не завершатся. См.
D71 для bootstrap-семантики.
Возвращает unit. Trailing expression body отбрасывается. Результаты
концurrent-выполнения — через mut-захваты или (для гомогенных) parallel for.
supervised {
spawn handle_requests()
spawn periodic_cleanup()
} // ← ждёт пока обе fiber'ы не завершатся
Time.sleep(0) внутри supervised body (на main-уровне) даёт main-flow yield
к queued fibers’ам — один full pass scheduler’а очереди.
parallel for x in iter { body }
Fan-out parallel map: для каждого элемента iter запускается fiber с body,
результаты собираются в массив в порядке итерации. Тип возврата — []T,
где T — тип body. Десугарится в supervised { for x in iter { spawn { body } } }.
Loop-переменная захватывается по value (snapshot на момент spawn’а).
// Семантически: параллельный map.
let responses []Response = parallel for url in urls { fetch(url) }
// Или с inferred return type:
fn fetch_all(urls []str) Net Fail -> []Response =>
parallel for url in urls {
fetch(url)
}
Не путать с обычным for! for x in iter { body } — это statement
(тип unit), тело для side-effects:
for url in urls {
Log.info(url) // только side effect, ничего не возвращается
}
Для sequential map (собрать массив результатов последовательно) —
использовать .map(), не for:
let names []str = users.map(|u| u.name)
let names []str = users.map() fn(u) => u.name // trailing-fn
Сводка:
| Форма | Тип | Семантика |
|---|---|---|
for x in iter { body } | unit | statement, side-effects |
iter.map(|x| body) | []T | sequential map |
parallel for x in iter { body } (body has trailing) | []T | parallel map (fan-out) |
parallel for x in iter { body } (no trailing) | unit | parallel side-effect loop |
Bootstrap-реализация (2026-05-06): array-mode работает для T ∈ {int, bool,
f64, str} и итераторов a..b, a..=b, array literal. Без trailing — старая
семантика (statement, unit). См. D71 в decisions/06-concurrency.md.
detach { body }
Fire-and-forget: тело живёт после возврата вызывающей функции, привязано к
глобальному supervisor’у. Требует эффекта Detach в сигнатуре (D50). В bootstrap-
default’е — SyncDetach исполняет тело inline.
fn handle_request(req Request) Net Db Detach -> Response {
let resp = process(req)
detach { write_audit(req, resp) }
resp
}
supervised(cancel: tok) { body }
Structured cancellation с внешним токеном. Обычный supervised-scope
с именованным аргументом cancel: (D102).
tok — caller-owned значение типа CancelToken: создаётся
вызывающим кодом, переживает scope, может быть захвачен/передан.
tok.cancel() извне валит все fiber’ы scope’а — на следующем
yield-point они бросят "scope cancelled".
let tok = CancelToken.new()
supervised(cancel: tok) {
spawn { do_thing() }
spawn { do_other() }
}
// внешний kill-switch:
let tok = CancelToken.new()
spawn { Time.sleep(5_000); tok.cancel() }
fetch_with_kill(urls, tok)
Token capabilities: tok.cancel(), tok.is_cancelled(),
tok.bind(other) для каскадной отмены. Один токен — один живой scope
(bind-check). Подробно — D75.
Keyword cancel_scope удалён (ревизия D75, 2026-05-14).
Channel[T] и select
Coordination между fiber’ами через message-passing. Channel[T] —
typed bounded channel с blocking-семантикой. Единственный safe
способ разделять данные между fiber’ами в production-runtime
(альтернатива — shared mut — UB при preemption).
let ch = Channel.new(10) // capacity = 10 (0 = unbuffered)
ch.send(value) // блокирует если буфер полон
let v = ch.recv() // Option[T]; None = closed + drained
ch.close() // idempotent
// drain pattern:
while let Some(msg) = ch.recv() {
process(msg)
}
select { ... } — мультиплексирование recv-операций с опциональным
timeout case:
select {
msg <- ch_a => process_a(msg)
msg <- ch_b => process_b(msg)
timeout(5.seconds()) => default_action()
}
Если несколько каналов готовы одновременно — выбор non-deterministic.
<- — recv-оператор только в pattern-position select-арма, не general.
Полная семантика (closed-channel, owner-actor pattern, отказ от Mutex/Atomic) — D79.
Bootstrap-status: Channel base реализован (раунд 5);
select parser отложен до spawn-block fix.
Time.sleep(ms)
Yield-point. По D62 — обычная функция, callable откуда угодно (Async ambient).
Семантика: блокирует текущий fiber на не менее чем ms миллисекунд.
Реализация (Plan 22 Ф.4): под капотом — libuv uv_timer_t. Fiber
паркуется через park/wake API (D93)
до срабатывания timer-callback’а. Scheduler в это время резюмит других
fiber’ов либо идёт в uv_run UV_RUN_ONCE (kernel-wait, CPU idle).
| Контекст | Реализация |
|---|---|
| Внутри fiber-body (spawn) внутри supervised | park-on-uv_timer_t (D93) — CPU idle, реальное время |
Вне fiber, внутри supervised body | drain queue пока deadline не пройдёт (Plan 22 Ф.5 → libuv-driven main) |
| Полностью вне scope | native OS sleep (Plan 22 Ф.5 → implicit main-scope, libuv) |
Cancel (D75) прерывает sleep
немедленно через generic stop_cb mechanism (D93): cancel-token
закрывает таймер и wake’ает parked fiber, который throw’ает "scope cancelled". Не нужно ждать срабатывания timer’а.
Time.sleep(0) — fast yield (один scheduler-pass, ~µs).
Тестирование без моков
test "name" { body } — тест-блок верхнего уровня. Имя — строковый
литерал (любые символы, обычно человеческое описание поведения).
Тело — обычный блок выражений; assert(cond) — функция из prelude
(D26), обязательно со скобками как любой
fn-call.
test "withdraw decreases balance" {
with Db = in_memory_db([acc1, acc2]) {
let acc = Account.new("alice")
acc.deposit(100)?
acc.withdraw(30)?
assert(acc.balance == 70)
}
}
test "insert and get" {
let mut m = HashMap[str, int].new()
m.insert("a", 1)
assert(m.get("a") == Some(1))
assert(m.get("b") == None)
}
Тесты собираются и запускаются только под nova test. В обычной сборке
тело пропускается — никаких #[cfg(test)]-обвязок. Эффекты подменяются
теми же with-блоками что и в проде, никакого mock-фреймворка.
Panic — не эффект, ловится только runtime’ом
Деление на ноль, выход за границы массива, переполнение — это
не эффект, это Panic. Программист не ловит panic в коде —
panic означает смерть текущего fiber’а, runtime обрабатывает на границе:
fn mean(xs []int) -> int =>
xs.sum() / xs.len() // никакого Fail[DivByZero]
fn handle(r Request) Db Log -> Response =>
process(r) // если panic — fiber умирает, runtime вернёт 500
panic — это смерть fiber’а, не процесса. В сервере падает только
текущий запрос, остальное работает. Если нужно гарантированно гасить
процесс — отдельная функция exit(code int, msg str) -> never
(D13).
Подробно — revolutionary.md R11, D13.