Syntax — синтаксис, литералы, операторы, методы
Решения этой группы фиксируют поверхностный синтаксис Nova: формы объявлений, стрелки, литералы, методы, операторы. Семантика типов и эффектов — в 02-types.md и 04-effects.md; здесь — только запись.
| # | Решение |
|---|---|
| D16 | Дженерики через [T], не <T> |
| D19 | Match-arms через =>, не -> |
| D20 | () вместо void + сводка стрелок |
| D22 | Closure: light |...| и full fn(...) |
| D23 | return — только для раннего выхода |
| D27 | Синтаксис массивов: []T префикс, [N]T фиксированные |
| D30 | Стиль именования |
| D33 | const vs let — compile-time vs runtime |
| D34 | if let и while let для pattern matching в условии |
| D35 | Методы инстанса через @, self отменён |
| D37 | Доступ к полям: .name для record, .N для позиционных |
| D38 | Создание массивов и turbofish для дженериков |
| D40 | Тело функции: => для одного выражения, {} для блока |
| D43 | Trailing: { block } без params, fn(p) body с params |
| D44 | Числовые литералы |
| D45 | Inferred return type для expression-body |
| D46 | Перегрузка операторов через @-методы |
| D48 | Tagged template literals |
| D49 | Statement separator и парсинг выражений |
| D54 | Операторы as (compile-time cast) и is (runtime type-check для any) |
| D58 | Range-литерал a..b, Iter[T] protocol, for x in c implicit iter |
| D59 | Array, tuple и позиционные partial patterns ([], [r], [_, ..], Cons(..)) |
| D60 | Spread ...x в литералах: массив [1, ...arr, 2] и record { ...obj, field: v } |
| D69 | Variadic-параметры через ...items []T |
| D83 | Keywords строго запрещены как identifier’ы (закрывает Q-keywords-as-fields) |
| D88 | Default-значения generic-параметров: [T = int], [T Bound = Default] |
| D90 | defer и errdefer — scope-level cleanup statement |
| D102 | Именованные аргументы f(name: val) и значения параметров по умолчанию fn f(x int = 0); параметр с дефолтом — keyword-only |
| D108 | Map-литерал [k: v] — конструирование HashMap[K, V] (D104-D107 зарезервированы Plan 45) |
| D126 | external type X[Generics] — opaque типы с runtime backing, без body (D109-D125 заняты другими планами) |
D16. Дженерики через [T], не <T>
Что
Параметры типа записываются в квадратных скобках, не угловых.
Правило
fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
type Option[T] | Some(T) | None
type HashMap[K, V] { ... }
let parsed = parse[int]("42")?
[T] — это generic-применение к именованному типу или функции
(Имя[T]). Само по себе [T] массивом не является — для массивов
есть []T (D27).
Грамматика однозначна:
Имя[T]после идентификатора — generic-применение.[]T,[N]Tбез имени слева — конструкция массива.arr[i]в позиции выражения — индексация.
Почему
- Парсер однозначен — после имени
[всегда генерик;<T>создаёт известную ambiguity (sort<int>(xs)— генерик или сравнение?). - Турбофиш не нужен —
parse[int]("42")работает напрямую (D38). - Скорость компиляции — нет backtracking, важно для AI-first, где LLM прогоняет компилятор много раз.
- Прецедент — Go и Scala 3 пришли к тому же по тем же причинам.
Что отвергнуто
<T>(Rust/TS/Java/C#) — парсер-ambiguity, требует turbofish::<>или backtracking;>>парсится как сдвиг.- Контекстный парсинг с backtracking — медленнее, ошибки непонятнее.
Связь
- D27 —
[]Tкак тип массива, разделение с[T]. - D38 —
явная передача параметров через
Имя[T], без::. - 02-types.md — generic-параметры в декларации типов.
Эволюция
В ранних черновиках [T] означал и «массив», и «генерик». D27
расщепил: []T для массива, [T] только в позиции generic-применения.
D19. Match-arms через =>, не ->
Что
В match разделитель «образец → результат» — =>, не ->. Match-arm
имеет две формы тела: pattern => expr (одно выражение) или
pattern => { block } (блок). Match-arm — исключение из общего
правила D40
«=> и {} не сочетаются».
Правило
-> — для типов и сигнатур:
fn f(x int) -> int // тип возврата
type Handler alias fn(Request) -> Response // функциональный тип через alias
=> — для тела и разветвлений:
match shape {
Circle { r } => 3.14 * r * r
Square { s } => s * s
}
let inc = |x| x + 1
fn double(x int) -> int => x * 2
Match-arm с блоком — через => и {} (Rust-стиль):
match entry {
Empty => insert_new(idx, key, value) // одно выражение
Occupied { value: old } => { // блок через => { ... }
@entries[idx] = Occupied { key, value }
return Some(old)
}
Tombstone => {
@tombstones -= 1
@entries[idx] = Occupied { key, value }
return None
}
}
Грамматика:
match-expr = 'match' expr '{' { match-arm } '}'
match-arm = pattern [ guard ] '=>' arm-body
arm-body = expression | block
guard = 'if' expr
block = '{' { statement } [ expression ] '}'
«Параметры → тело» и «образец → результат» — одна семантика «дай мне
это, я отдам тебе то», везде один символ =>.
Почему
- Разделение ролей.
->декларативно (тип),=>вычислительно (выражение). Глаз видит границу. - Прецедент. C#, F#, Scala 3, Rust унифицируют
=>для лямбд и match-arms. - AI-first. Один символ — одна роль, меньше путаницы у LLM.
=>всегда в match-arm. Без=>parser не отличал бы блок-arm от guarded-armpattern if cond => exprили от вложенного блока внутри сложного pattern’а.=>остаётся гарантированным маркером «начало результата».
Что отвергнуто
->для match-arms (Rust до 1.0, OCaml/Haskell) — перегрузка с типом возврата.:(Python) — конфликт с record-литералами.then— лишнее ключевое слово ради того же эффекта.- Блок-arm без
=>(pattern { block }). Без=>теряется единый маркер «начало результата»; парсер хуже различает arm с блоком от arm с guarded-pattern и от нестед-блока в сложном pattern’е.
Связь
- D20 — сводная таблица стрелок.
- D22 — closure-light
|x|без=>, closure-fullfn(...)подчиняется D40 как named fn. - D40 — общий
закон «
=>и{}не сочетаются» и match-arm как единственное исключение.
Эволюция
Старые примеры match ... -> result обновлены на =>.
D20. () вместо void, сводка стрелок, function type syntax
Что
Тип «без значения» — () (unit), не void. Плюс сводная таблица
стрелок (каждая роль закреплена за одним символом) и обязательный
fn-keyword для function type везде.
Правило
fn cleanup() Io -> () // явно
fn cleanup() Io // -> () можно опустить
let xs [()] = [(), (), ()] // unit как элемент массива
let r Result[(), str] = Ok(()) // unit как generic-параметр
Сводка символов:
| Символ | Роль |
|---|---|
-> | тип возврата, функциональный тип |
=> | тело функции (именованной или анонимной), match-arm |
= | присваивание (let x = 5) |
Один символ — одна роль.
Function type — всегда с fn префиксом
Function type записывается только через fn(args) Effects? -> Ret.
Бесколонная форма (args) -> Ret запрещена во всех контекстах.
// ✓ — function type везде с fn
fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
type Handler alias fn(Request) -> Response
let callback fn() -> int = ...
type Server { handler fn(Request) -> Response }
fn measure[T](action fn() Io -> T) Time -> (T, Duration)
// ✗ — без fn запрещено
let f () -> int = ... // ✗
type Handler alias (Request) -> Response // ✗
fn sort[T](xs []T, less (T, T) -> bool) // ✗
type Server { handler (Request) -> Response } // ✗
Где конкретно fn нужен:
| Контекст | Синтаксис |
|---|---|
| Type alias | type H alias fn(Args) -> Ret |
| Параметр функции | fn f(g fn(Args) -> Ret) -> ... |
| Let-annotation | let f fn(Args) -> Ret = ... |
| Поле record | type X { cb fn(Args) -> Ret } |
| Generic-bound | [T fn(Args) -> Ret] (если применимо) |
| Возврат функции | fn make() -> fn(int) -> int |
Почему fn обязателен
-
Парсер однозначен. Без
fnпарсер видит(int) -> boolи должен делать lookahead чтобы различить:- Group expression (parens around expression) в выражении.
- Tuple type
(int)в позиции типа (хотя одно-element tuple обычно не пишется в Nova). - Function type начало.
fnставит явный признак «дальше function type» — парсер не ошибается. -
AI-friendly. LLM, генерирующая код, не путает функциональный тип с tuple/grouping. Один синтаксис для function type, один путь.
-
Согласованность с named-fn.
fn name(args) -> Ret => body— именованная функция начинается сfn. Function typefn(args) -> Ret— то же начало. Это одна и та же концепция «function thing» —fnэто её префикс. -
D9 «один путь». Не два варианта (alias-form vs other-form). Везде одинаково.
-
Прецеденты. Rust (
fn(i32) -> bool), Go (func(int) bool) — оба требуют function-type keyword. TypeScript/Kotlin/Swift не требуют, потому что у них grammar не имеет(x)group-expr ambiguity (разные приоритеты parsing). Nova с её парсером ближе к Rust/Go.
Не путать с closure
Function type (тип) — fn(int) -> bool.
Closure value (выражение) — |x| x > 0 (light) или fn(x int) -> bool => x > 0 (full).
// Тип: fn(int) -> bool
let pred fn(int) -> bool = |x| x > 0
// ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^
// type annotation closure-light value
// closure-full — анонимная fn (см. D22):
let pred fn(int) -> bool = fn(x int) -> bool => x > 0 // closure-full
let pred fn(int) -> bool = fn(x int) -> bool { x > 0 } // closure-full block
fn встречается в трёх ролях, различимых по контексту:
- Декларация —
fn name(...) ...(top-level statement-position). - Тип —
fn(int) -> bool(в type-annotation position). - Closure-full —
fn(x int) -> bool => body(в expression-position).
См. D22 для closure-light vs full.
Почему
()— обычный тип. Может быть generic-параметром, элементом массива, полем.voidв C/Java — особый случай с дырами.- Двухсимвольное разделение яснее «всё через
->» (Rust) или «всё через=>»: глаз видит границу «тип / выражение». - Прецедент. Rust/Haskell/OCaml/Swift/Kotlin —
()/Unitкак нормальный тип. Дыраvoid— известная боль во всех языках, где её оставили.
Что отвергнуто
void— не может быть generic-параметром (Result[void, E]), требует обходных путей.- Везде один символ (
->или=>) — перегрузка, теряется визуальная граница. - Третий символ (
~>,:>) — экзотика без выигрыша.
Связь
Эволюция
Ранее = отделял тело именованной функции (fn f() = expr). D22
перенёс эту роль на =>, чтобы убрать дублирующий синтаксис. =
теперь — только присваивание.
D22. Closure: light |...| и full fn(...)
Что
В Nova две взаимодополняющие формы closure:
- closure-light —
|params| body— компактная untyped форма. Без типов параметров, без-> T, без эффектов. Тело — bare expression ИЛИ block. - closure-full —
fn(params T) Effects -> Type body— типизированная форма, идентичная named fn без имени. Тело —=> exprили{ block }, как у named fn (D40).
Эти формы не пересекаются: как только нужен хоть один тип
параметра, return-type или эффект — переключаемся на fn(...).
|...| — только untyped.
Тело именованной функции остаётся как было: => expr или { block }
(D40). = — только для let.
Правило
closure-light
let inc = |x| x + 1 // expr-body
let zero = || 0 // no params
let block = |x| { let y = x*2; y + 1 } // block-body
let any = |_| 0 // wildcard
list.filter(|x| x > 0) // closure-arg
list.fold(0, |acc, x| acc + x) // multiple params
list.map(|_| 42) // ignore element
spawn(|| compute()) // no-arg closure-arg
Грамматика:
closure-light = '|' params? '|' (expression | block)
params = identifier { ',' identifier }
identifier = name | '_'
В closure-light запрещено:
|x int| x + 1 // ❌ типы параметров — переключайся на fn(x int)
|x| -> int { ... } // ❌ return-type — переключайся на fn(x) -> int
|x| Db -> R { ... } // ❌ эффекты — переключайся на fn(x) Db -> R
|x| => x + 1 // ❌ нет `=>` в closure-light, body начинается сразу
closure-full
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) }
let void = fn(s str) Log { Log.info(s) }
Грамматика идентична named fn без имени:
closure-full = 'fn' '(' params ')' [ effects ] [ '->' type ] body
body = '=>' expression | block
params = param { ',' param }
param = identifier type // тип обязателен
Inference и context-sensitivity
closure-light валиден только когда контекст однозначно задаёт сигнатуру. Источники контекста:
- Параметр fn-call’а:
list.filter(|x| x > 0)— sig изfilter’а. - Annotated let:
let f fn(int) -> int = |x| x + 1. - Return-position:
fn make() -> fn(int) -> int => |x| x + 1. - Tuple-position при typed return:
(|x| ...)если parent объявил-> (fn(int) -> int, ...). - First-use inference (Rust-семантика):
let f = |x| x + 1 f(5) // first use фиксирует x: int → sig: fn(int) -> int f(3.14) // ❌ ошибка: sig уже зафиксирован
Если контекст недостаточен (closure-light нигде не используется):
let f = |x| x + 1 // ❌ cannot infer signature
→ либо использовать f далее, либо переключиться на closure-full:
let f = fn(x int) -> int => x + 1
Эффекты
closure-light никогда не пишет эффекты в сигнатуре. Эффекты, реально используемые в теле closure-light, должны:
- быть подмножеством contextual-sig’а, И
- покрываться ambient effect-set в точке создания closure’а
(= эффекты enclosing-функции ∪ активные
with-блоки).
fn process(users []User) Db -> []Result =>
users.map(|u| Db.find(u.id)) // Db: ✅ есть в parent
fn pure(xs []int) -> int =>
xs.fold(0, |acc, x| acc + x) // эффектов нет — ✅
fn no_db(users []User) -> []Result => // Db в parent НЕТ
users.map(|u| Db.find(u.id)) // ❌ Db не доступен
closure-full эффекты пишет явно — она «полная» по сигнатуре:
fn make_handler() -> fn(Request) Db -> Response =>
fn(req) Db -> Response { process(req) }
Эффекты на named fn остаются обязательными — D62/R1 «эффекты всегда видны в сигнатуре» не ослабляется. Inference применим только к closure-light, потому что closure-light не пересекает границу модуля.
Captures
Closure захватывает свободные переменные по ссылке через scope.
Никаких move / &mut / lifetime — это не нужно благодаря
managed-heap (D32, D62).
- Примитивы (
int,bool,f64, …) — copy-by-value. - Объекты (record, sum-type, array) — managed-reference, shared с enclosing scope.
let mutпеременные — closure модифицирует тот же slot; изменения видны снаружи и между вызовами closure’а.- Escape — если closure уезжает за пределы создавшей fn, захваченные переменные автоматически живут в managed-heap.
fn make_counter() -> fn() -> int {
let mut count = 0
|| { count = count + 1; count }
}
let f = make_counter()
let g = make_counter()
f() // 1 ← каждый вызов make_counter создаёт свежий scope
f() // 2
g() // 1 ← у g свой count, не shared с f
Несколько closure’ов, созданных в одном scope, разделяют capture:
fn make_counter() -> (fn() -> int, fn(int) -> int, fn() -> int) {
let mut count = 0
(
|| { count = count + 1; count },
|a| { count = count + a; count },
|| count,
)
}
let (f1, f2, f3) = make_counter()
f1() // 1 ← все три closure'а share один count
f1() // 2
f2(5) // 7
f3() // 7
Free-variable resolution
Свободные переменные резолвятся через lexical scoping на момент создания closure’а. Параметр одного closure’а не виден в теле другого:
let mut count = 0
(|a| count += a, || a) // ❌ `a` undefined в `|| a`
// ^
// parameter of previous closure, not in scope here
Body-type matching
Тип тела closure (выводимый или явный) должен совпадать с ожидаемым return-type из contextual sig:
fn make() -> (fn() -> int, fn(int) -> int) =>
(|| 0, |a| count += a)
// ^^^^^^^^^^^^^ ❌ `count += a` returns `()`, sig expects `int`
// fix: |a| { count += a; count }
return в closure-light
return в |x| { ... } выходит из самого closure, не из
enclosing fn. Это согласовано с D43 (return в trailing-block выходит
из блока):
let find = |xs []int| {
for x in xs {
if x > 100 { return Some(x) } // выход ИЗ closure
}
None
}
Wildcard _ в параметрах
_ валиден как имя параметра в closure-light, closure-full и named fn —
«параметр обязателен по арности, не используется в теле»
(расширение D59):
list.map(|_| 42)
fn handle(req Request, _meta Meta) Db -> Response { ... }
fn(_x int, y int) -> int => y * 2
Почему
- Освобождение
=>. В Nova=>— маркер тела (named fn, handler-method) и match-arm. Использование=>в лямбдах создавало перегрузку и запрещало блок-форму. Closure-light с|...|убирает перегрузку:=>остаётся только для тела/arm. - Two-level: light vs full. Untyped one-liner’ы (
filter,map,fold) получают компактный синтаксис. Typed/effect-aware closures пишутся полной формойfn(...), идентичной named fn — нет специальной грамматики anonymous-typed. - Парсер коммитится за один токен.
|...|в expression-position решается мгновенно (binary|без LHS невозможен). Старый(params) =>требовал unbounded look-ahead. - Trailing и closure ортогональны. closure-light только в
expression-position. Trailing — через
fn(...)или zero-param{}(D43). Парсер не путает. - Anonymous fn возвращается. D22-old запрещала
fn(...)без имени; новая D22 разрешает её как closure-full. - Блок-форма для closure-light.
|x| { stmts; expr }теперь разрешено — старая D22 явно запрещала=> { block }, что заставляло выносить любую closure сletв named fn. - Captures без
move/lifetime. Managed-heap (D32) делает escape автоматическим.
Что отвергнуто
(x) => expr(D22-old) — перегружает=>, требует unbounded look-ahead, не имеет блок-формы.x => exprбез скобок (JS-style) — не решает look-ahead для multi-param случая, оставляет=>перегруженным.fn(...)без типов (overlap с|...|) — две взаимозаменяемых формы создают выбор без правила. Граница «типы есть →fn, нет →|...|» чёткая.- Effect inference на named fn — отказ от R1 «эффекты всегда видны в сигнатуре». Inference допустим только для closure-light.
move-keyword / lifetime-маркеры — managed-heap автоматизирует escape.- Implicit
it— нелокальный reasoning, плохо для AI. - Trailing closure через
|x|—func(args) |x| bodyсоздавал ambiguity с binary|. Trailing с params — только черезfn(...), см. D43. => { block }для closure-light — closure-light не использует=>вообще. Тело всегда либо bare expression, либо block.
Связь
- D19, D20
—
=>остаётся в match-arm как маркер «начало результата». - D40 — правило
«
=>и{}не сочетаются» применяется к named fn, closure-full, handler-method. closure-light имеет отдельную грамматику. - D43 — trailing с params
через
fn(...), без params —{ block }.|...|в trailing-position запрещён. - 04-effects.md → D31 — handler-method, как fn, имеет две формы тела.
- D62 — closure-light наследует ambient effect-set.
- 02-types.md → D32 — captures через managed-heap.
Эволюция
Пересмотр D20: = исключён из «тел функций», его роль принял =>.
Ревизия (2026-05-1): «лямбда строго (params) => expr, без блок-формы».
Ревизия (2026-05-10): полная замена (params) => на two-level
closure: |x| (light, untyped) + fn(...) (full, typed). Триггер —
семантический перегруз =>, look-ahead в парсере, запрет блок-формы
лямбды, унификация с trailing-block. Anonymous-fn запрет (D22-old)
снимается — fn(...) без имени = closure-full. Block-форма closure
возвращается. Migration: ~30 примеров в spec/, патч parser/interp,
план — docs/plans/19-closure-and-error-ops.md.
D23. return — только для раннего выхода
Что
return есть, но используется исключительно для guard-clauses /
ранних выходов. Последнее выражение тела — автоматически результат.
return — это statement, поэтому он встречается только в блок-форме
тела (fn name(...) { ... }). В =>-теле (где должно быть ровно одно
выражение, D40)
guard-clauses через return не пишутся: либо вся функция выражается
одним match/if (тогда =>-тело подходит), либо нужны guard’ы — и
тогда блок-форма.
Правило
Разрешено:
// блок-форма с guard'ами
fn classify(x int) -> str {
if x < 0 { return "negative" }
if x == 0 { return "zero" }
"big" // последнее выражение = результат
}
fn process(req Request) Db Fail -> Response {
if req.method == "GET" { return next(req) }
do_work(req)
}
// =>-тело: одно выражение, return не нужен
fn classify(x int) -> str => match x {
n if n < 0 => "negative"
0 => "zero"
_ => "big"
}
Запрещено линтом (избыточно):
fn double(x int) -> int => return x * 2 // лишний return; и =>-тело
// вообще не допускает statement'ов
fn classify(x int) -> str {
if x < 0 { return "n" } else { return "p" } // обе ветки return
}
Если все ветви заканчиваются return — переписать через match/if
как выражение и использовать =>-тело.
Запрещено грамматически:
// =>-тело допускает ровно одно выражение, а не цепочку statement'ов
fn classify(x int) -> str =>
if x < 0 { return "negative" } // ← statement, не expression
if x == 0 { return "zero" }
"big"
Семантика:
returnв closure-light (|x| body) — выходит из самого closure, не из enclosing fn (D22). Аналогичноreturnв trailing-block.returnв closure-full (fn(...) body) — выходит из closure (точно как named fn).returnв match-arm — match-arm тоже строгоpattern => expr(D40), поэтомуreturnв arm тоже отсутствует. Если в arm нужен ранний выход — match вынесен в блок-форму fn, иreturnстоит после match’а.returnвwith-блоке (block-body) — выходит из enclosing-функции.returnв trailing-block (D43) — выходит из самого блока (это блок, не лямбда), не из enclosing fn.
Почему
- Guard-clauses естественно пишутся в блок-форме — middleware, валидация, ранние выходы.
- AI-first. LLM рефлекторно генерит
return— полный запрет требовал бы переучивания. - Один стиль на функцию. Линт против избыточного
returnв последней позиции. - Прецедент. Rust идиоматически использует
returnтолько для ранних выходов. =>строго одно выражение. Раньше D23 разрешал чередование guard-if {return}+ финальное выражение в=>-теле. Это нарушает «=>= одно выражение» (D40); убрано — guard’ы только в блок-форме.
Что отвергнуто
- Полное отсутствие
return(OCaml/Haskell) — заставляет вкладыватьif/elseглубже. break/done— нестандартно, без выгоды.returnобязателен (Go/Java) — противоречит «функция = выражение».- Guard-цепочки в
=>-теле (как было в старой D23). Конфликтовало с D40 —=>-тело это одно выражение, statement-цепочки требуют блок-формы.
Связь
- D22 —
returnв closure-light и closure-full выходит из самого closure, не из enclosing fn. - D19 — match-arm строго
pattern => exprилиpattern => { block };returnв arm выходит из enclosing fn (т.к. arm не функция). - D40 —
=>и{}не сочетаются; guard-цепочки требуют блок-формы.
Эволюция
Ревизия (2026-05): убраны примеры guard-clauses в =>-теле fn.
Раньше D23 допускал fn classify(x) -> str => if x<0 {return "n"} ... "big"
— цепочка statement’ов после =>. Это противоречило D40 («=> =
ровно одно выражение»). Теперь правило единое: guard’ы только в
блок-форме fn name(...) { ... }.
D27. Синтаксис массивов: []T префикс, [N]T фиксированные
Что
Массивы записываются префиксом (Go-стиль): []T динамический,
[N]T фиксированный, [N1][N2]T многомерный — порядок размеров
совпадает с порядком индексации.
Правило
let xs []int = [1, 2, 3] // динамический
let buf [5]u8 = [0, 0, 0, 0, 0] // фиксированный
let zeros [4]u8 = [0; 4] // повторение через ;
let matrix [2][3]int = [[1, 2, 3], [4, 5, 6]]
matrix[i][j] // i: 0..2, j: 0..3 — порядок совпадает
let opt Option[int] = Some(42) // generic не меняется
Парсер по позиции:
- В позиции типа без имени слева — массив (
[]T,[5]T). - В позиции типа после имени — generic (
Option[T]). - В позиции выражения — индексация (
arr[i]).
Layout: [N]T — N подряд, без указателя. []T — { ptr, len, cap },
24 байта на 64-bit. [N1][N2]T — плоский row-major. [][]T —
jagged (массив указателей на массивы).
Почему
- Соответствие индексации —
[2][3]int↔arr[i][j]. В Rust[[T; 3]; 2]порядок обратный; программисты ошибаются. - Парсер однозначен —
[различается по позиции в грамматике. - Чтение слева направо — «массив 2×3 целых».
- Generic не страдает —
Option[T]остаётся. - Прецедент Go.
Что отвергнуто
- Java
T[]/int[2][3]— парсер сложнее, конфликт сOption[T]. - Rust
[T]/[[T; N]; M]— обратный порядок размеров, конфликт «массив vs generic» одного символа. [T; N]для одномерного —;читается странно в многомерных, нет соответствия индексации.
Связь
- D16 —
[T]теперь только generic-применение. - D38 — static-методы
на типе массива (
[]T.with_capacity(n)). - 02-types.md — sum/record не конфликтуют по грамматике.
Эволюция
Старо: [T] динамический, [T; N] фиксированный — конфликт с
generic. Перешли на Go-style; ~50 мест в документах исправлено.
D30. Стиль именования
Что
Один стиль на весь язык: PascalCase для типов и протоколов, snake_case для функций/полей/локальных, SCREAMING_SNAKE_CASE для констант. Акронимы — PascalCase без исключений.
Правило
| Что | Стиль | Пример |
|---|---|---|
| Типы, варианты sum-type, эффекты, протоколы | PascalCase | User, HashMap, Some, Db, Hashable |
| Generic-параметры | PascalCase, односимвольные | T, K, V, E |
| Функции, методы, поля, параметры, локальные | snake_case | parse_url, @deposit, user_id |
| Константы | SCREAMING_SNAKE_CASE | MAX_PAYLOAD, DEFAULT_TIMEOUT |
| Модули | snake_case через точки | module admin.audit |
Акронимы PascalCase, не UPPERCASE:
type Db effect { ... } // не DB (эффект — protocol)
type Io effect { ... } // не IO
type Url str // не URL (newtype над str)
type Http effect { ... } // не HTTP
type JsonValue { ... } // не JSON (record)
type SqlBuilder { ... } // не SQL (record с полями)
Договорные конвенции имён методов:
| Имя | Когда |
|---|---|
T.new(...) | стандартный конструктор |
T.from(v X) | general-purpose конверсия из X через D73 From[X] |
T.from_X(...) | доменный конструктор (from_secs, from_polar, from_imag) — когда from(v) не передаёт смысл |
v.into() | парная форма для T.from через D73 Into[T] |
@is_X() | bool-предикат |
@as_X() | дешёвая конверсия (без аллокации) |
@to_X() | возможно дорогая конверсия |
@hash(), @clone(), @iter(), @next() | стандартные методы |
is_/as_/to_ — семантическая разница, следуй ей.
try_* / failable pair convention (D30 §2, Plan 108):
Когда операция может завершиться с ошибкой, определяются две формы:
| Форма | Сигнатура | Семантика |
|---|---|---|
try_op(...) | -> Result[T, E] | возвращает результат без эффектов; вызывающий сам обрабатывает ошибку |
op(...) | Fail[E] -> T | unwrap-обёртка через !!; кидает E через эффект при провале |
Правило реализации: op реализуется как Nova-body через try_op:
// Примитив — только эта функция знает как читать байт:
export external fn ReadBuffer mut @try_read_byte() -> Result[u8, ReadBufferError]
// Обёртка — один лайнер на Nova, без дублирования C-логики:
export fn ReadBuffer mut @read_byte() Fail[ReadBufferError] -> u8 => @try_read_byte()!!
Зачем try_* первичен:
- C-логика живёт в одном месте (
try_*),*= тонкая обёртка - Нет дублирования кода ошибок между парами
- Вызывающий выбирает стиль:
op()(throw-style) илиtry_op()(result-style)
Применяется везде: ReadBuffer, WriteBuffer, I/O, парсинг, преобразования типов.
Полные слова, не сокращения
Имена методов, типов, параметров и полей — полные слова, не сокращения. Приоритет — читаемость, а не количество символов.
fn StringBuilder @capacity() -> int // не @cap()
fn ReadBuffer @position() -> int // не @pos()
fn copy_into(destination []u8) -> () // не dest
fn parse(input str) -> Result[T, E] // не buf, не val
Запрещены ad-hoc сокращения (mainstream-precedent): pos, cap,
dest, src, buf, val, tmp, cnt, idx (кроме mainstream-исключений
ниже), arr, len (кроме mainstream-исключения), msg (кроме Error.msg
field — закреплено D26), cfg, ctx.
Mainstream-исключения (Rust/Go/Swift convention — слишком устоявшиеся формы, чтобы менять):
| Сокращение | Где разрешено | Прецеденты |
|---|---|---|
len | длина коллекции (s.len(), arr.len(); method-only по D117) | Rust, Go |
iter | итератор (coll.iter(), Iterator) | Rust |
idx | index — только в локальных переменных (for idx in ...) | Rust convention |
Ровно три исключения, никаких других. Остальные — full word:
length если не коллекция-len, iterator если не protocol-имя,
index если параметр или поле.
Operator-overloading имена (D46)
— @plus, @rem, @neg, @shl, … — фиксированы и не подчиняются
правилу полных слов. Это исторически зацементированная convention из
Rust/C++/Swift; менять @plus → @addition бессмысленно.
Acronyms работают по правилу выше (PascalCase в типах, snake_case
в методах: JsonParser, parse_json). К full-word правилу не относятся.
Зачем строго:
- AI-friendly. LLM не должна угадывать когда
posэтоposition, а когдаposix. Один canonical full word — однозначность. - Code review consistency. Reviewer видит
destи спрашивает «destination or destruct?» — лишний cycle. Full word убирает класс багов. - Прецедент Swift API Guidelines. Swift строго запрещает abbreviations, и это даёт API surface, которую читать как естественный язык.
Типы ошибок: Parse<TypeName>Error, <Operation><Domain>Error
Имена ошибок в публичных API должны включать тип / домен который породил ошибку, а не быть generic-словом:
| Стиль | Пример | Прецедент |
|---|---|---|
Parse<TypeName>Error | ParseIntError, ParseComplexError, ParseUrlError | Rust std, num-complex |
<Domain>Error | DbError, HttpError, RepoError | стандартный backend-стиль |
<Operation>Error | OverflowError, TransferError | для конкретной операции, не типа |
Не использовать generic-имена:
| Плохо | Почему | Лучше |
|---|---|---|
ParseError | коллизии: URL/JSON/datetime/complex/… | ParseUrlError, ParseComplexError, … |
Error (как пользовательский тип) | конфликт с prelude Error (D65) | конкретное имя |
Exception, Failure | пустые слова без домена | по операции / домену |
ValueError, TypeError | заимствование из Python — слишком общо | по операции / домену |
Вариантам внутри sum-типа доменный префикс не нужен — они уже живут в namespace своего типа:
type ParseComplexError | InvalidFormat | NotANumber
throw InvalidFormat // имя варианта без префикса
throw ParseComplexError.InvalidFormat // полная форма (если ambiguous)
Это согласовано с D65 lookup’ом: throw InvalidFormat находит
активный Fail[ParseComplexError] handler по типу варианта.
_prefix — только для полей record (по конвенции, означает
«используй методы, не прямой доступ»). Для функций/методов _prefix
не используется — есть только export / приватно (07-modules.md → D47).
Зарезервированные имена для operator overloading: @plus, @minus,
@times, @div, @rem, @neg, @or, @and, @xor, @shl,
@shr, @eq, @lt, @le, @gt, @ge, @not, @get, @set —
D46.
Test-имена — строки естественного языка: test "insert and get" { ... }.
Почему
- Одно правило без исключений для акронимов — программисту и LLM не помнить «2 буквы UPPER, 3+ Pascal».
- Composability —
HttpClient,JsonParserчитаются без «плотностей» из заглавных. СравниHTTPClient,JSONParser. - AI-friendly. LLM плохо угадывает «сколько букв в акрониме» — единое правило.
- Прецедент. Swift API Guidelines, современный .NET, Rust.
Что отвергнуто
- Java/C# до 2010-х (UPPERCASE для коротких акронимов) — каша
на стыке (
parseXMLForJSONFromHTTPResponse). - snake_case для всего (Python) — типы и значения визуально не отличаются.
- camelCase для функций (Java/JS) —
to_strчитается лучшеtoStr; границы слов чётче.
Связь
- 07-modules.md → D47 —
export/ приватно; стиль не зависит от видимости. - D33 —
SCREAMING_SNAKE_CASEдляconst. - D46 — зарезервированные имена.
D33. const vs let — compile-time vs runtime
Что
const — для compile-time констант, известных при компиляции.
let — для runtime значений (immutable binding); let mut —
mutable. Это два разных ключевых слова, не сахар.
Правило
// const — compile-time
const MAX_PAYLOAD = 4096
const TIMEOUT_SEC = 60 * 5 // арифметика над литералами
const GREETING = "hello"
// let — runtime
let now = Time.now()
let user = Db.find(user_id) ?? throw UserNotFound(user_id)
// let mut
let mut counter = 0
counter += 1
const требует:
- Compile-time computable: литералы, арифметика, конструкторы record/sum-type из const-значений.
- Не runtime-вызовы, эффекты, ссылки на не-const.
const fn (compile-time функции) — отложено до Q7 (comptime).
До этого const NOW = Time.now() — ошибка.
const живёт в data-segment (zero-cost). let-объекты — в managed
heap (или на стеке через escape analysis).
Почему
- Compile-time гарантия.
const— программист уверен, нет runtime-зависимостей. - Размеры массивов.
[N]T(D27) требуютconst Nдля имени. constявно говорит «в data-segment», не нужно угадывать.- AI-first. LLM, видя
const X = compute(...)→ compile error, получает явный сигнал «используйlet».
Что отвергнуто
:=(Go) — дублируетlet; источник shadowing-багов в Go.final(Java) — лишнее ключевое слово рядом сlet.- Без разделения — массивы
[N]Tпотребуют литералов всюду; comptime станет несовместимым.
Сравнение с readonly / mut field — три оси immutability
Nova имеет три разных keyword’а связанных с immutability — let,
const, readonly/mut field. Они не конкурируют, потому что
работают на разных уровнях программы:
| Конструкция | Что фиксирует | Где живёт | Решает |
|---|---|---|---|
let x / let mut x | binding | в функции / scope | можно ли переприсвоить переменную |
const X = ... | compile-time placement | top-level или scope | известно ли значение при компиляции |
readonly field T | поле record’а never-mut | внутри type X { ... } (D36) | можно ли мутировать поле даже у let mut binding’а |
mut field T | поле record’а always-mut | внутри type X { ... } (D36) | можно ли мутировать поле даже у let binding’а |
let / let mut — про binding
let x = 5 // binding x не переприсваивается
let mut y = 0 // binding y переприсваивается
y = y + 1
Default immutable (D32) — let без префикса всегда
immutable. let mut — явный opt-in в mutable, аналогично Rust
let mut, Swift var, Kotlin var. Программист видит let mut —
знает что переменная меняется.
const — про compile-time
const MAX = 4096 // compile-time, в data-segment
let limit = compute_limit() // runtime, в heap/stack
Оба immutable. Разница — const накладывает требование
compile-time computability (литералы + арифметика над ними +
const-record’ы). let принимает любое runtime-выражение.
const нужен для:
- Размеров фиксированных массивов:
[N]T(D27) требуетconst N. - Compile-time оптимизаций (свёртка, размещение в data-segment).
- Семантической декларации «это всегда константа», не «immutable до scope-exit».
readonly / mut field — про поле record’а
type Account {
readonly id u64 // поле never-mut, даже у `let mut acc`
balance money // поле default — mut если binding mut
mut log_count int // поле always-mut, даже у `let acc`
}
let mut acc = Account { id: 1, balance: 100, log_count: 0 }
acc.balance = 200 // OK — поле default + binding mut
acc.id = 999 // ERR — id readonly
acc.log_count += 1 // OK — log_count mut
readonly / mut per-field — это freeze/unfreeze конкретного
поля относительно дефолта. Они не пересекаются с let/let mut:
binding управляет «можно ли модифицировать переменную», поле
управляет «можно ли модифицировать конкретное поле в записи».
Пример где они комбинируются:
| binding | field declaration | можно acc.field = ... |
|---|---|---|
let acc | field T (default) | ❌ — binding immutable |
let acc | mut field T | ✅ — поле always-mut |
let acc | readonly field T | ❌ |
let mut acc | field T (default) | ✅ |
let mut acc | mut field T | ✅ |
let mut acc | readonly field T | ❌ — readonly сильнее |
Почему три, а не одно
Альтернативы и почему они хуже:
-
Только
let/let mutбезconst— массивы[N]Tтребовали бы compile-time выводимости изlet N = 5. Компилятор должен проводить escape-analysis на каждыйlet, чтобы понять const-eligible. Программист не видит явно «это compile-time», а получает компилятор-error при первом нарушении. AI-unfriendly. -
Только
let/let mutбезreadonly/mut field— потеря per-field freeze. Альтернатива — newtype wrappers (type AccountId(u64)для каждого immutable поля), что ведёт к verbose-коду и потере ergonomics (acc.id.value()вместоacc.id). Cell/RefCell-style wrappers (как в Rust) ещё хуже для AI-кодинга. -
Только
const/readonly(безlet/let mut) — теряем обычные mutable переменные в функциях. Можно через field record’а (тип-обёрткуCounter { mut value int }), но это противоестественно для локальных счётчиков.
Это три разные оси ответственности, каждая решает свою задачу:
let/let mut— binding mutability (можно ли переприсвоить).const— compile-time vs runtime placement.readonly/mutfield — per-field freeze в record’е.
Связь
- D27 —
constдля размеров фиксированных массивов. - D30 —
SCREAMING_SNAKE_CASEдляconst. - D32 — default immutable bindings;
mutдля переменных и параметров. - D36 —
readonly/mutмодификаторы полей record’а; per-field freeze. - 07-modules.md —
export constэкспортирует.
D34. if let и while let для pattern matching в условии
Что
Синтаксис if let pattern = expr { ... } и while let pattern = expr { ... }
— pattern matching прямо в условии с локальным binding в скоупе блока.
Несколько условий через запятую.
Правило
if let Some(user) = cache.get(key) {
process(user)
}
if let Ok(user) = Db.find(id) {
process(user)
} else {
Log.warn("user not found")
}
while let Some(item) = queue.pop() {
process(item)
}
// несколько условий через запятую
if let Some(user) = lookup(id), user.is_active {
process(user)
}
// else if let
if let Some(a) = lookup_a() {
use(a)
} else if let Some(b) = lookup_b() {
use(b) // a НЕ доступна
}
⚠ Chain-форма
if let … , …пока не реализована (parser drift, 2026-05-27). Парсер падает на запятой после первого cond’а сexpected '{', got ','. Реализовано только одиночноеif let pattern = exprиwhile let pattern = expr. Workaround — вложенныеif’ы. Полная грамматика (включая("," if-cond)*) — см. Plan 106.
Грамматика:
if-expr := "if" if-cond ("," if-cond)* block ("else" (if-expr | block))?
while-expr := "while" if-cond ("," if-cond)* block
if-cond := "let" pattern "=" expr | expr
Скоуп: связанные let-имена доступны только в теле блока.
? работает: if let user = Db.find(id)? { ... } пробрасывает
ошибку наверх; внутрь блока заходим только при успехе.
Почему
- «Получить и использовать если есть» без полного
match-блока. - Эквивалент Go
if v, err := f(); err == nilсо скоупом переменной = тело if. - Условные циклы — итерация пока паттерн совпадает.
- Прецедент. Rust 1:1.
Что отвергнуто
- Go-стиль
;-разделитель — нарушает D17 «один разделитель — запятая». :=оператор — shadowing-проблемы Go.- Smart-cast (Kotlin) — магия в типе, AI-first против.
- Без
let(if Some(x) = ...) — парсер не отличит от сравнения.
Связь
- D33 —
letдля runtime binding с локальным скоупом. - 02-types.md → D17 — pattern matching в
match.
D35. Методы инстанса через @, self отменён
Что
Методы инстанса объявляются как fn Type @method(...) с неявным
self. Поля self — через @field. Мутирующий метод —
fn Type mut @method(...). Конструкторы и static — через точку
fn Type.name(...). Ключевое слово self отменено.
Правило
type Account {
readonly owner str
_balance money
}
// конструктор / static — через точку, без @
fn Account.new(owner str) -> Account =>
Account { _balance: 0, owner }
// метод инстанса — через пробел и @, неявный self
fn Account @balance() -> money => @_balance
fn Account @summary() -> str => "${@owner}: ${@_balance}"
// мутирующий — mut перед @name
fn Account mut @deposit(amount money) =>
@_balance += amount
Грамматика:
free-fn := identifier "(" params ")" effects? ("->" type)? "=>" body
static-method := Type "." identifier "(" params ")" ...
instance-method := Type ("mut")? "@" identifier "(" params ")" ...
После имени типа: . → static, @ или mut @ → instance.
Receiver — любой тип, включая примитивы
Receiver-тип может быть любым именованным типом: record, sum, newtype,
unit-тип, protocol — и встроенный примитив (int, str, bool,
f64, u8, …). Это естественное следствие того, что в Nova
примитивы — обычные типы (D30, D32), просто с lowercase-именами и
особым представлением в runtime.
// Static method on a primitive — `str` is a regular type.
fn str.from(i int) -> Self => /* ... */
// Instance method on a primitive — used via `value.method()`.
fn int @to_hex() -> str => /* ... */
fn f64 @round() -> int => /* ... */
let s = str.from(42) // static via D35
let h = (255).to_hex() // instance, parens around literal
let r = 3.7.round() // chained on numeric literal
Применение: From[X] для str (D73) — основной механизм
строковой конверсии. Также int.parse(s str), bool.from(n int)
и другие фабрики, не требующие отдельного wrapper-типа.
Ограничения: примитивы — закрытые типы, программист не может
добавить новые поля (нет type str { ... } для существующего
str). Только методы. Это согласовано с тем, что extension functions
в Nova не вводятся (D46): метод определяется один раз в модуле,
владеющем типом-receiver. Для примитивов это stdlib: fn int.method
определяется только в stdlib-модулях, пользовательский код может
определять методы только на собственных типах.
В теле метода @field — единственная форма доступа к self-полю.
@.field невалидно. @ без поля — значение текущего инстанса
(аналог self):
fn Account @copy() -> Account => @
fn Account @send(ch Channel[Account]) => ch.send(@)
Вызов методов — скобки обязательны:
let acc = Account.new("alice")
acc.deposit(100)
let bal = acc.balance() // getter, обязательные ()
Bound vs unbound:
let f = acc.balance // bound: fn() -> money
let g = Account.@balance // unbound: fn(Account) -> money
Generic’и: [T] после имени типа (fn Vec[T] @len()) и/или после
@name (fn Vec[T] @map[U](f T -> U)).
Почему
- Минимум строк.
fn Account.deposit(mut self, ...)→fn Account mut @deposit(...)экономит 6-9 символов на метод. - Один смысл
@— «принадлежит self». В сигнатуре@method, в теле@field. - Чёткое разделение. Точка = static (
Account.new),@= instance. Программист и LLM видят роль из синтаксиса. - Скобки обязательны —
acc.balance()явно вызов, не поле. Property-механизмы (C#/Kotlin) делают это невидимым.
Что отвергнуто
fn Type.method(self, ...)— повторяющийсяselfв каждом методе и каждом обращении к полю.- Property (
property balance { get; set }) — невидимое «поле или вызов?»; известный источник путаницы в C#. @как параметр (fn deposit(mut @, ...)) —@приобретает два смысла.fn mut @Type.method—mutна типе vs на binding’е, разные смыслы.fn Type new(...)без точки — расходится с namespace path.
Связь
- D32 (если есть) / 05-memory.md —
mutсемантика mutable-binding’а. - D37
—
@field/@Nдля self. - D38 — generic на типе и методе.
- D46 — operator overloading
через
@-методы. - 01-philosophy.md → D1 — методы как часть
парадигмы
protocols + data.
Перегрузка методов
Полная семантика перегрузки методов (по типу аргумента, arity, mangling, bootstrap-status, ambiguity, disambiguation) — в D84. Здесь лишь напоминание: метод может быть перегружен несколькими сигнатурами на одном receiver-типе, резолв выполняется по статическим типам аргументов.
Method values (Plan 11 Ф.4)
Методы — first-class values: можно сохранить в переменную, передать в HOF, вернуть из функции. Три формы:
type Account { balance int }
fn Account.new(b int) -> Self => Self { balance: b }
fn Account @get() -> int => @balance
fn Account @add(n int) -> int => @balance + n
let acc = Account.new(42)
// 1. Bound method value: захватывает obj как self.
// Тип: fn(<remaining-params>) -> R
let f = acc.@get // тип: fn() -> int
let g = acc.@add // тип: fn(int) -> int
let v = f() // 42
let r = g(10) // 52
// 2. Unbound method value: self передаётся явно как первый аргумент.
// Тип: fn(Receiver, <params>) -> R
let h = Account.@add // тип: fn(Account, int) -> int
let r2 = h(acc, 10) // 52
// 3. Static method value: обычная свободная функция.
// Тип: fn(<params>) -> R
let mk = Account.new // тип: fn(int) -> Self
let acc2 = mk(7)
Семантика
- Bound копирует / захватывает receiver внутрь closure-структуры. Subsequent calls используют captured self.
- Unbound — fn pointer без env’а. Caller обязан передать receiver как первый аргумент.
- Static — fn pointer без receiver’а вообще.
Использование в HOF
let nums = [1, 2, 3]
let negated = nums.map(int.@neg) // unbound: применяет @neg к каждому
let total = nums.fold(0, acc.@add) // bound: добавляет каждый num к acc
Disambiguation для overloaded methods (Ф.5)
Если у метода несколько overload’ов, нужна type annotation:
fn Buffer mut @write(s str) -> ()
fn Buffer mut @write(b []u8) -> ()
let buf = Buffer.new()
let f1 = buf.@write as fn(str) -> () // выбор по annotation
let f2 = buf.@write as fn([]u8) -> ()
Без annotation — compile error «ambiguous method value». Annotation
либо на cast (as fn(...)), либо на let-binding type
(let f fn(str) -> () = buf.@write — также работает).
C-runtime представление
Bound и unbound — оба используют generic NovaClosBase layout:
typedef struct { void* fn; void* env; } NovaClosBase;
fn указывает на сгенерированный wrapper, env — указатель на
struct с captured receiver (для bound) или dummy struct (для unbound).
Call-site: cast fn к нужной сигнатуре, передача env + args.
Static method values — bare fn pointer (без env’а) — но в bootstrap для единообразия тоже оборачиваются в NovaClosBase.
Self в expression position (D66 расширение, Plan 11 Ф.4.5)
Self ранее работал только в type position (return type, parameter
type). Plan 11 Ф.4.5 добавляет expression position:
type Account { balance int }
fn Account.with_initial(amount int) -> Self =>
Self { balance: amount } // record literal
fn Account.new() -> Self =>
Self.with_initial(0) // call current type's static
Резолюция: Self в expression context резолвится в имя текущего
receiver-типа из метода (тот же current_receiver_type что для
type-position). Полезно для default → parameterized constructor
chain’ов и DRY.
Прецеденты: Rust impl Foo { fn make() -> Self { Self::new(2) } },
Swift Self.method(). D66 расширяется этим Plan’ом 11.
D37. Доступ к полям: .name для record, .N для позиционных и кортежей
Что
Доступ к полю / элементу — через точку:
obj.name— поле record по имени;obj.0,obj.1— поле позиционной структуры или кортежа по индексу (0-based);@name,@0,@1— то же внутри методов инстанса для self.
Правило
// record — доступ по имени
let u = User { id: 1, name: "alice" }
println(u.name)
// позиционная структура — по индексу
type Point(f64, f64)
let p = Point(1.0, 2.0)
println(p.0) // 1.0
println(p.1) // 2.0
// кортежи — то же
let pair = (1, "alice")
println(pair.0)
println(pair.1)
Внутри методов:
fn Point @magnitude() -> f64 =>
math.sqrt(@0 * @0 + @1 * @1)
fn Account @summary() -> str =>
"${@owner}: ${@balance}"
Mutation работает по правилам 05-memory.md (mut binding +
поле без readonly):
let mut p = Point(1.0, 2.0)
p.0 = 5.0 // ок
Pattern matching как альтернатива:
match p {
Point(x, y) => x + y
}
let Point(x, y) = p // деструктуризация
Парсер: .N после идентификатора или ) — field access. После
числового литерала точка — только decimal. 1.foo — ошибка.
Почему
- Точечный доступ для одного поля без полной деструктуризации.
.0/.1— стандарт Rust/Swift, AI-friendly.- Compile-time проверка границ (в отличие от runtime
obj[i]).
Что отвергнуто
- Только pattern matching — многословно для простого доступа.
- Аксессоры (
fst/snd) — не масштабируются для 3+ кортежей. obj[0](TS array-style) — конфликт с runtime-индексацией массивов.
Связь
- 02-types.md → D17 — позиционные структуры
(
type Point(f64, f64)) объявляются через(). - D35 —
@name/@Nвнутри методов.
D38. Создание массивов и turbofish для дженериков
Что
Пустые массивы — литералом с annotation или static-методом на типе
массива ([]T.with_capacity(n)). Когда inference не справляется —
turbofish через те же [T] после имени, без Rust’овского ::.
Правило
Создание массивов:
// 1) литерал + annotation
let mut buckets []Slot[K, V] = []
let xs []int = [1, 2, 3]
// 2) inference из контекста
fn first(xs []int) -> Option[int] => ...
let result = first([]) // [] выводится из аргумента
// 3) static-методы
let buckets = []Slot[K, V].with_capacity(cap)
let empty = []int.new()
let zeros = []u8.filled(0, 1024)
Turbofish — те же [T], без :::
fn parse[T](s str) -> Result[T, ParseError] => ...
let n = parse[int]("42")? // в Result-возвращающей функции
let c = Cache[str, int].new()
let buckets = []Slot[K, V].with_capacity(16)
let result = m.@get[int]("key")
Грамматика — generic-application:
generic-application := identifier "[" type ("," type)* "]"
Работает для функций, static-методов, конструкторов, instance-методов.
Почему
- Парсер однозначен (D16) —
::не нужен. Rust сами признают::<>ошибкой дизайна. - Static-методы на типе массива — тип явный, pre-allocation доступна.
- Один синтаксис
[T]— везде, без специальных операторов.
Что отвергнуто
- Rust
::<T>— нужен только из-за<T>-ambiguity, у Nova её нет. - Глобальный
make[T](n)(Go) — не вписывается. Vec[T].new()—[]Tэто встроенный синтаксис, не отдельный типVec.
Связь
Эволюция
D16 уточнён: [T] сам по себе не
является типом — только generic-применение к именованной сущности.
Bootstrap (2026-05-07): turbofish реализован в codegen-парсере.
Активируется в expression-position через peek-disambiguation: после
Ident[T1, T2, ...] смотрим post-] token; если это ( (call),
.IDENT( (method-call) или ? (Try) — это turbofish-узел
(ExprKind::TurboFish { base, type_args }); иначе — обычный
Index-доступ. Параллельно с этим, multi-arg внутри [...] —
однозначно turbofish (Index не имеет comma). Bootstrap-codegen
прозрачно делегирует TurboFish в base (monomorphization идёт по
call-site / receiver-type), но AST сохраняет type_args для будущих
этапов inference. Тесты — nova_tests/types/generics.nv.
Plan 98 (закрыт 2026-05-23): type-argument inference расширена на
generic-параметризованные типы в позиции param. До Plan 98
infer_type_param_binding (emit_c.rs) выводил T только из голого
T и []T — Option[T] / Result[T,E] / пользовательские
Box[T]/HashMap[K,V] молча игнорировались → каждый generic-helper,
принимающий generic-тип, требовал turbofish (check[int](a)
вместо естественного check(a)). Хуже Rust/Go/TS, где это базовая
unification. Plan 98 конвертировал функцию из associated fn в метод
&self + добавил три рекурсивные ветки: Option[T] (recovery из
NovaOpt_<sani> через novaopt_value_types), Result[T,E]
(novares_ok_err), user-generic (через generic_type_instance_info).
Граница (known limitation): []Option[T] / []Result[T,E]
(массив generic-элементов) пока НЕ выводится — codegen эрейзит
element type в receiver_type_c_ident (NovaArray_nova_int* для
не-примитивов), теряя generic-инфу до inference; отдельный gap, не
scope Plan 98. Тесты — nova_tests/plan98/.
Built-in API для []T (Plan 17 Ф.1, закрывает Q-array-api)
[]T — встроенный тип, не запись stdlib (Vec[T] нет). Граница
между built-in API (компилятор знает напрямую) и stdlib
extensions (методы добавлены через fn []T @method по D35) —
зафиксирована ниже.
Built-in API — известно компилятору:
| Категория | API | Семантика |
|---|---|---|
| длина | xs.len(), xs.is_empty() | len() — method-call, zero-cost lowering в arr->len (O(1)); is_empty() ≡ len() == 0 (D117) |
| capacity | xs.capacity() | размер выделенного storage’а; len() ≤ capacity(). Renamed from .cap (Plan 60 / D117 — Rust/C++/Swift naming) |
| доступ | xs[i], xs.get(i) | [i] — panic при out-of-bounds (D13); get(i) → Option[T] |
| мутация | mut xs.push(v), mut xs.pop() -> Option[T] | push grow при len() == capacity() |
| итерация | xs.iter() -> Iter[T], for x in xs { ... } | for — sugar над .iter().next() (D58) |
| создание | []T.new(), []T.with_capacity(n), []T.filled(v T, n int) | static-функции на типе |
xs.capacity() — присутствует, но не часть стабильного API для
прикладного кода (detail of representation D32). Использование — для
оптимизации pre-allocation; при изменениях representation может
исчезнуть.
Field-access form (xs.len, xs.cap, xs.is_empty без скобок) —
запрещена (D117).
Compiler выдаёт E_SIZE_ACCESSOR_FIELD. Для legacy .cap —
diagnostic подсказывает rename .capacity().
Stdlib extensions (std/collections/vec.nv через D35) — то, что
пишется как обычный пользовательский метод:
| Метод | Что делает |
|---|---|
xs.map[U](f fn(T) -> U) -> []U | каждый элемент через f |
xs.filter(pred fn(T) -> bool) -> []T | оставить совпадения |
xs.fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc | свёртка слева |
xs.any(pred), xs.all(pred) | bool-предикаты |
xs.first(), xs.last() | Option[T] head/tail |
Расширяется по необходимости (contains, index_of, reverse,
sort, zip, take, drop, unique, enumerate — добавляются по
запросу use-case’ов; формальный D-block не нужен, любой fn []T @method валиден по D35).
Слайсинг xs[a..b] — реализовано Plan 96 (см. D144).
Поддержаны 5 форм Range: a..b, a..=b, a.., ..b, .. (Rust
RangeBounds parity). Возвращает sub-slice view (cap == len, push →
realloc → silent detach). OOB → panic (D13).
Embed use []T — допустим по D39 (имя поля обязательно):
type Holder[T] {
use data []T
extra str
}
let h = Holder[int] { data: [1, 2, 3], extra: "info" }
let n = h.len() // прокси к data.len() (D117 method-only)
h.push(42) // прокси к data.push
Подробно — Plan 17 Ф.1, Q-array-api (closed), 02-types.md → D39 (use-delegation).
D40. Тело функции: => для одного выражения, {} для блока
Что
Два взаимоисключающих способа задать тело именованной функции:
=> expr (ровно одно выражение) или { stmt; ...; expr } (блок).
Общий закон: => и {} не сочетаются. Распространяется на fn
(named и closure-full), handler-method.
Closure-light (|x| body) — отдельная грамматика
(D22): тело — bare expression ИЛИ
block, без =>. D40 к ней не применяется.
Единственное исключение — match-arm (D19):
arm может быть pattern => expr или pattern => { block } (Rust-стиль).
Причина исключения — => гарантирован как маркер «начало результата»
после pattern’а с возможным if-guard’ом, поэтому терять его в блок-форме
нельзя.
Indentation не значим.
Правило
fn-decl = 'fn' name '(' params ')' [effects] ['->' type] body
closure-full = 'fn' '(' params ')' [effects] ['->' type] body
body = '=>' expression | block
block = '{' { statement } [ expression ] '}'
closure-light = '|' params? '|' (expression | block) // без =>
match-arm = pattern [ guard ] '=>' ( expression | block ) // исключение
Везде, где есть => (named fn, closure-full, handler-method), после
него идёт ровно одно выражение. Ни fn f() => { ... }, ни
fn f() { => x }, ни fn(x) => { stmt; expr } — запрещены.
Closure-light => вообще не использует.
Симметрия по контекстам:
| Контекст | => expr | { block } | => { block } |
|---|---|---|---|
fn name(...) (named fn) | ✅ | ✅ | ❌ |
fn(...) (closure-full) | ✅ | ✅ | ❌ |
|...| (closure-light) | ❌ (нет =>) | ✅ | — |
| Match-arm | ✅ | — | ✅ (D19) |
| Handler-method | ✅ | ✅ (без =>) | ❌ |
Если нужно несколько statement’ов:
- для
fn(named) и closure-full — блок-форма{ stmt; ...; expr }; - для closure-light — block-форма прямо в
|x| { stmt; expr }(D22); - для match-arm —
pattern => { stmt; expr }(D19); - для handler-method — блок-форма без
=>:op(p) { stmt; expr }(04-effects.md → D31).
// expression-body
fn double(x int) -> int => x * 2
fn HashMap[K, V].new() -> HashMap[K, V] =>
HashMap[K, V].with_capacity(16) // одно выражение, перенесённое
// block-body
fn next_pow2(n int) -> int {
if n <= 1 { return 1 }
let mut p = 1
while p < n { p *= 2 }
p
}
Многострочный match/if — это одно выражение, поэтому => match {...}
и => if {...} else {...} остаются легальными:
fn classify(n int) -> str => match n {
0 => "zero"
n if n > 0 => "positive"
_ => "negative"
}
fn abs(x int) -> int => if x < 0 { -x } else { x }
Граница: появилось ли что-то кроме самого выражения (statement,
let, return, for, while)? Тогда нужен { block }.
// НЕ ОК — `let` это statement, `=>` ожидает одно выражение
fn area(r f64) -> f64 =>
let pi = 3.14
pi * r * r
// ОК — блок-форма
fn area(r f64) -> f64 {
let pi = 3.14
pi * r * r
}
Почему
- Один общий закон.
=>означает «ровно одно выражение после» для лямбд, телаfn, handler-method. Match-arm — единственное исключение, оправданное необходимостью гарантированного маркера «начало результата» после pattern’а с возможнымif-guard’ом (D19). - Indentation-significant грамматика ломает copy-paste, плохо переживает auto-format (Python-стиль отвергнут).
- Парсер сложнее при значимых отступах.
- AI-инструменты часто переформатируют код — невидимая разница становится багом.
- Явные
{}— ноль двусмысленности для форматера, линтера, LSP. - Граница
fnvs лямбда видна по форме. Блок-тело может иметь толькоfn name(...) { ... }, trailing-block и handler-method. Лямбда — никогда.
Что отвергнуто
=> indented-block(F#/OCaml/Python-стиль) — indentation-significant.- Только
{}для всех тел — теряется компактная expression-body. {}после=>(Kotlin/JS-стиль(x) => { ... }) — два маркера для одного, размывает границу «выражение vs блок».- Сочетание
=>и{}для лямбд при запрете дляfn— непоследовательно: общий закон должен работать одинаково для всех «безымянных» и «именованных» функций. Match-arm имеет особую природу (всегда требует=>как маркер) и потому делает исключение.
Связь
- D22 — closure-light
|x|имеет отдельную грамматику (bare expr или block, без=>); closure-fullfn(...)подчиняется D40 как named fn. - D19 — match-arm:
pattern => exprилиpattern => { block }(единственное исключение из правила «=>и{}не сочетаются»). - D23 — guard-clauses
через
returnтребуют блок-формы. - D43 — trailing-block (без
params) —
f(args) { block }; trailing-fn (с params) —f(args) fn(p) body. - 04-effects.md → D31 — handler-method
имеет две формы (
=> exprили{ block }), какfn. - D45 — inference работает только на expression-body.
- D49 —
{}правит newline-разделители.
Эволюция
Ревизия (2026-05-10): правило «=> и {} не сочетаются» больше не
применяется к closure-light (|x|), у которой своя грамматика без =>.
Изначально правило покрывало «лямбды» как единый класс; после
перехода на two-level closure (D22)
«лямбды» расщепились на closure-light (отдельная грамматика) и
closure-full (fn(...), подчиняется D40 как named fn).
D43. Trailing: { block } без params, fn(p) body с params
Что
Если последний параметр функции — функционального типа, аргумент-функция
может быть вынесен за () вызова в одну из двух форм:
- trailing-block —
f(args) { block }— для callback’ов без параметров (DSL-форма:with_timeout,retry,transaction). - trailing-fn —
f(args) fn(params) body— для callback’ов с параметрами. Синтаксис идентичен closure-full (D22) без имени.
Скобки () вызова всегда обязательны; trailing-форма должна начинаться
на той же строке, что ).
|...| (closure-light) в trailing-position запрещён — для
callback’ов с params используется fn(...), иначе ambiguity с
binary |. Closure-light с параметрами передаётся через args:
f(|x| body).
Правило
// trailing-block — без параметров (DSL)
with_timeout(2.seconds) {
Db.exec(sql`UPDATE counters SET v = v + 1`)
}
retry(3) {
Net.get(url)
}
transaction(db) { ... }
// trailing-fn — с параметрами; обе формы тела
list.filter() fn(x) => x > 0 // expr-body
list.fold(0) fn(acc, x) { acc + x } // block-body
list.map() fn(s str) Fail -> int { parse(s)? } // typed + effects
// closure-light — в args, не в trailing
list.filter(|x| x > 0)
list.fold(0, |acc, x| acc + x)
Грамматика:
call = primary '(' args ')' [ trailing ]
trailing = trailing-block | trailing-fn
trailing-block = '{' block-body '}'
trailing-fn = 'fn' '(' params ')' [ effects ] [ '->' type ] body
body = '=>' expression | block
block-body = { statement } [ expression ]
Trailing-fn идентична closure-full (D22).
Параметры пишутся как у named fn — (x int, y int), типы опциональны
если выводятся из ожидаемой сигнатуры callee.
Правила:
()обязательны — trailing должен следовать сразу после).- На той же строке — для trailing-block
{сразу после); для trailing-fnfnсразу после). Перенос строки между ними запрещён. - Тип последнего параметра — функциональный. Иначе type error.
- Один trailing на вызов.
|...|(closure-light) в trailing-position запрещён — пишетсяfn(...)или передаётся через args вызова.- Trailing-block — без параметров. Если callback требует параметры
— использовать trailing-fn (
fn(p) ...) или закрытие в args. - Implicit
itзапрещён — параметр всегда именован. - Method chain — те же правила:
list.filter() fn(x) => x > 0.
spawn— исключение.spawn— keyword-конструкция, не вызов функции, поэтому не подчиняется D43. Его синтаксис:spawn expr, гдеexpr— любое выражение: вызов функции (spawn foo()), блок (spawn { body }), и т.д.spawn() { body }— запрещено (пустые скобки без смысла вводят в заблуждение).
Дисамбигуация с record-литералом:
let u = User { name: "alice" } // record (имя типа, без ())
fn_call(arg) { name: "alice" } // trailing-block (после `)`)
fn_call(arg) fn(x) => x.value // trailing-fn
fn_call(arg, User { name: "a" }) // record внутри args
Многие language primitives становятся обычными функциями stdlib:
fn with_timeout[T](dur Duration, body fn() -> T) Fail -> T
fn transaction[T](db mut Db, body fn() Db Fail -> T) Db Fail -> T
fn retry[T](attempts int, body fn() Fail -> T) Fail -> T
Keyword-блоки остаются (без ()): with X = h { ... },
parallel for x in xs { ... }, region { ... }, match/if/for/while.
Различие с trailing — наличие ().
Почему
()обязательны — локальный парсер без type-directed parsing. Kotlin/Swift вынуждены смотреть на тип, чтобы различить trailing и record-литерал.- trailing-fn = closure-full без имени. Симметрия — программист
учит одну грамматику параметров. Парсер коммитится за
fn-keyword после), никаких ambiguity. - Closure-light не в trailing.
func() |x| bodyсоздавал ambiguity с binary|в expression-position. Запрет даёт парсеру мгновенный ответ:|...|→ closure-light в args;fn(...)после)→ trailing-fn;{...}после)→ trailing-block. - Trailing-block — DSL-ниша. Для
with_timeout/retry/transactionнет параметров callback’а, и{ block }визуально маркирует «здесь начинается тело DSL’а». - Не closure-литерал внутри
(). Closure-light с params передаётся через args (f(|x| ...)), trailing — для последнего функционального параметра. Программист выбирает по форме (длина тела, наличиеlet’ов).
Что отвергнуто
- Опциональные
()(Kotlin) — нет локального способа развести с record-литералами. ()опционально в method chain — лишнее исключение.- Implicit
it— нелокальный reasoning. do { body }keyword — лишнее ключевое слово.- Indentation-significant — конфликт с D40.
- Trailing-block = лямбда (до 2026-05) — переклассифицировано в самостоятельную грамматику.
- Trailing-block с параметрами через
{ x => body }(до 2026-05-10) — заменено на trailing-fn (fn(x) ...) для симметрии с closure-full. - Trailing closure через
|x|—func(args) |x| bodyсоздавал ambiguity с binary|в expression-position;fn(...)решает за один токен.
Связь
- D22 — closure-light в args через
|x|; trailing-fn идентична closure-full без имени. - D40 —
trailing-fn body подчиняется правилу
=>↔{}как named fn; trailing-block — block-only (без=>). - 04-effects.md — handler-блоки
with X = h { ... }— keyword-блок, не trailing. - 06-concurrency.md —
parallel for,supervised,race,select— keyword-блоки.
Эволюция
Ревизия (2026-05): переименование «trailing-lambda» → «trailing-block».
Раньше форма f(args) { params => body } называлась лямбдой и
конфликтовала с правилом «лямбда = одно выражение». Тогда же
переклассифицировано в самостоятельную грамматику.
Ревизия (2026-05-10): trailing расщеплён на trailing-block (без
params, для DSL) и trailing-fn (с params, через fn(...)). Старая
форма f(args) { x => body } отменена. Триггер — переход closure
на two-level (|x| + fn(...), D22);
старая форма с => внутри {} после ) создавала путаницу с новым
правилом «=> не используется в closure-light». Симметрия trailing-fn
↔ closure-full даёт парсеру и программисту одно правило вместо двух.
Migration: ~10 примеров trailing с params в spec/.
D44. Числовые литералы
Что
Полный набор числовых форм; _ как разделитель между цифрами;
default — int для целых, f64 для дробных. Type-suffixes
(100u32, 1.5f32) отвергнуты — type через annotation или as-cast.
Правило
// целые: десятичные / hex / binary / octal
1
1_000_000_000
0xFF 0xFF_FF_FF_FF
0b1010_0001
0o755
// float
1.5 1_234.567_89
1e10 1.5e-3 1_000.5e6
// type через cast или аннотацию
let x i32 = 100
100 as u8
0xFF as u32
Default-типы: int (платформенно-зависимая ширина) для целого,
f64 для дробного. Контекст (annotation, тип параметра, тип поля)
переопределяет:
let x u8 = 200 // 200 это u8
fn write(b u8) -> () => ...
write(0xFF) // 0xFF это u8
let arr []f32 = [1.0, 2.0]
Разделитель _ — только между цифрами. Запрещено: в начале
(_1), в конце (1_), подряд (1__0), сразу после префикса
(0x_FF), вокруг точки (1_.5), вокруг e (1_e10).
Regex:
decimal-int = [0-9] (_? [0-9])*
hex-int = "0x" [0-9a-fA-F] (_? [0-9a-fA-F])*
binary-int = "0b" [01] (_? [01])*
octal-int = "0o" [0-7] (_? [0-7])*
float = decimal-int "." decimal-int (("e"|"E") ("+"|"-")? decimal-int)?
| decimal-int ("e"|"E") ("+"|"-")? decimal-int
Почему
- Без suffixes — меньше шума.
100u32,0xFFu8,1.5f32хуже100 as u32.let x u32 = 100уже работает через inference. - Тренд новых языков (Swift, Go, Zig) — без суффиксов.
- AI-friendly — меньше форм записи.
intплатформенно — компромисс между Rust (фиксированный) и Python (bigint)._строгий regex запрещает мусор (1__0,_1).
Что отвергнуто
- Type-suffixes (
100u32,1.5f32) — шум, дублирование с annotation, прецедент новых языков против. - Свободные
_— хочется без1__0и_1. 'как разделитель (C++14) — экзотический выбор,_стандарт.
Связь
- D27 — литералы
длин массивов берут тип
int. - D33 — литералы в
const. - D40 — литералы в expression-body.
Строковые литералы и интерполяция ${expr}
Строковый литерал "..." хранит UTF-8 байты (тип str). Внутри
литерала разрешена интерполяция через ${expr} (D-string-interp,
закрыт в Plan 17 Ф.1):
let name = "alice"
let age = 30
let s = "Hello, ${name}, you are ${age}" // → "Hello, alice, you are 30"
Семантика — sugar над + и str.from(...) (D73 [Into]). Литерал
с N интерполяциями развёртывается в N+1 литеральных частей и N
выражений:
"a${x}b${y}c"
// = "a" + str.from(x) + "b" + str.from(y) + "c"
Каждое выражение ${expr} должно иметь тип, удовлетворяющий
Into[str] (через D73 это автоматически верно для int, f64,
bool, str, char, Option[T] где T: Into[str], и любых
user-типов с реализованным From[Self] for str или Into[str]).
Escape для буквального ${ — обратный слэш: "price: \${value}"
печатает ${value} без интерполяции.
Multi-line работает через обычные newlines в литерале (\n или
сырой newline между "..."); tag-форма (D48) для raw-строк отдельная.
Пустое выражение "${}" — compile error.
// Что разрешено
let v = "x = ${1 + 2}" // sub-expression — ok
let v = "user = ${user.name()}" // method call — ok
let v = "${a}${b}" // соседние интерполяции — ok
let v = "literal \${name}" // escape — буквальное "${name}"
// Что НЕ работает
let v = "${}" // ✗ пустое выражение
let v = "${let x = 1; x}" // ✗ statement, не выражение
Bootstrap status (2026-05-08): ✅ реализовано в lexer/parser/codegen (Plan 17 Ф.4):
- Lexer видит
\$как escape — сохраняет sentinel-байт\x01$(SOH+$), чтобы парсер мог отличить literal-${от interpolation-${. - Parser разворачивает
TokenKind::Str(s)в expression-position вExprKind::InterpolatedStr { parts: Vec<InterpStrPart> }. Каждое${expr}парсится через sub-Lexer + sub-Parser; balanced{}внутри expr поддерживается. Пустое${}— compile error. - Codegen эмитит цепочку StringBuilder с pre-size estimate:
Nova_StringBuilder_static_with_capacity(N)→Nova_StringBuilder_method_append_str(...)per fragment →Nova_StringBuilder_method_into(sb). Одна аллокация на итоговый buffer; нет O(N²) от цепочки+. Per-fragment dispatch по типу:nova_strpass-through,nova_bool→nova_bool_to_str,nova_f64→nova_f64_to_str,CharLit→nova_char_to_str(UTF-8 encode), user-тип с@into() -> str(D73) —Nova_T_method_into, fallbacknova_int_to_str. - Interp (для тестов и
nova run) — обычная конкатенация черезformat!("{}", value). - Const-инициализатор: интерполяция запрещена (требует runtime StringBuilder); compile error «not allowed in const initialiser».
Тесты — nova_tests/types/string_interpolation.nv (13 тестов, все
PASS): int / negative int / str / bool / f64 / char-литерал /
multi-interpolation / expression в ${} / escape \${ / большие
строки через StringBuilder.
В tag\…`-литералах ([D48](#)) tag-функция получает части и аргументы раздельно — для них интерполяция работает по той же грамматике ${expr}`, но обработка идёт user-функцией.
Связь: D48 (tagged templates —
raw-строки tag\…`без интерполяции по такой же грамматике${expr}, но обработка зависит от tag-функции), [08-runtime.md → D73](/spec/decisions/runtime/#d73) (str.fromчерезFrom/Into), [08-runtime.md → D26](/spec/decisions/runtime/#d26) (str` тип
- конкатенация).
D45. Inferred return type для expression-body
Что
В expression-body (=> expr) тип возврата -> T опционален —
выводится из тела. В block-body ({ ... }) -> T обязателен,
если тип не unit.
Правило
// expression-body — -> T опционален
fn double(x int) => x * 2 // -> int выведен
fn Duration @as_nanos() => @nanos // -> i64 выведен
fn Duration @is_zero() => @nanos == 0 // -> bool выведен
fn HashMap[K, V] @len() => @count // -> int выведен
// block-body — -> T обязателен
fn next_pow2(n int) -> int {
if n <= 1 { return 1 }
let mut p = 1
while p < n { p *= 2 }
p
}
fn process() { // -> () можно опускать
Log.info("hello")
}
Inference локальный (по одной функции, одному выражению), не Hindley-Milner:
- литерал → его тип;
@field→ тип поля; - вызов → тип возврата вызываемого; record-литерал
T { ... }→T; - match/if-else → unification веток.
Style-guide:
exportфункции — писать-> Tявно (линтер предупреждает).- Сложные match’и — писать явно.
- Generic-функции — связь параметра с возвратом полезно видеть.
- Простые геттеры/предикаты/конструкторы — опускать.
Почему
- Compact form для тривиальных методов — getters, predicates.
- Локальный inference — дёшев, прозрачен, не масштабирует на весь модуль.
- Граница совпадает с D40 — где
=>, там и inference; где{}, там типы обязательны. - Прецедент Kotlin.
Что отвергнуто
- Inference в block-body — теряется явный контракт; диф большой функции мог бы молча менять тип возврата.
- Полный inference (Haskell) — public API теряет явный контракт.
-> Tобязателен везде — шум для тривиальных одностроек.
Связь
- D40 — граница применимости.
- D20 —
-> ()опускается всегда. - 07-modules.md → D47 —
exportфункции и линтер.
Реализация (Plan 55 Ф.3, 2026-05-16)
Bootstrap-codegen (compiler-codegen/src/codegen/emit_c.rs::return_type_c)
реализует только Expr-body inference (FnBody::Expr) — Block-body
без аннотации → nova_unit (как раньше; см. «Что отвергнуто» выше).
Inference при registration call-site signatures (free fn + method)
делегируется в return_type_c. Это гарантирует что caller’ы видят
правильный return type до emit_fn собственно body.
Edge-case: если body Expr возвращает void* или unknown — fallback
на nova_unit (safety).
D46. Перегрузка операторов через @-методы
Что
Стандартные операторы автоматически вызывают instance-методы с фиксированными именами. Если у типа есть метод нужного имени — оператор работает. Custom-операторы запрещены.
Правило
fn Duration @plus(other Duration) -> Duration =>
Duration { nanos: @nanos + other.nanos }
fn Duration @times(n i64) -> Duration =>
Duration { nanos: @nanos * n }
let total = 1.hour() + 30.minutes() // вызывает @plus
let triple = 5.seconds() * 3 // вызывает @times
Mapping:
| Оператор | Метод | Возврат |
|---|---|---|
a + b | @plus(b) | свободный |
a - b | @minus(b) | свободный |
-a | @neg() | обычно Self |
a * b | @times(b) | свободный |
a / b | @div(b) | свободный |
a % b | @rem(b) | свободный |
a | b, a & b, a ^ b | @or / @and / @xor | свободный |
a << n, a >> n | @shl / @shr | свободный |
a == b, a != b | @eq(b) (!= выводится) | bool |
a < b, <=, >, >= | @lt / @le / @gt / @ge | bool |
!a | @not() | обычно bool или Self |
a[i] (read), a[i] = v | @get(i) / @set(i, v) | свободный / () |
Правила:
- Только методы инстанса — привязка к первому операнду.
&&,||не перегружаются — short-circuit предсказуем.!=выводится из@eq— отдельно объявлять не надо.- Custom-операторы запрещены (
:+,>>=и т.п.) — фиксированный набор символов. - Никаких protocol/trait — структурное соответствие по имени.
- Type coercion нет —
Duration + 30ошибка, нуженDuration + 30.seconds(). - Overloading методов по типу аргумента разрешён, если сигнатуры различимы:
fn Vector @times(s f64) -> Vector => // умножение на скаляр
Vector { x: @x * s, y: @y * s }
fn Vector @times(other Vector) -> f64 => // dot product
@x * other.x + @y * other.y
Почему
- Просто и предсказуемо — структурное matching по имени, без trait-механики.
- Закрытый набор операторов — Scala-style символьные методы
(
:+,<>) известны как источник нечитаемости. &&/||фиксированы — short-circuit семантика.- Прецедент Kotlin — фиксированные имена методов.
Что отвергнуто
- Через
protocol/trait(Rustimpl Add, Swift) — избыточно. - Custom-операторы (Scala/C++) — нечитаемый код.
- Свободные функции (
fn plus(a, b)) для операторов — unification-ambiguity при резолвеa + b. Overloading свободных функций по типам аргументов сам по себе разрешён (D84), но привязка операторов к receiver-методам (@plus/@times) однозначнее: компилятор знает, где искать реализацию. - Перегрузка
&&/||— нарушает short-circuit. - Auto-derive
@eq/@lt— отдельный механизм, не часть D46.
Связь
- D35 — те же
@-методы. - D45 — методы операторов имеют inferred return при expression-body.
- 02-types.md — отсутствие trait/impl.
Эволюция
Закрывает Q16 (bitflags): type Permission(int) с @or/@and/@not
для |/&/!.
D48. Tagged template literals
Что
Литералы вида tag`raw_text` — синтаксический сахар над вызовом
функции tag, получающей сегменты текста и интерполированные значения
раздельно.
Правило
let j = json`{"name": "alice"}`
let q = sql`SELECT * FROM users WHERE id = ${user_id}`
let h = html`<div>${escape(name)}</div>`
let r = regex`\d{3}-\d{4}`
let b = bytes`deadbeef`
Грамматика:
tagged-template = identifier '`' template-body '`'
template-body = ( raw-char | escape-seq | interpolation )*
escape-seq = '\\' ( '`' | '\\' | '${' | 'n' | 't' | ... )
interpolation = '${' expression '}'
Desugar:
sql`SELECT * FROM users WHERE id = ${user_id} AND name = ${name}`
// эквивалентно
sql(
["SELECT * FROM users WHERE id = ", " AND name = ", ""],
[user_id, name]
)
Tag-функция получает parts []str (сегменты, длина = args.len() + 1)
и args []T. Сигнатура:
fn tag_name(parts []str, args []T) -> ResultType => ...
Стандартные теги stdlib MVP: json, sql, regex, bytes. html,
css, graphql — user-space.
Compile-time validation через @comptime — для тегов без интерполяций
(пустой args); если функция помечена, литерал проверяется при
компиляции (некорректный JSON → compile error). В MVP @comptime
откладывается на v2.
Multiline и raw escapes естественны:
let r = regex`\d+\.\d+` // не нужно дважды экранировать
let q = sql`
SELECT id, name
FROM users
WHERE created_at > ${cutoff}
`
Почему
- Типобезопасная интерполяция — главное преимущество. Tag получает raw parts и args отдельно, сама эскейпит / передаёт через prepared statement (защита от SQL injection).
- User-defined теги — обычные функции, любое имя.
- Compile-time валидация через
@comptime— JSON/regex/SQL без runtime-парсинга. - Прецедент JavaScript по синтаксису, Scala/Rust по compile-time.
Что отвергнуто
s"..."/r"..."(Scala) — ограничивает имя одним символом, нет user-defined.tag.raw("...") + tag.interp("...", args)— слишком многословно.- Macros (Rust
sql!) — требует механизма макросов. - Implicit tag — ambiguity со строками.
Связь
- D33 —
@comptime-теги без интерполяций могут бытьconst. - D27 —
partsиargs— обычные[]T. - D40 — tag-функции обычные.
- 09-tooling.md → D24 —
requiresдля валидации parts/args.
D49. Statement separator и парсинг выражений
Что
Перенос строки — основной разделитель statement’ов. ; —
опциональный, нужен только при нескольких statement’ах на одной строке.
Правило
let x = 1 // newline разделяет
let y = 2
foo(x, y)
let a = 1; let b = 2; foo(a, b) // ; для одной строки (редко)
Лексер игнорирует NEWLINE, если statement очевидно продолжается:
-
После висящего бинарного оператора в конце предыдущей строки:
let total = a + b + c -
Внутри открытых
(,[,{— newlines игнорируются. -
Перед
.(method chain) и перед?(error propagation):let r = list .filter(|x| x > 0) .map(|x| x * 2) .sum() -
После
,в списках. -
Перед
else/else if— продолжениеif-выражения:let label = if s is Origin { "at-origin" } else if s is Circle { "circle" } else { "square" }Без этого правила multi-line
if/elseприходится писать через повторное присваиваниеlet mut x = default; if ... { x = ... }. -
Перед
||/&&/or/and— продолжение boolean expression:fn is_alnum(c char) -> bool { (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }Это исключение из общего правила «бинарные операторы — в конце предыдущей строки» (Go-стиль).
||и&&часто пишут leading’ом для читаемости; обе формы допустимы. Реализовано через look-ahead вparse_or/parse_and.
Бинарные операторы — в конце предыдущей строки (Go-стиль) для
большинства операторов (+, -, *, и т.п.). Исключения
зафиксированы в правилах 5 и 6 выше: else/else if и
||/&&/or/and — leading-форма допустима. + в начале новой
строки воспринимается как унарный.
Compound-assignment
Compound-операторы — синтаксический сахар:
| Оператор | Десахар |
|---|---|
a += e | a = a + e |
a -= e | a = a - e |
a *= e | a = a * e |
a /= e | a = a / e |
Target обязан быть lvalue — одна из трёх форм:
// 1) Локальная mut-переменная
let mut n = 0
n += 1 // ✅
// 2) @field на self в методе (D35)
fn Counter mut @inc() -> () {
@value += 1 // ✅
}
// 3) Element массива/индексируемой коллекции
let mut xs = [10, 20, 30]
xs[0] += 5 // ✅
Compound-assign — это statement, не expression. После => в
match-arm или в expression-body функции его нельзя писать без
обёртки в { ... }:
match c {
Some('\n') => { @line += 1; @col = 1 } // ✅ блок
Some(_) => { @col += 1 } // ✅ блок
None => ()
}
// ❌ парсер не поймёт `+=` в expression-position arm:
// Some(_) => @col += 1
Правая часть compound-assign — обычное выражение (любое допустимое в
RHS обычного =). Type-check соответствует базовому оператору:
a += e валидно ⇔ a + e валидно и его тип присваиваем a.
Перегрузка через @plus/@minus/@times/@div (D46)
работает прозрачно — compound на user-типе с @plus десахарится в
a = a.plus(e).
Edge cases:
let x = foo
(arg) // ❌ два statement'а: foo и (arg)
let x = foo(arg) // ✅ одна строка
let x = foo( // ✅ открытая ( игнорирует newline
arg
)
Trailing-block: ) и { на одной строке (D43).
Match-arms — , или \n оба разделяют:
match x {
Some(v) => v * 2 // newline разделяет
None => 0
}
match x {
Some(v) => v * 2, // запятые тоже работают
None => 0,
}
Пустые ; запрещены — всегда баг.
Иерархия приоритетов (от низкого к высокому):
| Уровень | Операторы | Ассоциативность |
|---|---|---|
| 1 | =, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= | right |
| 2 | .., ..= (range) | non-associative |
| 3 | || | left |
| 4 | && | left |
| 5 | ==, != | left |
| 6 | <, <=, >, >= | left |
| 7 | | (bitwise or) | left |
| 8 | ^ (bitwise xor) | left |
| 9 | & (bitwise and) | left |
| 10 | <<, >> | left |
| 11 | +, - (binary) | left |
| 12 | *, /, % | left |
| 13 | as (cast) | left |
| 14 | !, - (unary) | right |
| 15 | ?, (), [], . | left |
Грамматика (упрощённо):
program = statement*
block = '{' statement* '}'
statement = ( decl | expr ) statement-end
statement-end = ';' | NEWLINE | look-ahead '}'
postfix-expr = primary ( '.' name | '[' expr ']' | '(' args ')' | '?' )*
primary = literal | identifier | '(' expr ')' | block | if | match | ...
Почему
- Современный тренд (Go/Kotlin/Swift/TS): newline-разделитель, меньше шума.
- Простые правила вместо JS ASI — JavaScript ASI известный
источник багов (
return\n{...}возвращает undefined). Nova строит на «висящий оператор», «незакрытая скобка», «.method/?». - Бинарный оператор в конце — Go-практика, иначе унарный парсинг ломает выражение.
Что отвергнуто
- Обязательный
;(Rust/C) — лишний шум. - Indentation-significant блоки — конфликт с D40.
- JS ASI с edge cases — известный источник багов.
- Перенос оператора в начало строки — унарный/бинарный конфликт.
Связь
- D40 — внутри
{}newlines разделяют statement’ы. - D43 —
)и{на одной строке как частный случай. - D45 — последнее выражение блока становится возвратом через newline-разделитель.
- 04-effects.md — handler-литералы используют те же
правила внутри
{...}.
D54. Операторы as и is
Что
Два оператора с разной семантикой:
as— compile-time конвертация значения между совместимыми типами (numeric cast, newtype ↔ underlying, sum → int). Возвращает значение целевого типа. Если конвертация невозможна по правилам типов — ошибка компиляции.is— runtime type-check для значений типаany. Возвращаетbool. Также используется как pattern вmatchиifдля биндинга и smart cast’а.
as — про «сделай этим типом» (статически). is — про
«проверь, какой это тип сейчас» (runtime).
Правило
as — compile-time конвертация
as работает в позиции выражения: <expr> as <type>. Возвращает
значение целевого типа.
Numeric cast (см. D44):
let n = 100 as u32 // литерал → u32
let big = 0xFF_FF as u16
let x = 1.5 as i32 // f64 → i32 (truncate)
let y = some_int as f64 // int → f64
Семантика narrowing-конверсий
Поведение as при потере точности зависит от пары source→target.
В отличие от C (где out-of-range float→int это UB), Nova даёт
defined behavior на любом входе:
| From → To | Семантика | Пример |
|---|---|---|
iN → iM (M < N) | wraparound (modulo 2^M) | 0x1_FFFF as i16 == -1 |
iN → uM | bit-pattern truncate | -1i32 as u16 == 65535 |
uN → uM (M < N) | wraparound | 0x1_FFFF as u16 == 0xFFFF |
uN → iM | bit-pattern, signed reinterpret | 0xFFFFu16 as i16 == -1 |
f64 → f32 | IEEE rounding | 1.1 as f32 ≈ 1.1 (с потерей) |
f → iN | saturation + NaN→0 | 70000.5 as i16 == 32767 |
f → uN | saturation + NaN→0 + neg→0 | -1.0 as u16 == 0 |
iN → f | exact (или nearest IEEE) | 123 as f64 == 123.0 |
| newtype ↔ underlying | identity | 42 as UserId reuses bits |
Float → integer — saturation, не UB. Out-of-range, NaN, ±Infinity дают defined значение, не зависящее от платформы:
- Out-of-range positive →
INT_MAX/UINT_MAX. - Out-of-range negative →
INT_MIN/0(для unsigned). - NaN →
0. +Infinity→INT_MAX/UINT_MAX.-Infinity→INT_MIN/0.
Если нужна проверка out-of-range — используйте
TryFrom:
let n = f as i16 // saturation, infallible
let n = i16.try_from(f)? // throws Fail[OutOfRangeError]
as остаётся pure (без Fail-эффекта). Throw-форма доступна через
D77 как explicit choice.
Прецеденты. Saturation для float→int согласован с Rust 1.45+
(RFC #2484 «sealed casts») — прямой аналог. C/C++ дают UB, Nova
улучшает. Swift делает trap (panic), нет pure as — Nova выбирает
saturation для совместимости с D54 «as это pure». Java делает
IEEE round + wraparound (defined, но не saturation).
Newtype ↔ underlying (см. 02-types.md → D52):
type UserId u64
let u UserId = 42 as UserId // u64 → UserId
let n u64 = u as u64 // UserId → u64
Sum → int (для sum’ов с числовыми discriminants, D52):
type ErrorCode | NotFound = 404 | InternalError = 500
let code = NotFound as int // 404
Запрещено:
any → T(x as intгдеx any) — нет статической конвертации. Используйтеis-pattern илиtry_as[T]()(см. ниже).- Произвольные типы без явного правила (
User as Account) — ошибка компиляции. - int → Sum через
as— type-небезопасно (число может не попасть в варианты). Только через pattern match (см. D52).
Запрещённые as-cast’ы для char/u8/bool
Рrune as-cast’ов где seemingly-numeric mappingвыражает unsafe
семантику. Программист должен использовать try_from (с
range-check’ом) или explicit comparison:
Запрещено через as | Альтернатива |
|---|---|
int as char, iN/uN as char | char.try_from(n)? (range 0..0x10FFFF, не surrogate) |
char as u8 | u8.try_from(c)? (fails если codepoint > 0xFF) |
int/u8/f64/etc as bool | n != 0 (или n != 0.0) |
str as int/i32/f64/bool/char | T.try_from(s)? (parse) |
int/f64/bool/char as str | str.from(v) (format) |
Исключение для char-литералов: 'A' as int, 'A' as u8
разрешены — программист видит codepoint буквально на
write-time, range-check не нужен.
Исключение для int-литералов → char: 0x41 as char, 65 as char
разрешены, если литерал — compile-time-known integer в валидном
Unicode-диапазоне U+0..=U+10FFFF исключая surrogate range
U+D800..=U+DFFF. Range-check выполняется статически в checker’е,
runtime Fail не нужен. Off-range литерал — compile error с указанием
конкретного codepoint (не generic suggestion). Для переменных типа
int правило прежнее — нужен char.try_from(n)?. Введено в Plan 14
Ф.7 (2026-05-09).
Прецеденты. Rust требует char::from_u32(n) (Result), не n as char. Swift Character.init(extendedGraphemeClusterLiteral) — нет
прямого n as Character. Kotlin n.toChar() существует но deprecated
для unsafe usage. Java (char)n — narrow с silent overflow (UB-class).
Nova выбирает Rust-стиль strict.
Bool-restrictions — то же из Rust/Swift/Kotlin: if cond требует
bool, n as bool — explicit ошибка с suggestion. Это закрывает
известный bug-class C/JavaScript/Python.
Strict if cond: bool / while cond: bool
if cond { ... }, while cond { ... }, cond1 && cond2,
cond1 || cond2 — cond обязан быть bool. C-стиль truthy-int
(if a где a: int) запрещён.
let n int = 5
if n { ... } // ❌ compile error: cond must be bool
if n != 0 { ... } // ✅ explicit comparison
Прецеденты. Rust/Swift/Kotlin/Go (если игнорировать nil-check shortcut) — все требуют bool. Python/C/JavaScript разрешают truthy — известный bug-class.
is — runtime type-check
is работает в двух сценариях:
any → T— type-check для значений top-type’аany. Возвращаетbool(или используется как pattern в match).Sum → Variant— variant-check для sum-значений: «является ли это значение конкретным вариантом sum-типа?» (revision v2).
На остальных «обычных» типах (record без вариантов, primitives,
аносу́ты) is — ошибка компиляции: тип известен статически, проверка
бессмысленна.
Сценарий 1: any is T
Boolean-выражение:
fn dump(x any) Io -> () =>
if x is int { println("got int") }
if x is str { println("got str") }
Pattern в match:
match arg {
n is int => process_int(n) // биндинг + smart cast
s is str => process_str(s)
is bool => println("bool") // без биндинга
_ => throw UnsupportedType
}
Pattern-форма: <binding> is <type> или is <type> (без биндинга).
Smart cast в if:
fn process(x any) -> str =>
if x is str {
x.upper() // x здесь имеет тип str автоматически
} else if x is int {
str.from(x) // x здесь int (D73)
} else {
"unknown"
}
После if x is T { ... } внутри блока компилятор автоматически
уточняет тип переменной до T (Kotlin smart cast). Работает если
переменная не переприсваивается в блоке.
Сценарий 2: <sum> is <Variant>
is работает на любом sum-значении, проверяя соответствие конкретному
варианту:
type Shape | Circle { radius f64 } | Square { side f64 } | Origin
let s Shape = Circle { radius: 1.0 }
if s is Circle { println("circular") } // ✅ true
if s is Square { println("squarish") } // ✅ false
if s is Origin { println("at origin") } // ✅ unit-вариант
// Также для prelude sum-типов:
let r Result[int, str] = Ok(42)
if r is Ok { println("happy path") } // ✅
if r is Err { handle_error() } // ✅
let opt Option[User] = Some(u)
if opt is Some { ... }
if opt is None { ... }
Без биндинга — is это просто bool. Для извлечения значения
из варианта используется if let (D34), который комбинирует check
и binding в одном выражении:
// Без биндинга — только yes/no:
if r is Ok { println("ok") }
// С биндингом — if let:
if let Ok(n) = r { use(n) }
Это даёт чёткое разделение:
is= «yes/no» (короткий guard).if let= «yes + extract» (binding form).
Поэтому is не поддерживает binding-форму на sum-типах —
r is Ok(n) ошибка, нужно if let Ok(n) = r. Это согласовано
с D9 «один очевидный путь»: одна форма для одной задачи.
Реализация: компилятор знает теги вариантов и эмитит
runtime-проверку tag’а sum-struct’а (shape->tag == NOVA_TAG_Shape_Circle).
Стоимость — одно сравнение integer’ов.
На не-sum / не-any — ошибка компиляции:
type User { id u64 }
fn process(x User) -> () =>
if x is int { ... } // ОШИБКА: User — record, не sum и не any
Методы на any для extraction (комплементарные is)
Для if let-стиля и работы через эффект Fail:
// Опциональный cast — Option[T]
fn any.try_as[T](x any) -> Option[T] =>
// runtime-проверка тэга, Some если совпал, None иначе
// Cast через Fail — для строгих случаев
fn any.as[T](x any) Fail[TypeMismatch] -> T =>
// throw TypeMismatch если тег не совпал
Использование:
// if let
if let Some(n) = arg.try_as[int]() {
process_int(n)
}
// ?-стиль
let n int = arg.as[int]?
Три инструмента под разные сценарии:
| Способ | Когда применять |
|---|---|
match { is T => ... } | несколько вариантов, exhaustive обработка |
if let Some(n) = x.try_as[T]() | один-два типа, mostly happy path |
let n = x.as[T]? | один тип, ожидается этот тип; несовпадение — ошибка |
Почему
Раздельные as и is — два разных вопроса
as — «как сделать значение типа T» (compile-time, статически
решаемая задача). is — «какой тип у значения сейчас» (runtime,
нужен для top-type extraction).
В языках, использующих один оператор для обоих (Swift as/as?/as!,
C++ static_cast/dynamic_cast), программист путается. В Nova
разделение явное — два keyword’а с непересекающимися ролями.
is для any и sum-типов — без overhead на остальных типах
is работает там, где runtime-tag уже есть структурно:
any-значения содержат tag дискриминирующий конкретный тип (boxing-цена для top-type — обязательная).- Sum-типы содержат tag дискриминирующий вариант (это часть
layout’а sum-struct’а —
tag + payload).
Для record/primitives/protocol — tag’а нет, и is ошибка компиляции:
тип уже известен статически, проверка бессмысленна.
В Kotlin/C# is T работает на любом типе через RTTI (Runtime
Type Information) — каждое значение несёт type-tag. Это глобальный
overhead. Nova избегает этого: is использует существующие теги
(any-boxing, sum-discriminant), не добавляет новых. Поэтому стоимость
is localized.
Sum-вариант check vs match:
// Короткая форма для yes/no:
if shape is Circle { return "round" }
// Полная форма с biding'ом:
if let Circle(r) = shape { use(r) }
// Exhaustive обработка:
match shape {
Circle(r) => ...
Square(s) => ...
Origin => ...
}
Каждая форма для своего сценария: is — guard, if let — guard +
extract, match — exhaustive multi-way.
Smart cast — стандартная эргономика
if x is T { x.method_of_T() } без явного re-binding — фича Kotlin,
TypeScript narrowing, C# pattern matching, Swift binding-pattern. Все
сообщества любят smart cast, и этого не избегают.
Прецеденты ключевых слов
as: Rust, Swift, C#, Kotlin, TS — для cast (numeric и иначе). Nova берёт это значение.is: C# (x is T), Kotlin (x is T), TS (typeof/instanceof, но неis—isв TS это type predicate). F# использует:?, что менее красиво. Nova берёт C#/Kotlin-стиль.
Что отвергнуто
- Один оператор для cast и type-check (Swift
as?/as!). Усложняет mental model, путает пользователя. is Tдля любого типа без tag’а (Kotlin-style RTTI). Требует runtime-tag на всех значениях — глобальный overhead. Nova ограничена типами, у которых tag уже есть структурно (any-boxing, sum-discriminant). Для record/primitives — compile error.is Variant(binding)с биндингом на sum-типах. Дублируетif let Variant(binding) = expr(D34). Чтобы избежать двух форм для одной задачи —isбез binding,if letс binding.x.is[int]()метод вместо оператора. Менее читаемо в условиях (if x.is[int]()-запись хужеif x is int). Operator проще.asдляany → Tбез runtime-проверки. Type-небезопасно (программист может написатьx as intдляx anyбез гарантии). Используйтеisилиtry_as[T].- Implicit cast между типами без
as. Все конвертации явные. - Flow-sensitive narrowing на
!isв MVP. Дляif !(x is T) { return }после блокаxне уточняется автоматически. Можно расширить позже.
Цена
- Два keyword’а в синтаксисе языка вместо одного.
isранее не использовался — теперь зарезервирован. - Runtime-tag для
any-значений — стоимость в реализации (memory overhead на boxing). - Smart cast требует поддержки в type-checker — переменная имеет разный тип в разных ветках одной функции. Усложняет реализацию.
try_as[T]()иas[T]?— два метода stdlib наanyповерх оператораis. Нужно зафиксировать в prelude (D26).
Связь
- 02-types.md → D52 — newtype, sum, discriminants —
типы, для которых
asопределён. - 02-types.md → D53 —
anyкак пустой protocol-тип, для которого работаетis. - D44 — numeric
as-cast (100 as u32) как частный случай D54. - D34 —
if let Some(n) = x.try_as[T]()используетif let-форму. - D19 —
=>в match-arms,is-pattern наследует ту же стрелку. - 08-runtime.md → D26 —
try_asиasметоды наanyв prelude.
Открытые вопросы
- Flow-sensitive narrowing на
!is— можно ли послеif !(x is T) { return }уточнять тип в продолжении функции? Отложено. isдля protocol-types (runtime structural check) — дорого, не входит в MVP.isдля error/cancel-detection вResult[T, E].r is Errработает (variant check), но иногда хочется проверить конкретный payload —r is Err(NotFound). Сейчас это не поддерживается (binding запрещён), нужноif let Err(NotFound) = r.
Эволюция
v1: is работал только для any-значений. Sum-варианты
проверялись через match или if let — короткой is-формы не было.
Это вынуждало писать convention @is_circle() методы для часто
проверяемых вариантов, что засоряет API типов.
v2 (текущая, 2026-05-06): is расширен на sum-варианты —
shape is Circle работает. Cтоимость localized: tag для sum уже
есть в layout’е, никакого нового runtime-overhead’а. Биндинг-форма
не добавлена — это работа if let (D34); чёткое разделение
ролей: is = yes/no, if let = yes + extract.
Это убрало нужду в @is_X convention’ах из syntax.md.
Эволюция
До D54 as использовался без формального D-решения (упоминался в
D44, D52). D54 фиксирует семантику явно: as — compile-time
конвертация; is — runtime type-check. Закрывает Q-any-extract
(извлечение типа из any-значения).
D58. Range-литерал, Iter[T] protocol, for x in c implicit iter
Что
Три связанных правила, объединённых одним D-блоком, потому что они взаимно поддерживают друг друга:
a..bиa..=b— литералы Range в любой expression-позиции (не только вfor). Open-ended формыa..,..b,..=b,..— расширение Plan 96 (D144): только в slice- context (arr[range]). В materialize / for-loop / quantifier / parallel-for — compile-error (нужна bounded форма).Iter[T]— структурный protocol в prelude (D26):protocol { mut next() -> Option[T] }. Любой тип с таким методом — итератор.for x in cбез.iter()— implicit-iter. Еслиcуже итератор, используется напрямую; если есть методiter(), компилятор подставляет вызов.
Правило
Range-литералы
let r1 = 0..5 // Range { start: 0, end: 5, inclusive: false }
let r2 = 0..=5 // Range { start: 0, end: 5, inclusive: true }
let r Range = 1..10 // в let-binding'е работает
fn count(r Range) -> int => r.end - r.start
count(0..100) // в позиции аргумента работает
let ranges []Range = [0..5, 10..20, 100..200] // в массиве
a..b — синтаксический сахар, разворачивается компилятором в
Range { start: a, end: b, inclusive: false }. a..=b →
inclusive: true.
Range — обычный тип (08-runtime.md → D26 prelude):
type Range {
readonly start int
readonly end int
readonly inclusive bool
}
Имеет методы @iter(), @contains(x), @len(), @is_empty().
Подробно — examples/stdlib_range.nv.
Iter[T] protocol
type Iter[T] protocol {
mut next() -> Option[T]
}
Любой тип с структурно-совместимым методом mut next() -> Option[T]
— итератор по D42/D53.
Примеры реализаций (структурно автоматические):
type RangeIter { ... }
fn RangeIter mut @next() -> Option[int] => ... // Iter[int]
type VecIter[T] { ... }
fn VecIter[T] mut @next() -> Option[T] => ... // Iter[T]
type LinesIter { ... }
fn LinesIter mut @next() -> Option[str] => ... // Iter[str]
В сигнатурах функций можно использовать как параметр:
fn count_items[T](it Iter[T]) -> int {
let mut n = 0
for _ in it { n += 1 }
n
}
Структурная типизация — никаких impl Iter for ...-блоков, любой
mut next() -> Option[T] подходит.
for x in c — implicit iter
for-loop принимает любое выражение справа от in, разворачиваясь
по правилу:
for x in c { body }
компилируется как:
- Если
cимеетmut next() -> Option[T]— используется напрямую как итератор. - Иначе если
cимеетiter() -> Iter[T]— компилятор вставляетc.iter(). - Иначе — ошибка компиляции.
Это означает, что программист пишет for x in c для коллекций
(используется c.iter() под капотом), и то же самое для
итераторов напрямую (без двойного .iter()).
let v []int = [1, 2, 3]
for x in v { ... } // []T.iter() автоматически
let r = 0..5
for x in r { ... } // Range.iter() автоматически
for x in 0..5 { ... } // тот же
let it = v.iter()
for x in it { ... } // it уже Iter[T], без двойного iter()
Почему
- Range как expression — естественно. В for-loop
0..nуже работает. Расширение на любую expression-позицию устраняет асимметрию: «range можно в for, но не в let». Прецедент Rust, F#, Haskell, Scala. Iter[T]как protocol — fits structural typing. Никакого специального механизма, обычный protocol с одним методом. Прецедент RustIterator-trait, OCamlSeq.t, Python__iter__.for x in cбез.iter()— стандарт mainstream. Kotlin, Swift, Python, C#, Rust (черезIntoIterator) — везде sugar. Только Go требуетrange-keyword.- AI-friendly.
for x in cкороче, чемfor x in c.iter(). Меньше boilerplate, меньше ошибок «забыл.iter()».
Что отвергнуто
- Range только в for-loop (текущая ситуация до D58). Ограничивает использование — нельзя передать range как аргумент, сохранить в переменную.
Rangeкак примитив языка (без Range-типа в stdlib). Полезно, но изоляция от системы типов хуже — нельзя добавить методы, написать функцию, принимающую Range.for x in cстрогое — только Iter[T] (без implicititer()сахара). Программист пишетfor x in v.iter()каждый раз, избыточно.for-inчерез специальный keyword (Gorange). Лишний синтаксис, нет преимущества над implicit iter через protocol.
Цена
- Range type в prelude. Расширение D26 (prelude растёт).
a..bкак expression. Парсер должен пониматьa..bв любой expression-позиции, не только в for. Лёгкая правка грамматики.for-in-сахар. Компилятор делает desugaringfor x in c→ выборc.iter()vs использованиеcнапрямую. Простое правило, но требует type-resolution.Iter[T]имя. Короткое, но конфликтует с потенциальными user-defined type’амиIter. Согласовано с D30 (типы PascalCase).
Связь
- 02-types.md → D42, D53
—
Iter[T]как обычный protocol через структурную типизацию. - D38 —
0..nкак range-выражение в существующем синтаксисе for-loop. - 08-runtime.md → D26 —
Iter[T],Range,RangeIterв prelude.
Открытые вопросы
- Reverse range (
5..0или(0..5).reverse()) — что значит range сstart > end? Пустой? Идущий назад? — открытый Q-range-extras. (0..5).step(n)— step-итерация. Q-range-extras.collect[Out]()generic-collection-construction — требует bound’ов (Q-bounds) и static-method-protocol. Q-collect-mechanism.- Type-as-value (передача типа как значения,
xs.collect([]int)) — отдельный вопрос Q-type-as-value. @-префикс в protocol-методах (симметрия с реализацией) — Q-protocol-method-prefix.Static-метод в protocol через— ✅ RESOLVED Plan 97 (2026-05-23). Leading-точка.method()-префикс.method(args) -> Retвprotocol {}теле помечает метод статическим (симметрично D35fn Type.name); реализация ожидается черезfn Type.method(...). Bare-имяmethod(args)остаётся instance (backwards-compat: все существующие протоколыIter/Hashable/Equatable/Comparable/Display/Into/TryIntoбез изменений).From/TryFromобновлены под новый синтаксис (.from(t T) -> Self/.try_from(t T) -> Result[Self,E]). Hard-enforcement static↔instance mismatch — followup.
D59. Array, tuple и позиционные partial patterns
Что
Pattern matching на массивах ([]T), кортежах ((A, B)) и
позиционных конструкторах sum (Cons(T, T')). Покрывает разрозненные
фичи, которые уже использовались в examples ([], [r],
[_, ..], Cons(..)), но не были формально зафиксированы.
.. (rest-pattern) — единый маркер «остальные элементы игнорируются»
во всех трёх контекстах: record ({ field, .. } — D17/D52),
позиционные конструкторы (Cons(..), Click(x, ..)), массивы
([head, ..], [.., last], [a, .., z]).
Правило
Array patterns
match xs {
[] => "empty" // пустой массив
[x] => "one: ${x}" // ровно 1 элемент, bind в x
[a, b] => "two: ${a}, ${b}" // ровно 2
[a, b, c] => "three: ..." // ровно 3
[head, ..] => "first: ${head}" // ≥1, bind первого
[.., last] => "last: ${last}" // ≥1, bind последнего
[a, .., z] => "first/last: ${a}, ${z}" // ≥2, bind первого+последнего
[_, ..] => "non-empty" // ≥1, без bind
[_, _, third]=> "exactly third" // ровно 3, bind третьего
_ => "other" // wildcard
}
Правила:
- Ровные позиции (
[a, b],[a, b, c]) — соответствуют точной длине. ..rest-pattern — означает «0 или больше элементов». Допустим в позициях:[items, ..]— head + остальное.[.., items]— остальное + last.[a, .., z]— head + middle (игнорируется) + last.
..itemsс биндингом — biind остатка как массива:match xs { [head, ..rest] => process(head, rest) // rest : []T [.., last] => last // без bind остального }_placeholder — игнорировать один элемент, точно как в record.- Не более одного
..в массиве-pattern — иначе ambiguous (Rust то же правило).
Tuple patterns
let p = (1, "alice", true)
match p {
(1, _, true) => "first variant"
(n, name, _) => "n=${n}, name=${name}"
_ => "other"
}
let (a, b, c) = (1, 2, 3) // destructuring let
let (x, _, z) = (1, 2, 3) // ignore middle
Правила:
- Tuple-pattern соответствует точно — длина фиксирована типом.
..в tuple запрещён (длина известна на этапе типизации,..не нужен).- Деструктуризация в
letчерез tuple-pattern — поддерживается.
Positional sum-variant partial-pattern
type LinkedList[T] | Empty | Cons(T, LinkedList[T])
match list {
Empty => "nil"
Cons(h, _) => "head only" // явный _ для tail
Cons(..) => "non-empty" // partial: оба поля игнорируются
Cons(h, ..) => "head: ${h}" // bind первого, остальное ..
}
type Event | Click(int, int) | Move(int, int, int) | Idle
match event {
Idle => "idle"
Click(..) => "click"
Move(x, ..) => "move at x=${x}"
Move(.., z) => "move with z=${z}"
_ => "other"
}
Правила:
..в позиционном конструкторе работает так же, как в массиве: head/tail/middle-rest.- Один
..на конструктор. - Согласовано с D17/D52 partial-pattern для record-форм.
Почему
- Используется в examples.
effect-density/repository.nv,orm_demo.nv,stdlib_linkedlist.nvуже активно применяют[],[r],[_, ..],Cons(..). Без формализации парсер не знает грамматику, LLM не знает правила, code review не имеет опоры. - Прецедент Rust. Array/tuple/sum-positional patterns в Rust
имеют точно такой синтаксис (
[],[head, ..],[.., tail],Variant(..)). Программисты с Rust-фоном узнают мгновенно. - Единый
..для всех partial-форм. Record (D17/D52), позиционный sum, массив — везде..означает «остальное игнорируется». Один концепт. - Tuple destructuring в
let— стандартная фича современных языков (Rust/Swift/Kotlin/Python).
Что отвергнуто
Cons(_, _)как единственная форма для позиционного sum. Шумно для конструкторов с 3+ полями (Move(_, _, _)). С..→Move(..).- Cons-list pattern (
head :: tail) для массивов, как в Scala/OCaml. Nova не имеет cons-семантики массивов —[]Tэто slice, не linked list. Используем bracket-syntax. - Multiple
..в одном pattern ([a, .., b, .., c]). Ambiguous — какое..сколько элементов берёт? Запрещено. ..в tuple-pattern. Длина tuple фиксирована,..не несёт информации. Запрещено для строгости.- Slice-binding
[head, ..rest]с типомrest : []T— частично отложено. Bind через..items(без значения по умолчанию) поддерживается. Расширения вроде[a, b, ..rest, c, d](rest в середине с bind) — не в MVP.
Цена
- Парсер усложняется — три новых формы pattern (array, tuple, positional-rest). Стандартное расширение, прецедент Rust.
- Exhaustiveness check для массивов сложнее. Длина
динамическая, компилятор не может проверить «все случаи покрыты»
как для sum-вариантов. Wildcard
_обязателен в array-match, если не покрыты все возможные длины (которых бесконечно). Это как в Rust. ..itemsslice-binding требует runtime-аллокации сегмента массива (rest : []T). В zero-copy случае —restэто slice (start, len). Согласовано с D32 (slice-семантика).
Связь
- D17, D52 — partial-pattern
..для record-форм. D59 расширяет на массивы и позиционные конструкторы. - D27 —
[]Tкак тип, на котором работают array-patterns. - D34 —
pattern-bind в условиях; array/tuple-patterns доступны и в
if let/while let. - Закрывает Q-positional-partial-pattern.
Открытые вопросы
[a, b, ..rest, c]— rest в середине с bind. Не в MVP.- Slice-bind на массиве с
[]int.alloc(...)vs zero-copy slice — деталь runtime, не дизайн. - String-as-array patterns (
match s { "hello" => ..., _ => ... }— strings как массивы char) — отдельный вопрос Q-string-patterns.
D60. Spread ...x в литералах: массив и record
Что
Оператор ... (три точки) внутри array- и record-литералов
вставляет элементы/поля из существующего значения. Двойственная
к D59 partial-pattern: D59 разбирает, D60 строит.
let arr1 = [1, 2, 3]
let arr2 = [0, ...arr1, 4] // [0, 1, 2, 3, 4]
let user1 = User { id: 1, name: "alice", email: "a@x.com" }
let user2 = { ...user1, name: "bob" } // copy + override name
Правило
Array spread
let a = [1, 2, 3]
let b = [4, 5]
let c = [...a, ...b] // [1, 2, 3, 4, 5]
let d = [0, ...a, ...b, 6] // [0, 1, 2, 3, 4, 5, 6]
let e = [...a] // копия (не reference)
Правила:
- Источник
...srcдолжен быть[]T, гдеTсовпадает с типом элементов целевого массива. - Несколько spread’ов в одном литерале разрешены:
[...a, ...b, ...c]. - Смешивание spread и обычных элементов — в любом порядке:
[1, ...a, 2, ...b, 3]. - Стоимость: O(total length) — концептуально concatenation. Компилятор может оптимизировать (пред-аллокация по сумме длин).
Record spread
type User { id u64, name str, email str, role str }
let alice User = { id: 1, name: "alice", email: "a@x.com", role: "user" }
// Override одного поля:
let alice2 = { ...alice, name: "ALICE" }
// Override нескольких:
let admin_alice = { ...alice, role: "admin", email: "admin@x.com" }
// Все поля из spread — то же значение:
let copy = { ...alice } // эквивалентно alice (но новый record)
Правила:
- Источник
...srcдолжен быть того же типа, что и target (или иметь совпадающее множество полей). - Override: явные
field: valueпосле...srcперезаписывают значения из spread. Порядок в литерале — left-to-right.let r = { ...src, name: "new", ...override, id: 99 } // ↑ ↑ ↑ ↑ // src.все override("name") override.все override("id"=99) - Все required-поля должны быть покрыты — компилятор проверяет. Если spread + явные не дают полного покрытия — ошибка.
- Один spread на record-литерал в MVP.
{ ...a, ...b }— отложено (нужны правила приоритета). - Тип источника: в MVP — строго тот же тип, что target. В будущем — может быть подтип/совпадение по полям (требует structural-subtyping, Q-anonymous-union).
Совместимость с D52 literal coercion
type User { id u64, name str }
let u User = { id: 1, name: "alice" } // D52 record-coercion
let u2 User = { ...u, name: "bob" } // D60 spread + D52 coercion
let u3 User = { ...u } // полный copy через spread
В позиции с явным целевым типом spread работает с D52-coercion: имя типа подразумевается из аннотации.
Совместимость с D17/D52 field punning
let name = "bob"
let u User = { ...other, name } // shorthand + spread
Field punning (D52) работает после spread — если имя поля совпадает с переменной в scope, shorthand обязателен.
Почему
-
Immutable update. В функциональном стиле (доминирующем в Nova:
mutчерез эффект, GC по умолчанию) immutable-обновление record — частая операция. Без spread:let u2 = User { id: u.id, name: "bob", email: u.email, role: u.role }С spread:
{ ...u, name: "bob" }. Краткость + защита от ошибок (если вUserдобавилось поле, программист не должен обновлять каждый use-site). -
Concatenation массивов.
[head, ...rest]— элегантнее[head].concat(rest)или ручного цикла. -
Прецедент TypeScript.
...spreadмассово используется в современном TS/JS. Программисты знают. -
Симметрия с D59 partial-pattern. D59 разбирает значение через
.., D60 строит через.... Концептуально — две стороны одной медали. Разные токены (..vs...) убирают синтаксическую путаницу. -
AI-friendly. LLM генерирует
{ ...other, name: "bob" }— очевидное намерение, нет boilerplate.
Что отвергнуто
..(две точки) для spread (Rust struct-update style). Конфликт с range-литералом (D58) и rest-pattern (D59). Парсер мог бы различать по контексту, но...(три точки) однозначен и согласован с TS-прецедентом.*arr/**obj(Python-style). Два разных оператора для array vs record — лишнее. Один...для всего.{ src with name = "bob" }(OCaml-stylewith-keyword). Новый keyword, менее знакомый, не симметричен с array-spread.- Multiple record-spread
{ ...a, ...b }в MVP. Семантика «правый перезаписывает» интуитивна, но требует продумать edge-cases (что если поле есть в обоих и target требует один тип — компилятор должен проверить). Отложено до measured-need. - Spread в pattern-position (
match xs { [1, ...rest, 5] => ... }). D59 уже даёт[head, ..rest]через две точки — отдельный механизм для destructuring....остаётся только для construction. - Spread с подтипом. В MVP target и source строго одного типа. Расширение — Q-spread-subtype.
Цена
- Парсер расширяется —
...exprв array/record литералах. Стандартное расширение, прецедент TS. - Type-checker проверяет покрытие required-полей при spread в record. Не сложнее, чем уже есть для D55 literal coercion.
- Runtime cost array-spread — O(total length). Программист знает (концептуально concat).
- Runtime cost record-spread — O(field count) копирование полей. Минимально, по аналогии с обычным record-литералом.
Связь
- D52 — record-coercion. D60 расширяет: spread в позиции с явным типом тоже coerce’ится.
- D17/D52 field punning —
{ ...src, name }shorthand работает после spread. - D58 —
..(две точки) для range. D60 использует...(три точки) для spread — разные токены, нет конфликта. - D59 —
partial-pattern
..в destructuring. D60 — spread...в construction. Двойственные операции, разные синтаксисы. - D27 —
[]Tкак тип, на котором работает array-spread.
Открытые вопросы
- Multiple record-spread (
{ ...a, ...b, ... }) — отложено. - Spread с подтипом/совпадением полей — Q-spread-subtype.
- Spread в tagged template literal args — нет в MVP, не нужен.
- Tuple-spread (
(1, ...t, 5)) — длина tuple фиксирована типом, spread даёт компилятору всю информацию. Не вводится в MVP за ненадобностью.
D69. Variadic-параметры через ...items []T
Что
Последний параметр функции может быть помечен префиксом ... —
параметр объявляет, что на call site его можно вызвать одним из
двух способов:
- Через spread существующего массива:
f(...arr). - Через отдельные элементы:
f(a, b, c)— компилятор соберёт их в[]T.
Тип параметра — обычный []T. Внутри функции items это []T,
никакой специальной семантики.
Правило
Декларация
fn print[T](...items []T) Io -> () {
for x in items { // items: []T внутри функции
Io.write(str.from(x))
}
}
fn fmt(template str, ...args []str) -> str {
// template — обычный параметр; args — variadic []str
...
}
Грамматика:
param = [ '...' ] name type
... допустим только перед последним параметром. Тип после ...
обязан быть []T (или []Type любой формы) — не element type.
Call site
// Способ 1: spread массива
let names = ["alice", "bob"]
print(...names) // эквивалентно print("alice", "bob")
// Способ 2: отдельные элементы
print("alice", "bob") // компилятор собирает в ["alice", "bob"]
// Микс — spread в любой позиции после обычных аргументов
print("prefix", ...names, "suffix")
// ↑ ↑ ↑
// обычный spread обычный
// → результат: ["prefix", "alice", "bob", "suffix"]
Spread на call site можно использовать только для variadic-параметра.
Для обычного items []T параметра spread не разрешён —
программист передаёт массив явно: f(["a", "b"]).
Семантика
...items []Tв декларации — это синтаксический marker, не новый тип. Типitemsэто[]T.- На call site spread
...arrразворачиваетarr: []Tв позиционные аргументы. - Без spread’а: компилятор собирает все аргументы в
[]Tнеявно (compile-time, zero overhead). - Только последний параметр может быть variadic — упрощает парсинг и неоднозначности.
- Type checking: каждый аргумент проверяется против element type
T; spread-выражение должно иметь тип[]T.
Generic-variadic
fn first[T](...items []T) -> Option[T] {
if items.len() == 0 { None } else { Some(items[0]) }
}
first(1, 2, 3) // T = int
first("a", "b") // T = str
first(...["x", "y"]) // T = str через spread
T выводится из элементов или spread-массива.
Heterogeneous-variadic через any
Когда нужен print("count=", 42, " items") (разные типы):
fn print(...items []any) Io -> ()
any — top-type из D54. Каждый элемент конвертируется в
строку через str.from(v) (D73). Это разрешает
print принимать смешанные типы без T-параметра.
Что НЕ делается
- Variadic не последним параметром (
fn f(...xs []int, last str)). Усложняет грамматику без выгоды; в крайнем случае программист переставляет параметры. - Несколько variadic-параметров — нет смысла.
- Keyword args (Python
**kwargs) — отдельная фича, не нужна для variadic use-case. - Postfix-синтаксис как в Go (
items ...string). Префикс...единый для всех spread’ов в Nova (D60 для массивов, D69 для variadic) — symmetric. - Element-type как в Go (
...items T). Декларация показала бы «items: T» с magic-преобразованием в []T. Nova предпочитает явный array-type без скрытой обёртки.
Почему
- D60 symmetry. В литералах массивов уже используется prefix
...arrдля spread. Variadic-call-spreadf(...arr)— та же форма. - D40 «один способ». Нет «двух типов в одной декларации»
(element vs array как в Go). Тип параметра =
[]T, конец. - TypeScript-прецедент. Самый популярный variadic-синтаксис в современных языках, LLM знает.
- AI-friendly. Сигнатура
(...items []T)сразу показывает:...→ variadic;[]T→ точный тип параметра;- element type выводится естественно.
- Минимальные изменения грамматики. Парсер уже распознаёт
...в spread-литералах (D60). Расширение на параметры функции — маленькое дополнение.
Что отвергнуто
- Без variadic вообще (всегда явный
f([a, b, c])). Отвергнуто: частые отладочныеprint(...)стали бы шумнее. Variadic — конкретное улучшение DX. - Macro-style (
println!-как-в-Rust). Отвергнуто: у Nova нет macro-системы; добавлять её только ради variadic — overkill. - Variadic через Java-style autoboxing (
Object...). Отвергнуто: no implicit boxing в Nova; используемanyявно.
Связь
- D60 — spread
...arrв литералах массивов и record’ов; D69 распространяет на параметры функций. - D54 —
anyдля heterogeneous-variadic. - D27 —
[]Tкак тип параметра. - 08-runtime.md → D26 —
print/printlnтеперь имеют сигнатуруfn print(...items []any) Io -> ().
Эволюция
Bootstrap-stdlib изначально имел print как Native-функцию принимающую
переменное число аргументов (Rust-side &[Value]), но в спеке D26
определял fn print(s str) — fixed arity 1. Это был drift между
implementation и spec.
D69 фиксирует variadic как полноценную фичу языка и приводит сигнатуру
print к fn print(...items []any) Io -> ().
D83. Keywords строго запрещены как identifier’ы
Что
Зарезервированные слова языка (fn, type, let, mut, if, for,
while, in, match, use, import, export, и др.) не могут
использоваться как имена переменных, полей, параметров, типов,
методов, импортов или любых других user-defined identifier’ов.
Никаких escape-механизмов не предусмотрено.
Закрывает Q-keywords-as-fields вариантом 1 (строгий запрет).
Правило
Полный список зарезервированных слов
Декларации: module, import, use, export, external, fn,
type, protocol, effect, handler, alias.
Bindings: let, const, mut, readonly.
Control flow: if, else, match, for, while, loop, in,
return, break, continue.
Effects/concurrency: with, throw, interrupt, forbid,
realtime, spawn, supervised, parallel, detach, blocking,
select.
Cleanup (D90): defer, errdefer.
Operators (как слова): as, is, and, or, not.
Литералы: true, false.
Test: test.
Special: Self (D66), _ (wildcard / discard).
Что запрещено
// все следующие — compile error «expected identifier, got keyword `X`»
let if = 5 // ✗
let mut while = 0 // ✗
type Queue[T] {
in []T // ✗ — «expected identifier, got `in`»
}
fn process(match int) -> int => // ✗ — параметр не может быть `match`
match * 2
fn export() -> int // ✗ — `export` зарезервировано
import std.use // ✗ — `use` в module path
Что разрешено
Зарезервированные identifier’ы (D26 prelude — Self, any,
never, Option, Some, None, Result, Ok, Err, Error,
int, f64, etc.) — это обычные имена в prelude scope, не
keyword’ы. Программист может переопределить локально (см.
overview.md «Зарезервированные identifier’ы»),
но это анти-паттерн (lint выдаёт warning).
let int_array []int = [1, 2, 3] // ✓ — `int_array` обычный identifier
fn shadow() {
let int = "string" // ⚠️ shadow's prelude name (warning, не error)
println(int)
}
Контекстуальные keywords — отвергнуто
Альтернатива из Swift/C# (async, var, dynamic контекстные —
keyword только в специфичных позициях, иначе обычные identifier’ы)
не принимается в Nova. Все keyword’ы — глобально зарезервированы.
Escape-механизм (r#identifier, `identifier`) — отвергнуто
Альтернативы:
- Rust-style
r#fn— raw identifier черезr#префикс. - C#-style
@class— verbatim identifier. - Swift/Kotlin
`class`— backticks.
В Nova сейчас не предусмотрены. Программист переименовывает поле/переменную если оно конфликтует с keyword.
Когда может появиться: если накопится боль FFI с C-библиотеками
у которых функция называется match, или ORM/JSON-данные с keyword-
полями. До v1.0 — не вводим, после v1.0 — отдельный D-decision
(вероятно r#identifier Rust-style).
Backtick’и `...` в Nova уже заняты для tagged template
literals (D48 raw strings) — Swift-style `identifier` создаст
конфликт.
Почему
-
Простота парсера. Один-проход рекурсивного спуска, никакого lookahead’а для разрешения «keyword vs identifier».
-
AI-friendly. LLM никогда не путается между keyword и identifier. Никаких escape-форм для запоминания.
-
Читаемость. Программист видит
if— control flow. Видитclass— class. Никакихifкак имени переменной. -
Прецедент мейнстрима. Java, Go, C, Python — все строго запрещают. Default ожидание программиста.
-
Future-proof по версии. Без escape — добавление нового keyword’а это явный breaking change, программист видит compile error и переименовывает (как Rust 2018/2021 editions).
Что отвергнуто
-
Контекстуальные keywords (Swift/C# style). Сложнее парсер, AI-unfriendly. Прецедент Swift: contextual keywords постепенно становятся глобальными.
-
r#identifier(Rust-style). Полезен для FFI, но не приоритет в bootstrap’е. Можно добавить позже без breaking change. -
@identifier(C#-style). В Nova@занято (D35 self-method/field). -
`identifier`(Swift/Kotlin). Backtick’и заняты для raw strings (D48). Конфликт. -
Только-в-полях ослабление (например
mut in []Tразрешено посколькуinконтекстный дляfor x in iter). Отвергнуто — специальное правило для одного keyword’а нарушает D9.
Связь
- Q-keywords-as-fields — закрывается этим D-decision.
- D29 — module/import grammar.
- D30 — naming convention. D83 — жёсткое правило поверх D30.
- D48 — backtick’и заняты.
- D26 — prelude names — это identifier’ы, не keyword’ы.
Цена
- Sweep
std/collections/queue.nv— полеin []Tпереименовать вinputилиinputs. - Будущая FFI работа будет требовать обёртки если C-функция называется так же как Nova-keyword. Не блокер.
Эволюция
До D83 вопрос был open в Q-keywords-as-fields с тремя вариантами. D83 закрывает вопрос окончательно — Java/Go/C/Python style строгий запрет, без escape.
Если когда-либо в будущем (v1.0+) накопится FFI-боль — отдельный
D-decision вводящий r#identifier Rust-style. До v1.0 — строгий
запрет без escape.
D88. Default-значения generic-параметров
Что
Generic-параметры могут иметь default-значение через [T = Default]
или с bound’ом [T Bound = Default]. Default используется когда
компилятор не может вывести параметр из аргументов и программист не
указал его явно.
Закрывает Q-default-generic.
Триггер принятия — D87 (Effect[E, IRT = never]).
Правило
Базовый синтаксис
type Complex[T = f64] {
re T
im T
}
// Старые вызовы продолжают работать без [T]:
let z = Complex.from(2.0) // T выводится как f64 (из default + arg)
let z Complex = Complex.new(1.0, 2.0) // тип Complex без скобок ≡ Complex[f64]
// Новые — с явным параметром:
let z32 Complex[f32] = Complex.new(1.0_f32, 2.0_f32)
С bound’ом
fn run[T Numeric = int](a T) -> T => a + 1
run(5) // T = int (вывод из аргумента)
run(5.0) // T = f64 (вывод из аргумента)
run[i64](5) // T = i64 (явно)
Грамматика для одного параметра: name [bound] [= default].
Семантика
| Случай | Что происходит |
|---|---|
Аргументы дают информацию о T | Inference побеждает default |
Аргументов нет / T не выводится / нет явной аннотации | Используется default |
Программист указал [T_value] явно | Default игнорируется |
fn first[T = int](xs []T) -> Option[T] { ... }
first([1, 2, 3]) // T = int (вывод из []int)
first[]([]) // ERROR: empty array, T не выводится
// default не применяется (тип элемента
// не из argument-type)
first[str]([]) // T = str (явно)
Несколько параметров
Параметры с default’ом должны идти после обязательных:
type HashMap[K, V, S = DefaultHasher] { ... } // ✅
type Bad[T = f64, U] { ... } // ❌ обязательный после default'а
Все default’ы могут быть опущены частично:
let m HashMap[str, int] = ... // S = DefaultHasher
let m HashMap[str, int, FxHasher] = ... // S явно
Default — это тип, не выражение
type X[T = f64] { ... } // ✅ default = тип
type Y[N = 10] { ... } // ❌ const-generic — отдельная фича, не входит
В D88 default — только тип. Const-generic (значения как параметры типа) — отдельная задача, не покрывается.
Default через bound
type Sorted[T Ord = int] { ... } // T должен реализовать Ord; если не указан — int
fn sort[T Ord = int](xs []T) -> []T => ...
Default-тип должен удовлетворять bound’у — компилятор проверяет это при объявлении.
Почему
- Backward-compat. Добавление generic к существующему типу/функции
= breaking change без default’ов. С default’ами — ноль ломаний:
// Раньше: type Complex { re f64, im f64 } // Теперь generic, но старый код работает: type Complex[T = f64] { re T, im T } let z = Complex.from(2.0) // ← без правок - Default — не выбор для программиста. Это сокращённая запись, не два пути с разной семантикой. Нарушения D9 «один очевидный путь» нет — программист либо не пишет параметр (получает default), либо пишет (получает явное значение).
- Прецеденты: Rust (
Vec<T, A: Allocator = Global>), C++ (template<typename T = int>), TypeScript (Foo<T = string>). - Realistic consumer. D87
Effect[E, IRT = never]— главный практический use-case в Nova prelude.
Что отвергнуто
[T default int]keyword-форма — длиннее, без выгоды.- Const-generic в default’е (
[N = 10]) — отдельная фича, отложена. - Forward-references в default’е (
[T = SelfType]) — запрет: тип должен быть уже объявлен в момент парсинга generic-списка. - Default-параметры функции (
fn f(x int = 0)) — отдельная задача и отвергнута (history/rejected.md) в пользу опции-record + spread. D88 касается только generic-параметров типа.
Связь
- D16 — синтаксис
[T]. - D72 — generic bounds (
[T Hashable]); D88 расширяет до[T Hashable = SomeDefault]. - D52 — newtype/alias; D88 дополняет alias-механику (alias для конкретной инстанции, default — для самой частой).
- D87 —
Effect[E, IRT = never]главный consumer.
Эволюция
Зафиксировано 2026-05-10. Раньше — открытый вопрос
Q-default-generic, помечен
DEFERRED до появления реального consumer’а. Триггер — D87
параметризация Handler interrupt-типом.
Migration: ~10 примеров Effect[E] в spec/, где требуется
Effect[E, IRT] для interrupt-делающих handler’ов. См.
D87 миграция.
D90. defer и errdefer — scope-level cleanup statement
Закрывает Q20 «Нужен ли defer?».
Что
Два keyword-statement’а для отложенного выполнения при выходе из текущего scope’а:
defer <body>— выполнить<body>при любом exit’е из enclosing scope (normal flow,return,throw,interrupt, panic).errdefer <body>— выполнить<body>только при exit’е через ошибку (throw/panic). При normal exit илиreturnerrdeferне выполняется.
Назначение — детерминированный cleanup (close, unlock, rollback) в языке без RAII-destructor’ов (D6 managed heap — нет detrministic destruction; см. цена D6).
Правило
Грамматика
statement = ...
| 'defer' body
| 'errdefer' body
body = expression
| block // { stmt1; stmt2; ... }
body — обычное выражение или block. Никаких params, никаких
=> — это statement, не closure.
Примеры
Простой defer:
fn read_config(path str) Fs Fail -> Config {
let file = Fs.open(path)
defer file.close() // выполнится на exit из fn
let raw = file.read_all()
Config.parse(raw)
}
Block-form:
fn process() Db Log -> () {
defer {
Log.info("done processing")
Metrics.record_completion()
}
Db.exec(...)
}
Несколько defer — LIFO (последний defer’нутый — первый выполнится):
fn nested() Fs -> () {
defer println("3") // выполнится последним
defer println("2")
defer println("1") // выполнится первым
// exit prints: 1, 2, 3
}
Scope-level (не function-level):
fn process() Fs Log -> () {
let log_file = Fs.open("app.log")
defer log_file.close() // выход из fn
if condition {
let temp = Fs.create_temp()
defer temp.cleanup() // выход из if-блока
write_to(temp)
} // <- здесь выполняется temp.cleanup()
// <- здесь выполняется log_file.close() при exit из fn
}
errdefer — откат при ошибке:
fn create_user(data UserData) Fail[Db] Db -> User {
let user = Db.insert_user(data)
errdefer Db.delete_user(user.id) // откат если что-то дальше упадёт
let profile = Db.insert_profile(user, data)
errdefer Db.delete_profile(profile.id)
Db.send_welcome(user.email) // если throw — оба delete сработают
// в LIFO порядке (delete_profile, потом delete_user)
user // normal exit — errdefer'ы НЕ выполняются
}
Комбинированно — defer + errdefer:
fn transaction() Fail Db -> Receipt {
Db.begin()
defer Log.info("transaction finished") // ВСЕГДА
errdefer Db.rollback() // только при throw
let r = do_work()
Db.commit()
r
}
// normal exit: Db.commit() → Log.info(...)
// throw exit: Db.rollback() → Log.info(...)
Семантика
1. Scope-level. defer/errdefer привязаны к enclosing
block (function body, if/else branch, for body, with-block,
supervised-body, etc.). Выполняются при exit’е именно этого scope’а.
2. LIFO order. Несколько defer’ов выполняются в обратном
порядке регистрации (последний defer — первый выполняется).
3. Eager argument evaluation. Аргументы defer-выражения
вычисляются в момент defer, тело — откладывается:
let i = 5
defer println(i) // i = 5 захвачено сейчас
let i_new = 100 // другая переменная (immutable)
// exit prints: 5
Для mut-переменной с теми же captures-правилами:
let mut counter = 0
defer println(counter) // counter — захвачен по reference (как closure)
counter = 42
// exit prints: 42
Это симметрично closure-семантике D32 (managed heap, mut-captures through reference).
4. Defer body — Fail-allowed с composition (amended by D158,
Plan 100.4.1, 2026-05-23). Тело defer/errdefer может иметь
Fail[E]-эффект; cleanup-failure композируется с propagating error через
Plan 49 multi-error infrastructure. Enclosing fn-sig обязан declare
Fail[E'] с совместимым E ⊆ E'.
fn process() Fail[CommitErr] -> () {
consume tx = begin()
defer { tx.commit() } // ✅ Fail[CommitErr] body
do_work()? // throws WorkErr
// composite: { primary: WorkErr, suppressed: [CommitErr] }
}
Если defer body имеет Fail[E], но enclosing fn-sig не declares Fail —
compile error D158-defer-fail-not-in-sig. Это force’ит explicit
visibility cleanup-fail в API.
Backward-compat: handler-wrap pattern продолжает работать как opt-in shorthand для silent-suppress:
defer {
with Fail = handler {
fail(e) { Log.error("cleanup failed: ${e}"); interrupt () }
} {
risky_cleanup() // Fail caught в inner with
}
}
Подробно — composition rules, MultiError API, diagnostic format — D158.
Historical (pre-D158, Plan 20 Ред. 1): body было infallible —
любой Fail[E] в defer body выдавал compile error. Programmer обязан
был ручной handler-wrap. D158 (Plan 100.4.1) снял это ограничение,
сохранив compile-time visibility через required fn-sig Fail[E']
declaration. Скрытого поглощения ошибок по-прежнему нет: cleanup-fail
видна either как composite-error caller’у, либо через explicit handler-
wrap внутри defer.
5. Defer body — suspend allowed (amended by D159, Plan 100.4.2,
2026-05-23). В теле defer/errdefer разрешены suspend-операции:
Time.sleep, Net.*, Fs.*, Db.*, Channel.recv — для production
graceful cleanup (socket close с FIN+ACK, DB drain, async commit).
Запрещены только AST-level concurrency constructs: spawn,
parallel for, supervised, detach, blocking — они leak supervised
hierarchy (новый fiber переживает scope cleanup’а). Это compile error
E (D159-spawn-in-defer).
Cancel-safe semantics (D159): runtime обеспечивает что cleanup
completes-then-propagates cancel. Programmer должен использовать
Time.timeout(d) { ... } (Plan 22) для bounded cleanup.
Historical (pre-D159, Plan 20 Ред. 1): body было no-suspend —
любая suspend operation в defer выдавала compile error. Programmer
обязан был ручной with Time.timeout обёртка. D159 (Plan 100.4.2)
снял ограничение для production-grade async cleanup.
6. Top-level return / break / continue / interrupt в defer-body —
запрещены (Вариант 3 — Plan 20 Ф.3 revised). Нельзя hijack scope-exit
окружающей функции/цикла через defer — defer сам часть exit-процесса.
Локальный control разрешён, только внутри вложенных конструкций:
return— разрешён внутри nested fn-литерала в defer body (returnлокален к этому fn-литералу, не к enclosing fn).break/continue— разрешены внутри nested loop (for/while/loop) в defer body (локальны к этому loop’у, не к enclosing).interrupt— всегда запрещён на любом уровне (hijack scope-exit с-effect-block’а; не failable cleanup).throw/?/!!— разрешены (D158, Plan 100.4.1) если enclosing fn-sig объявляетFail[E]; cleanup-fail композируется через Plan 49 multi-error (см. пункт 4 и D158).
defer {
for x in items {
if x.bad { break } // ✅ local break в nested loop
}
return 0 // ❌ top-level return — hijack scope exit
}
defer {
let cleanup_fn = || {
if early_done { return } // ✅ local return в nested fn-literal
do_more()
}
cleanup_fn()
}
Type-check: DeferBodyCtx { loop_depth, fn_depth } инкрементируется
при заходе в nested loop/fn-literal; проверка > 0 на каждом
return/break/continue.
7. errdefer запускается на:
throw err(любойFail[E]).panic(msg)— пока fiber не умер.interrupt v— нет, это normal control flow (с точки зрения errdefer scope’а — exit «успешный»).exit(code, msg)— нет, exit гасит процесс без cleanup’ов (D13).
8. defer запускается на:
- Normal exit (последнее выражение block’а вычислено).
return.throw err.panic(msg)— пока fiber не умер.interrupt v— да (exit scope’а, неважно как).exit(code, msg)— нет (D13: exit без cleanup’ов).
Почему
Зачем нужен defer в Nova
В Nova нет deterministic destructor’ов (D6:
managed heap + GC). RAII Rust/C++ невозможен. Без defer resource
cleanup (file.close, unlock, rollback) пишется через handler-блоки
с copy-pasted error-paths:
// Без defer — verbose:
fn create_user(data UserData) Fail Db -> User {
let user = Db.insert_user(data)
let mut profile_id Option[int] = None
with Fail = effect Fail {
fail(e) {
if let Some(pid) = profile_id { Db.delete_profile(pid) }
Db.delete_user(user.id)
throw e
}
} {
let profile = Db.insert_profile(user, data)
profile_id = Some(profile.id)
Db.send_welcome(user.email)
}
user
}
Десятки строк boilerplate. С defer/errdefer — 6 строк
(см. пример выше). Это значительная экономия.
Прецеденты
| Язык | Конструкция | Scope-level? | errdefer? |
|---|---|---|---|
| Go | defer expr | function-level | нет |
| Swift | defer { body } | scope-level | нет |
| Zig | defer expr; errdefer expr | scope-level | да |
| D | scope(exit/success/failure) expr | scope-level | да + extra |
Nova берёт Zig-style: scope-level + errdefer. Не function-level
(Go), потому что Nova имеет вложенные scope’ы с богатой семантикой
(if, for, with, supervised) — function-level
ограничивал бы. Не D-style scope(success) — редко нужно, можно
писать обычным кодом перед exit’ом.
Почему scope-level, не function-level
Function-level (Go) накапливает все defer’ы в стеке функции:
func f() {
if cond {
temp := create()
defer temp.cleanup() // выполнится в КОНЦЕ func, не на exit if
}
long_running_work() // temp висит всё это время
}
В Nova scope-level позволяет локальный cleanup, что часто естественнее.
Почему eager argument evaluation
Если бы аргументы вычислялись lazy:
let mut i = 0
defer println(i)
i = 42
// exit: print 42 (хотел печатать 0?)
Это regular для closure-семантики, но сюрприз для programmer’а ожидающего «defer фиксирует значение тогда же».
Eager arguments + lazy closures (через captures) — баланс. Это путь Go (которому 15 лет программистской практики симпатизируют).
Почему failable body + composition (а не infallible — historical)
Plan 20 Ред. 1 (2026-05-11) выбрал infallible body. D158 (Plan 100.4.1, 2026-05-23) revised к failable + composition. Аргументы.
Допустим, defer-body может падать:
fn process() Fail[CommitErr] -> () {
consume tx = begin()
defer { tx.commit() } // commit may fail
do_work()? // throws WorkErr
// exit: WorkErr propagating → defer fires → commit throws CommitErr → ???
}
Языки решают по-разному:
- Rust: panic-in-Drop =
abort()процесса. Безопасно, но programming совершенно непрактичен —tx.rollback()который может fail = abort. - Go: defer возвращает error через named return — manual handling, легко пропустить. На практике все игнорируют.
- TS (ES2024) / Java:
Symbol.dispose/close()throws → compositeSuppressedError/addSuppressed()chain. Структурированно, caller видит весь chain.
Nova D158 выбрал TS/Java-подход: composition через MultiError chain.
Plan 49 multi-error infrastructure уже даёт kinded throws + typed payload;
D158 добавляет nv_compose_suppressed для chain append’а и MultiError
prelude type для caller-side inspection.
Visibility сохранена через fn-sig: enclosing fn-sig обязан declare
Fail[E'] где E ⊆ E' для defer body. Без этого — compile error
D158-defer-fail-not-in-sig. Это сильнее Go/TS (которые не enforce’ят
visibility в сигнатуре), сравнимо с Java checked exceptions, но без
их verbosity — Fail[E] уже часть base effect-system.
Backward-compat: handler-wrap pattern сохраняется как opt-in shorthand для silent suppress (см. пункт 4 example).
Почему suspend allowed (а не no-suspend — historical)
Plan 20 Ред. 1 (2026-05-11) запретил suspend в defer body argument’ируя
«cleanup быстрый». D159 (Plan 100.4.2, 2026-05-23) revised: production
cleanup ОБЯЗАН suspend — graceful socket close с FIN+ACK, DB drain через
Channel.recv, async transaction commit. Без suspend programmer вынужден
делать leak-y fire-and-forget cleanup.
D159 решение: suspend allowed, но:
spawn/parallel for/supervised/detach/blocking— запрещены (leak supervised hierarchy: новый fiber переживает scope cleanup’а).- Programmer отвечает за bounded cleanup через
Time.timeout(d) { ... }(Plan 22 sleep-libuv-integration). - Runtime обеспечивает cancel-safe semantics: cleanup completes
before cancel-propagation (production-grade — Plan 100.4.2 followup
[M-100.4.2-cancel-shielding]для full runtime enforcement; в bootstrap defer runs after throw, cancel-as-throw тоже triggers cleanup).
Что отвергнуто
- Function-level defer (Go-style) — слабее scope-level, ограничивает локальный cleanup.
successdefer(Dscope(success)) — редкий case, обычный код перед exit покрывает.deferбезerrdefer—errdeferкритичен для transactions, без него boilerplate тот же что и безdefer. Включаем сразу.- Lazy argument evaluation — surprise factor, eager — стандарт Go/Swift/Zig/D.
- Failable defer body banned-as-such — first revision (Plan 20)
запретила Fail в defer body absolutely. Revised D158 (Plan 100.4.1):
failable body разрешён с composition через Plan 49 multi-error chain
(
MultiError); fn-sig обязан declareFail[E']. См. пункт 4. defer return X— нельзя hijack exit-значение через defer.recover(Go) — поглощение panic из defer. Сложная семантика, не нужно в Nova (panic — смерть fiber’а, D13).
Связь
- D6 — managed heap без RAII, мотивирует
потребность в
defer. - D13 —
panic/exitсемантика.deferвыполняется при panic пока fiber жив; не выполняется приexit(D13: exit гасит процесс без cleanup’ов). - D22 — closure семантика; defer использует те же mut-capture правила.
- D32 — managed-heap captures, base для defer captures.
- D85 —
?/!!; в теле defer запрещены (требуютFail, defer body infallible). - D91 — Channel revision; defer
tx.close()— main use-case для defer в concurrency. - Q20 — закрыто этим D-блоком.
Bootstrap-status
-
✅ Реализовано (Plan 20, 2026-05-11). Все 7 фаз закрыты:
- Ф.1 Лексер: keyword’ы
defer/errdefer(commit 75673d7). - Ф.2 Парсер + AST:
Stmt::Defer { body },Stmt::ErrDefer { body }(commit 380b457). - Ф.3 Type-checker constraints (revised: Вариант 3, local control
разрешён, commit fdb53be + 3faf9f0):
throw/?/!!/interrupt/suspend-effects — всегда запрещены.return/break/continue— запрещены только на top-level defer body; внутри nested fn-литерала/loop — разрешены.
- Ф.4 Codegen: per-scope DeferScope с активационными флагами; NovaFailFrame setjmp wrapper для errdefer throw-path с longjmp re-throw; integration во все emit_block_* paths; early-exit cleanup для return/break/continue (commits 94151c3 + b058968).
- Ф.5 Interp: per-scope defer-stack, LIFO invocation, errdefer skip non-error exit (commit c96f7f3).
- Ф.6 Positive-тесты: defer_basic.nv, errdefer_basic.nv, errdefer_throw.nv (interrupt handler).
- Ф.7 Spec uplift: текущий блок.
- Ф.8 Production-grade hardening (2026-05-11, commits e04ca85d
- 61af5af4 + 007bb9ba + d913aa08 + 33c1e050):
- (1) Type-check enforcement D61 §1430-1434: handler-method для
эффект-операции с return type
neverОБЯЗАН закончитьсяinterrupt/throw/panic/exit. Static analysis вcheck_handler_never_ops+ helpers (expr_diverges,block_diverges). Покрывает Fail.fail + user-defined effects с never-методами. - (2) Defer/errdefer на interrupt-path: codegen эмитит local
NovaInterruptFrame setjmp wrapper аналогично fail-frame.
На interrupt — invoke только
defer(skiperrdefer— это handled exit), pop interrupt-frame, re-interrupt с тем же value. - (3) Loop/branch body defer integration: while/loop/while-let/ for-in-array/for-in-iter/else-branch/match-arm — все эмитят defer scope (раньше только for-range body был покрыт).
- (4) D65 правило 3 (re-throw): NovaVtable_Fail.prev = outer handler; Nova_Fail_fail на время handler-body invocation swap’ает _nova_handler_Fail = current->prev, восстанавливает после. Throw в handler-body dispatch’ится на outer (skip current frame — нет infinite recursion).
Ф.8 positive-тесты:
syntax/defer_in_blocks.nv(9 кейсов) — defer внутри while/loop/for-in-array body, else-branch, match-arm-block, nested defer scopes (LIFO между inner/outer).syntax/errdefer_rethrow.nv(3 кейса) — re-throw из inner handler → outer (1-level и 3-level); errdefer + outer interrupt → errdefer корректно skip.syntax/defer_on_interrupt.nv(4 кейса) — defer fires на interrupt-path; errdefer skip; defer+errdefer combo; LIFO для multiple defer’ов.
Ф.8 negative-тест:
negative_capability/fail_handler_no_exit_rejected.nv— handlerfail()без exit-control → compile error.
Все 12 positive + 6 negative defer-relevant тестов PASS. 10/10 effects + 17/17 concurrency без регрессий после Ф.8.
- Ф.1 Лексер: keyword’ы
Известные ограничения
- Suspend (Db/Net/Fs/Time/spawn) в defer body — compile error (Ф.3). Это spec-compliant strict ограничение, не gap.
exit(code, msg)не запускает defer’ы (D13: exit гасит процесс без cleanup’ов) — by design.- Cleanup на
panic(msg)— для bootstrap’а purposefully простой: если fiber жив, defer тоже срабатывает через fail-frame longjmp-path (panic dispatch’ится через nova_throw).
D102. Именованные аргументы и значения параметров по умолчанию
Status: active (spec). Базовая реализация — Plan 46 (закрыт). Ревизия «дефолт → keyword-only» (2026-05-15) — Plan 50.
Что
Параметр функции может иметь значение по умолчанию; на месте вызова аргумент может передаваться по имени. Ключевое правило: параметр с дефолтом передаётся только по имени, позиционно — нельзя.
fn connect(host str, port int = 8080, tls bool = false) -> Conn
connect("localhost") // ок — обязательный позиционно
connect("localhost", port: 9000) // ок — дефолтный по имени
connect("localhost", tls: true, port: 80) // ок — именованные переставимы
connect("localhost", 9000) // ОШИБКА — port с дефолтом, только по имени
connect("localhost", 9000, true) // ОШИБКА — нечитаемые позиционные флаги
Ментальная модель одной строкой: обязательный параметр — позиционно, опциональный — по имени.
Это общая фича языка, не спецсинтаксис. supervised(cancel: tok)
(D75) — обычный именованный аргумент.
Правило — объявление
fn f(required int, opt int = 0, flag bool = false)
// ^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
// без дефолта с дефолтом с дефолтом
- Параметры с дефолтом идут после параметров без дефолта.
fn f(x int = 0, y int)— compile error. - Default-выражение вычисляется на месте вызова, каждый вызов
заново (не Python-style def-time). Может ссылаться на
предшествующие параметры и module-level
const:fn slice(xs []int, from int = 0, to int = xs.len()) - Variadic-параметр (D69) остаётся последним и не может иметь дефолта (его дефолт — пустой пакет). Параметры до variadic могут иметь дефолты.
Правило — вызов
// fn f(required int, opt int = 0, flag bool = false)
f(1) // opt, flag опущены → дефолты
f(1, opt: 5) // дефолтный по имени
f(1, flag: true, opt: 5) // именованные переставимы
f(required: 1, opt: 5) // обязательный тоже можно по имени
f(1, 5) // ОШИБКА — opt с дефолтом, позиционно нельзя
f(opt: 5, 1) // ОШИБКА — позиционный после именованного
- Параметр с дефолтом — keyword-only. Передаётся только по имени; позиционно — compile error. (Исключение — trailing-форма для последнего функционального параметра, см. «Взаимодействие».)
- Параметр без дефолта связывается позиционно или по имени.
- Позиционные аргументы идут первыми, связываются слева направо.
Именованный аргумент не может предшествовать позиционному —
f(opt: 5, 1)— compile error. - Именованные аргументы переставимы между собой.
- Каждый параметр связывается ровно один раз. Передать параметр
и позиционно, и по имени — compile error (
f(1, required: 2)). - Параметр с дефолтом можно опустить; параметр без дефолта — обязателен (позиционно или по имени).
- Имя в
name: expr— это имя параметра callee, не выражение.
Грамматика
param = ident type [ '=' expr ]
params = param { ',' param } [ ',' '...' ident '[]' type ]
call-args = [ pos-args ] [ ',' named-args ] | named-args
pos-args = expr { ',' expr }
named-args = named-arg { ',' named-arg }
named-arg = ident ':' expr
Внутри (...) вызова ident ':' expr всегда именованный аргумент
— коллизии с record-литералом нет (record-литерал — Имя { ... } в
фигурных скобках, D43).
f(User { name: "a" }) — позиционный аргумент-record.
Взаимодействие
- D43 trailing-block / trailing-fn. Trailing-форма связывается с
последним функциональным параметром. Trailing-форма синтаксически
отлична от позиционного аргумента в
(...), поэтому остаётся допустимой даже если этот параметр имеет дефолт — это не «позиционный аргумент дефолтного параметра». Передать тот же параметр и trailing-формой, и именованным аргументом нельзя (правило 5, «связан дважды»). - D69 variadic. Именованные аргументы — только для параметров
до variadic. После
...itemsименованных аргументов нет. - Overloading отсутствует — в Nova нет перегрузки функций, поэтому разрешение «какой параметр» однозначно по имени, без type-directed resolution.
@-методы / protocol-методы — именованные аргументы работают одинаково для свободных функций и методов.
Почему
- Нечитаемые флаги — compile error, а не «нежелательно».
connect("h", false, true)— позиционныеbool/int-флаги нечитаемы и это классическая ошибка LLM-генерации. Правило «дефолт → keyword-only» превращает её из стиль-замечания в ошибку компиляции. Для AI-first языка перевод целого класса багов в compile error — прямо по миссии. - Одно правило, обучаемая граница. «Обязательный — позиционно,
опциональный — по имени». Не нужно решать на каждом вызове, называть
или нет; не нужна система двух имён, как в Swift (
_+ label). Опциональные параметры — это как раз те, чей порядок не запоминается. - Убирает builder/option-struct boilerplate для простых случаев «функция с несколькими опциональными настройками».
- Включает
supervised(cancel: tok)— синтаксис structured concurrency (D75) опирается на эту фичу. - Call-site evaluation дефолтов — нет Python-гочи с разделяемым mutable-дефолтом.
Что отвергнуто
- Spread аргументов в вызов —
f(...record)(record → именованные) иf(..array)(массив → позиционные). Причины: два разных оператора несогласованны;...уже занят variadic (D69) и spread-в-литералах (D60); позиционный spread тихо ломается при перестановке параметров callee; call-site становится непрозрачным. Бандл связанных параметров выражается option-struct’ом (fn f(host str, opts Opts = Opts{})) или именованными аргументами. - Python-style def-time вычисление дефолта — mutable-default гоча.
- Все параметры обязательно-именованные (Swift-style, имя
обязательно на call-site для каждого параметра) — лишняя церемония
для унарных и math-функций (
abs(x: -5),add(left: a, right: b)), и делает имя каждого параметра жёстким API. Keyword-only применяется только к параметрам с дефолтом — обязательные остаются позиционными. - Исключение «если дефолтный параметр один — разрешить позиционно» —
отвергнуто: количество дефолтов не показатель риска (один
bool-флаг так же нечитаем, как один из трёх); добавление второго дефолтного параметра тихо ломало бы существующие позиционные вызовы (рефакторинг-ловушка); теряется простота «одного правила». - Per-параметр opt-in в позиционность (Swift
_) — добавляет сложность на декларации; пока не нужно. Если math-функции начнут раздражать многословием — вернуться к этому отдельным решением. - Позиционный аргумент после именованного — неоднозначно, запрещён.
Эволюция
Ревизия (2026-05-15): добавлено правило «параметр с дефолтом —
keyword-only на месте вызова». Раньше дефолтный параметр можно было
передать и позиционно. Триггер — позиционные bool/int-флаги
(connect("h", false, true)) остаются нечитаемыми и частой ошибкой
LLM-генерации даже при наличии именованных аргументов; правило делает
их compile error. Рассмотрены и отвергнуты: обязательные имена для
всех параметров (Swift-style) и исключение для «одного дефолта»
(см. «Что отвергнуто»). Реализация ревизии — Plan 50;
существующие call-site’ы из Plan 46 с позиционными дефолтными
аргументами требуют миграции.
Связь
- D69 — variadic-параметры; variadic несовместим с дефолтом, остаётся последним.
- D60 — spread
...xв литералах; spread-в-вызов (отвергнут здесь) — другая операция. - D43 — trailing closure связывается с последним функциональным параметром.
- D75 —
supervised(cancel: tok)использует именованный аргумент; ревизия D75 зависит от D102. - Plan 46 — базовая реализация (named args + дефолты), закрыт.
- Plan 50 — реализация ревизии «дефолт → keyword-only».
D108. Map-литерал [k: v]
Status: active (spec). Реализация — Plan 52. (Номера D104-D107 зарезервированы Plan 45.)
Что
Map-литерал [k: v, ...] конструирует HashMap[K, V]. Ключи и
значения — выражения, вычисляются в рантайме.
let m HashMap[int, str] = [1: "a", 2: "b"]
let m = [1: "a", 2: "b"] // K, V выводятся из литерала
let a = 10
let m HashMap[int, str] = [a: "x", a + 1: "y"] // ключи — выражения
let m HashMap[str, bool] = ["has space": true] // не-идентификаторный str-ключ
let empty HashMap[int, str] = [] // пустой — тип из контекста
Дополняет map-coercion {field: v} (02-types.md → D55):
{...}— ключи это статические имена-идентификаторы →HashMap[str, V].[k: v]— ключи это выражения (int, переменная, не-идентификаторная строка, computed) →HashMap[K, V].
Правило — синтаксис и парсинг
collection-literal = '[' ( map-body | array-body | (empty) ) ']'
map-body = expr ':' expr { ',' expr ':' expr } [ ',' ]
array-body = expr { ',' expr } [ ',' ] // D27/D38
Парсинг локальный, без type-directed:
- После
[парсим первое выражение. - Следующий токен
:→ это map-литерал, дальше парыexpr : expr. - Следующий токен
,или]→ это array-литерал (D27/D38). [](пусто) → array-или-map, разрешается на type-check по ожидаемому типу — ровно как уже работает пустой массив (D38).
Внутри [...] слева от : — выражение, не имя. Коллизии нет: в
[] вообще нет понятия «имя поля» (в отличие от record-литерала
{}). Первый : вне вложенных ()/[]/{} — разделитель пары.
Правило — типы и coercion
- Тип литерала —
HashMap[K, V];K/Vвыводятся из ключей/значений либо из ожидаемого типа. - Key-позиция — D55 «known-target-type position» с ожидаемым типом
K; value-позиция — с ожидаемымV. Значит sum-/record-/map-coercion (D55) композируются на ключах и значениях:let m HashMap[str, JsonValue] = ["name": "alice", "age": 30.0] // значения: "alice" → Str(...), 30.0 → Num(...) - Все ключи унифицируются в один
K, все значения — в одинV.
Правило — порядок вычисления
Порядок вычисления зафиксирован нормативно — это улучшение над Go, spec которого оставляет порядок вычисления map-literal expressions неспецифицированным:
[k1: v1, k2: v2, ...]— пары вычисляются слева направо; внутри каждой пары — сначала ключ, потом значение. Итоговый порядок side-effect’ов:k1, v1, k2, v2, ....- Этот порядок observable — побочные эффекты в ключах/значениях наблюдаемы именно в нём.
Правило — порядок итерации
HashMap создаваемый литералом — unordered, как Go и Rust. Порядок
итерации не специфицирован и может рандомизироваться между запусками
программы (Go-стиль, защищает от случайной зависимости от порядка) либо
быть устойчивым в пределах процесса (Rust-стиль, per-instance random
seed). Конкретная политика — деталь реализации stdlib и может меняться
в будущем (например, при переходе на swisstable-implementation).
Это намеренное проектное решение — без него users пишут fragile
тесты («первый элемент в map это X»), которые ломаются при изменении
load-factor или hash-seed. Если требуется детерминированный порядок —
используйте OrderedMap (insertion-order, отдельный тип через
FromPairs протокол, Plan 52.1) или явный sort после .entries().
Сравнение:
- Go: random per-iteration (агрессивно ломает reliance) — мы можем выбрать то же
- Rust: random per-instance (стабилен в пределах HashMap, но между HashMap’ами разный)
- TS
Map: preserves insertion (но это другая структура — мы для этого даёмOrderedMap)
Правило — десугаринг
Map-литерал десугарится сразу в вызовы методов, без промежуточного массива пар:
[k1: v1, k2: v2]
// →
{
let mut _m0 = HashMap[K, V].with_capacity(2)
let _ = _m0.insert(k1, v1)
let _ = _m0.insert(k2, v2)
_m0
}
- Пустой (
[]в map-позиции) →HashMap[K, V].new(). - Ноль промежуточных объектов на куче — только сам
HashMap(подход Rustvec![]: преаллокация + вставки). with_capacity(n)несёт контракт «nвставок без rehash» — аргумент это entry-count, не bucket-count (см. Plan 52).@insertвозвращаетOption[V](старое значение); в десугаринге возврат всегда явно отбрасывается черезlet _ = ....- Temp-переменная —
_m0,_m1, … (per-scope счётчик): valid ISO C11, без$; вложенные литералы ([1: [10: "x"]]) не конфликтуют именами. - Дубликаты ключей — last-wins, естественно из семантики
@insert. Если два ключа — одинаковые compile-time константы (int/str/bool literal илиconst), компилятор выдаёт lint-предупреждение «duplicate key — second entry overwrites first» (паритет сgo vetиtsc). Произвольные выражения не проверяются. - Plan 52 Ф.23 — расширяемость через
#from_pairsattribute. Десугаринг по умолчанию вызываетHashMap, но если expected type помечен#from_pairs, target меняется на этот тип. User-типы получают support литерала добавив#from_pairs+ статическийwith_capacity(int) -> Self+mut @insert_new(K, V). ПолныйFromPairs[K, V]протокол (с bound-check через Plan 15) — future generalization, не в bootstrap. HashMap.from(arr)остаётся как обычный метод для рантайм-массива пар; литерал через него не идёт.
Правило — NaN как ключ (документированный footgun)
Если K — float (f64/f32) и реализует Hashable, то [f64.NAN: "x"]
синтаксически валиден. Но по IEEE 754 NaN != NaN, поэтому вставленный
NaN-ключ невозможно найти обратно — @get(f64.NAN) всегда вернёт
None. Rust решает радикально (f64 не реализует Hash + Eq); Go и TS
документируют, но не предотвращают. Nova документирует и предупреждает:
если ключевое выражение — константа f64.NAN / f32.NAN, компилятор
эмитит warning «NaN as map key — inserted key can never be found». Runtime-
проверку не вводим (дорого для non-NaN случаев).
Почему [], а не {}
{...} — это record-литерал (D17/D55).
{ ident: x } неустранимо неоднозначен: ident — имя поля record’а
или выражение-ключ? Различить можно только type-directed parsing
(Nova отвергает, D43)
или JS-гочей ({a:1} — ключ это строка "a", не переменная). Внутри
[...] понятия «имя поля» нет — [a: x] однозначно: a — выражение.
Прецедент — Swift (словари на [], не {}).
{field: v} всё равно даёт str-keyed map — через map-coercion (D55),
для подмножества «ключи это статические идентификаторы». Это не
TIMTOWTDI: {} и [] покрывают разные случаи (имя vs выражение).
Что отвергнуто
- Map-литерал на
{}({1: "a"},{[expr]: v}) —1не имя поля,{}пришлось бы парсить тремя способами (блок / record / map) с различием по «идентификатор ключ или нет», что молча меняет семантику ({x: v}record vs{x(): v}map). Фрагильно. - Десугаринг через
HashMap.from([(k,v),...])— строит промежуточный[](K,V)массив + tuple’ы на куче только ради инициализации. Десугарим сразу вwith_capacity+@insert. [:]как токен пустой мапы (Swift-style) — лишний спецтокен;[]+ ожидаемый тип уже однозначно даёт пустую мапу.- Map-литерал как compiler builtin —
HashMapостаётся stdlib-типом на Nova; литерал — чистый сахар, компилятор знает только именаHashMap/with_capacity/@insert, не реализацию.
Связь
- D27 /
D38 — array-литерал
на
[]; map-литерал делит с ним скобки, разводится по:. - D55
— map-coercion (
{field: v}); key/value-позиции литерала — D55 known-target-type positions. - D17 — record-литерал
{...}, с которым[]намеренно не конфликтует. - Plan 52 — реализация D108 + ревизии D55 (map-coercion).
Spread в map-литерале (Plan 55 followup, 2026-05-16)
...m внутри map-литерала разворачивает другую map того же типа:
let defaults HashMap[str, int] = ["a": 1, "b": 2]
let m HashMap[str, int] = [...defaults, "c": 3] // {a:1, b:2, c:3}
let m HashMap[str, int] = [...defaults, "a": 100] // {a:100, b:2} (override)
let m HashMap[str, int] = [...a, ...b] // merge two maps
Семантика «right-most wins»: при duplicate keys позже встретившаяся
запись побеждает (как JS object spread, Python {**a, **b}).
Парсер использует lookahead для disambiguation: [...x, y, z]
рассматривается как array, [...x, k: v] — как map. Edge case
[...x] (только spread без pairs) — type-directed: если expected тип
помечен #from_pairs (HashMap), интерпретируется как map.
Status (bootstrap): parser + desugar + annotator готовы; codegen
для [...src] с non-empty src блокирован orthogonal
[M-mono-tuple-element-types] (Plan 56 scope). Эффективно работает
spread пустых map’ов + pair-only литералов.
Mono invariants (Plan 55 Ф.4, 2026-05-16)
Codegen (emit_c.rs) при monomorphization сохраняет следующие
invariants:
current_fn_return_tysave/restore вemit_fnчерезmem::replace+ restore в конце. Это предотвращает leak prior return type в recursive emit (mono’d transitively’d deps).- Protocol-method return-type whitelist — для well-known
protocol methods (
eq/ne/lt/le/gt/ge/is_*→bool;hash→int) infer возвращает stable тип до fallback наfn_ret_<m>lookup (который может содержать stale из другой fn). - Placeholder mono skip —
register_mono_method_instance+drain_generic_type_worklistотвергают type_subst содержащийNova_<G>*placeholders (G ∈ fn.generics). Это предотвращает broken erased generic emit для recursive generic calls (e.g.HashMap[K,V].with_capacityвнутриHashMap.clone()body). current_type_substsave/restore в local scope — каждая recursive mono call имеет свой subst stack, не leak глобально.- Pattern::Record bindings —
collect_pattern_inner_bindingsдля record-form variant patterns (Slot.Occupied { key: k }) используетrecord_variant_field_typesmap с lookup mono’d sum_name first, fallback на base. Это предотвращает leak stale var_types между mono’d instances.
Полное описание — Plan 55 Ф.0-Ф.6.
D104. Синтаксис doc-comment’ов — /// outer, //! inner
Status: active (spec). Реализация — Plan 45 Ф.1.
Cross-refs: D101 (
#doc "..."module-attr сосуществует с//!); D105 (#doc(...)типизированные атрибуты делят namespace#doc); D106 (code-блоки внутри doc-comment’ов).
Что
Два префикса doc-comment’ов:
///— внешний doc-comment (outer): привязывается к следующей декларации (function, type, constant, effect, handler, protocol).//!— внутренний doc-comment (inner): привязывается к окружающему модулю/файлу. Допустим только в начале файла (после строкиmodule Xи любых строкimport), до первой декларации.
Голое // остаётся обычным комментарием (doc-token не эмитится).
//! Краткое описание модуля.
//!
//! Подробное описание того, что предоставляет модуль, включая
//! примеры, охватывающие несколько items.
module std.example
import std.io
/// Возвращает модуль числа `x`.
///
/// # Examples
///
/// ```nova
/// assert(abs(-5) == 5)
/// ```
fn abs(x int) -> int =>
if x < 0 { -x } else { x }
Правила
-
Outer (
///) — привязывается к следующей декларации в порядке исходника. Подряд идущие///строки сливаются в один doc-блок. Пустая///строка не разрывает блок (становится пустой строкой в content); не-doc строка завершает блок. -
Inner (
//!) — допустим только в начале модуля: после строкиmodule <path>и любыхimportstatement’ов, но до первой декларации item’а. Подряд идущие//!строки сливаются. -
////(четыре или больше слэшей) — обычный комментарий, не doc-comment. Это копирует поведение rustdoc и предотвращает случайное doc-promotion для идиомы section-divider’ов (//// SECTION). -
Multi-line merging — подряд идущие
///(или//!) строки без разделяющих blank-строк или других токенов конкатенируются с\n-разделителями. С каждой строки снимается префикс///(или//!) плюс ровно один опциональный ведущий пробел:/// Первая строка. /// /// Третья строка (после пустой doc-строки).даёт content
"Первая строка.\n\nТретья строка (после пустой doc-строки).". -
Indentation stripping — когда doc-блок занимает несколько строк, общий leading whitespace (после префикса
//////!+ одного опционального пробела) снимается единообразно с каждой непустой строки. Это нормализует индентацию markdown:/// Indented doc: /// inner detailдаёт
"Indented doc:\n inner detail"(четырёхпробельный внешний отступ снят равномерно; двухпробельный внутренний — сохранён). -
Doc не допускается на
module,import,letна module-scope,test-блоке. Документация уровня модуля — через//!(inner doc) или через#doc "..."module-attr (D101); уtest-блока doc- convention нет (если нужен комментарий — обычный//). -
Пустой doc-блок (
///за которым blank line или///\n) — warning, обрабатывается как отсутствие документации. Style guide запрещает пустые doc-блоки кроме явных случаев#hide_doc(D105).
Position rules — примеры
//! ok: в начале модуля, после module + imports.
module foo
import bar
//! WARNING: //! после первого item — отбрасывается с warning'ом.
/// ok: outer doc на item ниже.
fn baz() -> int => 1
/// orphan outer doc — warning: за ним нет item'а.
fn outer() -> int {
//! ERROR: //! внутри тела функции недопустим.
/// ERROR: outer doc на let-statement не поддерживается.
let x = 1
x
}
Кодировка и escapes
- Content doc-comment’а — сырой текст (CommonMark markdown слой применяется позже, в D106 / Plan 45 Ф.5).
- На уровне лексера escape-последовательности не интерпретируются. Backslash’ы, backtick’и и пр. — часть raw content.
- Только UTF-8. BOM в начале файла снимается перед doc-recognition.
- Trailing whitespace на каждой строке сохраняется (от него зависит markdown line-break семантика).
Грамматика на уровне лексера
doc-outer-line = "///" [content-char ...] NEWLINE
doc-inner-line = "//!" [content-char ...] NEWLINE
doc-block-outer = doc-outer-line { doc-outer-line }
doc-block-inner = doc-inner-line { doc-inner-line }
content-char = любой символ, кроме NEWLINE; при этом строка НЕ
ДОЛЖНА начинаться с `/` сразу после префикса (т.е.
`////` — обычный комментарий, а не doc-prefix + лишний
слэш).
Сосуществование с #doc "..." (D101)
D101 определяет атрибут
module-level #doc "...", который может стоять перед строкой
module X в _module.nv и пропагируется на все peer-файлы. Это
комплементарно к //!:
#doc "..."— для коротких summary модуля, особенно в folder-module’ах с_module.nv.//!— для длинной документации модуля в одном каноническом файле, включая markdown-тело и# Examples-секции.
Модуль может иметь оба одновременно. Если оба присутствуют:
- Текст
#docстановится module summary (первое предложение). - Тело
//!добавляется как module description.
nova doc склеивает их; конфликта нет, но lint redundant-module-doc
предупреждает, если оба содержат идентичный текст.
Почему
///+//!— копирует rustdoc-конвенцию, знакомую широкому developer-сообществу. Заимствование устоявшейся конвенции снижает friction для новичков и AI-ассистентов.////отвергнут как doc — сохраняет идиому headings-as-comment’ы (//// SECTION) без случайного doc-promotion. rustdoc сделал этот выбор; мы повторяем.- Никаких
/** ... */-style блочных doc-comment’ов — в Nova вообще нет блочных комментариев (только//line по существующей языковой конвенции). Добавлять блочные doc-comment’ы только ради документации — вводить новый синтаксис комментариев для одной цели. - Английский как рекомендованная convention — для широкого охвата и AI/LLM-consumption Plan 45 §11.5 рекомендует писать doc-content на английском. Однако технически lexer/codegen не ограничивают язык — content treated как opaque UTF-8 text, и при необходимости разработчик/команда выбирает язык под свою аудиторию.
Что отвергнуто
///для inner-doc через position next-line — неоднозначно с привязкой к следующему item’у. Отвергнуто;//!однозначно inner.//* ... */-блочные doc-comment’ы — добавляет вариант синтаксиса комментариев для одной цели; line-форма покрывает все случаи одним правилом.- Авто-promotion
//обычных комментариев в doc, когда они предшествуют exported item — неявно и неожиданно. Doc-promotion обязан быть явным (///). - Doc на
import— import’ы не часть public API surface, в output’е не рендерятся.
Связь
- D101 — module-level
#docattribute; правила сосуществования выше. - D105 — типизированные doc-
атрибуты, включая
#doc(summary = "..."). - D106 — code-блоки внутри doc-comment’ов являются doc-test’ами.
- D107 — JSON output включает сырой doc-content плюс распарсенную структуру.
- Plan 45 — реализация; §11.5 style guide.
D117. Size-like accessors require call syntax
Status: active (spec). Реализация — Plan 60. (Номера D112–D116 заняты другими планами 33.x.)
Что
Для любого типа T методы, возвращающие размер/cardinality/
capacity (len, capacity, byte_len, is_empty, плюс будущие
count, size если они появятся как built-in convention), вызываются
только через method-call с круглыми скобками: t.method().
Запись t.method (без скобок) — это bound method value типа
fn() -> T, и компилятор отдельно её обрабатывает (D-block method-
values, Plan 11).
В подавляющем большинстве случаев это user error.
Правило
let v = [1, 2, 3]
let n = v.len() // ✓ корректно
let m = v.len // ✗ error E_SIZE_ACCESSOR_FIELD
let z = v.is_empty() // ✓
let c = v.capacity() // ✓ (renamed from .cap — Rust/C++/Swift naming)
Что попадает под D117 (по conventional имени):
| Имя | Где |
|---|---|
len | любая коллекция |
capacity | любая коллекция (включая []T, HashMap, Set, etc.) |
byte_len | str (длина в байтах UTF-8) |
is_empty | любая коллекция |
count, size | если когда-нибудь добавятся как built-in convention |
Имя cap — legacy alias для capacity; diagnostic при попытке
field-access t.cap подсказывает rename на .capacity().
Diagnostic при нарушении
error[E_SIZE_ACCESSOR_FIELD]: size-like accessor `len` is method-only
(Plan 60 / D117)
--> file.nv:42:23
|
42 | println("${vec.len}")
| ^^^ help: append `()` — use `.len()` method call
|
= note: bare `.len` is bound method value `fn() -> int`,
rarely intended in argument position
Для .cap:
= help: rename to `.capacity()` (Rust/C++/Swift naming; D117)
Почему
- Predictable cost. Nova сознательно отвергает TS/Swift-style
computed properties (без скобок) — это спрятало бы O(n) операции
за field-syntax (например,
s.lenдля UTF-8 string требует codepoint count, O(n)). Скобки везде = «здесь происходит вычисление, возможно дорогое». - Consistency. Без D117 — built-in коллекции (
[]T,str) дают.lenfield-style, а user-defined (HashMap,Set) —.len()method-style. Это паттерн Java (arr.lengthfield vslist.size()method), worst-of-both: программист и LLM не могут запомнить «для какого типа какая форма». - AI-friendly. D117 — explicit spec’ed contract. LLM, читающий
spec, имеет однозначный сигнал. Rust имеет тот же result, но
через implicit convention (rustc не выдаёт error если вы определите
публичное поле
len— Nova выдаёт). - Internal C-поля сохранены.
arr->len/arr->capв C-runtime остаются — это implementation detail.arr.len()lowers в zero-cost(arr->len); никакого function-call overhead.
Соответствие state-of-the-art
| Language | Array size | String size | Map size | Inconsistency? |
|---|---|---|---|---|
| Rust | vec.len() method | s.len() method | map.len() method | none |
| Go | len(slice) builtin | len(s) builtin | len(m) builtin | none (но top-level fn) |
| TS | arr.length property | s.length property | map.size property | none (но field) |
| Swift | arr.count property | s.count property | dict.count property | none (но field) |
| Java | arr.length field | s.length() method | m.size() method | inconsistent |
| Python | len(arr) builtin | len(s) builtin | len(m) builtin | none |
| Nova | arr.len() method | s.len() method | map.len() method | none (D117) |
Nova = Rust паритет, + explicit D-block (Rust полагается на convention без compiler enforcement).
Что отвергнуто
- Field-style для всех типов — невыразимо для user-types
(encapsulation: HashMap внутри
_count+ invariant’ы). - TS/Swift-style property (no parens) — противоречит D14 «скобки обязательны для вызова» и главное — спрячет O(n) операции за field-syntax.
len(x)builtin (Go-style) — global-function-namespace конфликт с user-types; не работает с method-chainingvec.map(f).len().cap()(Go naming) — отвергнуто; для редко используемого accessor’а Nova выбирает полное словоcapacity()(Rust/C++/Swift parity), D29 «явность над краткостью».- Allow bare
.lenкак warning, не error — отвергнуто для bootstrap; method-value form требует явного intent (Plan 11 syntax).
Связь
- D32 — array layout
(ptr, len, cap); D117 скрывает эти поля от user-language. - D26 — prelude API; D117 добавляет методы
[]T.len(),[]T.capacity(),[]T.is_empty(),str.is_empty()в список prelude-API. - D38 — built-in
API для
[]T; D117 amend’ит таблицу (раздел “Built-in API”). - Plan 11 —
method-value semantics (
let f = x.@lenlegitimate; barex.lenerror). - Plan 37 — refine arg-position vs non-arg method-value disambiguation (post-Plan 60 follow-up).
- Plan 45 — stdlib doc-comments
обновлены на consistent
.len()form. - Plan 56 — bound-K vtable dispatch для size-accessors на erased generics.
D126. external type — opaque типы без body
Status: active (spec). Реализация — Plan 62.D.bis. (Номера D109–D125 заняты другими планами — см. memory
project-spec-dblock-numbering.md.)
Что
external type X [Generics] — модификатор type-декларации, означающий
что тип реализован в runtime (C-коде nova_rt/), а Nova-уровневая
декларация даёт только имя + optional generic параметры. Тело
(variants/fields/protocol/effect/alias/newtype) отсутствует —
type «opaque». Аналог D82
external fn, но для типов.
external применяется к типам через D126; к функциям — через
D82.
Один и тот же keyword, два валидных позиционирования
(external fn ... / external type ...).
Правило
Грамматика
type-decl = ['export'] ['external'] 'type' name [generic-params] [body]
Порядок modifiers строгий: export первым, external вторым. Body у
external type должен отсутствовать (никаких { ... },
| variant, effect { ... }, protocol { ... }, alias TYPE, или
newtype TYPE), иначе compile error «external type cannot have a body».
Примеры
// Public external (built-in, Plan 62.D.bis в std/prelude/collections.nv)
export external type StringBuilder
export external type WriteBuffer
export external type ReadBuffer
// Generic external (future Channel use-case)
export external type Channel[T]
// Two-param generic external (future Region use-case)
export external type Region[T, Capability]
// Private external (внутри runtime module'а)
external type Nova_intrinsic_buffer
Связь с D26 prelude
Built-in opaque-типы из D26
(StringBuilder, WriteBuffer, ReadBuffer) объявляются через
external type в std/prelude/collections.nv (Plan 62.D.bis,
2026-05-18). Раньше (Plan 04) типы были «known-by-name» без formal
declaration; D126 даёт canonical source-of-truth + nova doc surface +
eligible для type-annotations / cross-file resolve.
// std/prelude/collections.nv
module std.prelude.collections
export external type StringBuilder
export external type WriteBuffer
export external type ReadBuffer
// + Iter[T] protocol (D58)
Methods на opaque-типах объявляются отдельно через external fn
(D82)
в std/runtime/<type>.nv:
// std/runtime/string_builder.nv
module std.runtime.string_builder
export external fn StringBuilder.new() -> Self
export external fn StringBuilder mut @append(s str) -> Self
export external fn StringBuilder @into() -> str
// ... 11 more methods
Связь декларация ↔ methods — по receiver-type name (StringBuilder).
Нет syntactic block’а, объединяющего type-decl с methods (по
D52 это правильно — methods orthogonal к
declarations, free-fn-style).
Связь с D5/D47 видимостью
export external type — публичный: имя видно из других модулей.
external type без export — модуль-private. Те же правила, что для
обычных type-декл. external ортогонален export.
Связь с D52 kind-tokens
D52 фиксирует kind-tokens type / protocol /
effect. D126 не добавляет нового kind-token’а — external это
модификатор перед type (mirror D82 для fn), не отдельный kind.
В AST это кодируется через TypeDeclKind::Opaque (новый variant,
Plan 62.D.bis Ф.1), параллельный existing Record / Sum / Effect /
Protocol / Alias / Newtype. С точки зрения user’а — external type X это specialised type-declaration формы, не отдельный kind.
Связь с будущим FFI
external type — для типов, реализованных в Nova-runtime
(nova_rt/*.h/.c). Для типов, импортируемых из сторонних
C-библиотек (libuv handles, OS-libs), будет отдельный keyword
extern("C") type (Q-ffi, не реализуется сейчас). Семантика разная:
| Keyword | Реализация | C-name | Разрешён программисту |
|---|---|---|---|
external type | Nova-runtime (nova_rt/) | Nova_<Name>* mangled | нет (только в std.runtime.* / std.prelude.*) |
extern("C") type (TBD) | сторонний C/lib | as-is | да (FFI) |
Restriction: только std.*-whitelist
Программистский Nova-код не пишет external type. Этот keyword —
экспозиционный: только модули в std.runtime.* и std.prelude.*
имеют право его использовать. Компилятор отклоняет external type
в любом другом namespace’е:
error: `external type` is only allowed in `std.runtime.*` / `std.prelude.*`
modules (this module is `myapp.foo`); for FFI to external C libraries
a future `extern("C") type` keyword will be added (Q-ffi)
Whitelist реализуется через manifest::is_stdlib_runtime_module || is_prelude_self_module (тот же check что для external fn per D82).
Mangling и codegen
external type X не эмитит struct definition в C output —
определение живёт в runtime header (nova_rt/<x>.h):
// nova_rt/string_builder.h
typedef struct {
char* data;
size_t len;
size_t cap;
} Nova_StringBuilder;
Codegen reference на external type X использует mangling Nova_X*
(pointer, opaque). Это идентично mangling user-defined record-типов
(type Foo { ... } → Nova_Foo*), что обеспечивает consistency.
| Nova-form | C-name |
|---|---|
let sb StringBuilder = ... | Nova_StringBuilder* sb = ... |
fn f(sb StringBuilder) | void f(Nova_StringBuilder* sb) |
external type Channel[T] | Nova_Channel* (T erased в bootstrap) |
emit_type_decl skip’ает emission для TypeDeclKind::Opaque.
Forward-declarations (typedef struct Nova_X Nova_X;) skip’аются
через BUILTIN_RUNTIME_TYPES skip-list — runtime header сам
предоставляет.
Validation
Аналогично D82,
компилятор validate’ит что декларированный external type реально
существует в runtime (через BUILTIN_RUNTIME_TYPES list + at-emit-
time check). Если user добавит external type FooBar, но
nova_rt/foo_bar.h отсутствует → C-toolchain ошибётся при линковке
с undefined reference to Nova_FooBar при первом методе.
Полная Nova-side validation (компилятор знает все runtime-implemented
типы и заранее ошибётся «type ‘FooBar’ not implemented in runtime»)
— требует registry runtime types, который сейчас живёт неявно в
BUILTIN_RUNTIME_TYPES. Q-codegen-runtime-types-registry — отдельная
задача аналогично D82 builtins.nv validation; bootstrap relies на
list maintenance.
Почему
Зачем нужен external type
-
Source-of-truth для
nova doc. Программист (и AI) видит формальную декларацию типа в одном месте —nova doc std.prelude.collectionsпокажет StringBuilder/WriteBuffer/ ReadBuffer как canonical API. Раньше (Plan 04, до Plan 62.D.bis) типы существовали только как bare-name строки в D26 spec’е — не visible в tooling. -
Eligibility для cross-file resolve. После formal declaration типы участвуют в R26+R27 resolve (Plan 35). User-код может писать
import std.prelude.collections.{StringBuilder}или полагаться на auto-import через prelude. -
D29 W_PRELUDE_SHADOW работает. User declaration
type StringBuilder { ... }теперь генерирует warning (mirror Plan 62.A behavior для Option/Result). Раньше silent shadow. -
Symmetry с D82
external fn. Если методы opaque-типа объявляются черезexternal fn, сам тип должен иметь parallel form. Без D126 semantic asymmetry: methods are first-class, type itself isn’t. -
Future-proof для opaque user-types (Channel, Region, mmap’ed buffers). Когда возникнет use-case, mechanism уже есть — нужно только relax whitelist (или ввести
extern("C") typeдля FFI).
Почему не opaque type
- Один keyword (
external) для двух concepts (fnиtype) — снижает cognitive load. Прецедент: OCamlexternal, Dartexternal, Kotlinexternal— все используют один keyword для функций и (когда уместно) типов. opaqueподразумевает abstraction-from-user-code, а semantic нужный здесь — implementation-elsewhere.externalточнее семантически.
Почему не #external attribute
- Per D82
уже decision: «Атрибуты в Nova зарезервированы для тестов /
dev-tools (Q-attributes). Modifier-форма единообразна с
export/mut». D126 follows тот же principle. #externalдублировал бы syntax:#external type Xvsexternal type X. Choose one — modifier form for consistency.
Почему restrict scope
- Bootstrap MVP — программист не должен объявлять opaque types произвольно. Runtime backing — это compiler-versioned artefact, не user-extensible (см. D82 same argument). User-extensibility опасна: declaration без runtime impl приведёт к undefined-reference C errors.
- Future relaxation требует либо:
- Plugin mechanism (compiler plugin defines runtime — too heavy для bootstrap).
- FFI keyword
extern("C") type(Q-ffi) — для внешних libs, не Nova-runtime.
Что отвергнуто
- Bare-name
type X(no modifier, no body) — parser ambiguity с newtype branch (type X SomeType). opaque type X— separate keyword без явного gain.#externalattribute — modifier consistency lost.type X { _ runtime }body — magic body, parsing complexity.- Auto-discovery по runtime header presence — magic, debugging
nightmare. Explicit
externalлучше. - Включить methods в декларацию типа (
external type X { fn @method ... }): per D52 + Plan 11, methods orthogonal к type-decl (free-fn-style). Не ломаем consistency только для opaque types.
Связь
- D5 / D47 —
exportmodifier;external— ортогональный второй modifier. - D26 — prelude содержит
StringBuilder/WriteBuffer/ReadBuffer; декларации типов — через D126
в
std/prelude/collections.nv. - D30 — naming convention;
external— full word. - D52 — kind-tokens (
type/effect/protocol); D126 не добавляет нового kind-token’а —externalэто modifier. - D82
—
external fn; D126 — type-analog того же принципа. Один keywordexternal, два valid позиционирования.
Эволюция
До Plan 62.D.bis (2026-05-18) типы StringBuilder/WriteBuffer/ReadBuffer
существовали как «known-by-name» (D26 prose-only), без formal Nova-
side declaration. D82 (2026-05-08, Plan 04) явно отложил external type как «not yet — built-in only».
Plan 62 main (2026-05-18) выявил это как последний «known-by-name» hole в D26 visible prelude (все остальные items мигрированы 62.A– 62.F). Plan 62.D.bis закрывает.
D126 numbering — выбран чтобы продолжить chronology D124/D125 (Plan
62.F.bis, 2026-05-18); ставит этот D-block в 03-syntax.md (syntax-
extension), отдельно от runtime-side D82 в 08-runtime.md.
Bootstrap status (2026-05-18)
- ✅ Lexer:
KwExternaltoken уже существует (Plan 04 Этап 2). - ✅ Parser: relax
externalcheck наKwType(Plan 62.D.bis Ф.1). - ✅ AST:
TypeDeclKind::Opaquevariant добавлен (Plan 62.D.bis Ф.1). - ✅ Type-checker: whitelist enforcement (
std.runtime.*/std.prelude.*) — Plan 62.D.bis Ф.1. - ✅ Codegen: skip
emit_type_declдля Opaque kind (Plan 62.D.bis Ф.1). - ✅
std/prelude/collections.nv: добавлены 3 declarations (Plan 62.D.bis Ф.2). - ✅
std/prelude.nvfacade: re-export (Plan 62.D.bis Ф.2). - ⏳ Validation: формальная registry runtime-types — deferred
(Q-codegen-runtime-types-registry), bootstrap полагается на
BUILTIN_RUNTIME_TYPESlist maintenance.
D132. -> @ — fluent-return (метод возвращает receiver)
Plan 77. Принято 2026-05-21 (вариант B обсуждения Plan 73).
Что
Тип возврата -> @ означает: метод возвращает сам receiver.
Тип результата — receiver-тип (эквивалент Self), плюс гарантия, что
возвращается именно тот объект, на котором метод вызван.
fn StringBuilder mut @append(s str) -> @ // вернёт сам StringBuilder
fn Counter mut @bump() -> @ { @n = @n + 1; @ }
Зачем — Self отвечает «какой тип», @ отвечает «какой объект»
Self (D66) — referential тип: «тот же тип, что
у receiver’а». Метод @m() -> Self может вернуть и новый объект
того же типа (@clone() -> Self — копия). Builder-/fluent-методам
нужно строго «тот же объект» — для chaining (sb.append("a") .append("b")) и для проверяемых инвариантов.
-> @ даёт это явно: @ в позиции return-type — value-level двойник
type-level Self, консистентно с @ = receiver везде в Nova.
Правила
- Только instance-метод.
-> @требует@-receiver’а; на static-методе (Type.method) и свободной функции — parse error. - Тело обязано вернуть
@. Non-external метод с-> @: тело завершается выражением@. Иначе compile error — иначе гарантия-> @была бы ложной. external fn ... -> @— C-реализация по контракту runtime’а возвращает receiver (напр.Nova_StringBuilder_method_append→return b).- Тип результата для type-checker / codegen — receiver-тип (как
Self).
Что это разблокирует
- Sound builder-chain alias в consume-checker (D131):
let sb2 = sb.append("x")— разappendобъявлен-> @,sb2гарантированно алиасsb; use-after-consume через chain ловится. - Самодокументируемые fluent-API — fluent виден из сигнатуры (важно для AI-first: локальность контекста).
Сравнение
Rust выражает «возвращает receiver» через &mut self -> &mut Self
(заём) либо self -> Self (move) — точно, но ценой borrow-checker /
ownership-модели. Go сознательно отказался от builder-chaining
(b.WriteString(...) отдельными statement’ами). TS this-тип — как
наш Self, «тот же тип», без гарантии объекта. -> @ даёт
Rust-уровень точности без borrows / lifetimes — поверх GC.
Поправка (Plan 91 Ф.2.6, 2026-05-28) — wrapper-метод и инверсная проверка
Правило 1 (уточнение). Тело -> @ метода обязано завершаться
выражением, которое статически гарантированно возвращает receiver:
- Bare
@— всегда OK. - Вызов другого метода того же типа, объявленного
-> @, на@(@write(),@append(s)) — OK, поскольку он гарантированно вернёт сам receiver. if/else, где все ветки удовлетворяют условиям выше — OK.- Всё прочее — compile error (D132).
fn Buf mut @write() -> @ { @n = @n + 1; @ } // ✅ bare @
fn Buf mut @push() -> @ => @write() // ✅ делегирует в -> @ метод
Правило 2 (инверсное, новое). Если метод объявлен -> Self, но
все пути тела статически возвращают receiver (@ или вызов -> @
метода), это compile error:
error[E_FLUENT_SELF]: метод `step` объявлен `-> Self`, но все пути
возвращают сам receiver (`@`). Используйте `-> @`.
Рационал: -> Self и -> @ — разные семантики. -> @ = «возвращает
тот же объект» (гарантия aliasing). -> Self = «возвращает значение
того же типа» (может быть копия/новый). Объявить -> Self там, где
тело делает только -> @ семантику — это дезинформация для
type-checker’а (нарушает consume-aliasing D131).
Связь
- D131 —
consume; главный потребитель-> @(builder-chain alias). - D66 —
Self(referential тип);-> @— его value-level уточнение «именно receiver». - D35 — методы инстанса.
D143. Static-метод в protocol {} через leading-точку
Plan 97. Принято 2026-05-23. Закрывает
Q-static-method-protocol(был в D58 разделе открытых вопросов).
Что
В теле protocol {} метод объявленный с leading-точкой
(.method(args) -> Ret) — статический (D35: ожидаемая реализация
fn Type.method(...)); метод без префикса (method(args) -> Ret) —
instance (ожидаемая реализация fn Type @method(...)).
type From[T] protocol {
.from(t T) -> Self // static — Type.from(v)
}
type Hashable protocol {
hash() -> u64 // instance — value.hash()
}
type Builder[T] protocol {
.new() -> Self // static — Type.new()
@push(item T) -> @ // instance, mutating, fluent return (D132)
}
Правило
Синтаксическое различение
protocol-method := [ "#pure" ] [ "." | "@" ]? ident generics? "(" params? ")" effects? ret? contracts?
.name(...)— static-метод (симметрично D35fn Type.name).@name(...)— instance-метод (явный маркер, симметрично D35fn Type @name). Bare-имяname(...)остаётся instance по умолчанию (backwards-compat).- Static + instance с одинаковым именем в одном протоколе —
запрещены (parse error «duplicate method
fooin protocol»).
Matching типа против протокола
При проверке «тип T удовлетворяет protocol P»:
- Для
is_static = trueметодаP— ищетсяfn T.method(...)(D35 static-форма, регистрируется компилятором среди статиков). - Для
is_static = false(instance) — ищетсяfn T @method(...)(D35 instance-форма, регистрируется среди методов receiver-типа).
Несовпадение static/instance — compile error «type T does not
satisfy P: method foo declared .foo (static) but T provides
instance @foo» (либо обратное). Это hardening аналогичный Plan 79;
вводится постепенно — на момент Plan 97 Ф.1 matching остаётся
структурно ленивым (см. [M-protocol-static-enforcement-deferred]).
Backwards-compat
Все существующие протоколы (Iter/Hashable/Equatable/Comparable/
Display/Into/TryInto) написаны bare → остаются instance без
изменений. Меняются только From/TryFrom в std/prelude/protocols.nv
(их методы .from/.try_from — static).
Почему
- D35 симметрия: реализация
fn Type.name(...)— статический метод (точка); реализацияfn Type @name(...)— instance (@). Декларация в протоколе должна те же маркеры использовать; без этогоFrom.from(t T) -> Selfнеотличимо от instance, что противоречит D35. - Самодокументированность прелюдии:
From[T] protocol { .from(t T) -> Self }сразу читается «статический фабричный метод»; без точки — неоднозначно. - Spec hint:
D58раздел открытых вопросов уже предложил именно.method()-префикс (см.Q-static-method-protocolдо резолва); Plan 97 этот hint реализует. - Bare = instance (а не «требовать
@явно») — backwards-compat: существующие протоколы не переписываются. Явный@-префикс — Q-openQ-protocol-method-prefix(followup, не блокер).
Что отвергнуто
static method(...)keyword — отвергнут (нетstaticв Nova, противоречит D35 «точка для static»).[static]атрибут — несимметричен D35 и громоздок.- Инференция static из «возвращает Self без self-параметра» —
фрагильно (
fn into() -> Uтоже без явного self-параметра, но instance). @methodобязательный для instance — отвергнут ради backwards- compat. Может вернуться как optional symmetry-маркер (Q-open).
Связь
- D35 — реализация: static через
., instance через@. D143 — декларация в протоколе через те же маркеры. - D58 — раздел открытых вопросов;
Q-static-method-protocolзакрывается этим D-блоком. - D53 — protocol declaration (контейнер для D143).
- D142 — symmetry effect/protocol declaration ↔ literal (соседний D-блок Plan 97).
- D77 —
From/TryFrom4-way auto-derive (главные потребители static в протоколах). - Plan 97 Ф.1 —
имплементация (parser + AST
is_static).
D158. Failable cleanup body — Fail effect разрешён в defer/errdefer
Plan 100.4.1. Принято 2026-05-23 (proposed; implementation pending). Amend D90 §4 — снимает ограничение «defer body INFALLIBLE».
Что
defer { ... } и errdefer { ... } body теперь может содержать Fail-
effect (вызов failable consume-метода / любой Fail-action). Cleanup-fail
композируется с propagating error через D85 /
D118 multi-error infrastructure: каждая ошибка
сохраняется в chain (primary + suppressed), caller получает composite
через MultiError.
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() } // commit may fail — теперь валидно
do_work() // may throw Err1
// Если do_work fails:
// 1. unwinding starts
// 2. defer fires — tx.commit() fails Err2
// 3. composite: { primary: Err1, suppressed: [Err2] }
// 4. caller получает composite через Fail[Err]
}
Зачем
D90 §4 (Plan 20) запретил Fail-effect в defer-body как защита от
тихого поглощения ошибок. Это работало для simple cleanup (log,
mutex.unlock), но блокирует production resource-management:
Transaction.commit()/.rollback()— failable (network drop, deadlock, constraint violation).File.close()— может fail (disk error).Socket.shutdown()— может fail.Connection.disconnect()— может fail.
Без D158 каждый такой cleanup — 6-строчный handler-wrap, что не production-grade ergonomics. D158 force’ит explicit Fail в fn-sig (compile-time visibility), а composition handles runtime.
Изменение D90 §4
БЫЛО: defer body не должно иметь Fail effect; обернуть в handler.
СТАЛО: defer body может иметь Fail effect; ошибка композируется через
Plan 49 multi-error. Enclosing fn-sig ОБЯЗАН declare Fail[E].
Правила composition (3 сценария)
A. Defer-fail на normal exit:
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() } // may fail
do_work() // success
}
// Exit: defer fires; commit может throw — caller получает Fail.
B. Defer-fail во время error-propagation:
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() }
do_work()? // throws Err1
// defer fires during unwinding:
// tx.commit() fails CommitErr → composite
// { primary: Err1, suppressed: [CommitErr] }
}
C. Multiple defers, each can fail — детально в D161 (Plan 100.4.4 multi-defer accumulation).
MultiError API
type MultiError {
primary: Err,
suppressed: []Err, // в порядке firing (LIFO)
}
fn MultiError @primary() -> Err
fn MultiError @suppressed() -> []Err
fn MultiError @fmt_chain() -> str
Caller inspect:
match process() {
Ok(_) => Log.info("done"),
Err(MultiError { primary, suppressed }) => {
Log.error("primary: ${primary}")
for s in suppressed { Log.error(" suppressed: ${s}") }
}
}
Compile-time visibility — fn-sig обязан Fail[E]
fn process() -> () { // ❌ нет Fail[E]
defer { tx.commit() } // ❌ Fail[CommitErr] body
}
// E (D158-defer-fail-not-in-sig): add `Fail[CommitErr]` к fn-sig.
Force’ит explicit visibility в API.
Diagnostic format
error: composite error during scope exit
primary error:
Err1 ("operation failed") at do_work (process.nv:12)
suppressed during defer LIFO (in order of firing):
[1] CommitErr ("network timeout") at tx.commit() in defer (process.nv:14)
[2] Err3 ("disk full") at tx1.commit() in defer (process.nv:13)
Сравнение
| Capability | Go | Rust | TS (ES2024) | Java | Nova D158 |
|---|---|---|---|---|---|
| Cleanup body может fail | ✅ (return err) | ❌ panic-in-Drop = abort | ✅ Symbol.dispose throws | ✅ AutoCloseable.close throws | ✅ Plan 49 composition |
| Error composition при cleanup-fail-mid-error | ⚠️ manual | ❌ abort | ✅ SuppressedError chain | ✅ addSuppressed | ✅ MultiError tree |
| Visibility в сигнатуре | ⚠️ method-by-method | n/a | ⚠️ TS types | ⚠️ throws-list | ✅ Fail[E] effect |
Nova matches Java/TS на composition; превосходит Rust (no
double-panic-abort) + Go (нет manual defer error-handling).
Backward-compat
Existing handler-wrap код продолжает работать. D158 — расширение capabilities, не breaking change.
Связь
- D90 §4 — amend’аем.
- D85, D118 — composition infrastructure.
- D131, D133 — consume foundation.
- D159, D160, D161, D162 — sibling sub-sub-plans Plan 100.4 family.
D159. Async/suspend в cleanup body — cancel-safe
Plan 100.4.2. Принято 2026-05-23 (proposed). Amend D90 §5 — снимает «no-suspend».
Что
defer/errdefer body теперь может содержать suspend-операции
(Time.sleep, Channel.recv, Net.*, Fs.*). Cancel-safe
semantics: cleanup completes-then-cancel-propagates (runtime shield’ит
cleanup от cancel signal до его завершения).
fn process() -> () {
consume socket = open_socket()
defer { socket.graceful_close() } // includes Net.* — теперь валидно
do_io()
}
// Exit + pending cancel:
// 1. graceful_close может suspend (FIN+ACK).
// 2. cleanup completes (shielded).
// 3. cancel propagates AFTER cleanup.
Запрещено
spawn / parallel for в defer body — error E (D159-spawn-in-defer).
Создание новых fiber’ов в cleanup → leak supervised hierarchy.
Изменение D90 §5
БЫЛО: defer body NO-SUSPEND (Time.sleep, Channel.recv, Net.* запрещены).
СТАЛО: suspend разрешён; cancel-safe (cleanup completes-then-propagates);
spawn/parallel for остаются запрещены.
Time.timeout для bounded cleanup
defer {
with Time.timeout(5_s) {
socket.graceful_close() // если >5s — abort
}
}
(Полная реализация Plan 22 libuv async — already ✅.)
Сравнение
| Capability | Rust | TS | Kotlin | Nova D159 |
|---|---|---|---|---|
| Async cleanup body | ⏳ Rust 2024+ work-in-progress | ✅ await using | ✅ coroutine use{} | ✅ defer body suspend |
| Cancel-safe (cleanup completes first) | ⚠️ manual shielded | ✅ AbortSignal | ✅ withContext(NonCancellable) | ✅ shield-by-default |
Связь
- D90 §5 — amend’аем.
- D158 — failable cleanup (parallel).
- D85 — cancel-routing foundation.
- Plan 22 ✅ — async foundation.
D160. okdefer + reason-aware defer |result|
Plan 100.4.3. Принято 2026-05-23 (proposed). Новые scope-level statements; complement к D90 defer/errdefer family.
Что
Два новых construct’а:
-
okdefer { ... }— complement кerrdefer. Выполняется только на success-path (normal exit /return expr); skipped при throw/panic/interrupt. Симметризует defer-family. -
defer |result| { ... }— reason-aware форма. Body имеет доступ к exit-reason через patternresult(Ok(value)/Err(e)/Panic(m)).
Использование
consume tx = begin()
errdefer { tx.rollback() } // error path → rollback
okdefer { tx.commit() } // success path → commit
do_work()?
// На обоих paths tx covered — exhaustive coverage.
defer |result| {
match result {
Ok(value) => Log.info("success: ${value}"),
Err(e) => Log.error("failed: ${e}"),
Panic(m) => Log.fatal("panic: ${m}"),
}
}
Триггерные правила
| Exit-path | defer | errdefer | okdefer |
|---|---|---|---|
| Normal end-of-scope | ✅ | ❌ | ✅ |
return expr (без error) | ✅ | ❌ | ✅ |
throw err / expr? / expr!! | ✅ | ✅ | ❌ |
panic(msg) | ✅ | ✅ | ❌ |
interrupt v (после D162 amend) | ✅ | ✅ | ❌ |
exit(code) | ❌ | ❌ | ❌ |
okdefer + errdefer — exhaustive (один и только один срабатывает при non-exit() exit’е).
Exit-path определяется в start, НЕ retro-fires
Если okdefer { tx.commit() } запустился (success-path) и commit()
fail’ит — exit-path остаётся success. errdefer того же scope’а
НЕ fires ретро-активно. Failure okdefer’а propagates через D158/
D161 multi-error composition (composite { primary: cleanup-fail }).
consume tx = begin()
errdefer { tx.rollback() }
okdefer { tx.commit() }
do_work()
// normal exit → exit-path = SUCCESS
// okdefer fires → commit fails Err1
// errdefer SKIPPED (success exit-path не retro-changes на error)
// Err1 propagates через D158 composition
Почему так: (1) tx уже Consumed через commit (failed or not) — rollback on Consumed = error; (2) commit-failure не означает «rollback safe» (may have partial DB state); (3) Предсказуемая семантика: exit-path fixed at start.
Если programmer хочет «rollback-if-commit-fails»:
okdefer {
with Fail = handler {
fail(e) {
tx.rollback()?
throw e
}
} {
tx.commit()
}
}
Mixed LIFO
defer A
okdefer B
errdefer C
okdefer D
defer E
- Normal exit LIFO:
E → D → B → A(defer + okdefer; errdefer skipped). - Error exit LIFO:
E → C → A(defer + errdefer; okdefer skipped).
Сравнение
Unique среди GC-языков — никто не имеет success-only cleanup distinction:
| Capability | Go | Rust | TS | Kotlin | Nova D160 |
|---|---|---|---|---|---|
| Success-only cleanup | ❌ | ❌ | ❌ | ❌ | ✅ okdefer |
| Reason-aware cleanup | ❌ | ❌ | ❌ | ⚠️ try-finally manual | ✅ defer |result| |
| Symmetric defer family | ❌ | ❌ | ❌ | ❌ | ✅ defer + errdefer + okdefer |
Связь
- D90 — defer/errdefer foundation.
- D158 — failable body может Fail в okdefer тоже.
- D159 — suspend body тоже.
- D162 — consume-integration uses okdefer для commit-on-success.
D161. Multi-defer LIFO error accumulation + panic-in-defer composition
Plan 100.4.4. Принято 2026-05-23 (proposed). Extends D158 composition на multi-defer + panic. Amend D90 §«panic».
Что
- Multi-defer LIFO continues после partial failure. Если defer N fail’ит → defer N-1 still runs (все N attempted; errors accumulate в Plan 49 multi-error chain). Превосходит Rust уверенно (no abort + all cleanups attempted).
- Panic в defer body композируется с propagating через Plan 49 multi-error — нет Rust-style double-panic-abort.
LIFO с partial failure
fn process() Fail[MultiErr] -> () {
defer A_runs // fail E_a
defer B_runs // fail E_b
defer C_runs // success
body // fail E_main
}
// Exit semantics:
// 1. body throws E_main
// 2. C_runs — success; no contribution
// 3. B_runs — fails E_b; suppressed
// 4. A_runs — fails E_a; suppressed (LIFO continues!)
// 5. caller получает MultiError {
// primary: E_main,
// suppressed: [E_b, E_a] // LIFO order: first to fail = first
// }
Panic-in-defer composition
fn process() Fail[Err] -> () {
defer { panic("cleanup broken") }
do_fails()? // throws Err1
}
// Exit:
// 1. body throws Err1
// 2. unwinding starts
// 3. defer fires — panic("cleanup broken")
// 4. panic composes с Err1 → composite { primary: Err1, suppressed: [Panic("cleanup broken")] }
// 5. propagation continues with composed error
Никаких abort’ов. Plan 49 multi-error already supports panic-as- throw; D161 расширяет composition на panic.
Defer-stack runtime structure
for entry in stack.reverse() {
let result = run_defer_body(entry)
match result {
Ok(()) => continue
Err(e) => { propagating = compose(propagating, e); continue }
Panic(m) => { propagating = compose(propagating, Panic(m)); continue }
}
}
throw propagating
LIFO walk completes даже при ошибках. Rust does NOT do this.
Diagnostic — chain visibility
error: composite error during scope exit
primary error:
Err1 ("operation failed") at do_work (process.nv:12)
suppressed during defer LIFO:
[1] Err_B ("cleanup B failed") at B_cleanup() in defer (process.nv:10)
[2] Err_A ("cleanup A failed") at A_cleanup() in defer (process.nv:8)
[3] Panic("cleanup C broken") at panic() in defer (process.nv:11)
Сравнение
| Capability | Go | Rust | TS | Kotlin | Java | Nova D161 |
|---|---|---|---|---|---|---|
| Multi-cleanup LIFO continues после partial fail | ⚠️ defer continues errors lost | ❌ first-panic-abort | ✅ SuppressedError | ⚠️ partial | ✅ addSuppressed | ✅ Plan 49 multi-error |
| Panic в cleanup body | ✅ recover() | ❌ double-panic-abort | ⚠️ SuppressedError | ⚠️ try-catch | ⚠️ silent if not addSuppressed | ✅ composition + no abort |
| All N cleanups attempted | ⚠️ depends | ❌ first-Drop-only-tries | ⚠️ depends | ✅ try-finally chain | ✅ try-with-resources | ✅ guaranteed |
Nova превосходит Rust уверенно (no double-panic-abort + all cleanups attempted) + matches TS/Java на composition + превосходит на visibility (effect-typed).
Связь
- D90 §«panic» — amend’аем.
- D158 — failable cleanup foundation.
- D85 — multi-error composition.
- D162 — consume-integration uses D161 для multi-consume failures.
D162. Consume-integration final — check_consume + defer-family + cancel
Plan 100.4.5. Принято 2026-05-23 (proposed). Amend D90 §7 (
interrupttriggers errdefer). Финал Plan 100.4 umbrella.
Что
check_consume pass (D133) распознаёт defer/errdefer/okdefer
как покрывающие consume-vars на соответствующих exit-paths:
| Statement | Покрывает consume на path’е |
|---|---|
defer { tx.commit() } | все exit-paths (success, error, panic, interrupt) |
errdefer { tx.rollback() } | error-paths (throw, panic, interrupt — amend D90 §7) |
okdefer { tx.commit() } | success-path (normal exit, return) |
Amend D90 §7
БЫЛО: errdefer triggers on throw + panic; NOT on interrupt.
СТАЛО: errdefer triggers on throw + panic + INTERRUPT (за исключением exit()).
Логика: errdefer = «exit без normal completion». throw/panic/interrupt — все «abnormal» exits относительно success-path. Backward-compat impact — handler-flow user-code: errdefer’ы now fire on interrupt. Plan 100.4.5 Ф.0 GATE audit’ит existing fixtures.
Multiple defers на одну consume-var
consume tx = begin()
errdefer { tx.rollback() } // error path
okdefer { tx.commit() } // success path
do_work()?
// tx covered: error (errdefer) + success (okdefer) = exhaustive
Double-cover — error
consume tx = begin()
okdefer { tx.commit() }
tx.commit() // ❌ E (D162-double-cover):
// okdefer already commits.
Partial coverage — error
consume tx = begin()
errdefer { tx.rollback() }
do_work()?
// ❌ E (D162-not-consumed-on-path): success path tx Live.
// Suggest: добавить `okdefer { tx.commit() }` или explicit `tx.commit()`.
Exit-path fixed at start (НЕ retro-fire)
См. D160 §«Exit-path определяется в start, НЕ retro-fires» — если okdefer fail’ит на success-path, errdefer не fires дополнительно. Failure composes через D158/D161 multi-error composition. Exit-path определяется в начале unwinding’а и не меняется по ходу defer-execution.
Supervised cancel + consume cleanup
supervised(cancel: tok) {
spawn {
consume tx = begin()
errdefer { tx.rollback() } // покрывает cancel-path после D90 §7 amend
long_op() // may cancel
tx.commit()
}
}
// На cancel: errdefer fires → tx.rollback() runs (cancel-shielded по D159);
// rollback completes; fiber dies; supervised continues unwinding.
Async-await preservation
fn process() Fail Async -> () {
consume tx = begin()
errdefer { tx.rollback() }
await long_async_op() // suspend; may cancel
tx.commit()
}
// Pre-await: tx Live, errdefer registered.
// Post-await: tx still Live.
// Cancel-during-await: errdefer fires → tx Consumed via rollback.
Canonical Transaction lifecycle
fn process_order(data Data) Fail[OrderErr] Db -> Receipt {
consume tx = Db.begin()
errdefer { tx.rollback()? } // failable rollback (D158)
okdefer { tx.commit()? } // failable commit (D158)
let order = Db.insert(data)?
let receipt = Db.notify(order)?
return receipt // okdefer fires → commit
}
// Error: errdefer fires → rollback (composite если rollback fails)
// Success: okdefer fires → commit (throw если commit fails)