Types — record, sum-type, protocol, generic, поля
Решения этой группы задают систему типов Nova: четыре формы объявления
данных, структурные контракты-протоколы, семантику передачи параметров
и мутабельность полей, делегацию через use. Синтаксические детали
(методы через @, generic-применение [T], литералы) — в
03-syntax.md.
| # | Решение | Status |
|---|---|---|
| D17 | Объявление типов: единый синтаксис без | | revised → D52 |
| D52 | Объявление типов revised: newtype, alias, sum через leading | | active |
| D53 | Унификация: protocol под type, protocol как kind-токен | active |
| D55 | Literal coercion в позиции с явным типом: sum-конструкторы и record-литералы | active |
| D42 | protocol keyword для структурных интерфейсов | revised → D53 |
| D15 | Структурные интерфейсы | revised → D42 → D53 |
| D39 | Embed и delegation: use name Type (alias обязателен) | active |
| D32 | Семантика передачи параметров | revised для полей → D36 |
| D36 | Поля типа: дефолт mutable у mut-binding’а, readonly для never-mut | active |
| D175 | readonly field — полный freeze, транзитивность (амендмент D36) | active |
| D176 | readonly T — тип-модификатор, coercion rules, zero overhead | active |
| D66 | Self universal: ссылка на обобщающий тип в методах, effects, protocols | active |
| D72 | Generic bounds через [T Protocol] — protocol как тип | active |
| D110 | Ghost state — spec-only bindings | active |
| D122 | Hybrid dispatch для bound-K methods | active |
| D123 | Tuple monomorphization | active |
| D119 | Method-level type parameters в generic methods | active |
| D180 | Canonical .new() constructors (convention) | active |
| D181 | Array methods — -> @ fluent mut chain + slice syntax | active |
| D182 | Self в return-type static methods — required form для parametric types | active |
| D183 | Canonical comparison protocols + default method bodies (Plan 91.8a) | active |
D17. Объявление типов: единый синтаксис без |
⚠️ REVISED. Заменено D52. Старый синтаксис (
type X = Yдля alias,type X = A, Bдля sum) — запрещён. Новый:type X Y(newtype),type X alias Y(alias),type X | A | B(sum). Текст ниже — для исторической справки.
Что
Все формы объявления типа — record, позиционная структура, unit, alias,
sum-type — используют один разделитель списка (запятая) и
синхронизированы по =: = ставится только когда справа выражение
типа (alias или sum-type), не когда форма данных ({...} или (...)).
Правило
Полный синтаксис:
// alias
type UserId = u64
// record (именованные поля)
type User { id u64, name str }
// позиционная структура
type Point(f64, f64)
// unit-тип (без полей)
type Empty
// sum-type
type Color = Red, Green, Blue
type Shape =
Circle { radius f64 },
Square { side f64 },
Triangle { a f64, b f64, c f64 }
type Result[T, E] = Ok(T), Err(E)
Парсер однозначен по первому токену после имени типа:
После type X идёт | Что это |
|---|---|
{ ... } | record-структура |
( ... ) | позиционная структура |
| ничего | unit-тип |
= потом тип | alias |
= потом список вариантов через запятую | sum-type |
type X { ... } — это record с полями. Методы внутри {...}
запрещены: набор методов = поведение, для него используется protocol
(D42). Эффекты —
это protocol, использованный в позиции эффекта между ) и ->
(04-effects.md → D18).
Создание значений и pattern matching — обычные:
let p = Point(1.0, 2.0)
let u = User { id: 1, name: "alice" }
let c = Circle { radius: 5.0 }
match shape {
Circle { radius } => 3.14159 * radius * radius
Square { side } => side * side
Triangle { a, b, c } => heron(a, b, c)
}
Field punning для record-литералов: если имя поля совпадает с именем переменной в скоупе, можно писать имя один раз:
let key = "alice"
let value = 42
let entry = Entry { key, value } // shorthand
let entry = Entry { key, value, extra: "data" } // можно смешивать
Парсер однозначен: name: → полная форма, name, или name} →
shorthand. Если переменной нет в scope — compile error.
Partial pattern matching — две эквивалентные формы:
// явная — с маркером ..
match @buckets[idx] {
Occupied { value, .. } => Some(value)
_ => None
}
// неявная — без маркера, остальные поля игнорируются
match @buckets[idx] {
Occupied { value } => Some(value)
_ => None
}
Явная форма — visual cue «здесь ещё поля». Неявная — краткость.
Переименование при деструктуризации остаётся явным: Occupied { key: k, value }.
Construction всегда требует все обязательные поля — частичное
заполнение типа Rust ..default отдельным синтаксисом не зафиксировано.
Почему
- Один разделитель списка на весь язык — запятая. Параметры,
элементы массивов, поля записи, варианты sum-type — везде
,. Меньше правил, меньше ошибок LLM. =означает «справа выражение типа». Когда справа форма данных —=лишний.- Парсер по первому токену — никакого backtracking, чистые сообщения об ошибках.
Что отвергнуто
- ML-style
| Variant(OCaml/Haskell/F#/Rust). Два разделителя подряд (= |), чужд языкам не из ML-семейства, дублирует роль запятой. type Point = | Point(f64, f64)для одно-вариантного sum-type — дубль. Sum-type с одним вариантом и структура — это одно и то же.type User = { id u64, name str }для record.=лишний, когда справа форма данных.
Связь
- 03-syntax.md → D27 — массивы (
[]T,[N]T) как отдельные конструкции типов, не вариантыtype. - 03-syntax.md → D38 — generic-применение
Имя[T]для параметризованных типов. - 02-types.md → D42
— почему
protocolотдельный keyword, а неtype X = { методы }. - 02-types.md → D36
— префиксы полей (
readonly,mut) и group-syntax внутри record.
D52. Объявление типов revised: newtype, alias, sum через leading |
Что
Полная пересборка D17.
Один keyword type для всех data-форм, никаких = в декларациях,
форма различается первым токеном после имени. Шесть форм:
- newtype —
type X Y(X — новый тип, типизированно отличный от Y, Go-style) - alias —
type X alias Y(X и Y совместимы, для длинных дженериков) - record —
type X { поля } - tuple —
type X(типы) - unit —
type X(ничего после имени) - sum —
type X | A | B | C(leading|обязателен)
Sum-варианты могут иметь числовые discriminants с auto-increment.
protocol остаётся отдельным keyword’ом для поведения
(D42).
Правило
Полный синтаксис
// 1. Newtype — type X Y, без =
type UserId u64
type Email str
type Score f64
// 2. Alias — type X alias Y, для сокращения длинных дженериков
type StringMap[V] alias HashMap[str, V]
type Cache[K, V] alias HashMap[K, (V, Time)]
// 3. Record — type X { поля }
type User { id u64, name str }
type Point3D { x, y, z f64 } // group-syntax (D36)
type Account {
readonly id u64
balance money
mut last_access time
}
// 4. Tuple — type X(типы)
type Point(f32, f32)
type Pair[A, B](A, B)
// 5. Unit — type X
type Empty
type Sentinel
// 6. Sum — type X | A | B (leading | обязателен)
type Color | Red | Green | Blue
type Direction | North | East | South | West
// Sum многострочный
type Result[T, E]
| Ok(T)
| Err(E)
type Shape
| Circle { radius f64 }
| Square { side f64 }
| Triangle { a f64, b f64, c f64 }
Парсер однозначен по первому токену после имени (с учётом дженериков)
После type X (или type X[params]) идёт | Форма |
|---|---|
| | sum |
( | tuple |
{ | record |
alias | alias |
<base-type> | | sum с явным базовым типом для discriminants |
| идентификатор/тип, конец строки | newtype |
| конец строки сразу | unit |
Парсер видит первый токен — сразу знает форму. Никакого backtracking, никакого lookahead за пределы одного-двух токенов.
Sum-варианты с числовыми discriminants
// Auto-increment без явных значений (от 0)
type ExitStatus | Ok | Failure | Critical // 0, 1, 2
// Auto-increment от заданного
type FileMode | Read = 1 | Write | Execute // 1, 2, 3
// Все явные
type ErrorCode
| NotFound = 404
| Unauthorized = 401
| InternalError = 500
// С отрицательными
type Sign | Negative = -1 | Zero = 0 | Positive = 1
// Decreasing/non-monotonic — разрешено
type Code | A = 10 | B = 5 | C // A=10, B=5, C=6
// Явный базовый тип
type Bit u8 | Off = 0 | On = 1
type HttpCode i32 | Ok = 200 | NotFound = 404
⚠ Явный базовый тип пока не реализован (parser drift, 2026-05-27). Формы с
u8/i32/etc. между именем и|парсер отвергает сexpected fn / type / let / const / test, got '|'. Работает только дефолтная форма (без базового типа, implicitint). См. Plan 105.
Правила discriminants:
- Базовый тип — дефолт
int. Опционально явный (type X i32 |,type X u8 |). - Auto-increment от первого варианта:
- Первый без значения → 0.
- Каждый следующий без значения → предыдущий + 1.
- Отрицательные значения — разрешены.
- Decreasing/non-monotonic последовательности — разрешены.
- Конфликт значений (два варианта с одинаковым discriminant) — запрещён компилятором.
- Mixed (некоторые с полями, некоторые без, у всех discriminants) —
разрешено:
type Event | Click(x int, y int) = 1 | KeyPress(key str) = 2 | Idle = 3 | Data { payload []u8, crc u32 } = 10
Cast между sum-типом и числом
Sum → int — безопасный, всегда работает:
let c = Red // Color
let n = c as int // 0 (если auto-increment)
let e = NotFound // ErrorCode
let n = e as i32 // 404
int → Sum — через pattern match obligation:
let n = read_from_db()
let c = match n {
0 => Red
1 => Green
2 => Blue
_ => throw InvalidColor
}
Никакого n as Color — программист сам обрабатывает «нет такого
варианта». Это согласовано с эффектом Fail[E].
stdlib может предоставлять Color.from_int(n) для удобства:
fn Color.from_int(n int) Fail[InvalidVariant] -> Color =>
match n {
0 => Ok(Red)
1 => Ok(Green)
2 => Ok(Blue)
_ => Err(InvalidVariant)
}
Параметризованные sum
type Option[T] | Some(T) | None
type Result[T, E] | Ok(T) | Err(E)
type Tree[T]
| Leaf
| Node { value T, left Tree[T], right Tree[T] }
Параметры в [...] после имени работают везде, как и раньше.
Сравнение alias и newtype
type AliasUserId alias u64
type NewUserId u64
let a AliasUserId = 42 // ok
let b u64 = a // ok — alias совместим с u64
let c u64 = 42
let d AliasUserId = c // ok — обратное тоже работает
let n NewUserId = 42 // ok (литерал подгоняется под целевой тип)
let e u64 = n // ОШИБКА: NewUserId не u64
let f u64 = n as u64 // ok через cast
Альтернативу newtype через record-обёртку (type X { value u64 })
никто не запрещает, но type X u64 — компактнее и привычнее
программистам с фоном Go.
Field punning — расширено и обязательно
D52 расширяет field punning из D17 двумя правилами:
1. Shorthand для @field-доступов (новое в D52):
type RangeIter { end int, inclusive bool, mut cur int }
fn Range @iter() -> RangeIter =>
{ @end, @inclusive, cur: @start }
// ↑ ↑ ↑
// @end shorthand полная форма (имя поля cur ≠ start)
{ @end } означает «поле end, значение @end (то есть self.end)».
По симметрии с D17 ({ name } для переменной name в scope) —
теперь { @field } для self-доступа.
2. Shorthand обязателен, когда имя поля совпадает с источником:
// Переменная в scope:
let key = "alice"
let value = 42
let entry = Entry { key, value } // ✓ обязательная форма
let entry = Entry { key: key, value: value } // ✗ ОШИБКА: избыточная форма
// @field-доступ:
let r = { @end, @inclusive, cur: @start } // ✓
let r = { end: @end, inclusive: @inclusive, ... } // ✗ ОШИБКА: избыточная
// Явная форма обязательна, когда имя источника отличается:
let entry = Entry { name: user_name } // ✓ имя поля ≠ переменной
let r = { cur: @start } // ✓ имя поля cur ≠ start
let r = { end: other.end } // ✓ источник — выражение, не @field
Парсер: { name/{ @name/{ name,/{ name } — shorthand;
{ name: expr — полная форма. После : ожидается выражение,
но если выражение — это ровно тот же identifier или @+identifier,
что и имя поля → ошибка компиляции «избыточная форма, используйте
shorthand».
Status: ✅ enforced (2026-05-17, commit 34666922c35). Реализация
в compiler-codegen/src/types/mod.rs RecordLit walker. AST flag
RecordLitField.at_shorthand различает parser-generated @field
shorthand от explicit { field: @field } (одинаковая AST форма).
Test guards: nova_tests/negative_capability/d52_redundant_field_literal_rejected.nv
d52_redundant_self_field_rejected.nv.
Mixed разрешён:
{ @end, @inclusive, cur: @start, kind: "iter" } // shorthand + полные
Когда расширение работает:
| Имя поля | Источник | Правило |
|---|---|---|
name | переменная name в scope | shorthand { name } обязателен |
name | @name (self-поле) | shorthand { @name } обязателен |
name | переменная other (другое имя) | полная форма { name: other } |
name | @other или выражение | полная форма { name: @other } |
name | obj.field | полная форма { name: obj.field } |
name | литерал, вызов, любое выражение | полная форма |
Pattern matching и construction
match @buckets[idx] {
Occupied { value, .. } => Some(value) // partial с ..
Occupied { value } => Some(value) // partial без ..
_ => None
}
Construction всегда требует все обязательные поля. Частичное
заполнение типа Rust ..default отдельным синтаксисом не зафиксировано.
Что запрещено
type X = Yдля alias — старый D17 синтаксис, заменён наtype X alias Y.type X = A, Bдля sum — заменён наtype X | A | B.type X = { ... }для record — синтаксис никогда не был активным (D17 уже отвергал),=в этой позиции запрещён.,для разделения вариантов sum — заменено на leading|.- Sum без leading
|у первого варианта — обязателен (type X Red | Green✗,type X | Red | Green✓). - Single-variant sum — запрещён (как в D17), используйте record.
- Конфликт discriminants — запрещён.
- Избыточная форма
{ name: name }— обязателен shorthand{ name }. Аналогично{ field: @field }— обязателен{ @field }. Если имя источника совпадает с именем поля, программист обязан использовать shorthand. См. «Field punning» выше.
Почему
- Системность. В D17 правило «
=для выражений типа, без=для форм данных» работало для alias, но спотыкалось на sum-type:type Color = Red, Green, Blue— справа не «выражение типа» в обычном смысле, а список конструкторов. С D52 sum обрабатывается как именованная форма (через|), как и record/tuple/unit. - Никаких
=в декларациях типов — устраняется напряжение «иногда есть, иногда нет».=остаётся за binding’ом значений (let x = ...) и parameter defaults (если будут). - Newtype как first-class. Domain-modeling (
type Email str,type Score f64) даёт реальную защиту типов без шумной record-обёртки. Прецедент Go (type UserId int64). - Discriminants для wire-протоколов. HTTP-коды, syscall-коды, serialization tags — программист может задать стабильные значения, как в C/TS/Swift enum.
- Парсер однозначен по первому токену — никакого lookahead глубже одного-двух токенов. AI-friendly: LLM с одного взгляда понимает форму.
- Leading
|для sum — visual symmetry: все варианты выровнены, прецедент OCaml/F#/Scala 3. - Согласованность с D1 «protocols + data, без классов» —
typeтолько для данных,protocolотдельно для поведения. - Field punning расширен и обязателен. Один способ записать
«поле = источник с тем же именем» — shorthand. Запрет избыточной
формы
{ name: name }устраняет «два пути к одному результату», что AI-unfriendly (LLM генерирует случайно). Также покрывает{ @field }для self-доступов — частый паттерн в record-литералах методов-конструкторов. Прецедент: TS/Rust имеют shorthand, но не делают его обязательным; Nova идёт строже ради единого стиля (D40/D43-стилевая последовательность).
Что отвергнуто
- Сохранить
type X = Yдля alias. Создаёт асимметрию: alias и sum с=, record/tuple/newtype без — нет единого правила. - Kind-токен
enumдля sum (type X enum { A, B }). Длиннее, чем leading|, не даёт дополнительной информации. - Литералы как sum-варианты (
type State | "open" | "closed", TS-style literal types). Полезно, но это отдельная фича (subtyping, runtime representation), отложена на следующую версию языка. - Итерация по вариантам (
for c in Color). Связано с reflection и stdlib, отложено до Q9. type X protocol { ... }под единымtype. Семантически protocol — поведение, не данные; отдельный keyword чище.type X newtype Yс явным kind-токеном.type X Yбез токена короче и согласовано с Go.- Implicit cast int → Sum. Type-небезопасно (число может не попасть в варианты). Только через pattern match.
Цена
- Большой breaking change. Все существующие декларации в spec/, decisions/, examples/ переписать. Кода пока мало, миграция разовая.
aliasстановится keyword’ом. Раньше был обычным идентификатором.- Программистам с фоном Rust/TypeScript:
type X = Yбольше не alias, а ошибка. Адаптация через документацию. - Парсинг
type X Y(newtype) vstype X(unit) — различие по следующему токену (тип vs конец строки). Просто, но требует внимательности. |имеет двойную роль — разделитель в sum и@orв операторах (D46). Парсер различает по контексту.
Связь
- D17 — старая версия, помечена revised → D52.
- D42 —
protocolостаётся отдельным keyword’ом для поведения. - D36
— префиксы полей (
readonly,mut) и group-syntax внутри record. - D39 —
delegation через
use Type. Newtype с embed (type X { use Y }) — альтернатива alias для случаев, когда нужна обёртка с дополнительными полями. - 03-syntax.md → D44 — числовые литералы
(
0xFF,1_000, негативные) — используются для discriminants. - 03-syntax.md → D46 —
|в operator overloading (@or) — разрешается компилятором по контексту. Полная семантика overloading — D84.
Открытые вопросы
- Литералы как sum-варианты (TS-style
| "open" | "closed") — отложено до следующей версии. - Итерация по вариантам (
for c in Color,Color.values()) — связано с reflection, откладывается до Q9 (stdlib). - Implicit cast литерала в newtype. Сейчас
let u UserId = 42— допустим (литерал подгоняется), ноlet n u64 = 42; let u UserId = n— требует явного cast. Точную семантику зафиксировать в Q (литералы vs binding’и).
Эволюция
D17 был первой
итерацией, основанной на правиле «= для выражений типа». Со
временем выяснилось, что:
- Sum-type с
=— натяжка («справа выражение типа» не точно описывает список вариантов). - Newtype отсутствовал как явная фича — программистам приходилось
делать record-обёртки
type X { value u64 }, что шумно. - Discriminants на sum-вариантах не были специфицированы — но реальные wire-протоколы их требуют.
D52 решает все три, ценой breaking change по syntax-site всех type-объявлений. Подробно — history/evolution.md.
D53. Унификация: protocol под type, protocol как kind-токен
Что
protocol перестаёт быть отдельным keyword’ом. Становится kind-
токеном в системе D52, наряду с alias. Все объявления типов
(включая структурные контракты-protocol’ы) идут через единый keyword
type. Анонимный protocol-тип в позиции параметра пишется через
protocol { ... } (с явным маркером, симметрично []T, (A, B),
fn() -> T).
any — пустой именованный protocol-тип в prelude:
type any protocol { }
Правило
Объявление через type X protocol { ... }
// Раньше (D42): отдельный keyword
protocol Hashable {
hash() -> u64
eq(other Self) -> bool
}
// Теперь (D53): kind-токен в системе D52
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
type Logger effect {
log(msg str) -> ()
}
type Iterator[T] protocol {
next() -> Option[T]
}
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
exec(q Sql) Fail[DbError] -> int
}
Парсер: protocol как kind-токен после имени
Расширение таблицы D52:
После type X (или type X[params]) идёт | Форма |
|---|---|
protocol | protocol-тип |
| | sum |
( | tuple |
{ | record |
alias | alias |
<base-type> | | sum с явным базовым типом |
| идентификатор/тип, конец строки | newtype |
| конец строки сразу | unit |
protocol встаёт в один ряд с alias. Парсер однозначен по первому
токену после имени (или generic-параметров).
Анонимный protocol-тип в позиции параметра
protocol { ... } в позиции типа — анонимный protocol-литерал,
симметрично []T, (A, B), fn() -> T:
fn log_one(x protocol { show() -> str }) Log -> () =>
Log.info(x.show())
fn closer_call(c protocol { close() -> () }) Io -> () =>
c.close()
fn process(x any) -> () => // any — именованный пустой protocol
...
fn process2(x protocol { }) -> () => // эквивалент через анонимный
...
Маркер protocol обязателен — { ... } без префикса в позиции типа
запрещено. Это убирает двусмысленность с record-литералами и
выражениями-блоками.
any в prelude
// В prelude:
type any protocol { }
Любой тип удовлетворяет пустому контракту (структурная типизация),
поэтому any — top-type. Использование:
type Logger effect {
log_event(level int, fields []any) -> ()
// ^^^^^ массив значений любого типа
}
fn dump(x any) Io -> () =>
println(x)
Имя any lowercase — исключение в D30 naming
convention, по аналогии с примитивами (int, str, bool, f64,
()). Top-type концептуально близок к примитивам — встроенный
универсальный тип.
Эффекты — без изменений
Эффект — это protocol-тип, использованный в позиции эффекта (между
) и ->). Меняется только синтаксис объявления, не использования:
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
exec(q Sql) Fail[DbError] -> int
}
fn list_users() Db -> []User => // Db в позиции эффекта — как раньше
Db.query(sql`SELECT * FROM users`)
Generic-параметры — без изменений
D42-уточнение про две модели (на protocol-уровне и на методе) сохраняется. Меняется только синтаксис объявления:
// Модель A — generic на protocol
type Container[T] protocol {
add(item T) -> ()
get(idx int) -> T
}
// Модель B — generic на методе
type Tracer effect {
span[T](body fn() -> T) -> T
measure[U](body fn() -> U) -> Duration
}
Структурная совместимость — без изменений
Любой тип со структурно совпадающими методами автоматически удовлетворяет protocol’у:
type User { id u64, name str }
type Printable protocol {
show() -> str
}
fn User @show() -> str => "User(${@name})"
fn log_one(x Printable) Log -> () =>
Log.info(x.show())
log_one(my_user) // ok, User совместим со Printable
Self внутри protocol { ... } блока — это «late-bound» тип,
определяется при удовлетворении (см. также D66 — Self
universal во всех type-контекстах).
Почему
- Унификация под одним keyword. Все типы (data + behavior) идут
через
type. Один keyword для объявления, kind-токен различает форму. Согласовано с D52, который вводитaliasкак kind-токен —protocolвстаёт в тот же ряд. - Снимается асимметрия. До D53:
protocol Foo— отдельный keyword, ноFooиспользовался как тип (в позиции параметра). Программист спрашивал «если protocol — тип, почему не объявляется через type?». D53 отвечает: теперь объявляется. - Анонимные protocol-типы становятся явными. Раньше
fn f(x { ... })без префикса — двусмысленно (record-литерал? record-тип? protocol-тип?). Сprotocol { ... }— намерение явно. any— пустой именованный protocol. Простое и согласованное решение для top-type, через ту же систему. Прецедент Go (type any = interface{}), Swift (protocol AnyObject { }).- Прецедент Go. Go объявляет
type X struct { }иtype X interface { }через единыйtypeс kind-токеном. D53 повторяет эту схему точно (толькоinterface→protocol). - AI-friendly. Один keyword
typeв начале — LLM сразу видит «это объявление типа», kind показывает форму. Меньше keyword’ов для запоминания.
Что отвергнуто
- Сохранить
protocol Foo { ... }как отдельный keyword (текущий D42). Создаёт асимметрию: data объявляется черезtype, behavior — черезprotocol, оба используются как типы — два пути к одной концепции «тип». D53 устраняет. type any alias protocol { }как форма дляany. Для protocol’ов alias-форма семантически тождественна newtype-форме (структурная типизация делает имена незначимыми). Дополнительный синтаксис без выигрыша. Прямаяtype any protocol { }короче и яснее.Any(PascalCase). Согласовано с D30 строже, ноanylowercase привычнее (Go, TS) и согласовано с примитивами.- Анонимный protocol без префикса
{ ... }. Двусмысленно с record-литералами и блок-выражениями.protocol { ... }всегда явно. - Литеральные protocol’ы со значениями полей (как
interface{}в Go допускает методы и встраивание других interface’ов через composition). Composition protocol’ов (Foo : Bar) — открытый вопрос (см. D42 раздел «Открытые вопросы»), не входит в D53.
Цена
- Большой breaking change. Все
protocol Foo { ... }в spec/, decisions/, examples/ переписать вtype Foo protocol { ... }. Это — повторение масштаба D52 миграции. - На одно слово длиннее.
type Hashable protocol { ... }противprotocol Hashable { ... }— лишнийtype(5 символов). protocolтеперь kind-токен, не keyword. Грамматически разные роли (kind-token ≠ leading keyword), хотя пишется одинаково.- Анонимные protocol-типы в позиции параметра — новая форма,
старая (без префикса) запрещена. Все
fn f(x { method() })→fn f(x protocol { method() }). - Q22 закрывается этим решением — больше не открытый вопрос.
Связь
- D17 — старая система объявлений, revised → D52.
- D52
— D53 расширяет:
protocolвстаёт в ряд kind-токенов рядом сalias. - D42 — D53
заменяет
protocolkeyword на kind-токен. Семантика структурной типизации и generic-параметров сохраняется. - 04-effects.md → D18 — эффект как использование protocol-типа в позиции эффекта. Меняется только объявление.
- 08-runtime.md → D26 —
anyдобавлен в prelude. - 03-syntax.md → D30 — naming:
anylowercase как исключение, по аналогии с примитивами.
Открытые вопросы
- Type-pattern-match для значений
any. Извлечение конкретного типа изany-значения (match x { int(n) => ..., str(s) => ... }) требует runtime-tag и новой формы match. Не входит в D53. - Composition protocol’ов (
Foo : BarилиFoo extends Bar) — не входит, см. Q21 «proliferation эффектов» как родственный вопрос.
Эволюция
D42 ввёл
protocol как отдельный keyword. После D52 (kind-токены alias)
выявилась асимметрия: protocol используется как тип, но объявляется
не через type. D53 снимает асимметрию — protocol становится
kind-токеном в системе D52, унифицируя объявление всех типов под
единым keyword’ом.
Q22 («унификация type/protocol») — закрыт принятием D53.
Method-prefix в protocol-блоке (Plan 17 Ф.1)
В protocol-объявлении instance-методы можно писать в обеих формах
— и с префиксом @, и без. Они эквивалентны:
type Hashable protocol {
hash() -> u64 // ✅ голое имя
eq(other Self) -> bool
}
type Hashable protocol {
@hash() -> u64 // ✅ с @, симметрия с реализацией
@eq(other Self) -> bool
}
@ факультативен потому что в protocol-блоке метод всегда
instance — без receiver-выражения, контекст однозначный. С @
форма читается как «копия декларации из реализации» (точно как fn User @hash() -> u64); без @ — короче. Структурная совместимость
работает одинаково.
Когда писать что:
@method()— для визуальной симметрии с реализацией; для объявлений где соседние static-методы (если они появятся через Q-static-method-protocol) пишутся через.method().method()— для краткости в простых protocol’ах.
Mut-методы — mut @method() обязательно с @ (mut-modifier
требует receiver-маркера; голое mut method() отвергнуто как
двусмысленное с mut-binding’ом):
type Iter[T] protocol {
mut @next() -> Option[T] // ✅
mut next() -> Option[T] // ✅ (текущая prelude-форма, D26)
}
В bootstrap’е (2026-05-08) обе формы парсятся; std/testing/property.nv и std/collections/* используют голую форму.
См. также Q-protocol-method-prefix (closed этой секцией).
Реализация в bootstrap (2026-05-09)
Plan 15 D53 strict-mode (Plan 15 Ф.5) ввёл различие protocol/effect
на уровне AST. Раньше оба keyword’а маршрутизировались в один
TypeDeclKind::Effect(Vec<EffectMethod>), что нарушало D72:
любой method-bag тип permissively принимался как generic-bound.
Текущее состояние:
TypeDeclKind::Protocol(Vec<EffectMethod>)— дляtype X protocol {…}.TypeDeclKind::Effect(Vec<EffectMethod>)— дляtype X effect {…}.- Парсер маршрутизирует по ключевому слову (отдельные match-arm).
- Codegen эмитит vtable только для Effect-kind. Protocol —
compile-time-only; type_ref_to_c для protocol-методов не
вызывается. Это попутно зафиксировало pre-existing bug:
Selfв protocol-методе раньше ломал codegen (искал несуществующийNova_Self*). - Type-checker (D72 enforcement) регистрирует только
Protocol-kind в
protocol_specs. Попытка использовать Effect как bound — compile error c hint’ом «Xis an effect, not a protocol — declare astype X protocol {…}». - Анонимные protocol-литералы в позиции типа (
fn close(c protocol { close() -> () }), §628 этой секции) — ✅ реализованы в Plan 97 Ф.2 через новыйTypeRef::Protocol(ProtocolSig)variant. - Protocol-литералы в expression-position (
let l = protocol Name { ops }) с runtime vtable + dispatch — ✅ реализованы в Plan 97.1 (codegen vtable struct +emit_protocol_lit+ Plan 56 D122 box-pattern). См. также D142.
D55. Literal coercion в позиции с явным типом: sum-конструкторы и record-литералы
Что
В позиции, где компилятор явно знает целевой тип T (let с
аннотацией, аргумент функции, return-выражение), литерал
автоматически подгоняется под T. Три случая:
- Sum-coercion. Значение типа
Sоборачивается в единственный unary-конструкторC(S)sum-типаT. - Record-coercion. Анонимный record-литерал
{ field: value, ... }получает типTбез необходимости писать имя типа перед{}. - Map-coercion. Анонимный record-литерал
{ name: value, ... }в позиции, ожидающей str-keyed map (HashMap[str, V]— тип с compiler-recognized markerFromFields[V]), превращается в map: имена полей становятся строковыми ключами. Это не record-coercion (поля литерала ≠ поля struct’аHashMap) — отдельное правило, см. ниже.
Без runtime-cost, без subtyping. После coercion тип значения — сам T.
// Sum-coercion
type StrOrInt | S(str) | I(int)
let a StrOrInt = "test" // компилятор: a = S("test")
let b StrOrInt = 25 // компилятор: b = I(25)
fn process(x StrOrInt) -> str => ...
process("alice") // компилятор: process(S("alice"))
process(42) // компилятор: process(I(42))
// Record-coercion
type User { id u64, name str }
let u User = { id: 2, name: "Bob" } // компилятор: u = User { id: 2, name: "Bob" }
fn create_user() -> User =>
{ id: 3, name: "Carol" } // компилятор подставляет User
fn save(u User) -> () => ...
save({ id: 4, name: "Dave" }) // компилятор: save(User { ... })
Правило
Позиции с «явно ожидаемым типом»
Coercion (и sum-, и record-вариант) применяется только там, где компилятор точно знает целевой тип:
| Позиция | Coercion применяется? | Реализовано (bootstrap)? |
|---|---|---|
let x T = value (явная аннотация) | да | ✅ record (Plan 51 Ф.1) |
const X T = value | да | ✅ record |
fn f() -> T => value (return-выражение) | да | ✅ record |
fn f(x T) — на caller-стороне (f(value)) | да | ✅ sum/record/map (Plan 52 Ф.3a) |
Generic-параметр после конкретизации (Maybe[int]) | да | ⛔ ещё нет |
| Match-arm result (когда тип ветки фиксирован) | да | ⛔ ещё нет |
Литерал коллекции с явным типом ([]T) | да для каждого элемента | ⛔ ещё нет |
let x = value (без аннотации) | нет — выводится тип значения | — |
В позициях без явного типа никакая coercion не применяется — литерал
имеет «свой» тип ({ id: 2 } — анонимный record, 42 — int, и т.д.).
Статус реализации (2026-05-15). В bootstrap-компиляторе sum-/record-/map-coercion для безымянного литерала реально работает в позициях, помеченных ✅ (включая аргумент-позицию после Plan 52 Ф.3a —
f({...}),f([k:v]), named-args). Для ⛔-позиций безымянный{ ... }пока даёт codegen-ошибку — там пишиT { ... }. Полная реализация D55 во всех позициях — отдельная задача (investigation в Plan 51 показал, что «~900 избыточных мест» — переоценка; основная масса — это перенос имени, а не устранение).⚠️ Пример
save_all([{id:1,name:"a"}, ...])ниже некорректен для bootstrap’а. Элемент-позиция литерала коллекции ([]T) помечена ⛔ — coercion на элементах массива пока не работает. Пример станет валиден после расширения Ф.3a на element-positions (за scope Plan 52). Пока там нужен[User{...}, ...]с явным именем типа на каждом элементе.
Запрет дублирования имени типа (Plan 51)
Там, где компилятор знает целевой тип, имя типа в record-литерале избыточно и запрещено — тип объявляется ровно один раз. Enforce’ится в двух позициях:
| Форма | Вердикт |
|---|---|
fn f() -> T => { ... } | ✅ каноничная |
fn f() -> T => T { ... } | ⛔ тип дважды |
fn f() => T { ... } | ⛔ нет return-типа — тип «спрятан» в литерале |
let x T = { ... } | ✅ каноничная |
let x = T { ... } | ✅ (тип один раз — в литерале) |
let x T = T { ... } | ⛔ тип дважды |
-> Self резолвится к типу receiver’а (-> Self => Counter { ... } в
методе Counter — тоже избыточно). Правило не срабатывает, когда
тип литерала ≠ целевой тип — это sum-coercion (fn f() -> Result[U,E] => U { ... }, fn g() -> Shape => Circle { ... }): имя варианта
обязательно. Применяется к fn, @-методам и closure-full с =>-телом.
Sum-coercion
В позиции с явным ожидаемым типом T (sum-тип) значение типа S
оборачивается, если:
- У
Tровно один unary-конструкторC(S), принимающий типS. - Значение точного типа
Tуже не подходит (нет exact match).
Стандартные prelude-типы:
let m Maybe[int] = 42 // Just(42)
let r Result[User, str] = User { ... } // Ok(User { ... })
let opt Option[str] = "alice" // Some("alice")
Коллекции:
type SqlValue | I(i64) | F(f64) | S(str) | B(bool) | Bytes([]u8) | Null
let args []SqlValue = [42, "alice", true] // [I(42), S("alice"), B(true)]
// В sql`...` тэге интерполяции тоже coerce'ятся: i64 → I, str → S, bool → B
let q = sql`SELECT * FROM users WHERE id = ${42}` // args = [I(42)]
Генерики:
type Wrapper[T] | W(T) | Empty
let w Wrapper[int] = 42 // W(42)
let w Wrapper[str] = "test" // W("test")
Record-coercion
В позиции с явным ожидаемым record-типом T анонимный record-литерал
{ field: value, ... } подгоняется под T. Имя типа перед {}
писать не нужно — компилятор подставляет.
type User { id u64, name str }
let u User = { id: 2, name: "Bob" }
// эквивалент:
let u User = User { id: 2, name: "Bob" }
fn save(u User) -> () => ...
save({ id: 4, name: "Dave" }) // эквивалент save(User { ... })
fn create() -> User =>
{ id: 5, name: "Eve" } // эквивалент User { id: 5, name: "Eve" }
fn make_default() -> Account =>
{ id: 1, balance: 0, closed: false } // в return-позиции с типом Account
Правила:
- Все обязательные поля должны присутствовать в литерале — как и для именованного record-литерала (D17 construction всегда требует все поля).
- Имена и типы полей должны точно соответствовать
T. Лишнее поле или несовпадение типа — ошибка компиляции. - Field punning (D17)
работает:
let u User = { id, name }еслиidиname— переменные в скоупе. - Без явного целевого типа литерал
{ id: 2, name: "Bob" }остаётся анонимным record-значением. Тип параметра функции или аннотацииletактивирует coercion.
Композиция с sum-coercion:
let r Result[User, str] = { id: 2, name: "Bob" }
// шаг 1 (record-coercion): { id: 2, name: "Bob" } → User { id: 2, name: "Bob" }
// шаг 2 (sum-coercion): User → Ok(User { ... })
Записывается как одно действие компилятора в позиции с явным типом
Result[User, str]. Один-единственный record-литерал → User → Ok.
Симметрия с массивами:
То же type-driven поведение работает для массивов и других литералов в позиции аргумента — это та же модель, которой Nova уже пользуется для пустых массивов:
fn first[T](xs []T) -> Option[T] => ...
let r = first([]) // [] : []T, T выводится из контекста
fn save(u User) -> () => ...
save({ id: 2, name: "Bob" }) // { ... } : User, тип параметра известен
fn save_all(us []User) -> () => ...
save_all([{ id: 1, name: "a" }, { id: 2, name: "b" }])
// каждый { ... } получает тип User из контекста []User
Аннотация типа параметра — единственный «локальный контекст», который читается, и он рядом с вызовом.
Sum-варианты с record-формой не получают анонимной формы — программист пишет конструктор:
type Shape | Circle { radius f64 } | Square { side f64 }
let s Shape = Circle { radius: 5.0 } // явный конструктор обязателен
let s Shape = { radius: 5.0 } // ОШИБКА: по полям невозможно
// выбрать между Circle и Square
// (даже если у них разные поля,
// программист пишет имя варианта)
Это сознательное ограничение: sum-варианты с record-формой требуют имени конструктора всегда. Иначе at parse-time нужно матчить по структуре полей — type-driven parsing, антипаттерн.
Map-coercion
В позиции с явным ожидаемым типом HashMap[str, V] анонимный
record-литерал { name: value, ... } превращается в str-keyed map:
имена полей литерала становятся строковыми ключами, значения —
значениями map.
let h HashMap[str, bool] = { debug: true, verbose: false }
// эквивалент: HashMap[str, bool] с ключами "debug", "verbose"
fn configure(opts HashMap[str, int]) -> () => ...
configure({ width: 80, height: 25 }) // ключи "width", "height"
Почему отдельное правило, а не record-coercion. HashMap[K, V] —
это struct (type HashMap[K, V] { buckets, count, ... }). Обычная
record-coercion матчила бы { debug: ... } против полей struct’а
HashMap (buckets, count) и падала бы. Map-coercion трактует
имена полей литерала как ключи, а не как поля struct’а. Чтобы
компилятор знал, какое из двух правил применить, целевой тип несёт
compiler-recognized marker FromFields[V]:
- Это не opt-in ради эргономики (которое D55 отвергает для
sum/record) — marker здесь load-bearing для дисамбигуации:
«трактовать
{...}как поля этого struct’а» vs «как строковые ключи». Без него правило неоднозначно. - Gating:
HashMap[str, V]несёт marker; случайный struct — нет, и не начнёт принимать произвольные record-литералы. - Bootstrap: marker захардкожен для
HashMap. ПротоколFromFields[V]как точка расширения (OrderedMap,BTreeMap[str, V]) — позже.
Правила:
- Ключи — только str (имена полей литерала). Нестроковые ключи,
не-идентификаторные строки, вычисляемые ключи — это map-литерал
[k: v](03-syntax.md → D108), не{...}. - Значения гомогенны — все поля одного типа
V(после возможной sum-coercion на каждом значении). - Композиция с sum-coercion:
let j HashMap[str, JsonValue] = { name: "alice", age: 30.0 } // "alice" → Str("alice"), 30.0 → Num(30.0); оба → JsonValue - Десугаринг — без промежуточных объектов: block-expression с
with_capacity+@insert, никакой промежуточный record не материализуется (литерал — только синтаксис):{ let mut _m0 = HashMap[str, V].with_capacity(n) let _ = _m0.insert("debug", true) let _ = _m0.insert("verbose", false) _m0 } - Пустой
{}— это НЕ пустая мапа.{}всегда парсится как пустой block-expression с типомunit— даже в позиции, ожидающейHashMap[str, V]. Пустая мапа записывается как[]+ ожидаемый тип (03-syntax.md → D108):let h HashMap[str, bool] = [] // ✅ пустая мапа (тип из контекста) let h HashMap[str, bool] = {} // ⛔ {} — пустой блок, тип unit ≠ HashMapРевизия (Plan 52 Ф.0). Прежняя формулировка §5 ошибочно допускала
{}в map-позиции →HashMap[str, V].new(). Это требовало type-directed parsing блока — Nova этого не делает (D43). Правило удалено; пустая мапа — только[]. - Дубликаты ключей невозможны — имена полей record-литерала уникальны by construction.
Граница с map-литералом [k: v]: {...} — когда ключи это
статические имена-идентификаторы; [...] — когда ключи это
выражения (см. D108).
Когда coercion НЕ применяется
Ambiguity — несколько конструкторов с тем же типом (sum-coercion):
type Ambiguous | A(int) | B(int)
let x Ambiguous = 42 // ОШИБКА: ambiguous, A(42) или B(42)?
let x = A(42) // явный конструктор — ok
Несоответствие — ни один конструктор не принимает тип значения:
type Color | Red | Green | Blue
let c Color = "red" // ОШИБКА: ни один конструктор не принимает str
let c = Red // unit-конструктор
Без аннотации — coercion отключён:
type StrOrInt | S(str) | I(int)
let a = "test" // a : str (не StrOrInt, аннотации нет)
let b StrOrInt = "test" // b : StrOrInt = S("test") (аннотация есть)
let r = { id: 2, name: "Bob" } // r : анонимный record { id int, name str }
let u User = { id: 2, name: "Bob" } // u : User (через record-coercion)
Newtype через D52 — coercion следует типу значения, не возможным кастам:
type UserId u64
type Wrapper | W(UserId) | N(int)
let w Wrapper = 42 // 42 : int → N(42) (тип значения int)
let w Wrapper = 42 as UserId // → W(42 as UserId) — явный as, потом coercion
let w Wrapper = UserId(42) // явный конструктор UserId
Несовпадение полей record:
type User { id u64, name str }
let u User = { id: 2 } // ОШИБКА: missing field `name`
let u User = { id: 2, name: "Bob", age: 30 } // ОШИБКА: unknown field `age`
let u User = { id: "two", name: "Bob" } // ОШИБКА: id expects u64, got str
Coercion не строит цепочку конверсий — только одна обёртка вокруг exact-type значения.
Multi-parameter и tuple-варианты
Multi-parameter конструкторы — coercion не применяется в MVP:
type Event | Click(int, int) | KeyPress(str)
let e Event = "enter" // ok — KeyPress("enter"), unary с str
let e Event = (5, 10) // ОШИБКА в MVP: tuple-coercion не вводится
let e = Click(5, 10) // явный конструктор
Tuple-coercion (5, 10) → Click(5, 10) — отложено. Усложняет правила
(как различать «tuple как значение» vs «tuple-coercion в multi-param»),
не критично для use-case’ов.
Unit-конструкторы — coercion бессмыслен
Unit-варианты не принимают значение, coercion не нужен — программист пишет конструктор напрямую:
type State | Open | Closed
let s State = Open // unit, coercion не применяется
Почему
- Огромный win в эргономике для prelude-типов.
Option[T]иResult[T, E]— самые частые sum’ы языка. Без coercion программист пишетSome(42),Ok(user)каждый раз. С coercion —42,user. Убирает значительную часть boilerplate. - Без subtyping. Тип значения после coercion — сам sum или
сам record, не подтип. На уровне типов всё чисто: pattern match
exhaustive, variance не возникает. Anonymous unions (TS-style
string | number) не вводятся — coercion не делает того же эффекта семантически. - Без runtime-cost. Sum-обёртка — обычный конструктор, runtime-tag уже есть в representation sum’а (D52). Record-coercion — это просто подстановка имени типа, никакого runtime-преобразования.
- Закрывает use-case’ы
any(sum) и убирает шум именования (record).sql\…${value}`теперь type-safe —valuecoerce'ится вSqlValueбез[]anyи безis-extract.let u User = { id: 2, name: “Bob” }` — без повтора имени типа. - AI-friendly. LLM пишет
[42, "alice"]для SQL-аргументов естественно, без думания о конструкторах.{ id: 2, name: "Bob" }в позиции с явным типом — естественный способ создать record. Имя типа из аннотации — единственный «локальный контекст», который нужно прочитать, и он уже рядом. - Прецеденты:
- Swift
ExpressibleByStringLiteral/ExpressibleByIntegerLiteral— opt-in protocol’ы для coercion. Nova делает это автоматически для unary-конструкторов sum’ов (без opt-in). - Scala 3
Conversion[A, B]— opt-in given-конверсии. - TypeScript — через subtyping для anonymous union, через
structural typing для record (
const u: User = { id, name }работает). Nova даёт похожую эргономику без subtyping. - Rust struct expressions требуют имени (
User { id, name }) — прецедент против record-coercion. Nova выбирает TS-эргономику для record в позиции с явным типом, но только в этой позиции.
- Swift
Что отвергнуто
- Subtyping (
int <: StrOrInt) — TS-style anonymous unions. Серьёзное расширение системы типов (variance, type inference, exhaustiveness), runtime-cost (boxing на каждой границе). Coercion даёт то же удобство без subtyping. Записан как Q-anonymous-union для возможного пересмотра. - Anonymous record-coercion вне позиций с явным типом.
let x = { id: 2, name: "Bob" }остаётся анонимным record-типом, не превращается вUser. Только явный целевой тип активирует coercion. AI-locality сохраняется. - Record-coercion для sum-вариантов с record-формой
(
type Shape | Circle { radius f64 } | Square { side f64 },let s Shape = { radius: 5.0 }). Программист обязан писать имя варианта (Circle { radius: 5.0 }), даже если поля уникальны для одного варианта. Альтернатива — type-driven parsing по совпадению полей, антипаттерн в Nova. - Tuple-coercion в MVP. Двусмысленность с tuple-литералами как значениями. Отложено до v1.0+.
- Coercion на цепочках конверсий (
int → UserId → Wrapper). Только одна обёртка. Иначе правила усложняются, и легко получить неожиданный результат. - Coercion без явной аннотации типа (
let x = "test"→ выводитьStrOrInt?). Type inference не должен «угадывать» sum или record. Только явный target type активирует coercion. - Opt-in coercion через protocol (Swift-style
ExpressibleBy*Literal). Программист объявляет sum/record, поведение работает автоматически без дополнительного opt-in. Это менее гибко, но проще. - Coercion для multi-parameter конструкторов через tuple
(
(5, 10) → Click(5, 10)). Отложено как tuple-coercion в MVP.
Цена
- Implicit conversion — первая в Nova. До D55 язык избегал неявного. Это философский сдвиг, обоснованный эргономикой prelude-типов и анонимных record. AI-friendly: LLM не должна угадывать конструктор или имя типа.
- Type-checker сложнее. В позиции с явным типом нужно проверить exact match, потом coercion (sum или record). Стандартное расширение, но code path не нулевой.
- IDE-подсказки усложняются. «Ожидается
StrOrInt, переданstr→ coerce вS», «ОжидаетсяUser, передан анонимный record → подгонка подUser» — IDE должна это показывать. - Migration sum’а опасна: добавление нового unary-конструктора
с тем же типом параметра ломает существующий код (был exact match
через coercion в
S(str), стал ambiguous из-заS(str) | S2(str)). Это breaking change для sum’а — программист должен учитывать. - Migration record’а тоже: добавление обязательного поля в record ломает все анонимные литералы без него. Это известная проблема record-типов вообще, не специфическая для D55.
- Закрывает большую часть use-case’ов
any— это плюс, но требует пересмотра примеров (args []any→args []SqlValue). - Парсер — без type-driven decisions. Coercion работает в
позициях, где целевой тип уже известен type-checker’у —
парсер по-прежнему чисто синтаксический.
{...}парсится как record-литерал/block-выражение по обычным правилам D17/D49, а тип ему присваивает type-checker по аннотации.
Связь
- D52 — sum-типы и unary-конструкторы, на которых coercion работает.
- D53
—
anyостаётся для подлинно открытых случаев (plugins, reflection), D55 закрывает большую часть use-case’ов через closed sum’ы. - 03-syntax.md → D44 — numeric literal coercion
(
100подгоняется подu8/u32в позиции типа) — D55 расширяет эту идею на sum’ы и record’ы. - 03-syntax.md → D54 —
as/isостаются явными для конвертации/проверки. D55 не вводит implicit cast между обычными типами, только для sum-обёрток и record-литералов. - 08-runtime.md → D26 —
Option[T],Result[T, E]в prelude получают эргономичный синтаксис через D55. - #d17-объявление-типов-единый-синтаксис-без-
(revised → D52) — record-литерал
User { id: 1, name: "alice" }с именем типа — обязательный, когда тип не выводится из контекста. D55 разрешает опускать имя в позиции с явным целевым типом. - 03-syntax.md → D108 — map-литерал
[k: v]; комплементарен map-coercion ({...}— ключи-имена,[...]— ключи-выражения). Реализация обоих — Plan 52.
Открытые вопросы
- Tuple-coercion для multi-parameter конструкторов. Отложено.
- Anonymous unions (
type StrOrInt | type str | type int) — TS-style без обёрток. Записан как Q-anonymous-union (требует subtyping, серьёзное расширение системы типов). См. open-questions.md. - Стандартные closed sum’ы в prelude (
SqlValue,JsonValue) — что именно положить, формат и набор операций. См. Q9 (stdlib). - Cross-type numeric coercion в D55 (
42→f64дляNumber(f64)). Сейчас строгий exact match. См. Q-numeric-coercion.
Style-guide: когда coerce, когда писать тип явно (Plan 17 Ф.1)
D55 разрешает обе формы — coerce и явный конструктор. Чтобы кодовая
база не превращалась в смесь стилей, ниже рекомендации для nova fmt/линтера и code review (это не правило компилятора, оба
варианта остаются валидными).
Coerce (короче, тип в аннотации) — предпочитать когда:
// 1. let с явной аннотацией — тип сразу слева, имя справа лишнее
let u User = { id: 1, name: "alice" } ✅
let m Maybe[int] = 42 ✅
// 2. return-position в expression-body, есть -> T
fn make_default() -> Account => { id: 0, balance: 0 } ✅
// 3. call-site с явным типом параметра — coercion даёт чистый литерал
serve({ ...SERVER_DEFAULTS, port: 9000 }) ✅
// 4. коллекции с разнородными элементами в позиции []SqlValue
let args []SqlValue = [42, "alice", true] ✅
// [I(42), S("alice"), B(true)] ❌ шумно
Явный конструктор — предпочитать когда:
// 1. let без аннотации — coercion не работает, имя обязательно
let r = if cond { Some(value) } else { None } ✅
let r = if cond { value } else { None } ❌ — нет аннотации
// 2. match-arms где хотя бы одна ветка — unit-вариант (None / Empty)
// — для визуальной симметрии писать ВСЕ ветки с конструкторами
match @cache.get(key) {
Some(v) => Some(v) ✅ симметрично с None
None => fallback()
}
match @cache.get(key) {
Some(v) => v ❌ value слева, None справа —
None => fallback() // асимметрично, читать сложнее
}
// 3. nested record-литерал внутри блока — { {...} } визуально шумно
fn compute() -> Money =>
if special { Money { amount: 100, currency: usd } } ✅
else { Money { amount: a + b, currency: c } }
fn compute() -> Money =>
if special { { amount: 100, currency: usd } } ❌ шум
else { { amount: a + b, currency: c } }
// 4. ambiguous unary-конструкторы (compile-error без явного имени)
type Mixed | A(int) | B(int)
let x Mixed = 42 ❌ ambiguous — обязателен A(42) / B(42)
Сводка:
| Контекст | Рекомендация |
|---|---|
let x T = ... (есть аннотация) | coerce |
let x = ... (нет аннотации) | явный конструктор |
fn f() -> T => ... (есть -> T) | coerce |
fn f(x T) call-site f(...) | coerce |
| match с unit-веткой | явный (симметрия) |
nested { ... } в блоке после if/else | явный (избежать { {...} }) |
| ambiguous unary-конструкторы | явный (обязательно) |
Аргумент. nova fmt не должен переписывать одну форму в другую —
выбор стилистический. Линтер может в будущем выдавать подсказку
для самых тяжёлых случаев (например, { {...} } в block-context),
но без флага --strict-style — это рекомендация, не ошибка.
См. также Q-style-coercion (закрыт этой секцией).
Эволюция
До D55 sum-варианты требовали явный конструктор на каждом значении
(Some(42), Ok(user), S("test")), а record-литералы — имя типа
перед {} (User { id: 1, name: "alice" }).
После D55 в позиции с явным целевым типом:
- sum-значение оборачивается автоматически (
42в позицииMaybe[int]→Just(42)), - анонимный record-литерал получает имя из аннотации (
{ id: 1, name: "alice" }в позицииUser→User { id: 1, name: "alice" }).
Это эргономический сдвиг уровня D52, без слома типовой модели.
Альтернатива (anonymous unions через subtyping) рассмотрена и отвергнута — слишком серьёзное расширение системы типов для эргономического выигрыша. D55 даёт похожее удобство более узким и контролируемым механизмом.
D42. protocol keyword для структурных интерфейсов
⚠️ REVISED. Заменено D53.
protocol— теперь не отдельный keyword, а kind-токен в системе D52:type Foo protocol { ... }. Семантика структурной типизации, generic-параметров и эффектов сохраняется. Текст ниже — для исторической справки.
Что
Структурные интерфейсы объявляются отдельным keyword protocol. type
— для данных (record, sum-type, alias), protocol — для
поведения (набор методов как контракт). Любой тип со структурно
совпадающими сигнатурами автоматически удовлетворяет protocol’у — без
явных impl-блоков.
Эффекты — это тоже protocol, использованный в позиции эффекта
(между ) и ->). Один и тот же protocol может играть роль эффекта
или роль структурного контракта-параметра — различение по контексту
использования (04-effects.md → D18).
type без полей с одними методами не допускается — нужен protocol.
Правило
type Hashable protocol { // D52/D53: kind-токен `protocol` под `type`
hash() -> u64
eq(other Self) -> bool
}
type Iterator[T] protocol {
next() -> Option[T]
}
type Login { // record (данные) — голый type
username str
password str
}
Self внутри protocol-блока — late-bound. См. D66 для других
контекстов где Self тоже валиден (static/instance методы, effects).
Структурная совместимость — автоматическая. Метод определяется у типа
через @-синтаксис (03-syntax.md → D35) и без
дополнительных деклараций удовлетворяет protocol’у:
type User { id u64, name str }
type Printable protocol {
show() -> str
}
fn User @show() -> str => "User(${@name})"
fn log_one(x Printable) Log -> () =>
Log.info(x.show())
log_one(my_user) // ok, User автоматически совместим
Параметр функции может декларировать требования прямо в типе, без именованного protocol’а:
fn log_one(x { show() -> str }) Log -> () =>
Log.info(x.show())
В protocol fn-префикс не нужен — там по определению все «члены»
это методы. В record-типе поле-функция объявляется явно с fn:
type Button {
text str
on_click fn() Io -> () // поле-функция в record, не protocol
}
Generic-параметры: на protocol-уровне vs на методе
В Nova есть две явных модели generic-параметров для protocol’а. Программист выбирает по семантике.
Модель A — generic на protocol (protocol P[T] { ... }).
T фиксирован для всего protocol’а: один handler = один T. Все методы
видят один и тот же T. Разные T = разные сущности (Iterator[Int] и
Iterator[String] несовместимы).
type Iterator[T] protocol {
next() -> Option[T]
peek() -> Option[T]
}
type Container[T] protocol {
add(item T) -> ()
get(idx int) -> T
size() -> int // методы без T тоже допустимы
}
type Channel[T] effect { // effect — нужен with-substitution
send(value T) -> ()
recv() -> T
}
type Cache[K, V] effect {
get(key K) -> Option[V]
set(key K, value V) -> ()
}
Когда применять: когда T — фундаментальная характеристика protocol’а, все или большинство методов работают с этим T, и разные T = разные handler’ы имеют смысл.
Модель B — generic на методе (method[T](...)).
T живёт только в скоупе одного метода. Один и тот же handler protocol’а
вызывает метод с разными T для каждого вызова.
type Tracer effect {
span[T](body fn() -> T) -> T // T живёт только здесь
measure[U](body fn() -> U) -> Duration // U независим от T
set_attr(key str, value Json) -> () // методы без generic тоже
}
type Db effect {
query(q Sql) Fail[DbError] -> []DbRow
in_transaction[T](body fn() Db Fail -> T) Fail -> T
// ↑ один Db handler оборачивает любой T
}
Когда применять: когда метод принимает/возвращает любой тип, а сам protocol не привязан к этому типу — один handler работает с любым T для каждого вызова.
Различие в семантике handler’а:
| Модель A | Модель B | |
|---|---|---|
| Объявление T | protocol P[T] | method[T] в сигнатуре |
| Scope T | весь protocol | один метод |
| Один handler работает с | одним T | любым T (per-call) |
| Использование | with P[Int] = ... | with P = ...; P.method[Int](...) |
| Реализация | мономорфизация по T | rank-2 polymorphism в handler’е |
В одном protocol’е можно комбинировать оба механизма:
type Stream[T] protocol {
next() -> Option[T] // T на protocol-уровне
fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc // Acc на методе
}
T фиксирован для stream (Stream[int]), Acc независим — fold
может собирать в разные accumulator-типы из одного и того же stream’а.
Почему
- Намерение должно быть явным. Старая форма
type X = { методы }визуально совпадала с record-формойtype X { поля }, различаясь только знаком=. LLM и человек различали намерение по единственному символу — хрупко. Отдельный keyword делает намерение явным с первого токена. - Прецедент.
protocolкак keyword для интерфейсов используется в Swift, Objective-C, Clojure, Elixir, Python (typing.Protocol). Семантически Nova ближе всего к Pythontyping.Protocol— чисто структурный subtyping. - Эффекты в сигнатурах методов делают protocol строже Go interface — реализация не может привнести эффект сверх объявленного. Это уникальное свойство Nova.
Что отвергнуто
type X = { методы }— слишком похоже на record, отличается одним знаком=. См. «Почему» выше.contract— занято под pre/post-условия (09-tooling.md → D24).promise— массовая ассоциация с async (JS Promise).interface— слишком сильный nominal-bias (Java/C#).trait— обещает Rust-фичи (default impl, supertraits, blanket impl), которых в Nova нет.shape— короче, но менее знакомо как keyword.ability— образно, но без знакомства; навязывает-ableсуффикс именам.- Implicit shared scope для generic-параметров (T в нескольких
методах одного protocol’а автоматически означает один и тот же тип).
Снижает локальность: чтобы понять
[T]в одном методе, нужно прочитать весь protocol-блок и проверить остальные методы. Невозможно выразить «независимый T в разных методах» без смены convention (использования других букв). Прецедентов нет — Rust/Swift/Scala/Haskell все используют либо явный protocol-уровень, либо явный method-уровень. Альтернатива (protocol P[T]) уже даёт ту же семантику явно.
Связь
- 02-types.md → D15 — D15 ввёл структурные интерфейсы; D42 уточняет грамматику отдельным keyword.
- 02-types.md → D39
—
use Typeдля делегации между record-типами;protocolне embed’ится. - 03-syntax.md → D35 — методы через
@как способ удовлетворить protocol. - 01-philosophy.md → D1
—
protocols+dataкак фундамент парадигмы.
Открытые вопросы
- Bounds на дженерики —
HashMap[K: Hashable, V]требует отдельного решения. Сейчас параметр без bound, компилятор полагается на структурное соответствие при использовании. - Default-методы в protocol — пока запрещены.
- Inheritance protocol’ов —
protocol A : Bпока запрещено; эквивалент достигается явным включением методовBвA.
Эволюция
Изначально структурные интерфейсы описывались через type X = { методы }
(см. D15). D42 заменил эту форму на
отдельный keyword protocol. Детали — в history/evolution.md.
D15. Структурные интерфейсы
Status: revised. Роль перешла к
protocolkeyword (D42).
Что
Изначальный механизм структурных «интерфейсов» в Nova: отдельной
концепции interface или trait нет; контракт — это набор сигнатур,
любой тип со совпадающими методами автоматически совместим. Сейчас
этот механизм обогащён keyword protocol (D42), который делает
объявление контракта синтаксически явным.
Правило
Структурная совместимость — автоматическая. Имя контракту даёт
protocol:
type Printable protocol {
show() -> str
}
type User { id u64, name str }
fn User @show() -> str => "User(${@name})"
fn log_one(x Printable) Log -> () => Log.info(x.show())
log_one(my_user) // ok, User автоматически совместим
Анонимный структурный тип прямо в сигнатуре параметра — без отдельного имени:
fn log_one(x { show() -> str }) Log -> () =>
Log.info(x.show())
Что сохранено:
- Эффекты в полях-функциях — часть сигнатуры, проверяются как обычно. Реализация не может привнести эффект сверх объявленного. Это ключевое отличие Nova от Go: контракт жёстче, потому что эффекты — часть сигнатуры.
- Структурная совместимость автоматическая, как в Go.
- Дженерики без bound’ов — требования описываются типом параметра.
Почему
- Следует из принципа «не добавлять фичи без оправдания центральной идеей или AI-first». Rust-style traits ни тому, ни другому не служат.
- Унификация: одна концепция «структурный тип» вместо двух («record»
- «interface»). Меньше синтаксиса — проще для LLM.
- Эффекты в сигнатурах методов делают структурный тип строже, чем Go interface — это уникальное свойство Nova, которое нельзя получить простым заимствованием Go.
Что отвергнуто
trait/interfaceкак отдельный keyword с nominal-семантикой (Java/C#/Rust).impl Trait for Typeблоки.[T: Trait]bounds в дженериках.dyn Traitvsimpl Traitразделение.- Ассоциированные типы.
- Дефолтные методы.
- Trait-наследование, specialization, HKT.
Цена
- Нет имени для контракта иначе как через
protocol. В IDE нельзя «найти всех, кто реализует X» так же легко, как в Rust/Java — поиск идёт по совпадению методов. - Нет номинальности. Если очень нужна — через newtype-обёртку (паттерн, не фича).
Связь
- 02-types.md → D42
—
protocolкак явное имя для контракта. - 02-types.md → D39 — embed/delegation как механизм композиции, не subtyping.
- 03-syntax.md → D35 —
@-методы как способ удовлетворить protocol.
Эволюция
Ранние черновики описывали контракт через type X = { методы } —
визуально неотличимо от record. D42 ввёл отдельный keyword protocol,
сохранив структурную семантику D15. Подробно — в
history/evolution.md.
D39. Embed и delegation: use name Type (alias обязателен)
Что
Композиция типов через use name Type внутри record-декларации. Имя
поля всегда явное — программист пишет alias в snake_case по
D30. Default-имя по типу (Go-style use Type →
поле Type) не вводится — нарушает D30 (поля snake_case, типы
PascalCase).
Это delegation, не наследование: обёртка не является подтипом встроенного.
Правило
Базовое использование
type AuditedAccount {
use account Account // имя поля = "account" (snake_case)
audit_log []AuditEntry
}
let acc AuditedAccount = ...
// Auto-proxy: прямой доступ к полям и методам Account
println(acc.balance) // = acc.account.balance
println(acc.owner) // = acc.account.owner
acc.is_solvent() // = acc.account.is_solvent()
// Доступ к встроенному объекту целиком — через имя поля
let just_account = acc.account
use Account без имени — ошибка компиляции: имя поля обязательно.
type AuditedAccount {
use Account // ОШИБКА: имя поля обязательно
audit_log []AuditEntry
}
Auto-generated прокси-методы
При use name Type компилятор генерирует прокси для каждого метода
Type:
type Account { balance money }
fn Account @balance_pct(of money) -> f64 => @balance / of * 100.0
type AuditedAccount {
use account Account
audit_log []AuditEntry
}
// Компилятор генерирует:
// fn AuditedAccount @balance_pct(of money) -> f64 =>
// @account.balance_pct(of)
let aa AuditedAccount = ...
aa.balance_pct(1000.0) // через auto-proxy
Zero-cost — компилятор инлайнит вызов, никакой vtable.
Грамматика согласована с record-полями
use name Type использует тот же порядок «имя тип», что и обычные
поля, параметры функций, let-bindings, for-loop:
type Wrapper {
item str // обычное поле: имя тип
use iter HashMapIter[K, V] // embed: use + имя тип
extra int
}
fn deposit(mut acc Account) -> () => ... // параметр: имя тип
let user User = ... // let: имя тип
for id u64 in ids { ... } // for: имя тип
Везде имя слева, тип справа — одно правило для всего языка.
use — keyword, не имя поля
use — зарезервированное слово (D29 для импортов
- embed-конструкция здесь). Имя поля
useзапрещено.
В декларации {use name Type} use — keyword embed-формы; имя
поля — alias после use:
type Set[T] {
use map HashMap[T, ()] // имя поля — "map"
}
// record-литерал — имя поля
let s Set[int] = { map: HashMap[int, ()].new() } // ✓
let s Set[int] = { use: HashMap[int, ()].new() } // ✗ use — keyword
// доступ — имя поля
fn Set[T] @len() => @map.len() // ✓
fn Set[T] @len() => @use.len() // ✗ use — keyword
Override метода
Если тип-обёртка определяет метод с тем же именем — он затмевает делегированный:
type AuditedAccount {
use account Account
audit_log []AuditEntry
}
fn AuditedAccount mut @deposit(amount money) {
@account.deposit(amount) // явный вызов «родителя» через имя поля
@audit_log.push(AuditEntry.deposit(amount))
}
let mut acc AuditedAccount = ...
acc.deposit(100) // вызовет AuditedAccount.deposit
Без @account. в теле — бесконечная рекурсия. Программист обязан
явно обращаться к встроенному через имя поля.
Конфликт имён — разные alias-имена
Если два use вводят одинаковые имена методов — программист даёт
разные alias-имена и явно решает, через какой:
type Logger effect { log(msg str) -> () }
type Auditor { log(msg str) -> () }
type Combined {
use console Logger
use audit Auditor
}
let c = Combined { ... }
c.log("...") // ОШИБКА: ambiguous (оба имеют log)
Решение — явный вызов через имя поля:
fn Combined @log_all(msg str) {
@console.log(msg)
@audit.log(msg)
}
let c = Combined { ... }
c.console.log("...")
c.audit.log("...")
Anonymous embed: use _ Type (без alias-имени)
Альтернатива явному alias — anonymous embed через _:
type Set[T] {
use _ HashMap[T, ()]
}
let s = Set[int].new()
s.insert(item, ()) // ✓ через auto-proxy на HashMap.insert
s.contains(item) // ✓ через auto-proxy
s.len() // ✓ через auto-proxy (D117 method-only)
_ — это wildcard: программист сознательно отказывается
от имени поля, потому что не нуждается в прямом доступе к встроенному.
Когда использовать
use _ подходит для simple wrappers где:
- Нет необходимости в прямом доступе к встроенному (
@base.method()). - Wrapper-методы не вызывают delegated в своём теле.
Set[T] — типичный case: вся семантика приходит из HashMap через
auto-proxy + override на одно поведение (insert возвращает bool
вместо Option).
Override через own-methods — работает
Программист может определить wrapper-метод того же имени что у embedded:
type Set[T] {
use _ HashMap[T, ()]
}
// Override @insert — заменяем семантику
fn Set[T] mut @insert(item T) -> bool {
// Здесь нельзя обратиться к HashMap.insert напрямую — нет имени
// поля для @<base>.insert(...). Override полностью заменяет
// логику.
Log.info("inserting...")
// ... custom impl, не делегируя к HashMap
}
Resolution через call-site overload resolution
(D84) с override-precedence: own-method
(определённый напрямую на receiver) wins over delegated (через
use).
let s Set[int] = ...
s.insert(42)
// → resolve_overload("insert", "Set[int]", [int])
// → 2 candidates: Set.@insert (own), HashMap.@insert (delegated)
// → override-precedence: own wins → Set.@insert
// → no ambiguity error
Когда не использовать
Если wrapper-метод нуждается в @base.method() для делегирования —
нужен named alias:
// ✓ named alias — есть `@account` для явного call
type AuditedAccount {
use account Account
audit_log []AuditEntry
}
fn AuditedAccount mut @deposit(amount money) {
@account.deposit(amount) // explicit base call
@audit_log.push(AuditEntry.deposit(amount))
}
// ✗ anonymous embed не подходит — нет имени для base call
type AuditedAccount {
use _ Account
audit_log []AuditEntry
}
fn AuditedAccount mut @deposit(amount money) {
??? // как вызвать Account.deposit?
// НИКАК — anonymous embed не даёт имени
}
Compile error в этом случае возникает естественно на call-site:
программист пишет @deposit(amount) (без имени поля), это рекурсивный
вызов Self — бесконечная рекурсия, которая, скорее всего, не то
что хотел программист.
Lint-warning (не error) предложит: «possible infinite recursion in anonymous embed override; use named alias for base-call».
Что запрещено
Два anonymous embed одного типа — недопустимо:
// ✗ COMPILE ERROR
type Wallet {
use _ Account
use _ Account // ambiguous — два anonymous Account
}
При вызове w.balance resolution даёт два candidates с одинаковым
priority — ambiguity unresolvable, потому что нет имени поля
для disambig’а. Compile error при declaration.
Решение — named alias:
type Wallet {
use primary Account
use backup Account
}
Резолвинг — общий механизм overload
Anonymous embed не вводит специальных правил в компилятор.
Resolution использует тот же resolve_overload (D84)
с двумя расширениями:
- Анонимные embed-методы регистрируются в overload registry с
kind = MethodKind::Delegated(via_use_anonymous)— флагом «delegated». - Override-precedence: own-methods (без флага) wins over delegated, при прочих равных (тот же receiver, та же arity, те же arg-types).
Это даёт желаемое поведение «own override затмевает delegated» без отдельной declaration-time проверки collision’а.
Сводка use _ Type vs use name Type
| Аспект | use name Type | use _ Type |
|---|---|---|
| Имя поля | явное (name) | нет |
| Auto-proxy | да | да |
| Override через own-method | да | да |
Доступ к base через @<name>.method() | да | нет |
| Multiple embed одного типа | да (разные имена) | нет (compile error) |
| Construction через literal | T { name: ..., ... } | через factory T.new(...) |
| Pattern destructure | возможен через имя | unsupported |
use для встроенных типов ([]T, tuples)
use поддерживает не только именованные record-типы, но и встроенные
конструкции — массивы ([]T), tuples ((A, B)), и т.п. Имя
поля обязательно (как и для именованных типов):
// VecBuf через embed []T — все методы массива доступны
type VecBuf[T] {
use data []T
extra str
}
let v = VecBuf[int] { data: [1, 2, 3], extra: "info" }
let n = v.len // прокси-метод к data.len ([]T API)
v.push(42) // прокси-метод к data.push
let x = v.get(0) // прокси к data.get
Этим механизмом строятся «именованные обёртки над массивами» с дополнительными полями/методами без переписывания базового API.
API расширяется обычными методами на типе (D35):
fn VecBuf[T] @first_or_default(def T) -> T =>
@data.get(0).unwrap_or(def)
API самих встроенных типов ([]T.len, []T.push, etc.) — открытый
вопрос Q-array-api в open-questions.md, формализуется в Q9 stdlib.
Что это НЕ
Не наследование. AuditedAccount не является Account:
fn process(a Account) -> () => ...
let aa AuditedAccount = ...
process(aa) // ОШИБКА
process(aa.account) // ок: извлекли Account-часть через имя поля
Если нужен полиморфизм — структурный protocol:
type HasBalance protocol {
balance() -> money
}
fn process(a HasBalance) -> () => ...
process(aa) // ок: AuditedAccount имеет balance()
// через delegation auto-proxy
Не множественное наследование. Можно use несколько типов, но
конфликты решаются alias’ом или явным обращением. Diamond-problem не
возникает — нет иерархии.
Почему
- Замена наследования (D1) — embed решает 80% задач композиции без сложности subtyping.
- Согласованность с D30 naming. Поля Nova — snake_case (D30). Default-имя по типу (Go-style) дало бы PascalCase-поле — нарушение D30. Явный alias обязывает программиста выбрать snake_case, всё единообразно.
- Согласованность с language-wide порядком.
use name Type— тот же порядок «имя тип», что параметры, поля, let-bindings, for-loop. Одно правило для всего языка. - AI-friendly. Никакой magic-conversion (
HashMap→hashmap/hash_map?), программист явно выбирает имя поля. LLM не догадывается.
Что отвергнуто
- Default-имя поля по типу (
use Account→ полеAccount, Go-style). Создаёт исключение в D30 (поля PascalCase в одном record-блоке с snake_case полями). Auto-conversion PascalCase → snake_case (HashMap→hash_map?) — magic, не очевидное правило. use Type as name(Rust import-style).asзафиксировано для cast в выражениях (D54) и импортов (07-modules.md → D29). В embed — «объявление поля», порядок «имя тип» согласован с остальным языком.- Subtyping — противоречит D1; полиморфизм через protocol.
- Множественное наследование — известный антипаттерн (diamond, fragile base).
Связь
- 01-philosophy.md → D1
—
useкак замена наследования. - 02-types.md → D17
—
useвнутри record-блока. - 02-types.md → D15, D42 — полиморфизм для embed-типов идёт через protocol, не через subtyping.
- 03-syntax.md → D30 — naming convention (поля snake_case, типы PascalCase). Обязательность alias следует из D30.
- 03-syntax.md → D35 —
@field.method()для явного вызова из метода обёртки. - 03-syntax.md → D38 — generic-применение в
embed:
use iter HashMapIter[K, V].
Эволюция
Первая редакция D39 разрешала default-имя = имя типа: use Account → поле Account (PascalCase, Go-style). Это создавало
нарушение D30 (поля должны быть snake_case) — в одном record-
блоке audit_log (snake) и Account (Pascal) выглядели несогласованно.
Что стало: alias обязателен. use Account без имени — ошибка
компиляции, программист пишет use account Account. Default-имя
отменено, никакой magic-conversion HashMap → hash_map.
Также поменялся синтаксис конфликтов: раньше предлагался «явный вызов
через имя типа» (c.Logger.log(...)), теперь только через alias-
имя поля (c.console.log(...)). Это согласовано с тем, что все
поля имеют alias-имя, и в коде используется оно.
Q-embed-syntax в open-questions всё ещё открыт — это отдельный
вопрос про keyword (use vs embed vs голый тип), а не про
обязательность имени.
Anonymous embed (2026-05-08): добавлена форма use _ Type для
simple wrappers где явное имя поля бессмысленно (use _ HashMap[T, ()]
в Set[T]). Программист не выбирает alias из bikeshedding map/inner/
s/value — _ явно говорит «безымянный embed, прямой доступ
не нужен».
Resolution для anonymous через lazy mechanism — общий call-site overload-resolution (D84) с override-precedence (own-method wins over delegated). Никаких declaration-time проверок collision’ов. Это упрощает компилятор — один путь для named и anonymous.
Trade-off anonymous vs named: anonymous теряет @<name>.method()
(прямой base-call) и pattern-destructure через имя поля. Эти возможности
трактуются как «escape hatches» — для них программист пишет
use name Type явно.
Прецеденты:
- Go
embedded interface{}— anonymous, прямой доступ через имя типа (s.Account). Nova не следует — D30 запрещает PascalCase поля. - D
alias this— anonymous embed с implicit conversion. Nova не следует — нет subtyping (D1). - Rust composition — нет anonymous embed; программист пишет
field + manual delegation. Nova
use _экономит boilerplate.
Bootstrap status (2026-05-08)
Реализовано в bootstrap-codegen (Plan 11 Ф.9):
- ✅ Parser:
use name Type(named embed) иuse _ Type(anonymous). Anonymous имя поля — синтетическое__embed_<TypeName>. - ✅ AST:
RecordField.is_embed: bool,RecordField.embed_anonymous: bool. - ✅ Codegen auto-proxy generation:
embed_fieldsregistry per record-type; для каждого Own-метода embedded-типа эмитится Delegated MethodSig + C-функция, которая делегирует черезnova_self->field. - ✅ Override-precedence (Own > Delegated) в emit_call и infer paths (Plan 11 Ф.9.3). Strict-match candidates сначала, затем фильтр Own.
- ✅ Multi-anonymous detection: declaration-time error если ≥2 anonymous embeds одного типа в одном record’е (Plan 11 Ф.9.4).
- ✅ Lint warning
possible infinite recursion: при detect own-method override на anonymous embed — stderr-warning о невозможности base-call’а (Plan 11 Ф.9.5).
Bootstrap-ограничения:
- C-name mangling по param-types: для overloaded delegated proxy
имена с suffix’ом
__<types>, как для own overload. - Generic embed (
use map HashMap[K, V]в generic wrapper) — работает для конкретных type-параметров; full generic monomorphization — открытый вопрос.
D32. Семантика передачи параметров
Status: revised для полей. D36 переписал семантику
mutна поле типа. Семантикаmutна параметре (этот D32) — без изменений.
Что
Параметры функций передаются by reference в managed heap (как Java/C#
для объектов, Go для maps/slices). Без mut — immutable view, с
mut — мутации видны вызывающему. Примитивы (int, bool, f64,
…) — by value в регистре. Borrow &T отсутствует как концепция.
Правило
Базовое поведение.
type Account { balance money }
// без mut — функция только читает
fn show(acc Account) Io -> () =>
println("balance: ${acc.balance}")
// с mut — функция меняет, изменения видны вызывающему
fn deposit(mut acc Account, amount money) {
acc.balance += amount
}
let mut my_acc = Account { balance: 100 }
deposit(my_acc, 50)
// my_acc.balance == 150 — мутация видна
Примитивы — by value. Числа, bool, char, u8, () —
всегда копия в регистре. С mut x int это локальная переменная
функции, изменения не видны вызывающему:
fn weird(mut x int) {
x = 999 // меняет локально
}
let n = 5
weird(n)
// n == 5 — примитив всегда by value
Объекты (record / sum-type / массивы) — managed reference.
Указатель в managed heap, отслеживаемый GC. В синтаксисе программист
пишет просто o Order — никакого & или *:
type Order { items []Item, total money }
fn add_item(mut order Order, item Item) {
order.items.push(item)
order.total += item.price
}
let mut my_order = Order { items: [], total: 0 }
add_item(my_order, item1)
// my_order содержит item1 и обновлённый total
&T (borrow в Rust-стиле) не существует в Nova. Escape analysis
закрывает большинство perf-кейсов автоматически; для real-time —
region { ... } (05-memory.md → D6).
Иммутабельный binding. Без mut параметр нельзя мутировать ни
одно поле (кроме помеченных mut per-field — см.
D36):
type Account { balance money }
fn read_only(acc Account) {
acc.balance += 50 // ОШИБКА: acc immutable
println(acc.balance) // ок, чтение
}
Семантика mut на параметре и mut на поле взаимодействуют через
правила D36 — для записи нужно соответствие на обоих уровнях.
Производительность. Когда нужна максимальная производительность
без GC overhead — escape analysis (автоматически) или
region { ... } (05-memory.md → D6):
fn process_audio(samples []f32) Realtime -> []f32 =>
region {
let buf = []f32.with_capacity(1024)
// обработка, без GC pauses
buf.to_owned()
}
Никаких &T borrow, никаких lifetime-аннотаций в обычном коде.
Сводка
| Форма параметра | Передача | Мутация видна снаружи |
|---|---|---|
x int (примитив) | by value | нет (примитив всегда копия) |
mut x int | by value | нет (локальная копия) |
o Order (объект) | managed reference | нет (immutable view) |
mut o Order | managed reference | да |
Почему
- Согласовано с managed heap (05-memory.md → D6) — объекты уже в куче, передача указателя дешёвая, копировать бессмысленно.
- AI-first видимость в типах (01-philosophy.md → D10)
— сигнатура
fn deposit(mut acc Account, …)противfn show(acc Account)сразу показывает контракт. Java/C#: всё mutable references по умолчанию, программист помнит наизусть. mut— единый префикс для разных случаев (let, поле, параметр). Везде «mut = разрешена мутация» — одно понятие, не разные. Согласовано с D36 и 03-syntax.md → D33.
Что отвергнуто
- By-value для всех типов (Go-стиль). Копирование больших structs дорого, несовместимо с managed heap, программист удивляется «изменил поле — не сохранилось».
- By-reference с обязательным
&mut(Rust-стиль). Слишком много синтаксиса для прикладного кода; в Novamutуже работает для let и полей. - Move-семантика (Rust для не-Copy). Сложна для прикладного программиста, не нужна с GC.
- Borrow
&T. Скопирован в раннем дизайне рефлекторно. Borrow существует в Rust, потому что нет GC; в Nova с GC передача = указатель. Escape analysis +regionзакрывают остальное. Lifetime checker — research-уровень, цена реализации высокая. Go показывает: без borrow инфраструктура интернета работает.
Связь
- 02-types.md → D36
— пересмотр семантики
mutдля полей типа. Параметры — без изменений. - 05-memory.md → D6 — managed heap делает
by-reference дешёвым;
regionдля real-time. - 04-effects.md → D62 —
Mut[T]как generic эффект удалён; мутация черезmutполя/параметры (локально) или специализированные state-эффекты (Counter/Cache/IdGen). - 01-philosophy.md → D10 — AI-first видимость мутации в типе.
- 03-syntax.md → D35 —
fn Type mut @methodиспользует тот жеmutдля self-binding’а.
Эволюция
В D32 поле типа mut field мутировалось только у mut-binding’а.
Для аккумуляторов (все поля mutable) приходилось писать mut 18 раз —
шум без пользы. D36 переписал это: дефолт mutable у mut-binding’а,
readonly для never-mut, mut per-field — только для cache/lazy.
Семантика параметров не менялась.
D36. Поля типа: дефолт mutable у mut binding’а, readonly для never-mut
Что
Поле без префикса мутируется, если binding mutable. readonly
запрещает мутацию даже у mutable binding’а (для id, foreign keys,
invariants). mut per-field разрешает мутацию даже у immutable
binding’а (для cache, lazy init, atomic counters — аналог C++
mutable). Group-syntax: несколько полей одного типа через запятую.
Правило
Базовое использование.
// Аккумулятор — все поля мутируемые, никаких префиксов не нужно
type RunAcc {
att_wins int, def_wins int, draws int
total_rounds int
total_moon_chance f64
atk_lost_m int, atk_lost_s int, atk_lost_h int
}
let mut acc = RunAcc { att_wins: 0, def_wins: 0, ... }
acc.att_wins += 1 // ок — binding mut, поле без readonly
// Структура с invariant'ами — readonly для read-only полей
type Account {
readonly id u64 // никогда не меняется
readonly owner str // тоже
balance money // мутируется у mut binding'а
closed bool
}
let acc = Account.new("alice")
acc.balance = 100 // ОШИБКА: binding не mut
let mut acc2 = Account.new("alice")
acc2.balance = 100 // ок
acc2.id = 999 // ОШИБКА: id объявлено readonly
// Cache/lazy — mut для полей, мутируемых через immutable binding
type LazyConfig {
path str
mut cached_value Option[str] // обновляется при первом read
}
fn LazyConfig @get() -> str {
if let Some(v) = @cached_value { return v }
let v = read_file(@path)
@cached_value = Some(v) // мутация через @-метод даже у let-binding
v
}
Group-syntax. Несколько полей одного типа — через запятую:
type Point { x, y, z f64 } // три f64
type Color { r, g, b u8 } // три u8
type RunAcc {
att_wins, def_wins, draws int
atk_lost_m, atk_lost_s, atk_lost_h int
atk_lost_pts, def_lost_pts f64
}
С префиксами:
type Account {
readonly id, owner_id u64 // два immutable
balance money // дефолт (mutable у mut-binding)
mut last_access_time time // mutable всегда
}
Сводная таблица
| Объявление поля | Mutable у let acc | Mutable у let mut acc | Use case |
|---|---|---|---|
field T (без префикса) | нет | да | большинство полей |
readonly field T | никогда | никогда | id, immutable invariants |
mut field T | да | да | cache, lazy init, atomic counters |
Почему
- Меньше шума для типичного случая. Аккумулятор с 18 mutable
полями писать без префиксов — все поля «обычные», никаких
акцентов. Раньше 18 раз
mut— визуальный мусор. - Сигнатура показывает только важное. Префикс ставится только
на исключения (
readonlyдля invariants,mutдля cache). LLM, читая тип, видит:readonly id— «не трогай», обычное поле — «можно мутировать с mut-binding’ом». - Прецедент Rust/Go/C++ — поля без префикса мутируются у
mut-binding’а;
readonlyдля never-mut близко к C++constmember.
Что отвергнуто
- Старая семантика D32 (поле
mutмутируется только уmut-binding). Заставляет писатьmutперед каждым полем аккумулятора; если все поля mut — выделение теряет смысл. - Rust-полное (поле всегда mutable у mut-binding, нет never-mut). Невозможно зафиксировать read-only invariant без приватного поля + getter.
type X mut { … }(mut на тип). Один маркер вместо 18 — короче, но при 90% mut + 10% read-only нужен опт-аут per field. Усложнение. Конфликт с современным паттерном «struct + immutable defaults + явная мутация» из Swift/Rust.final(Java-стиль) для never-mut полей. Короче, прецедент Java/Dart/Kotlin, но семантически перегружен (final method,final class,final var).readonlyпрямо говорит «только для чтения».letдля never-mut полей. Короче (3 символа), прецедент Swift, ноletуже значит «binding имени со значением» (03-syntax.md → D33). На поле без=необычно, не самообъясняемо.readonlyпрямо говорит цель.const(C++-стиль). Конфликт с 03-syntax.md → D33 — тамconst= compile-time константа. Здесь — runtime-immutable. Перегрузка термина, AI-first против — невозможно.
Связь
- 02-types.md → D32 —
пересмотр семантики
mutдля полей. Передача параметров (fn f(mut o Order)) остаётся:mutна параметре = mutable binding, внутри — мутации полей по правилам D36. - 02-types.md → D17 — group-syntax для полей одного типа внутри record.
- 03-syntax.md → D33 —
letэто immutable binding; на поле — аналогия в ролиreadonly. - 03-syntax.md → D35 —
fn Type mut @methodдаёт mutable-binding self, поля затем по правилам D36.
Эволюция
До D36 поле помечалось mut field T, мутируемое только у
mut-binding’а (D32). Для аккумуляторов это требовало 18 раз
повторить mut — шум без пользы. D36 инвертировал дефолт: «обычное
поле — мутируется у mut-binding’а», readonly — для исключений.
Семантика параметров (D32) не менялась. Подробно — в
history/evolution.md.
D175. readonly field — полный freeze (амендмент D36)
Status: active (Plan 108, 2026-05-28)
Что
Уточнение D36: readonly field T запрещает и переприсвоение поля,
и мутацию содержимого — транзитивно.
| Объявление | Переприсвоить | Мутировать содержимое | Use case |
|---|---|---|---|
field T | у mut binding | у mut binding | большинство полей |
readonly field T | ❌ никогда | ❌ никогда | id, invariants, frozen state |
field readonly T | у mut binding | ❌ никогда | mutable ref, immutable content |
mut field T | ✅ всегда | у mut binding | cache, lazy init |
mut field readonly T | ✅ всегда | ❌ никогда | swappable readonly view |
Транзитивность: если поле объявлено readonly, доступ через него
также запрещает мутацию вложенных полей и вызов mut-методов:
type Tags { mut items []str }
type Account {
readonly id u64
readonly tags Tags // нельзя acc.tags.items.push("x")
}
let mut acc = ...
acc.id = 999 // E_READONLY_FIELD
acc.tags = Tags{} // E_READONLY_FIELD
acc.tags.items.push("x") // E_READONLY_FIELD (транзитивно)
Связь
D176. readonly T — тип-модификатор
Status: active (Plan 108, 2026-05-28)
Что
readonly как prefix-модификатор типа в любой позиции:
fn str @as_bytes() -> readonly []u8 // возвращаемый тип
fn process(data readonly []u8) { ... } // параметр
type Wrapper { field readonly []u8 } // поле
let view readonly []u8 = s.as_bytes() // локальная переменная
Семантика
- Запрещает вызов
mut-методов на значении типаreadonly T - Запрещает запись через индекс:
view[i] = x→E_READONLY_CONTENT T→readonly Tcoercion разрешён автоматически (сужение прав)readonly T→Tзапрещён:E_READONLY_COERCE
let arr []u8 = [1, 2, 3]
let view readonly []u8 = arr // ✅ []u8 → readonly []u8
let back []u8 = view // ❌ E_READONLY_COERCE
view[0] = 99 // ❌ E_READONLY_CONTENT
take_readonly(arr) // ✅ auto-coerce при вызове
Escape hatch
Снять readonly в Nova-коде нельзя. Кому нужен mutable доступ —
явно копирует: let copy []u8 = view.to_owned(). Если необходим
обход через FFI, это делается в external fn на C-стороне.
Рантайм
Zero overhead — readonly только compile-time проверка, не влияет
на codegen. ABI readonly []u8 = NovaArray_uint8_t* (идентично []u8).
Применение
str.as_bytes() -> readonly []u8 — zero-copy view в UTF-8 буфер строки
без memcpy. UTF-8 invariant защищён: записать в буфер нельзя.
Связь
- D36 —
readonly fieldпредшественник - D175 — readonly field enforcement
- D144 — слайсы
arr[a..b] - Plan 108 — реализация
D66. Self universal — ссылка на обобщающий тип в методах, effects, protocols
Что
Self — keyword-ссылка на «тот тип, к которому принадлежит метод»,
валиден в любом контексте, ассоциированном с конкретным типом:
- Внутри
protocol { ... }—Self= тип, удовлетворяющий контракту (как сейчас по D42 (REVISED)/D53). - Внутри
effect { ... }—Self= тип эффекта (Db,Net, …). - В static-методе
fn T.name(...)—Self≡T. - В instance-методе
fn T @method(...)/fn T mut @method(...)—Self≡T. - Для generic-типа
T[A, B]—Self≡T[A, B](с теми же параметрами).
Правило
type Box[T] {
value T
}
// static method — Self вместо повтора Box[T]
fn Box[T].of(v T) -> Self =>
Self { value: v }
// instance method — Self в return type для builder pattern
fn Box[T] @with_value(v T) -> Self =>
Self { value: v }
// protocol — для type-safe equality
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool // Self = тот тип, что реализует
}
// effect — для transactional/recursive handler-операций
type Db effect {
query(q Sql) -> []DbRow
nested(body fn() Self -> ()) -> () // Self = Db
}
// sum-type method
type Tree | Leaf | Node(int, Tree, Tree)
fn Tree @clone() -> Self => match @ {
Leaf => Leaf
Node(v, l, r) => Node(v, l.clone(), r.clone())
}
Семантика
Selfподставляется в момент использования метода/протокола, не в момент объявления.- Для concrete-типа
T(record, sum, newtype)Self≡T. - Для generic
T[A, B]Self≡T[A, B](наследует ту же специализацию). - Внутри protocol-объявления
Selfостаётся «late-bound» — конкретный тип определяется при удовлетворении.
Static-методы знают свой тип через Self
Static-метод в Nova связан с типом на уровне компилятора — не
«просто функция в namespace» (как Go), а полноценный метод типа
с доступом к Self. Это влияет на три use-case’а:
1. Self в return type (DRY-форма)
type Box[T] {
value T
}
fn Box[T].of(v T) -> Self => // Self ≡ Box[T]
Self { value: v } // generic-параметры наследуются
// Эквивалент без Self (verbose):
fn Box[T].of(v T) -> Box[T] =>
Box[T] { value: v }
Без Self программист пишет Box[T] дважды; с Self — один раз
(в receiver). Compiler знает что Self ≡ Box[T] потому что метод
объявлен на Box[T].
2. Self в expression position — вызов другого статического
type Account { balance money }
fn Account.new() -> Self =>
Self.with_initial(0) // другой static-метод того же типа
fn Account.with_initial(amount money) -> Self =>
Self { balance: amount } // Self { ... } literal
Self.with_initial(0) резолвится compiler’ом в Account.with_initial(0).
То же для Self { ... } — это Account { ... } literal.
Это canonical pattern для default-конструктор → parameterized-конструктор:
fn HashMap[K, V].new() -> Self =>
Self.with_capacity(16) // default делегирует к parameterized
fn HashMap[K, V].with_capacity(n int) -> Self =>
Self { buckets: new_buckets(n), count: 0, ... }
Refactoring-safe: переименование HashMap → Map меняет только
заголовки методов, не тела. Все Self авто-резолвятся.
3. Self в полиморфных контекстах (через protocol bound)
type FromStr protocol {
from_str(s str) -> Self // late-bound
}
fn parse[T FromStr](s str) -> T => T.from_str(s)
// ^^^^^^^^^^^^
// На каждой инстанциации parse[int](...) / parse[Money](...)
// T резолвится в конкретный тип. Compiler через monomorphization
// знает Self ≡ T для каждого вызова.
Это post-monomorphization — для каждого parse[X] генерится свой
код где X.from_str(s) это конкретный static-метод X. Static-метод
знает что он на X в каждом инстанциации.
Что это не значит
- Нет runtime-рефлексии. Static-метод не имеет
cls-параметра (как Python@classmethod), не может узнать своё имя как строку, не может сравнить два типа в runtime. Знание чисто compile-time. - Self в expression — синтаксическая подстановка. Compiler
заменяет
Selfна имя receiver-типа в момент codegen’а; runtime никаких type-id не передаёт. - Нет inheritance / virtual dispatch. Self ≠ виртуальный reference на subclass. У Nova нет наследования (D1) — только generic-bound через protocol.
Прецеденты
- Rust:
impl Foo { fn make() -> Self { Self::new(2) } }— активно используется.Selfдоступен везде в impl-блоке. - Swift:
static func make() -> Self,Self.method(),Self()initializer. - Kotlin:
companion objectс methods, доступ кthis::class. - C#:
staticметод имеет доступ к containing type.
Не следуем:
- Go: static-методов нет, только receiver-функции. Static в Nova = named function в namespace типа.
- Python
@staticmethod: не получаетcls, не знает свой тип.@classmethodполучаетclsruntime — мы делаем то же на compile-time черезSelf.
Где запрещено
- На top-level (вне типа/protocol/effect) — compile error «Self не в type-контексте».
- Внутри лямбды, объявленной не в method-теле — compile error.
- В сигнатуре свободной (top-level) функции
fn name(...)— compile error.
Почему
- DRY. До D66 в каждом методе
fn Box[T].of(v T) -> Box[T]имя типа повторялось 2-3 раза. Refactoring (Box→Container) ломал копипастой.Selfустраняет повтор. - Generic-параметры наследуются автоматически.
fn Box[T].ofсSelfкорректно подставитBox[T], неBoxбез параметров — программисту не нужно указывать generics в методе. - AI-friendly. LLM генерирует
Selfдля return type без знания точного имени — снижает количество ошибок при автогенерации builder-методов. - Унификация. До D66
Selfработало только в protocol — это создавало впечатление, что для других контекстов нужен другой механизм. На самом деле семантика одинаковая — «текущий тип». Один keyword для всех контекстов = D40 «один способ». - Прецеденты. Swift, Rust используют
Selfуниверсально (везде где естьimpl T { ... }блок). Nova следует тому же паттерну.
Что отвергнуто
@type— конструкция вида@typeдля ссылки на свой тип в методе. Отвергнуто:@уже занят под self-field, добавление второго смысла создаёт двусмысленность.- Имя типа повторять везде. Отвергнуто: см. п.1 «DRY».
Selfтолько в generic-методах (как в Java<T extends Self>). Отвергнуто: семантика остаётся та же, ограничение лишнее.
Связь
- D42 (REVISED) / D53 —
Selfв protocol’ах (исходное правило, расширено D66). - 03-syntax.md → D35 —
@-методы и@field. - 04-effects.md → D61 — effect-типы и handler’ы.
Эволюция
В D42 Self был валиден только внутри protocol { ... } блока —
это ограничение унаследовано от первой редакции, где Self вводился
именно для type-safe equality (Hashable.eq(other Self)). На
practice’е Self оказался полезен также в:
- static-методах для DRY возврата того же типа,
- instance-методах для builder pattern’а,
- effect-методах для self-referential операций (transactions),
- sum-вариантах для
@clone/@with_*методов.
D66 убирает ограничение: Self валиден везде, где есть type-контекст.
D72. Generic bounds через [T Protocol] — protocol как тип
Что
Параметр-тип в generic-списке может иметь bound — protocol-тип, которому должны удовлетворять конкретизации параметра. Синтаксис — единое правило «name type» без двоеточия:
[T Hashable]
[K Hashable, V]
[K, T From[K]]
Без bound — [T] — параметр без ограничений (структурное соответствие
проверяется при использовании, как было до D72).
Bound — это protocol-тип (D53). Тот же Hashable стоит и в
позиции типа значения (fn f(x Hashable) — existential), и в позиции
bound’а (fn f[T Hashable](x T) — universal). Одна сущность —
тип со структурным контрактом — в трёх позициях:
- Тип значения:
fn f(x Hashable) -> u64 - Bound:
fn f[T Hashable](x T) -> u64 - Эффект (между
)и->):fn f(...) Db -> ()(D18)
Различение по позиции, не по keyword’у. Закрывает Q-bounds.
Правило
Синтаксис
generic-params = '[' generic-param { ',' generic-param } ']'
generic-param = identifier [ type ]
generic-param следует общему правилу Nova «name type», как
параметры функции (x int), поля record (id u64), let-bindings
(let x int = 5), for-loops (for x int in xs), embed
(use w HashMapIter[K, V]).
fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
// ^ без bound — структурное соответствие при использовании
fn dedup[T Hashable](xs []T) -> []T
// ^^^^^^^^^^^ T должен реализовывать Hashable
type HashMap[K Hashable, V] {
// ^^^^^^^^^^^ K — Hashable, V — без bound
...
}
fn fold[T, Acc](xs Iter[T], init Acc, f fn(Acc, T) -> Acc) -> Acc
// ^^^^^^ ни T, ни Acc bound'а не имеют
fn[T] ReceiverType @method префикс (Plan 101.1 partial, 2026-05-24)
Generic-параметры также декларируются через fn[T] префикс —
для receiver’ов без carrier-brackets ([]T, bare T, tuple). Параллель
D145.
Bound syntax из D72 применим в этой позиции — fn[T Hashable] []T @method.
fn[T] []T @map[U](f fn(T) -> U) -> []U // T через fn[T] (нет carrier)
fn[T Hashable] []T @dedup() -> []T // bound в fn[T] (D72 + Plan 101.2)
Plan 101.1 status (2026-05-24): parser + базовый codegen работают
для []int element type. Codegen mono-per-T для других element-types
([]str, []User) — известная limitation, marker
[M-fn-prefix-int-only-mono], deferred ~4-6h follow-up.
Порядок объявления параметров
Generic-параметры читаются слева направо. Имя в bound’е должно
быть уже объявлено — либо ранее в том же списке [...], либо в
type-контексте (top-level type, окружающий тип для метода).
fn func[K, T From[K]](v K) -> T => T.from(v)
// ^ ^
// объявлен раньше используется в bound
fn func[T From[K], K](v K) -> T // ОШИБКА: K используется до объявления
fn func[T Test[K]](v K) -> T // ОШИБКА: K не объявлен вообще
Это согласовано с правилом параметров функции: fn f(x int, y T) —
имена читаются слева направо, ранее объявленные доступны позже.
Forward-references запрещены ради простоты type-checker’а и
читаемости (LLM не нужно держать «отложенный контекст»).
Bound — это protocol-тип
Hashable, From[T], Into[T] и т.д. — обычные protocol-типы (D53):
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
// Bound в generic-объявлении:
fn map[K Hashable, V](m HashMap[K, V]) -> ...
// Тот же Hashable в позиции типа значения (existential):
fn dump_one(x Hashable) -> u64 => x.hash()
Existential vs universal — различение по позиции:
| Форма | Семантика | Dispatch | Аналог Rust |
|---|---|---|---|
fn f(x Hashable) | existential («какое-то значение типа Hashable») | dynamic (vtable) | fn f(x: &dyn Hashable) |
fn f[T Hashable](x T) | universal («для любого T : Hashable») | static (mono) | fn f<T: Hashable>(x: T) |
В обоих случаях Hashable — тип. Различие только в позиции:
внутри [...] — generic-параметр и его bound; в обычной позиции —
тип значения. Прецедент — Go (interface { M() } используется и как
тип, и как constraint).
Multiple bounds — анонимный protocol
Если параметру нужно несколько bounds, объединяются в анонимный
protocol-тип через protocol { ... } (D53):
fn min[T protocol { @lt(other Self) -> bool, @eq(other Self) -> bool }](xs []T) -> T
Долго, но без специального синтаксиса для intersection bound’ов. Если паттерн повторяется — выносится в именованный protocol:
type Ord protocol {
@lt(other Self) -> bool
@eq(other Self) -> bool
}
fn min[T Ord](xs []T) -> T => ...
Сокращённая форма [T A & B] — открытый вопрос
(Q-multi-bound).
Self в bounds
Self (D66) валиден внутри protocol/method-контекста. В bound’е
generic-параметра свободной функции — запрещён:
fn merge[T Eq](a T, b T) -> T => ... // ok
fn merge[T Eq Self](a T, b T) -> T => ... // ОШИБКА: Self вне type-контекста
В method-контексте (fn Box[T] @method[U Self]) — открытый вопрос,
пока запрещено.
Bound как effect — запрещено
Bound — это protocol-тип. Effect — тоже protocol, но используется
в позиции эффекта (между ) и ->). Использовать Db как bound
запрещено — это ошибка категории (D62: effect ≠ protocol для
generic-bound):
fn run[T Db](handler T) -> () // ОШИБКА: Db — effect, не bound-protocol
Если нужно «принимает Effect[Db]» — пишется явно: fn run(h Effect[Db]).
Bound на типах (не функциях)
Тот же синтаксис в declaration типов:
type HashMap[K Hashable, V] {
readonly buckets []Slot[K, V]
}
type Set[T Hashable] {
readonly inner HashMap[T, ()]
}
type Sorted[T Ord] | Empty | Node(T, Sorted[T], Sorted[T])
Bound применяется при инстанциировании: HashMap[User, int] требует
чтобы User реализовывал Hashable.
Проверка bound’а — структурная (D53)
Bound удовлетворён, если у конкретного типа есть методы из
protocol’а (структурно). Никаких явных impl/declaration не нужно:
type User { id u64 }
fn User @hash() -> u64 => @id
fn User @eq(other Self) -> bool => @id == other.id
// User автоматически удовлетворяет Hashable, потому что есть @hash и @eq
let m HashMap[User, str] = HashMap.new() // ok
Если методов нет — compile error на месте использования (HashMap[User, str]
с инстанциированием), не на declaration type User.
Почему
-
Закрывает Q-bounds. Generic-инфраструктура (HashMap, From/Into, collect, FromIter) требует bound’ов. Без них либо безопасности нет, либо ошибки откладываются до места использования с непонятным сообщением.
-
Согласовано с правилом «name type». Параметр функции
x int, полеid u64, generic-параметрT Hashable— единая грамматика. Двоеточие в Nova зарезервировано под key-value, использовать его для bound — нарушение D17. -
Protocol = тип (D53).
Hashableуже тип в Nova. Использовать его как bound — естественное расширение, не новый механизм. Existential (x Hashable) и universal ([T Hashable]) различаются позицией. -
Прецедент Go. Go 1.18+:
interface { M() }используется и как тип значения, и как constraint в generics. Один синтаксис, два контекста, проверено в большом продакшне. -
Структурная проверка вместо impl. Nova не имеет orphan rule (D42/D53) — нет
impl Trait for Typeблоков. Bound удовлетворяется автоматически, как и existential. Это последовательно. -
AI-friendly. LLM пишет
[T Hashable]без специальных keyword’ов (where,impl,:). Грамматика читается как естественный язык: «параметр T типа Hashable».
Что отвергнуто
[T: Hashable](Rust/Scala/Kotlin/Swift). Конфликтует с D17 — двоеточие в Nova только для key-value (record-литералы, dict). Делать исключение для generic-list — нарушение единства.[T is Hashable].isуже занят под runtime type-check (D54). Третий смысл (compile-time bound) перегружает keyword.where-clauses после сигнатуры (C# / Haskell-style). Многословно, раздваивает информацию между списком параметров и where-блоком. Bound у параметра — единое место.[T impl Hashable](Swiftsome-style). Нестандартно,implне используется в Nova ни для чего ещё.- Bounds через контракты (
requires implements(T, Hashable)). Контракты (D24) проверяются SMT на значениях, bound — type-checker’ом на типах. Разные уровни. - Sealed/closed bound’ы («только эти типы»). Открытый вопрос, не входит в D72.
Цена
- Type-checker сложнее. Проверка structural-bound при мономорфизации — дополнительная работа.
- Сообщения об ошибках. «
Userне реализуетHashable: missing method@hash» — нужно генерировать понятные диагностики. - Множественные bounds через анонимный protocol — многословно
для частых пар (
Hash + Eq). Сокращённая форма откладывается.
Связь
- 02-types.md → D53 — protocol = тип, основа D72.
- 02-types.md → D42 — структурная типизация, две модели generic-параметров.
- 02-types.md → D66
—
Selfв protocol-контексте. - 03-syntax.md → D16
—
[T]синтаксис для generic’ов. - 04-effects.md → D18 — protocol в effect-position, отличается от bound-position.
- 08-runtime.md → D73 —
From[T]/Into[T]используют bound[U From[T]]для generic-функций конверсии. - Q-bounds — closed by D72.
- Q-collect-mechanism — становится решаемой после D72.
Открытые вопросы
- Множественные bounds: сокращённая форма (
[T Hash & Eq],[T (Hash, Eq)]) — Q-multi-bound. - Bound на эффект-параметре: можно ли
[E SomeProtocolOnEffects]— связано с Q-effect-params. Selfв bound в method-контексте — отложено.- Conditional methods через
where-clause (fn Vec[T] @sort() where T Ord) — отложено вместе с conditional impls.
Эволюция
В MVP bounds были отвергнуты (D42 «Открытые вопросы»,
history/rejected.md: «[T: Bound] отвергнут
в MVP»). Пользовались структурным соответствием при использовании —
ошибка вылезала на месте вызова, не объявления. С ростом stdlib
(HashMap, From/Into, collect) стало ясно что без bound’ов нельзя:
generic-функции не могут опираться на методы T без явного контракта.
Q-bounds зафиксировал синтаксис заранее ([T Bound] без двоеточия).
D72 принимает это как формальное решение, расширяет до полной семантики
(structural check, existential-vs-universal через позицию, multiple
bounds через анонимный protocol).
D110. Ghost state — spec-only bindings
Статус: Принято (Plan 33.3 Ф.10, реализовано в AST и type-checker)
Решение
ghost let / ghost var объявляют spec-only переменные — они видимы
в requires/ensures/invariant и других ghost-statements, но
никогда не эмитируются в C-код (ни в debug, ни в release).
fn fill(xs mut []int) -> ()
ensures forall i in 0..xs.len() : xs[i] == 0
{
ghost let n = xs.len() // spec-only: виден в invariant
for i in 0..xs.len()
invariant forall j in 0..i : xs[j] == 0
{
xs[i] = 0
}
}
Правила видимости ghost:
- Ghost-binding виден: в других
ghost-stmts; вrequires/ensures/invariant; в теле#pureфункций. - Использование ghost-binding в non-ghost emit-code → compile error.
- Codegen: ghost-stmts и ghost-bindings полностью стираются (паритет с Dafny).
Следствие: invariants, использующие ghost-данные, в debug не проверяются runtime — только через SMT. Это задокументированное design-решение.
Обоснование
Ghost state позволяет писать контракты в терминах вспомогательных
концепций (счётчики, логические флаги, промежуточные значения), не
засоряя runtime-код. Паритет с Dafny ghost var, F* Ghost.
Реализация
compiler-codegen/src/ast/mod.rs— полеis_ghost: boolвLetDecl; enum-вариантStmt::Ghostдля ghost-блоков (Ф.10 scope).compiler-codegen/src/types/mod.rs— type-check: reject ghost-ref в non-ghost context.compiler-codegen/src/codegen/emit_c.rs— ghost-stmts стираются (пустой emit).compiler-codegen/src/verify/encode.rs— ghost-vars участвуют в SMT-encoding как обычные fresh-vars.
D122. Hybrid dispatch для bound-K methods
Status: active (spec). Реализация — Plan 56.
Что
Generic-bound method call’ы dispatch’аются по hybrid strategy:
-
Mono path — для concrete K на call-site (e.g.
HashMap[str, int]): compiler instantiates generic method с substituted K, V. Bound methods (key.hash(),key.eq()) resolve в direct call к concrete K methods (nova_str_hash(key)). Zero-cost — паритет Rustimpl<T: Hashable>. -
Erased path — для generic body emit (когда compiler не может / не должен mono’d, e.g. recursive generic call на Self type внутри generic method body): generic body эмитится как stub (call’еры полагаются на mono path для concrete instances). Bootstrap не использует vtable — простая stub-fallback стратегия.
-
Vtable path (future, Plan 56 Ф.2 full): для truly erased contexts (cross-crate generic,
dyn Trait-like), bound methods dispatch’аются через vtable structure. Vtable runtime defined вcompiler-codegen/nova_rt/vtables.h(Plan 56 Ф.1).
Bootstrap status (2026-05-16)
- ✅ Mono path для bound methods works (HashMap.clone() пример).
- ✅ Vtable runtime infrastructure готова (
NovaVtable_Hashable,NovaVtable_Comparable,NovaVtable_Display+ 4 primitive K vtables: int/bool/u8/f64/str). - ✅ Erased emit для bound-method-using generic methods stub’ится
(
emit_generic_method_erased— wider stub condition включает Array fields с generic inner type). - ⏸️ Vtable codegen integration (truly erased dispatch) — deferred до cross-crate compilation (Plan 03).
Acceptance criteria для bound methods
Type-checker (Plan 15 / D72) enforces:
- Bound должны быть protocol-типами (D53).
- Concrete K на call-site должен implement все bound methods (D72 enforcement).
Codegen (Plan 56 Ф.1 + Ф.2):
- Protocol-методы могут иметь эффекты (
Fail/Io/Db) — напр.type TryFrom[T, E] protocol { try_from(t T) Fail[E] -> Self }. Под mono-dispatch (текущий bootstrap) эффект protocol-метода пробрасывается как у обычной effectful-функции — без спец-кейса. (D122 amended 2026-05-20: снят запрет Plan 56 Ф.2.7 на pure-only bound methods.) Ограничение: true-vtable dispatch (Plan 03) не пробрасывает effect-handlers через vtable-ABI — в truly-erased контексте effectful-protocol bounds обязаны mono-dispatch’иться; чистая vtable-диспетчеризация effectful-метода — будущая работа Plan 03. - Self type в bound method signature substitutes runtime receiver type.
Связь
- D72 — generic bounds enforcement (type-checker side).
- D53 — protocol-типы.
- D24 — vtable lookups compatible с proven-contracts skip (no-op).
D123. Tuple monomorphization
Status: active (spec, 2026-05-17 EOD+2 — Phase 7 production polish applied). Реализация — Plan 59 (6 phases + Phase 7).
Что
Tuple типы (T1, T2, ..., TN) monomorphized — для каждой concrete
комбинации element types compiler generate’ит отдельную struct
с real field types (не nova_int slot erasure).
Mangle scheme (Plan 59 Phase 5, length-prefixed)
Itanium ABI / Rust v0 mangle analog — unambiguous для любой глубины nesting:
_NovaTuple_<arity>_<L1>_<T1>_<L2>_<T2>_..._<LN>_<TN>
где <Ln> — десятичная byte length sanitized name <Tn>. Parser
читает length, берёт точно столько chars, переходит к следующему.
Самоописательный, никаких ambiguity даже для tuple-of-tuples.
Примеры:
(int, int)→_NovaTuple_2_8_nova_int_8_nova_int(str, int)→_NovaTuple_2_8_nova_str_8_nova_int((int, int), int)outer →_NovaTuple_2_34__NovaTuple_2_8_nova_int_8_nova_int_8_nova_int(L1=34 — точно столько chars как T1)
Distinguishable от legacy _NovaTupleN (e.g. _NovaTuple2) по _
после NovaTuple.
Правило
let p (str, int) = ("a", 1)
// ^^^^^^^ generates _NovaTuple_2_8_nova_str_8_nova_int
// { nova_str f0; nova_int f1; }
for (k, v) in hashmap {
// ^^^^^^^^^^^^^^^^ implicit Iter (D58) + tuple destructure через
// mono'd struct (k: nova_str, v: nova_int direct
// field access)
}
match some_kv {
Some((k, v)) => ...
// ^^^^^^^ Plan 59 Phase 6 — variant payload mono'd tuple,
// heterogeneous types работают (str + int)
}
Параллель: Rust (T1, T2) mono’d per concrete instantiation,
zero-cost. C++ std::tuple<T1, T2> template — то же. Nova bootstrap
паритет (vs предыдущий int-slot erasure breaking struct elements).
Decision tree
При codegen tuple type:
- All elements concrete (resolved via current_type_subst,
no type-param placeholders) → use mono’d
_NovaTuple_<arity>_<L1>_<T1>...struct. Zero erasure cost. - Erased context (one or more element types unresolved) →
fallback legacy
_NovaTupleN(nova_int slot) с runtime cast. Bootstrap-compat для truly generic contexts.
Constraints
- Tuple field access (
p.0,p.1) — direct C field access (.f0,.f1) на mono’d struct. - Tuple destructure (
let (a, b) = ...) — direct binding, no cast. - Nested tuples (
((int, str), bool)) — recursive mono’d (inner tuple registered first; length-prefix encoding handles нестинг любой глубины — validated 5-level tests). - Tuple в variant payload (
Option[(K, V)],Result[(K, V), E]) — match destructureSome((k, v))/Ok((k, v))propagate mono’d element types через registry (Phase 6 + Plan 63 Fix F+). - Tuple in collections (
HashMap[K, V]returnsOption[(K, V)]fromiter().next()) — mono’d через template + subst at iter mono pass.
Diagnostics (Plan 59 Phase 7.1)
- Arity mismatch — destructure pattern имеющий разное число элементов чем actual tuple, reject’ится Nova-level clear error (file:line + hint) до C-emit’а. Покрывает 3 sites: let-destructure, for-pattern, match-variant inner Tuple. Раньше упирался в нечитаемый “no member named ‘fN’” C error.
Lint warnings (Plan 59 Phase 7.3)
- Large tuple warning — mono’d tuple с >5 элементов OR >128 bytes estimated size emit’ит W-warning suggesting record type (clarity + stable ABI). Estimate sums known element sizes: pointers=8, nova_str=16, scalars per type. Threshold выбран эмпирически — typical cache line 64 bytes, 2× giving safe margin.
Stdlib idiom (Plan 59 Phase 7.2)
После Plan 63 Fix E (mono’d tuple iter в generic method body
работает) — stdlib коллекции используют идиоматичный
for (k, v) in self / for (k, v) in @iter() вместо
direct-field workaround’ов. HashMap.@clone/@merge_from/@filter все
idiomatic.
Field literal style (related, D52 §2)
Record literal для tuple struct полей ({ end, idx: 0 } для
{end int, idx int} где end — variable в scope) — shorthand
обязателен при совпадении имени поля с источником ({ end: end }
запрещено, см. D52 §2).
Почему
- Correctness — struct value types (nova_str, user records)
не fit’ят в nova_int slot. Без mono
(str, int)was broken. - Zero-cost — direct field access, no intptr_t cast, no heap alloc для tuple value.
- Параллель Rust/C++ — индустриальный standard для tuples.
- Diagnostics quality — Plan 36 R7 bar (file:line + hint).
- Self-describing mangle — length-prefix encoding debug’абельно, ABI-tools (debuggers) могут decode.
Что отвергнуто (deferred с rationale)
- Universal tuple type (all elements
any) — type-erased, runtime type-tag overhead, breaks AOT zero-cost goal. - Named tuple fields (
(x: T1, y: T2)) — ОТКЛОНЕНО окончательно (Plan 59 Ф.7.4, 2026-05-21). Именованные поля кортежа почти идентичны record’у; заводить два почти одинаковых синтаксиса для одной семантики в Nova нет причин. Нужен агрегат с именованными полями — это record (type T { x int, y int }). Tuple остаётся позиционным (.0/.1). - Tuple subtyping (
(int, str) <: (any, any)) — ОТКЛОНЕНО окончательно (Plan 59 Ф.7.6, 2026-05-21). Реализация дорогая (требует variance-системы covariance/contravariance в type-checker, которой в Nova нет — язык не использует structural typing); под фичу не нашлось ни одной реальной задачи. Не реализуется. Full mono’d Result (✅ РЕАЛИЗОВАНО (Plan 59 Ф.7.5 increment 2, 2026-05-21): Result полностью мономорфизирован — per-(T,E) C-типNovaRes_<T>_<E>typedefs analogous Option) — Plan 63 Fix F+ targeted boxed-pointer tracking покрывает все observable cases без full sum-type mono refactor. Defer до Plan 65.NovaRes_<ok>_<err>*(аналогNovaOpt_<T>). Legacy единыйNova_Resultустранён; targeted Fix F+ boxed-tracking больше не нужен — Ok/Err payload типизируется реальным T/E inline.
Связь
- D27 — tuple литерал синтаксис.
- D52 §2 — field shorthand mandatory.
- [D58 Iter protocol] —
for (k, v) in collиспользует mono’d tuple через implicit.iter(). - Plan 48 — monomorphization infrastructure (mono pass).
- Plan 63 — Fix E (mono’d iter в generic method body) + Fix F/F+ (Result Ok payload tuple unboxing).
D119. Method-level type parameters в generic methods
Status: active (spec, 2026-05-17). Реализация — Plan 48 Ф.9. Закрывает частично Q-generic-receiver-method (для user-defined generic типов; built-in
[]Tостаётся V2).
Что
Generic methods могут иметь собственные type-параметры, независимые
от type-параметров receiver’а. Метод Wrapper[T] @map[U](f fn(T) -> U) -> Wrapper[U]
имеет два уровня generics: receiver-level T и method-level U.
Compiler через monomorphization создаёт отдельную mono-instance
для каждой комбинации (T, U).
Правило
export type Wrapper[T] { inner T }
// Receiver-level T, method-level U.
export fn Wrapper[T] @map[U](f fn(T) -> U) -> Wrapper[U] {
Wrapper[U].of(f(@inner))
}
// Call-site:
let w = Wrapper[int].of(5)
let a = w.map(|x| x * 2) // (T=int, U=int) instance
let s = w.map(|x| str.from(x)) // (T=int, U=str) instance
let s2 = s.map(|x| x + "!") // (T=str, U=str) instance
Compiler emits 3 distinct mono’d methods:
Wrapper____nova_int_method_map____nova_intWrapper____nova_int_method_map____nova_strWrapper____nova_str_method_map____nova_str
Параллель: Rust impl<T> Wrapper<T> { fn map<U>(self, f: impl Fn(T) -> U) -> Wrapper<U> }
— то же monomorphization per (T, U). C++ template<T> class Wrapper { template<U> Wrapper<U> map(...) } — то же. Nova bootstrap теперь паритет.
Decision tree
При codegen call’а obj.method[U](args):
- Receiver T — резолвится из obj C-type (
Nova_Wrapper____<T>*→ T =<T>). Существующая infrastructure (D72 + Plan 48 Ф.0). - Method-level U — резолвится через bidirectional inference
из call args:
- Non-closure args:
infer_expr_c_type(arg)→ bind U черезinfer_type_param_binding. - Closure-typed args (
|x| body): pre-populate closure-param types с T-substituted C-types, recurse в body для return type → bind U.
- Non-closure args:
- Method C-name включает обa уровней:
<TypeBase>____<T>_method_<m>____<U>.
Constraints
-
Method-level generics declared в
@method[U]— synтаксис как у free-function generics (fn name[U](...)); receiver[T]parsed отдельно. -
Closure args drive inference — без explicit turbofish (
obj.map::<int>(...)), U inferенtsя из closure return type. Если нет args или U не появляется в parameter types, compiler emit’ит clean diagnostic:cannot infer method-level type argument `U` for generic method `<TypeBase>____<T>.<method>` (only in return type — provide arg whose type binds it); provide a closure/arg whose type fixes `U`(См. реализацию в
compiler-codegen/src/codegen/emit_c.rspath 5b.) Раньше unresolved method-level params silently dropped →Nova_U_pplaceholder leak в emitted C → undefined-struct CC-FAIL. -
Per-(T, U) instances — каждая уникальная пара получает свою mono’d function. Worklist enrollment предотвращает дубликаты.
-
Return type substitution —
Wrapper[U]в return type корректно resolves вNova_Wrapper____<U>*(неNova_U_pplaceholder).
Почему
- Параллель Rust/C++ — индустриальный standard для generic methods.
- Zero-cost — каждая mono-instance это direct call, инлайнится, no void* boxing/cast.
- Composability —
w.map(f).map(g).filter(p)typical functional chain работает без erasure penalty. - Был CC-FAIL — без method-param mono
let m = w.map(|x| str.from(x))эмиттилNova_Wrapper____Nova_U_p* m = ...(undefined struct, C-compile fail).
Что отвергнуто
- Method-level type-erasure (
void*U) — для bootstrap проще, но ломает первый-class closures + breaks struct-typed U (record-value не fit’ит вvoid*без heap-box). Equivalent проблема к Plan 48 receiver-level erasure отвергнутой в V1. - Explicit-only U (
obj.map::<U>(...)обязателен) — verbose, не matches industry standard. Inference из args — first-class.
Связь
- D72 — generic bounds на type params; method-level U могут иметь bounds.
- D122 — hybrid dispatch для protocol-bound type params; orthogonal к method-level vs receiver-level.
- D123 — tuple mono пользуется тем же worklist infrastructure.
- Plan 48 Ф.9 — реализация (emit_call path 5b + infer_mono_method_ret_with_args).
- Plan 63 Fix C — remaining edge case Plan 63, закрытый этим D119.
- Q-generic-receiver-method
D125. Удаление byte: каноническое имя — u8
Решение: Тип byte удалён из языка. Единственное каноническое имя
для 8-битного беззнакового целого — u8. Срез байт пишется []u8.
Мотивация. Наличие двух равнозначных имён (byte и u8) порождает
неоднозначность в коде, документации и стандартной библиотеке: один и тот же
тип можно было написать двумя способами, что усложняло чтение и тулинг.
Миграция. Все вхождения byte как типа заменяются на u8:
[]byte→[]u8- параметры/поля типа
byte→u8 - в примитивном перечислении:
byteубирается из списка
Исключения (не меняются):
- Тег шаблонных строк
bytes`...`(D48) — это имя функции, не тип. - Слово «byte» в английском/русском тексте комментариев (единицы памяти).
Реализовано: Plan 69 — 2026-05-22.
byte удалён из builtin-типов компилятора (lexer/parser/type-checker/
codegen); все вхождения в spec/ / std/ / nova_tests/ мигрированы
на u8. C-typedef nova_byte (= uint8_t) сохранён как внутреннее имя
codegen — не пользовательская поверхность.
D126. Strict type propagation в codegen — no silent nova_int fallback
Решение. Codegen pass (compiler-codegen/src/codegen/) обязан
производить deterministic, явный C-type для каждого Nova expression и
type reference. Silent fallback к nova_int при failure type
resolution — запрещён. Любой site где type_ref_to_c(...)
возвращает Err без strict-error должен производить compile-time
diagnostic [E7001] и failing build, а не подставлять placeholder
type.
Мотивация. До Plan 70 паттерн type_ref_to_c(&ty).unwrap_or_else(|_| "nova_int".into()) встречался в codegen в 117 местах (audit 2026-05-18).
Семантика: «если type translation failed → silently emit nova_int
(long long) и продолжай». Результат — silent miscompilation:
- pointer cast to int → garbage address как число
- bool/char печатается как code-point (Plan 67 закрыл частный случай)
- record/sum-type memcpy с неправильным sizeof
- float → int truncation
Программа «работает», но возвращает мусор. Debug невозможен — компилятор ничего не сигналит.
Industry baseline. Rust / Swift / Go (post-1.18) — все производят compile error на любом unresolved type в codegen. Nova до Plan 70 был хуже всех baseline (silent default). D126 закрывает регрессию.
Категории erasure (Cat A/B/C/D). Audit разделил 154 fallback sites на четыре категории:
| Cat | Pattern | Семантика | Действие |
|---|---|---|---|
| A1 | type_ref_to_c(...).unwrap_or_else(|_| "nova_int") | Silent fallback при resolution failure | Strict error |
| A2 | _ => "nova_int" wildcard без комментария | Wildcard fallback unknown type | Strict error или Cat D classification |
| B | _ => "nova_int", // erased T (commented) | Pre-mono generic body emit — type-param ещё unresolved | Documented intentional erasure |
| C | WithResultCategory::IntLike => "nova_int" | Categorical mapping для int-family aliases | Legit, keep |
| D | Dispatch wildcard на известный receiver | Known type, unknown method (type-checker уже rejected) | Legit, keep |
Только Cat A даёт silent miscompilation. После Plan 70 closure все Cat A sites мигрированы к strict error path. Cat B/C/D documented в docs/codegen-erasure-sites.md.
Strict-error architecture. Две helper-функции в emit_c.rs:
-
err_no_int_fallback(context, cause) → String— для functions возвращающихResult<_, String>. Используется с?propagation:let ty = self.type_ref_to_c(&p.ty).map_err(|e| self.err_no_int_fallback("parameter `x`", &e) )?; -
record_strict_error(context, cause) → "nova_int"— для cascade-blocked sites (functions whose signature нельзя менять без massive caller-chain refactor:infer_expr_c_type(135 callers),register_mono_instance, etc). Pushes E7001 вstrict_errors: RefCell<Vec<String>>field; finalization gate вemit_moduleпроверяет non-empty и failit codegen pass с aggregated error message.
Оба helper’а используют unified diagnostic format [E7001] (range
E7001-E7099 reserved для Plan 70 family). Plan 36 R7 structured
diagnostic compatibility.
Production-grade default. Strict mode — always on, без opt-in env var. ANY silent fallback = build failure (Rust/Swift baseline). Это breaking change для user code который полагался на silent int default (R20 в Plan 70). Bootstrap convention: clean break с machine-applicable migration suggestions.
Diagnostic format (E7001).
[E7001] cannot infer C type for parameter `x`: <cause>. Silent
fallback к `nova_int` produced wrong runtime output для non-int
types (record/string/float/bool). Add explicit type annotation,
ensure generic is monomorphized, или register type в external_registry.
См. Plan 70 ([M-no-silent-nova-int-fallback]).
Internal lint guard (CI). scripts/lint-no-silent-int-fallback.sh
greps compiler-codegen/src/ против baseline counts из
docs/codegen-erasure-sites.md. Bumping baseline требует:
- Inline comment с rationale «почему erasure безопасна»
- Entry в
docs/codegen-erasure-sites.mdсо file:line + причина - PR review
CI gate fails если added counts превышают baseline без updates.
Acceptance criteria (Plan 70 closure).
- Helper infra
err_no_int_fallback+record_strict_error(Ф.1 / Ф.B0) - Cat A1/A2 migration: 90 → 8 (only Cat B holdovers remain)
- Cat B documentation: 10 sites listed в codegen-erasure-sites.md
- Internal lint guard
scripts/lint-no-silent-int-fallback.sh - Spec D126 (этот блок)
- 796+ PASS / 0 FAIL nova test (0 regressions vs baseline 761)
Реализовано: Plan 70 — sessions 1+2 (2026-05-18); 90+ Cat A1 sites migrated, infrastructure complete, lint guard active.
Связь:
- D118 — typed
Fail[E]codegen (similar precision-by-construction pattern) - Plan 67 — println overload fix (sibling: один из видимых частных случаев)
- Plan 48 — monomorphization (упрощает Cat B → меньше erasure)
- Plan 36 — diagnostic infra (R7 structured format)
- docs/codegen-erasure-sites.md — Cat B/D inventory
D128. char distinct from int в codegen mono’d generics
Решение. Тип char имеет собственный C-typedef nova_char (alias
над int64_t, same underlying storage как nova_int, но distinct C
identifier). Generic mono mangling использует nova_char separately от
nova_int, поэтому Option[char] и Option[int] производят разные
C-типы NovaOpt_nova_char vs NovaOpt_nova_int — структурно
неотличимы становятся различимы.
Мотивация. До Plan 70.3 оба char и int map’ились в один C-тип
nova_int. Результат — silent type collapse в generic mono:
Option[char]иOption[int]mangle в идентичныйNovaOpt_nova_int[]charи[]intобе →NovaArray_nova_int*Map[char, V]иMap[int, V]→ одинаковая mangled name
Concrete observed bug (триггер плана): str @char_at(idx int) -> Option[int]
declared, returned Option[char] де-факто. Type-checker не ловил
поскольку C-level structural compatibility. ~50 callers использовали
char literals (Some('/'), unwrap_or('.')) в slot expecting
Option[int] — silent collapse через NovaOpt_nova_int. User pre-fix
2026-05-19 corrected signature, Plan 70.3 — архитектурное предотвращение.
Industry baseline. Rust/Swift char is distinct primitive (char
vs u32); Go has rune distinct from int32. Nova до Plan 70.3 был
unusual в C-level collapse. D128 закрывает регрессию.
Implementation (Plan 70.3 Ф.1-Ф.2).
- Typedef:
typedef int64_t nova_char;вcompiler-codegen/nova_rt/nova_rt.h— zero ABI cost (same storage layout какnova_int). - Codegen mapping:
type_ref_to_c "char" => "nova_char"(was"nova_int") вemit_c.rsиexternal_registry.rs(двойная sync). - Array element:
[]char → NovaArray_nova_char*(separate instantiation parallelNovaArray_nova_int*). - Option element:
NovaOpt_nova_chartypedef + constructors +nova_opt_eq_nova_charhelper. - CharLit emission:
'x' → ((nova_char)<codepoint>LL)(was(nova_int)). - infer_expr_c_type:
CharLit => "nova_char"(was"nova_int"). - Runtime fn signatures:
nova_str_char_atupdated returnNovaOpt_nova_char(wasNovaOpt_nova_int).
Backward compat. В emit_binary_op special-case для
Nova_StringBuilder* + char accepts обе nova_char AND nova_int
для backward-compat — pre-fix existing code emitted char as nova_int,
existing test binaries reference legacy form. After full migration of
existing generated C (regen test fixtures), nova_int branch может
быть удалён.
ABI cost. Zero. nova_char is typedef int64_t — same size,
same alignment, same wire-format. Only difference — C type identifier
для compiler-level distinction.
Acceptance criteria.
- Ф.1 codegen mapping switch (
emit_c.rs+external_registry.rs) - Ф.2 runtime helpers parallel (
NovaArray_DECL(nova_char),NovaOpt_nova_charconstructors + eq helper) - Ф.3 audit + fixtures (2 PASS в
nova_tests/plan70_3/) - Ф.4 type-checker tightening (reject
let x Option[int] = Some('a')) - Ф.5 spec D128 (этот блок)
- 0 regressions в
nova test(801 PASS sustained)
Реализовано: Plan 70.3 — Ф.0-Ф.5 closed 2026-05-19.
Связь:
- D26 — Q-string-indexing (char = codepoint convention)
- D54 —
as-cast narrowing (explicit char↔int conversion) - Plan 70 — parent family (silent type bugs от Nova↔C collapse)
- Plan 70.4 — sibling proposal (f32/f64 generic-container distinct mangling)
D129. int как alias i64 в bootstrap Nova
Решение. Тип int в Nova bootstrap является alias для i64
(64-bit signed integer). Оба маппируются в C-тип nova_int
(typedef int64_t). Отсутствие distinction в codegen — намеренно:
это не collapse-баг (как в Plan 70.3 char/int), а архитектурный
bootstrap-invariant.
Мотивация. Audit Plan 70.4 выявил, что int и i64 используют
один C-тип. Mangle для Map[int, V] и Map[i64, V] идентичен. В
отличие от других collapse-паттернов Ф.1/Ф.2 плана 70.4 (ABI-real
silent miscompilation) или Plan 70.3 char/int (semantically distinct
types), int ≡ i64 является семантическим инвариантом — оба
означают 64-bit signed integer без разницы в значении или поведении.
Nova bootstrap targets x86_64 only (fixed 64-bit pointer width).
Industry baseline.
- Rust:
isizedistinct отi64(platform-pointer width varies на 32-bit) - Go:
intdistinct отint64(platform-pointer width) - C#:
int= aliasSystem.Int32(semantically identical) - Python/Java: нет fixed-width integer aliases
- Nova:
int= aliasi64— правильная аналогия C# для fixed-width platform
Future evolution path. Если Nova добавит multi-arch targets
(32-bit, WASM), int может стать platform-pointer-width type аналогично
Rust’s isize. На этот момент потребуется breaking change в codegen
mangling — Map[int, V] и Map[i64, V] станут distinct. D129
explicitly documents текущее bootstrap decision как alias-based,
чтобы будущий architect не принял отсутствие distinction за bug.
Migration path: introduce nova_iptr (platform-width) typedef, make
int resolve to it, maintain nova_int = int64_t for i64.
Codegen. Без изменений. type_ref_to_c "int" => "nova_int" и
"i64" => "nova_int" — оба корректны и эквивалентны по спецификации.
Distinct mangling не вводится, т.к. это создало бы необходимость явно
выбирать int vs i64 для каждого generic instantiation — user-hostile
и ортогонально семантической разнице (которой нет).
Acceptance criteria.
- Ф.3 spec D129 (этот блок) — формализует alias decision
- Нет codegen изменений — intentional collapse документирован
- Future: multi-arch migration path зафиксирован (Migration note выше)
Реализовано: Plan 70.4 — Ф.3 closed 2026-05-19.
Связь:
- D54 —
as-cast narrowing semantics - D128 — Plan 70.3 char/int distinction (contrast: там distinction нужна)
- Plan 70.4 — parent plan (этот блок = Plan 70.4 Ф.3)
- Plan 70 — parent family (silent type bugs)
D130. uint — unsigned 64-bit alias в bootstrap Nova
Решение. Тип uint является alias для u64 (64-bit unsigned
integer) в Nova bootstrap. Маппируется в C-тип uint64_t. Отличие
от int/i64 (alias pair, signed) — uint/u64 является
симметричным unsigned pair. int as uint cast saturates (negative → 0);
int as u64 — direct bit-cast (существующее поведение сохранено).
Дизайн (Q1-Q4, подтверждены 2026-05-19).
| Вопрос | Решение | Обоснование |
|---|---|---|
| Q1: alias или distinct? | Alias u64 (= uint64_t) | Mirror int = i64 alias pattern; нет multi-arch story в bootstrap |
| Q2: int→uint cast | as uint saturates (neg → 0) | D54 precedent (float→int); Rust bit-cast hostile; Swift trap verbose |
| Q3: Indexing | Keep int (no change) | Breaking change для 100+ APIs; Swift/Go/Kotlin используют signed indexing |
| Q4: Literal default | int (keep current) | Backward compat; 42 as uint или let x uint = 42 для opt-in |
Saturation semantics (int as uint).
-1000 as uint → 0
-1 as uint → 0
0 as uint → 0
1 as uint → 1
Реализован через nova_int_to_uint(int64_t x) helper в nova_rt/cast.h.
u64 as uint — direct cast (no-op; uint64_t → uint64_t).
Codegen mapping.
type_ref_to_c "uint" => "uint64_t"(scalar)[]uint → NovaArray_uint64_t*(parallel сu64)Option[uint] → NovaOpt_uint64_t(parallel сu64)uint.MAX— не поддержан parser’ом (parser не распознаётuintкак type-path prefix; используйu64.MAX= эквивалент).
Будущая эволюция. Аналогично D129 (int/i64): если Nova добавит
multi-arch, uint может стать platform-pointer-width unsigned (как
Rust’s usize). Bootstrap-grade alias.
Acceptance criteria.
-
let x uint = 42 as uintкомпилируется -
int as uintsaturates (neg → 0) —nova_int_to_uinthelper -
int as u64остаётся bit-cast (no saturation) -
[]uint→NovaArray_uint64_t* -
Option[uint]→NovaOpt_uint64_t - 3 fixtures
nova_tests/plan70_5/PASS - 0 regressions
-
uint.MAX— defer (parser keyword support)
Реализовано: Plan 70.5 — Ф.1-Ф.3 closed 2026-05-19.
Связь:
- D54 —
as-cast saturation precedent - D129 — int/i64 alias (signed symmetric pair)
- Plan 07 — original float→int saturation
- Plan 70.5 — parent plan (этот блок)
- Plan 70.4 — sibling (codegen type distinction family)
D133. type X consume — обязательная consume-семантика (must-be-consumed)
Plan 100.1. Принято 2026-05-23 (proposed; implementation pending). Extends D131 affine
consumequalifier.
Что
Квалификатор consume на type-decl. Помечает, что инстансы такого
типа обязаны быть потреблены до выхода из scope’а на каждом code-
path’е. Compile error если live consume-переменная остаётся на exit-
point’е.
type Transaction consume { id int }
type File consume { fd i32 }
type Lock consume { mutex *Mutex }
Расширяет D131 с противоположной стороны:
| Свойство | D131 affine consume (Plan 73) | D133 type-level consume (Plan 100.1) |
|---|---|---|
| Потребить ≤1 раз | ✅ enforce | ✅ enforce (наследуется) |
| Потребить ≥1 раз (обязательно) | ❌ забыть OK | ✅ enforce — must-be-consumed |
| Помечается на | receiver / param метода | type-decl + поле + binding |
Канонический use-case — Transaction.commit() / .rollback(),
File.close(), lock-guard .release().
Синтаксис
consume стоит после имени типа, перед {:
type Transaction consume { // type-decl marker
id int,
}
fn Transaction consume @commit() -> () // consume-method (D131)
fn Transaction consume @rollback() -> ()
consume на type-decl + хотя бы один consume-метод (D131) — обязательное
сочетание (compile error: «consume-type требует ≥1 consume-method»).
Правило — must-consume на каждом exit-path’е
Compiler проводит flow-sensitive анализ (расширение Plan 73 D131
check_consume pass’а). Для каждой переменной consume-типа отслеживается
VarState:
Live— значение доступно, обязательство активно.Consumed— значение потреблено (через consume-метод / consume- параметр /return).MaybeConsumed— потреблено лишь на части путей (branch join).
На каждой точке выхода scope’а проход по active consume-переменным:
LiveилиMaybeConsumed→ compile error E (D133-not-consumed) с указанием консьюм-методов.Consumed→ OK.
Точки выхода:
- конец function body (последний statement);
return expr— все live consume-vars (кроме возвращаемой) → error;panic/expr!!/expr?/ unwinding-paths;loop break;- branch join
if/match—Live ⊔ Consumed = MaybeConsumed.
defer / errdefer могут покрывать обязательство (см. D158+ Plan
100.4 family).
Что считается consume
| Действие | Эффект на VarState |
|---|---|
tx.commit() — вызов consume-метода | tx → Consumed |
f(tx) где f(consume tx Tx) — consume-param | tx → Consumed |
f(make_tx()) где f(consume t Tx) — rvalue → consume-param | rvalue ownership передаётся напрямую (без binding) ✅ |
return tx (тип consume) | tx → Returned (передача caller’у) |
record.field = tx где field declared consume | tx → Moved (в record) |
consume new_owner = tx (transfer alias) | tx → Consumed, new_owner → Live |
f(tx) где f(tx Tx) — view-param (no qualifier) | tx остаётся Live (callee — view-borrow) |
f(make_tx()) где f(t Tx) — rvalue → view-param | ❌ E (D133-consume-rvalue-in-view) |
f(tx) где f(mut tx Tx) — mut-view-param | tx остаётся Live (callee — mut-borrow) |
f(make_tx()) где f(mut t Tx) — rvalue → mut-view-param | ❌ E (D133-consume-rvalue-in-mut-view) |
let alias = tx — view-alias | оба в alias-class (Plan 73); consume любого инвалидирует |
let mut alias = tx — mut-view-alias | то же + mut-методы через alias |
let _ = tx (silent drop) | ❌ compile error D133-suppress-not-allowed |
Заразность через поля + explicit double-marker
Record/sum, имеющий поле consume-типа, обязан быть объявлен
consume:
type TxState consume { // ← ОБЯЗАТЕЛЬНО
consume tx Transaction, // ← ОБЯЗАТЕЛЬНО (тип = consume)
writes []Write, // обычное поле
}
Compiler enforces consistency:
- consume-поле без
consume-маркера → error E (D133-field-marker-missing); - consume-маркер на field без
consumeна type-decl → error E (D133-type-marker-missing); consume f int(тип поля не consume) → error E (D133-marker-on- non-consume) — keyword использован но не нужен.
consume-type БЕЗ consume-полей разрешён — каноничный паттерн
для opaque-resource типов (StringBuilder consume с runtime backing
через external type; consume-method @into() потребляет; никаких
consume-полей в декларации). Достаточно хотя бы одного declared
consume-метода.
Field-aware flow внутри методов record’а
@field отслеживается как независимый VarState slot. На exit’е метода:
| Тип метода | consume-поля должны быть |
|---|---|
fn X consume @method(...) | Consumed (record closes) |
fn X mut @method(...) | Live (invariant preserved) |
fn X @method(...) (regular) | Live (invariant preserved) |
Это позволяет реальные паттерны (rotate / reopen / replace):
type Service consume {
consume file File,
}
fn Service mut @reopen() -> Result[(), OpenErr] {
consume new_file = File.open()? // сначала добываем замену
@file.close() // только теперь закрываем старое
@file = new_file // rebind — @file опять Live;
// new_file → Consumed (transfer в @file)
} // mut exit: @file Live ✅
Compiler ловит реальные баги:
- забытый rebind на ветке → exit MaybeConsumed → error.
- early return без rebind → error.
- наивный close-then-open с error-path (
@file.close(); @file = open()?) → error если open Err (@file Consumed, не rebinded).
Assign в Live consume-поле / locals — запрещено
Прямое присваивание @field = expr разрешено только когда @field
уже Consumed (для simple-typed consume-поля) либо все consume-sub-
fields внутри @field уже Consumed (для nested-consume-record-поля).
Иначе compile error E (D133-assign-live-field).
fn Service mut @overwrite_naive() {
@file = File.open()? // ❌ @file Live, silent overwrite
}
fn Service mut @overwrite_correct() {
@file.close() // @file → Consumed
consume new = File.open()?
@file = new // ✅ @file Consumed → assign OK
}
Nested case — @inner содержит consume tx; assign в @inner
разрешён когда внутренний @inner.tx уже Consumed (recursively для
deep nesting):
fn Outer mut @reset() {
@inner.tx.commit() // @inner.tx → Consumed;
// @inner effectively «empty container»
consume new = Inner.new()
@inner = new // ✅ all consume-sub-fields Consumed
// → @inner replace OK
}
То же для локальных consume-var: повторный consume tx = ... без
consume старой — error.
Nested field paths
Multi-level field tracking — ConsumeCtx хранит state по произвольно
глубокому пути @f1.f2.f3:
type Inner consume { consume tx Transaction }
type Outer consume { consume inner Inner }
fn Outer mut @commit_inner() {
@inner.tx.commit() // deep path consume; @inner.tx → Consumed
// @inner — «empty container» (consume-sub-field Consumed)
consume new = Inner.new()
@inner = new // rebind inner — assign OK
// (внутренний tx был Consumed)
}
Реализация — ConsumeCtx::states: HashMap<FieldPath, VarState> где
FieldPath = Vec<String>.
Заразность через generic-args
type_is_consume(TypeRef) — рекурсивная функция (общая, не Option-
специфичная):
- тип в
LinearityRegistry(объявленconsume)? - record/sum с ≥1 consume-полем?
- generic-wrap
G[T1, ..., Tn]— хотя бы одинTiconsume? - generic-param
T(без bound) — false (bootstrap silent-ignore; закрывается D156 Plan 100.2 через[T consume]bound).
Option[Transaction] / Result[Transaction, E] / Box[Transaction] /
user Wrapper[Transaction] — все автоматически consume через wrap.
Никакого Option-специфичного хардкода — общее правило для любого
generic-wrapper’а.
Три mode’а binding-position: view / mut-view / consume
Единое правило везде (param / for / match / if-let / let-binding):
consume keyword маркирует ownership. Без него — view (read-
only borrow). mut — view + mutation.
fn read(tx Transaction) -> int // view (default; callee читает)
fn modify(mut tx Transaction) // mut-view (+ mut методы)
fn close(consume tx Transaction) // consume (transfer; tx → Consumed)
View (default — без qualifier’а)
| Действие | OK? |
|---|---|
tx.field (read) | ✅ |
tx.regular_method() | ✅ |
t.mut_method() | ❌ (нужен mut tx) |
t.consume_method() | ❌ E (D133-consume-via-view) |
| передача в view-param другой fn | ✅ |
передача в consume-param | ❌ E (D133-move-via-view) |
передача в mut-param | ❌ (нужен mut tx) |
return tx (escape) | ❌ E (D133-view-escape-return) |
| store в record-field | ❌ E (D133-view-escape-store) |
| capture в closure, returned | ❌ E (D133-view-escape-closure) |
let alias = tx (alias) | ✅ view-alias (Plan 73) |
Mut-view (mut tx qualifier)
То же что view, но mut-методы разрешены. Не consume, не escape.
Consume (consume tx qualifier)
Полный ownership-transfer. Callee/binding обязан consumed до scope- exit’а через один из 5 механизмов (см. §«Когда consume binding считается удовлетворённым»).
Consume-rvalue в arg-position (без binding)
Прямой call f(make_tx()), где make_tx() -> Tx consume возвращает
fresh consume-owner, без сохранения через consume name = … —
правила по qualifier’у callee-param:
| Callee param | OK? |
|---|---|
f(consume t Tx) — consume-param | ✅ ownership передаётся напрямую; callee обязан consumed внутри |
f(t Tx) — view-param (default) | ❌ E (D133-consume-rvalue-in-view) |
f(mut t Tx) — mut-view-param | ❌ E (D133-consume-rvalue-in-mut-view) |
Почему запрет на view / mut-view: view/mut-view-param не
consume’нят callee-стороной. После возврата из f rvalue остаётся
не consumed и не bound к локальной переменной → flow-checker не имеет
slot’а в ConsumeCtx для tracking’а → must-consume gate его не
увидит → ресурс утечёт молча. Запрет — единственное безопасное
правило: consume-value требует именованного owner’а либо немедленной
передачи ownership через consume-param.
Hint в diagnostic: «привяжи через consume name = make_tx(),
затем f(name); после consume-method/consume-param/return name
будет Consumed». Альтернатива — заменить sig f на consume-param,
если callee действительно должен потребить.
Цепочки (g(f(make_tx()))) — рекурсивно: rvalue-результат f
анализируется по тому же правилу для соответствующего param’а g.
Если f возвращает consume-value, а g-param это view → error на
внешнем вызове.
Глубокий peek без consume
match @file { // view-match (default)
Some(f) => f.fd, // f: view File, read-only
None => 0,
}
// @file остаётся Live ✅
См. D157 (Plan 100.3) — match-pattern в view-mode + closure capture analysis.
consume + -> @ несовместимы
fn Tx consume @prepare() -> @ { ... } → parse error. Противоречие
между «забираю целиком» и «возвращаю тот же объект» (D132 fluent-
return).
Binding: consume keyword обязателен для ownership
Для consume-типов consume keyword обязателен в LHS, когда binding
становится Live-linear-owner:
let tx = begin() // ❌ ERROR D133-consume-needs-keyword:
// consume-type требует `consume` keyword
consume tx = begin() // ✅ initial binding — owns
let alias = tx // ✅ view-alias (no ownership; Plan 73)
let mut alias = tx // ✅ mut-view-alias
consume new_owner = tx // ✅ transfer: tx → Consumed
Без consume keyword’а LHS = view-alias (alias-class Plan 73,
read-only borrow). Это симметрично param/for/match — везде «no qualifier
= view, consume = transfer».
Когда consume binding считается удовлетворённым
Live consume-binding обязан к scope-exit’у оказаться в одном из 5 состояний:
- Closed locally —
tx.commit()(consume-метод). - Returned —
return tx. - Transferred —
f(tx)гдеf(consume tx T). - Stored in record-field, который сам уходит наверх:
consume tx = begin() return Wrapper { tx: tx } // tx → record-field, record returns - Covered by defer/errdefer/okdefer (D158-D162 Plan 100.4 family).
Иначе error E (D133-not-consumed).
AI-first explicit-ness — почему mandatory
consume keyword обязателен специально — для loud visibility:
- 🟢 Каждое появление ownership видно с первого взгляда.
- 🟢 Refactor-safety — добавил
consumeк типу → compiler ловит все существующиеlet x = T.new()sites, force review. - 🟢 Единое правило симметрии с param / for / match.
Verbose-ness bounded — только для consume-типов (rare; resource- management).
Runtime mental model (Option-projection, не ABI)
Концептуально consume-тип проецируется в Option[T]-space:
Live≡Some(t).Consumed≡None.MaybeConsumed≡ branch-зависимо.
Это mental model для spec/docs. Реализация остаётся pragmatic (D131-style):
- pointer-based consume: NULL = None (zero overhead);
- value consume: zero-out fields после consume;
- compile-time
check_consume— основной механизм; runtime null-deref panic — defense-in-depth.
User-facing pattern-match match tx { Some(t) => ... } для runtime-
проверки не вводится — ослабит compile-time гарантии.
Что отвергнуто
- Universal affine/linear для всех
let— отвергнуто в D75 §«Compile-time token-scope enforcement»: «это Rust borrow checker ради одной фичи, несоразмерно для GC-языка». D133 — opt-in per-type, не default. - Suppress-механизм
let _ = v— anti-Rust#[must_use]gateway. Единственный канал — consume-метод. Если «иногда хочу забыть» — знак, что тип неправильно помеченconsume. - Drop-method auto-cleanup (Rust-style RAII) — размывает выбор commit/rollback. D133 требует явный consume-метод.
- Pattern-match destructure consume-record (
let { tx } = state) — ломает encapsulation (consume-поле уходит в независимый linear- binding). Вынос через явный consume-метод record’а:fn TxState consume @into_parts() -> (Transaction, []Write) => (@tx, @writes). - Strict-mode binding-form (
let tx =«обязан передать наверх» vsconsume tx =«обязан закрыть здесь») — отвергнуто (overspec, refactor friction). Финальная модель:consumekeyword mandatory для ownership;letдля consume-types = error либо view-alias (в alias-position). view Tkeyword как explicit qualifier — отвергнуто (default- view достаточно).viewmode = absence ofconsume/mutqualifier (см. D157 Plan 100.3).- Implicit
_ = txdiscard — суррогат suppress; force compile- error.
Сравнение с другими языками
| Свойство | Rust | TS (ES2024) | Kotlin | Go | Nova D133 |
|---|---|---|---|---|---|
| Compile-time enforcement | ⚠️ #[must_use] warning, suppressable | ❌ runtime via dispose | ❌ runtime via use{} | ❌ | ✅ error |
| Suppress escape hatch | ✅ mem::forget(v) / let _ = v | n/a | n/a | n/a | ❌ by design |
| Distinct cleanup methods (commit/rollback) | ⚠️ enum-в-Drop, awkward | ⚠️ single dispose | ⚠️ use{} block | ⚠️ convention | ✅ native (consume-методы) |
| Lifetime / borrow-checker cost | ❌ есть | n/a | n/a | n/a | ✅ нет (поверх GC) |
D133 строже Rust на suppress (нет mem::forget), expressive Rust на
distinct cleanup methods. Не требует lifetime’ов / move-семантики.
Связь
- D131 — affine
consumefoundation. D133 — extension on type-decl level. - D132 —
-> @fluent-return; sound builder-chain alias через-> @нужен для consume-checker’а builder API. - D75 — почему universal consume отвергнут.
- D90 —
defer/errdeferfoundation; интеграция через Plan 100.4 family (D158-D162). - D85 — kinded throws, cancel-routing; взаимодействие через D162 Plan 100.4.5.
- D156 Plan 100.2 — generic
[T consume]strict-mode bound. - D157 Plan 100.3 —
view Tread-only borrow для deep peek. - D158-D162 Plan 100.4.1-5 — defer/errdefer integration для cleanup- on-failure.
- D163 Plan 100.5 — FFI
external consume fn. - D164 Plan 100.6 — cross-module consume visibility + mangling.
- D165 Plan 100.7 — stdlib migration playbook.
- D166 Plan 100.8 — performance + IDE tooling.
D156. Generic [T consume] bound + collection-aware iteration
Plan 100.2. Принято 2026-05-23 (proposed; implementation pending). Extends D133 на generic-код. Closes silent-leak hole для consume-T в generic-функциях.
Что
Bound [T consume] на generic-параметр — opt-in strict mode: внутри
generic-body параметр T трактуется как possibly-consume; silent-forget
T-значения → compile error. Backward-compat: generic-функции без
bound сохраняют silent-ignore behavior (Plan 100.1 default), чтобы
existing stdlib generic-код продолжал работать.
// Strict mode — compiler enforces strict consume handling внутри:
fn box[T consume](consume x T) -> Box[T] => Box { val: x }
// Без bound — silent-ignore:
fn drop[T](x T) -> () // silent forget если T consume
Плюс — collection-aware iteration с 3 mode’ами (unified с D133):
for tx in vec (view default) / for mut tx in vec (mut-view) /
for consume tx in vec (consume, vec → Consumed).
Зачем
Без D156 generic-код имеет дыру:
type Transaction consume { id int }
fn Transaction consume @commit() -> ()
fn first[T](pair (T, T)) -> T => pair.0 // silent leak pair.1 если T=consume
consume tx1 = Transaction { id: 1 }
consume tx2 = Transaction { id: 2 }
consume chosen = first((tx1, tx2)) // tx2 уехала в first и потерялась
chosen.commit()
// tx2 LEAK — compiler молчит.
Это самый серьёзный hole D133 bootstrap’а — именно generic-helpers есть
в каждой stdlib. Rust решает через Move trait + ownership; D156 решает
через [T consume] bound + collection-aware iteration.
Синтаксис bound
fn box[T consume](consume x T) -> Box[T]
fn map[T consume, U consume](items []T, f fn(consume T) -> U) -> []U
fn id[T consume](consume x T) -> T => x
consume — bound в generic-position, мирится с другими bounds ([T Iter[U]]
из D72) — но bootstrap не поддерживает комбинации ([T consume + Clone] — parse error; будущее расширение).
Strict mode внутри [T consume] body
Внутри функции с [T consume] bound параметр T трактуется как
possibly-consume; compiler обращается строго:
| Действие с T-значением | Без bound | С [T consume] |
|---|---|---|
let _ = x (silent drop) | ✅ OK | ❌ error E (D156-strict-forget) |
| передача в non-consume fn | ⚠️ silently | ❌ error |
| destructure tuple, discard part | ⚠️ silently | ❌ error |
return x | ✅ | ✅ (передача наверх) |
передача в consume fn-param | ✅ | ✅ (consume) |
Force’ит honest API. Чтобы legitimately drop элемент — нужен явный
consume-параметр для drop:
fn first[T consume](consume a T, consume drop_b T) -> T => a
// ^^^^^^^^^^^^^^^^^^ — caller обязан передать
// drop_b как consume; внутри
// first drop_b силен забыть
// (это локальный binding).
Backward-compat и migration policy
- Default = silent-ignore для generic-functions без bound (Plan 100.1 behavior preserved). Иначе сломается весь stdlib generic-код.
- Opt-in
[T consume]для функций, которые хотят strict mode. - Migration: stdlib generic-functions (Plan 17/26/30/52/57
collection API) — постепенно аннотируются
[T consume]черезnova consume-migrateCLI (Plan 100.7).
Collection-aware iteration — 3 mode’а
Симметрично D133 param/match mode’ам:
consume tx1 = begin()
consume tx2 = begin()
consume txs = [tx1, tx2] // []Transaction — generic-заразность (D133 D6)
// txs владеет (consume keyword обязателен)
// View (default) — read-only, vec stays Live:
for tx in txs {
println(tx.id) // ✅ read field
// tx.commit() // ❌ view → не consume-метод
}
// txs Live после for; нужно consume другим способом.
// Mut-view — vec stays Live, элементы mutated in-place:
for mut tx in txs {
tx.update() // ✅ mut method
}
// txs Live, элементы updated.
// Consume — consume каждое, vec → Consumed:
for consume tx in txs {
tx.commit() // ✅ consume-метод
}
// txs → Consumed после for ✅
Loop-handling pragmatic: for consume tx in vec помечает vec Consumed
после loop (даже если break early — D161 multi-defer LIFO error
accumulation gracefully handles partial-consumed state).
Каждый tx в arm-теле проверяется стандартным check_consume
правилом для соответствующего mode’а (view / mut-view / consume).
Alternative consume-methods для collection
Чтобы consume collection без iteration:
vec.pop() -> Option[T]— single-element consume (Option auto- consume через D133 D6 generic-заразность).vec.drain() -> Iter[T]— consume через iterator.vec.into_first() -> Tconsume-method record’а возвращает один элемент (consume rest internally).
stdlib audit (Plan 100.7) аннотирует эти методы с [T consume] bound.
Generic propagation для HOF (map/filter/fold)
Closure-параметры HOF используют те же 3 mode’а через qualifier:
fn map[T consume, U consume](consume items []T, f fn(consume T) -> U) -> []U
fn filter[T consume](consume items []T, f fn(t T) -> bool) -> []T
// ^^^ — view (default; read-only)
fn for_each[T consume](consume items []T, f fn(consume T) -> ())
fn modify[T consume](mut items []T, f fn(mut T) -> ())
// ^^^^ — mut-view (in-place modify)
filter использует view-closure (default) — predicate читает T без
consume. map consume’ит каждое T → producer’ит U. modify mut-view
для in-place.
Compiler enforces consume-handling в closure-body через generic-bound propagation + view-default rules.
HashMap / user-generic propagation
type_is_consume рекурсивно (D133 D6): wrapper’ы с consume-arg сами
становятся consume:
consume tx_map = HashMap[str, Transaction].new()
// ↑ Transaction consume → HashMap consume
// через generic-заразность
// consume keyword обязателен (D133)
tx_map.insert("a", consume begin()) // insert требует consume value (transfer)
// На scope-exit tx_map должен быть Consumed (через consume-метод HashMap).
for consume (_, tx) in tx_map.drain() { // consume через drain-iteration
tx.commit()
}
HashMap (и другие collection API) — должны аннотировать [V consume]
на методах, манипулирующих consume-values (insert(k K, consume v V),
remove() -> Option[V], drain() -> Iter[(K, V)], etc.). Migration
audit — часть Plan 100.7.
Runtime cost
Zero. Все проверки compile-time. Runtime-представление generic’ов
не меняется. Bound [T consume] — type-level only, не влияет на
codegen mono’d functions.
Сравнение
| Capability | Go | Rust | TS | Kotlin | Nova D156 |
|---|---|---|---|---|---|
| Generic linear bound | n/a | ✅ T: Move (default) | n/a | n/a | ✅ [T consume] opt-in |
| Detection «generic drops linear arg» | n/a | ✅ compile-error | n/a | n/a | ✅ |
| Backward-compat: generic без bound | n/a | n/a | n/a | n/a | ✅ silent-ignore остаётся |
Vec<T> ownership iteration | n/a | ✅ | n/a | n/a | ✅ for tx in vec |
Nova превосходит Rust на одной оси — backward-compat: generic без bound сохраняет existing behavior; opt-in strict — choice.
Что отвергнуто
[T consume + Clone]combined bound — bootstrap parse-error; будущее расширение (комбинация с другими D72 bounds).[T !consume]anti-bound — не вводится; нет use-case в bootstrap.- Variance linear-typed wrappers — отдельный план (общая variance system).
Связь
- D133 — foundation type-level consume; D156 — generic-уровень.
- D72 — generic bounds
[T Protocol]; D156 идиоматически близок. - D157 —
view T(Plan 100.3);filter-style HOF использует view для read-only inspection. - D158-D162 (Plan 100.4 family) — defer/errdefer integration; orthogonal.
D163. FFI consume integration — type-driven, без отдельного keyword’а
Plan 100.5. Принято 2026-05-23. Ред. 2 (2026-05-24): drop
external consume fnkeyword — consume-ownership определяется через type, как у regular fn. Ред. 3 (2026-05-27): РЕАЛИЗОВАНО — parserneedsclause, type-checker D163-missing-cap, C codegen стабы для user-defined external fn,opaque_ffi_typesregistry. Extends D82external fn+ D126external type+ D63 capability.
Что
Никакого нового keyword’а для external fn — унифицировано с regular fn:
return-type carrying consume-ness (через D133 type-decl consume)
автоматически передаёт ownership caller’у. consume keyword
используется только на параметрах/receiver’ах (D131 semantic).
// Opaque consume-type (D126 + D133):
external type File consume
external type Mutex consume
external type Socket consume
// Return consume-type → caller получает ownership (через type, не keyword):
external fn nova_file_open(path str) -> File
needs Fs // capability required (D63)
// Param-side consume — D131 semantic, тот же keyword `consume` на param:
external fn nova_file_close(consume f File)
needs Fs
// Result wraps consume — generic-заразность из D133 D6:
external fn nova_open(path str) -> Result[File, IoErr]
needs Fs
// Caller обязан consume Result через match-Ok-arm.
Зачем drop keyword
Параллель с regular fn:
fn factory() -> Transaction => Transaction.new()
// ^^^^^^^^^^^ — return type carries consume-ness. NO `consume`
// keyword on fn declaration.
fn finish(consume tx Transaction) -> () { ... }
// ^^^^^^^ — consume on PARAM (D131).
Применяем то же к external — symmetry без нового keyword’а.
Capability requirement (D63)
external fn касающийся OS resource обязан declare capability —
это независимо от consume-семантики (общее правило D63):
external fn nova_file_open(path str) -> File
needs Fs // OS access → cap required
external fn nova_socket_accept(consume srv ServerSocket) -> ClientSocket
needs Net
Capability и consume — две ortogонные concern. Capability для OS privilege; consume для ownership. Combined через type-decl + needs-clause.
C runtime defensive helpers
C-side nova_file_close(consume f File) обязан:
nv_consume_validate(f)— assertf != NULLна entry.- После работы —
memsetполяFile*в zero / NULL (defense-in-depth per D131 Plan 73 pattern).
Это даёт двойную защиту: compile-time (D133 check_consume) + runtime (NULL-deref panic на use-after-consume).
Generic-заразность через FFI — uniform
external fn nova_open() -> Result[File, IoErr] needs Fs
// ^^^^^^^^^^^^^^^^^^^ — Result consume через generic-arg
// Caller обязан consume Result (через match Ok-arm с consume File).
Никакого FFI-специфичного правила — общее D133 D6 generic-заразность.
Cross-fiber FFI safety
FFI-call может суспендиться (libuv async I/O). Plan 47/22/49 fiber infra preserves consume-state через migration; D163 verify через runtime tests (Plan 100.5 Ф.6).
Сравнение
| Capability | Rust | Kotlin/JNI | Go cgo | TS Node N-API | Nova D163 |
|---|---|---|---|---|---|
| Ownership через FFI | ✅ unsafe fn + manual contract | ⚠️ manual | ⚠️ manual | ⚠️ manual | ✅ type-driven, без extra keyword |
| Auto-close на panic при FFI handle | ✅ через Drop wrapper | ⚠️ try-finally | ⚠️ defer | ⚠️ try-finally | ✅ через D162 |
| Capability tracking | ⚠️ unsafe fn | ⚠️ manual | ⚠️ manual | n/a | ✅ D63 needs-clause |
unsafe keyword нужен | ✅ да | n/a | n/a | n/a | ❌ нет (D6) |
| Уникальный FFI-syntax | ⚠️ unsafe fn | ⚠️ JNI prefix | ⚠️ cgo annotation | ⚠️ napi macro | ✅ унифицировано с regular fn |
Nova превосходит Rust — (a) нет unsafe keyword (D6 + D63
capability); (b) унифицировано с regular fn (одна mental model для
FFI и Nova-side functions).
Что отвергнуто
external consume fnkeyword (Ред. 1) — избыточный, return-type уже carries consume-ness. Drop в Ред. 2.- Vacuous-marker warning (Ред. 1 W D163-vacuous-consume) — отпадает вместе с keyword.
Связь
- D82 —
external fnfoundation; D163 расширяет. - D126 —
external typeopaque; combine’ится сconsume. - D63, D64 — capability enforcement.
- D131, D133 — consume foundation.
- Plan 18 — основной consumer (File/Mutex/Socket migration).
D164. Cross-module consume — visibility + mangling + package contracts
Plan 100.6. Принято 2026-05-23 (proposed). Extends D26 visibility + D134 mangling v0 + Plan 03 package ecosystem.
Что
consume-маркер (D133) — part of exported type signature. Visibility
(D26, D47 Plan 35 R26) propagates marker. Symbol mangling (extends
D134 Plan 81) включает consume-bit — ловит cross-version ABI break.
Plan 03 nova audit verifies cross-package consume-contracts.
Cross-package visibility
// package A, module a/types.nv
export type Transaction consume {
id int,
}
// package B, module b/main.nv
import a.types.Transaction
fn main() {
consume tx = Transaction { id: 1 } // ✅ consume-marker visible
tx.commit()
}
consume propagates через export + import. Plan 35 R26 (visibility
enforcement) — без special-case’ов; consume — обычный type-attribute.
Mangling extension (D134 amend)
Plan 81 D134 определил symbol-mangling v0:
nova_fn_<pkg>_<mod>_<name>_<param-types>_<return-type>
D164 amend:
nova_fn_<pkg>_<mod>_<name>_<consume-bit>_<param-types>_<return-type>
^^^^^^^^^^^^^^^
`c` если consume-маркер на type-decl, `_` иначе
Это ловит ABI mismatch — package A v1.0 имеет Transaction consume,
v2.0 убрал marker; linker ловит cross-version mismatch на load.
Re-export через export import (Plan 42.09)
// package B re-exports A.Transaction
export import a.types.{Transaction}
Re-export preserves consume-marker. Plan 42.09 уже работает; D164 verifies.
Folder-modules (Plan 42) + relative imports (Plan 84)
consume-types работают идентично в folder-modules + relative imports: не вводятся special-case rules. Plan 42 / Plan 84 уже работают; D164 verifies.
Package version contracts (Plan 03)
nova.toml consume-contracts:
[package]
name = "my_lib"
version = "1.0.0"
[exports.consume_types]
Transaction = "1.0" // consume contract v1
File = "1.0"
Cross-version compat:
- v1.0 → v1.x — consume-status unchanged.
- v1.x → v2.0 — consume-status может change (major-bump required).
nova audit (Plan 03.4) verifies — ловит «v1 → v1.1 breaking change»
unauthorized.
Cross-module diagnostic
error: consume value `tx` (type a::Transaction) not consumed
note: type defined in package 'a' v1.0 at a/types.nv:5
note: consume via .commit() or .rollback() (declared in 'a')
Includes package origin, version, consume-method hint.
Private consume не leak
type InternalCache consume { ... } // no `export`
// usable только в этом package; cross-package — invisible
Plan 35 R26 — без special-case’ов.
Сравнение
| Capability | Rust | Kotlin/Java | Go | TS | Nova D164 |
|---|---|---|---|---|---|
| Pub visibility consume-маркера | ✅ pub Drop visible | ⚠️ AutoCloseable interface | ⚠️ exported method | ⚠️ TS types | ✅ D164 propagation |
| ABI mangling включает ownership-info | ✅ через type | ⚠️ via signature | ❌ | n/a | ✅ consume-bit |
| Cross-package consume contracts | ✅ Cargo + Rust types | ⚠️ Maven coordinates | ⚠️ go modules | ⚠️ npm types | ✅ nova.toml |
| Re-export preserves marker | ✅ через pub use | n/a | n/a | n/a | ✅ Plan 42.09 |
Nova matches Rust на всех осях; превосходит на consume-bit-in- mangling (ловит silent ABI mismatch которого Rust не видит через type-id alone).
Связь
- D26, D47, Plan 35 R26 — visibility foundation.
- D134 — mangling v0 (Plan 81); D164 extends.
- D29 — modules + folder-modules.
- D126 — opaque types; cross-package consume может быть opaque.
- D131, D133 — consume foundation.
- Plan 03 / Plan 03.4 — package ecosystem,
nova audit. - Plan 42, Plan 42.09, Plan 84 — folder-modules, re-export, relative imports.
D135. Type-checker completeness — «no silent fallback» на уровне типов
Статус: принято, реализовано (Plan 79).
Контекст. D126 закрыл silent-fallback в кодогене («no
silent nova_int»). Но bootstrap type-checker (types/mod.rs) проверял
имена, структуру, эффекты, контракты — и не базовую совместимость
типов. Эмпирическая перепроверка 2026-05-21 показала: ряд элементарных
ошибок типов компилировался молча (silent miscompilation) либо
ловился только C-компилятором (CC-FAIL, поздняя нечитаемая диагностика):
| Случай | До Plan 79 | Severity |
|---|---|---|
let x int = true | компилируется И выполняется неверно | 🔴 silent |
want_bool(42) (int в bool-параметр) | то же | 🔴 silent |
fn g() -> Result[int] (1 type-arg вместо 2) | компилируется тихо | 🔴 silent |
let c = Foo (имя типа как значение) | CC-FAIL | 🟡 поздняя |
f.nonexistent (нет поля) | CC-FAIL | 🟡 поздняя |
Go / Rust / TS ловят все пять на compile-time. По базовой проверке типов Nova была позади всех трёх.
Решение. Type-checker обязан ловить базовые ошибки типов на этапе
компиляции собственной диагностикой (серия E73xx), а не молча и не
перекладывая на C-компилятор. Отдельный проход TypeCheckCtx (паттерн
NameResCtx / MapLitCtx):
- E7310 — арность type-аргументов. Использование generic-типа с
явно указанным, но неверным числом аргументов (
Result[int],Result[A,B,C],Foo[int]для не-genericFoo). Опущенные аргументы (fn f() -> Result { Ok(1) }) — легальны (выводятся из контекста), это не arity-ошибка. - E7301 — assignability.
let-аннотация ↔ RHS и аргумент ↔ параметр. Целочисленный литерал полиморфен (D44):let x u8 = 200валиден;let x int = true,want_bool(42)— нет. Сравнение по категориям типов; structural-конформность протоколов — забота D72, не этой проверки. - E7320 — существование поля / метода.
obj.name, гдеobj— concrete record:nameобязан быть полем либо методом (into/try_intoсинтезируются из D73/D77). - E7330 — type-vs-value. Имя непустого record/sum-типа в
value-позиции (
let c = Foo,Foo + 1) — ошибка: тип не значение.
Принцип «no any-hole» (строже TS). Ни один путь проверки не
присваивает выражению результат «молча неверно». Там, где тип
выражения не выводится (bootstrap type-checker по дизайну не
типизирует каждое выражение — вывод завершается в кодогене), проверка
пропускается локально — это не silent miscompilation: программа не
становится неверной, недостающая проверка либо ловится дальше по
пайплайну, либо случай корректен. any — только из явной аннотации
([]any), он не «заражает» и не отключает проверку соседних выражений.
Полная типизация каждого выражения на уровне type-checker’а — задача
пост-bootstrap full inference engine, вне scope Plan 79.
Сравнение. Go/Rust/TS ловят все пять случаев на compile-time;
Plan 79 выводит Nova на их уровень для перечисленных проверок. Строже
TS: у TS any молча гасит ошибки — в Nova такого пути нет.
Связь:
- D126 — sibling: «no silent fallback» для кодогена (Plan 70).
- D44 — полиморфизм числовых литералов.
- D72 — structural bounds (конформность протоколов — там).
- D73 / D77 —
into/try_intoсинтез. - Plan 79 — родительский план (этот блок).
- Plan 37 — newtype/alias
as-cast строгость (смежная, отдельная).
D142. protocol/effect declaration ↔ literal symmetry
Plan 97. Принято 2026-05-23. Объединяет
Q-keyword-symmetry(open-questions.md) сQ-static-method-protocol(D58).
Что
Декларация и литерал и для протоколов, и для эффектов — симметричны по ключевым словам:
// Declaration:
type Cron effect { run() -> () }
type Fan protocol { run() -> () }
// Literal (значение, реализующее контракт):
let h = effect Cron { run() => spawn_cron() } // value of type Effect[Cron]
let p = protocol Fan { run() => spin_blades() } // value реализующее Fan
Раньше литерал эффекта писался ключевым словом handler, а
литерала протокола не было. Теперь:
- литерал эффекта —
effect X { ... }(тот же keyword, что в declaration); - литерал протокола —
protocol X { ... }(тот же keyword, что в declaration); - встроенный тип
Handler[E, IRT]→Effect[E, IRT](Effect[E]≡Effect[E, Never]через D88 default).
Clean break — старое ключевое слово handler (литерал) удалено
без deprecated-алиаса; парсер при встрече выдаёт diagnostic
«handler keyword removed; use effect (D142)».
Правило
Декларация (без изменений)
type Db effect { query(q str) -> [str] }
type Hash protocol { hash() -> u64 }
Литерал — symmetry
// effect-литерал (value)
let h = effect Db {
query(q) => mock_rows()
}
with Db = h { ... }
// protocol-литерал (value реализующий контракт) — instance-only
let l = protocol Locker { lock() => state.lock() }
Анонимный protocol в type-position (D53 §628)
fn close_all(items []protocol { close() -> () }) {
for it in items { it.close() }
}
fn min[T protocol { @lt(other Self) -> bool }](xs []T) -> Option[T] => ...
Body анонимного protocol — тот же синтаксис, что у named: bare-имена =
instance; leading-точка .method = static (D143).
protocol-литерал: instance-only
Static-методы — это методы типа (Type.method, D35);
у литерала нет «своего типа» (анонимная impl). Попытка реализовать
static в protocol-литерале → diagnostic «static methods cannot be
implemented in protocol-literal; they belong to a type (D35) — use a
named type».
Capture-rules
Закрытие над окружающим scope’ом — как обычное closure (D22 / D6 managed heap). Никаких особых правил поверх closure не вводится.
Почему
- Симметрия снижает когнитивный налог. Один keyword из declaration
работает и в literal — нет «двух жаргонов» (
handlervsprotocolvseffect). - Анонимный protocol-литерал разблокирует pattern «capability-split
factory» —
Lock.new() -> (Locker, Unlocker)без двух named-обёрток. Кандидаты в stdlib Plan 18:Process.spawn,HttpServer.bind,Db.transaction. - Symmetry побеждает локальную точность.
let h = effect X { ... }читается чуть точнее как «handler», ноprotocol X { ... }-литерал всё равно нужен — приходится либо ввести ещё keyword, либо унифицировать. Унификация чище. - Clean break без deprecated — текущая база
.nvмаленькая (~30 файлов); миграция атомарным sweep’ом дешевле двух-keyword’ового периода + последующей чистки.
Что отвергнуто
Protocol[P]first-class тип — отвергнут как избыточный. Для эффектовEffect[E, IRT]нужен, потому что значение эффекта передаётся вwith X = h(нужна типизация значения). У протоколов «значение, реализующее контракт» — это тип реализации; обёртка не нужна. Тривиальныйaliasрешит, если когда-нибудь понадобится (Q-protocol-type-wrapping).deprecated handleralias — отвергнут (clean break, ~30 файлов миграции).- Static в protocol-литерале — отвергнут (нет «своего типа»; см. D35).
- Изменение семантики handler’ов — нет, только rename keyword’ов.
Связь
- D53 — protocol declaration; D53 §628 (анон-protocol в type-position) ✅ реализовано (Plan 97 Ф.2).
- Protocol-литерал codegen — value
protocol Name { ops }с runtime vtable + dispatch — ✅ реализовано в подплане Plan 97.1 (emit_protocol_lit+ расширенный Plan 56 D122 box-pattern). Capability-split factory pattern работает end-to-end. - D61 — handler-литерал; rename keyword
handler→effect(Plan 97 Ф.3). - D87 —
Effect[E, IRT]; rename вEffect[E, IRT](Plan 97 Ф.3). - D88 — default generics (
Effect[E]≡Effect[E, Never]). - D143 —
.method-префикс для static в protocol-body (закрывает Q-static-method-protocol). - D35 — static vs instance методы.
- D22 — closure capture-rules.
- Q-keyword-symmetry — закрывается этим D-блоком.
- Plan 97 — имплементация parser + AST + type-checker.
- Plan 97.1 — runtime codegen (vtable + dispatch) + followup-hardening (Nova-side enforcement, capture-mode by-value snapshot для factory, shadowing fix, scan_fwd recurse, GC stress, multi-method, nested).
- Ориентиры: Java/Kotlin (anonymous interface), TS (object-literal structurally), Koka/Eff (handler-literal).
Canonical example — capability-split factory pattern
Use-case D142, разблокированный Plan 97.1 codegen’ом:
type Reader protocol { read() -> int }
type Writer protocol { write(v int) -> () }
type Cell { mut value int }
fn Cell.new(initial int) -> (Reader, Writer) {
let state = Cell { value: initial }
let r = protocol Reader { read() => state.value }
let w = protocol Writer { write(v) { state.value = v } }
(r, w)
}
// caller:
let (r, w) = Cell.new(10)
let initial = r.read() // 10
w.write(99)
let after = r.read() // 99 — shared state через protocol-литералы
Реализация (Plan 97.1 emit_protocol_lit, Approach A):
- Литерал
protocol Reader { read() => state.value }создаёт synthetic structNova_ProtoLit_<N>с capture-fieldstate. - Free fn
Nova_ProtoLit_<N>_method_read(self, ...)используетself->state->value. - Allocate
NovaVtable_Reader*+ ctx; patch vt->read = impl_fn. - Возврат
NovaBox_Reader { .data = ctx, .vtable = vt }(fat-pointer pattern Plan 56 D122).
Method dispatch r.read() → r.vtable->read(r.data) — стандартный
vtable indirect call.
Capture-rules:
- Heap obj /
let mut→ by-pointer (alias, mutation visible). - Immutable scalar / fn-param → by-value snapshot (factory-safe, survives fn exit).
D144. Sub-slice views для []T и str — arr[a..b] / s[a..b]
Источник: Plan 96 (2026-05-23). Закрывает Q-array-slicing, Q-array-api.5, D27 §1663 drift («Слайсинг отложен»), D27 §1632 drift (raw
arr[i]без bounds-check). Зависит от D6 non-moving GC; D58 Range; D27[]TAPI; Plan 90 / D141 bulk-ops.
Семантика — sub-slice view
arr[range] где range : Range возвращает view — новый
24-байтовый header NovaArray_T* с data = orig->data + from,
len = cap = to - from. Без копии данных backing’а (O(1) creation).
str[range] возвращает codepoint-indexed view (двухпроходный walk
UTF-8 → byte offsets; structurally идентично nova_str_slice, но с
panic при OOB вместо clamp).
5 форм Range (Rust RangeBounds parity)
| Форма | Семантика | Open-ended? |
|---|---|---|
arr[a..b] | exclusive: [a, b) | нет |
arr[a..=b] | inclusive: [a, b] | нет |
arr[a..] | от a до конца | да (end = len) |
arr[..b] | от начала до b | да (start = 0) |
arr[..] | весь массив | да |
Open-ended формы — только в slice-context (arr[range]). В
materialize / for-loop / quantifier / parallel-for они отвергаются
с compile-time diagnostic «open-ended Range without bound (Plan 96)».
Single-type design
[]T — один тип для owner и view. Нет Slice[T] (Rust-модель
раздельных типов). View передаётся в функцию ждущую []T без
дополнительной конверсии.
cap == len invariant
View имеет cap == len == to - from. Push на view → realloc (как
обычно при exhausted cap) → view silent detach от parent.
Parent backing никогда не молча перезаписывается — это устраняет
Go-append-footgun без borrow checker’а.
let mut parent = [1, 2, 3, 4, 5]
let mut view = parent[1..4] \ view: [2, 3, 4]
view.push(99) \ realloc; view detached
\ parent == [1, 2, 3, 4, 5] — НЕ затронут
\ view == [2, 3, 4, 99]
Mut-семантика
mut-view только от mut-источника. Через mut-view write идёт в
shared backing — изменения видны parent. Несколько mut-view
одного backing’а разрешены (как в Go); caller responsibility,
никакого borrow checker’а.
Iterator invalidation
for x in view — len берётся snapshot’ом в начале цикла (Go-style).
Push на parent во время итерации view’а не виден view’у: parent
реаллоцирует, view продолжает указывать на старый backing через
interior-pointer.
GC requirement — interior pointers stable
Необходимое условие: runtime гарантирует stable interior pointers
(non-moving GC, D6). View хранит data = backing->data + from — это
указатель внутрь backing’а; Boehm (GC_set_all_interior_pointers(1))
держит backing alive по interior-ptr.
Любая будущая замена GC-backend на moving GC требует одновременной замены slice-представления (separate header struct + ptr-update on move). Это закрепляется здесь как нормативный invariant.
Bounds-check
from < 0→ panicto < from→ panicto > len→ panic (для str —to > total_codepoints)- Empty slice (
arr[a..a]) → валиден - Отрицательные индексы → panic, не Python-style wrap
Сообщение panic’а: "array: slice [N..M] out of bounds for length L"
(паритет с Go/Rust).
Также: raw arr[i] bounds-check (D27 §1632 drift)
D144 одновременно фиксирует pre-existing drift: codegen arr[i]
теперь эмитит runtime bounds-check (раньше эмитил голый
(arr)->data[i] — controlled buffer overflow на запись, UB на чтение).
Сообщение: "array: index N out of bounds for length L".
Concurrency / M:N
Slice-view = shared mut backing между fiber’ами в M:N runtime =
формально UB по D79. В D71 single-threaded
bootstrap — OK по факту. Передача view через Channel[]T] или
spawn-capture в M:N — inherits D79 disclaimer.
Header layout
24 байта (ptr + len + cap) — тот же что у owner. Не оптимизировано
до 16 байт (которое требовало бы отдельного типа Slice[T] — отвергнуто
single-type-design’ом).
str[a..b] — bracket syntax для строк
Bracket-форма унифицирует idiom: arr[a..b] ≡ str[a..b].
Codepoint-indexed (как существующий nova_str_slice метод).
Panic при OOB (consistent с arr[a..b]).
Старый s.slice(a, b) метод — сохраняется с clamp-семантикой
для backwards-compat; align на panic откладывается в Plan 94
(см. [P-str-slice-clamp-vs-panic] в docs/simplifications.md).
Verified против
- Go
s[a:b]— паритет, без append-footgun. - Rust
&[T]— близко, без borrow checker (caller responsibility для multi-mut). - TypeScript
TypedArray.subarray— паритет. - Swift
ArraySlice<T>— без CoW-disconnect (view сразу видит mut). - Python
memoryview— паритет.
Связь
- D6 — non-moving GC; interior-ptr invariant амендится здесь.
- D27 —
[]TAPI; §1632 bounds-check (D144 чинит drift); §1663 «Слайсинг отложен» (D144 закрывает). - D58 — Range-литералы; D144 расширяет до 5 форм (open-ended).
- D79 — shared mut между fiber’ами = UB в M:N; slice inherits.
- D141 — Plan 90 bulk-ops; работают на view автоматически.
D145. fn[T] префикс — receiver-generic decl + bounds (Plan 101)
Status: MOSTLY CLOSED (2026-05-25, ред. 6 — Plan 101.1/2/3/4 ✅, 101.5 partial). Plan 101.1 codegen для non-int mono-dispatch — единственная deferred edge case (marker [M-fn-prefix-int-only-mono] в simplifications.md).
Реализовано (Plan 101.1–101.4 + 101.2):
- 101.1 ✅ — Parser
fn[T] ReceiverType @method+ 5 disambiguation error codes (E_UNDECLARED_TYPEVAR_IN_RECEIVER, E_BARE_TYPEVAR_NEEDS_PREFIX, E_DUPLICATE_GENERIC_DECL, E_PREFIX_SHADOWS_NAMED_TYPE, E_UNUSED_PREFIX_TYPEVAR). Codegen mono[]intelement + bare-T + non-int element (через Plan 95 array-ext infrastructure). vec.nv migration: 7 методов.- 101.2 ✅ — Bound integration: method-call bound enforcement (check_method_call_bounds в types/mod.rs); receiver-generic
fn[T Bound] []T @mловит violation на call-sitexs.m().- 101.3 ✅ — Multi-bound
[T A + B]: GenericParam.bound → bounds Vec, parser+ Typechain, type-check iterate all bounds (conjunction), strict check_generic_bound_declarations (E_BOUND_UNKNOWN / E_BOUND_NOT_PROTOCOL).- 101.4 ✅ — Protocol composition
use TypeNameв protocol body: AST TypeDeclKind::Protocol { methods, embeds }, parser parse_protocol_body, type-check flatten DFS + 5 диагностик (E_PROTOCOL_EMBED_{UNKNOWN, NOT_PROTOCOL, CYCLE, DUPLICATE, AFTER_METHOD, NOT_NAMED}).- 101.5 partial — stdlib audit: только vec.nv использует fn[T] prefix (7 методов работают; non-int — deferred). HashMap/PQ/Lru используют carrier-brackets (Plan 15 D72 path, unchanged).
Deferred (followup):
- vec_map_int_str — T=int U=str cross-type case (M-fn-prefix-int-only-mono).
- LSP quick-fixes (Plan 101.5 V2).
Ред. 3 (2026-05-24): complete rewrite после critical review. Ред. 1 описывала narrow
fn[T]only. Ред. 2 ошибочно ввела implicit-T (моя misinterpretation D35). Ред. 3 — finalized design: никакого implicit T,fn[T]префикс обязателен везде где receiver не имеет carrier-brackets, + bounds через existing D72,
- multi-bound
+, + protocol compositionuse Foo.Ред. 5 (2026-05-25): Plan 101.3 (multi-bound
[T A + B]) и Plan 101.4 (protocol compositionuse TypeName— pivot от earlier discussion A1use A, Bк более читаемому line-per-use) финализированы и реализованы.Ред. 3 (2026-05-24): complete rewrite после critical review. Ред. 1 описывала narrow
fn[T]only. Ред. 2 ошибочно ввела implicit-T (моя misinterpretation D35). Ред. 3 — finalized design: никакого implicit T,fn[T]префикс обязателен везде где receiver не имеет carrier-brackets, + bounds через existing D72,
- multi-bound
+, + protocol compositionuse Foo.
Что
Generic-параметры функции в receiver-position декларируются по одному из двух механизмов, в зависимости от формы receiver’а:
- Carrier-brackets на named generic-типе — existing
D119:
fn Option[T] @map[U]— T вOption[T]декларирует T.fn HashMap[K, V] @keys()— K, V вHashMap[K, V].fn Result[T, E] @ok()— T, E.- С bound (D72):
fn HashMap[K Hashable, V] @from_pairs(...).
fn[T]префикс (новое, D145) — для receiver’ов без carrier brackets: bare T,[]T, tuple(T, U), composite без carrier:fn[T] T @identity() -> T => @— bare typevar.fn[T] []T @map[U](f fn(T) -> U) -> []U => ...— array.fn[T, U] (T, U) @swap() -> (U, T) => (@.1, @.0)— tuple.fn[T Hashable] []T @dedup() -> []T => ...— bounds через D72.fn[T A + B] []T @method() => ...— multi-bound через+(Plan 101.3).
Правило
Когда fn[T] обязателен
fn[T1, ..., Tn] префикс обязателен для каждого typevar в
receiver-position, который не декларируется через carrier-brackets
именованного generic-типа. Конкретно:
| Receiver-shape | Carrier? | fn[T] нужен? |
|---|---|---|
Option[T], HashMap[K, V] | да named-brackets | нет |
[]T | нет — [] not bracket-decl | да fn[T] []T |
T bare | нет | да fn[T] T |
(T, U) tuple | нет — tuple-parens not bracket-decl | да fn[T, U] (T, U) |
(T, Option[U]) mix | T нет, U через Option | да fn[T] (T, Option[U]) |
[]Option[T] composite | T через Option[T] | нет |
Запрет дублирования
fn[T] запрещён для typevar, который ТАКЖЕ декларируется через
carrier-brackets:
fn[K Hashable, V] HashMap[K, V] @method // ERROR E_DUPLICATE_GENERIC_DECL
// K, V уже декларированы через HashMap[K, V]; используй
// fn HashMap[K Hashable, V] @method
Disambiguation: bare T vs named type
fn-prefix | Receiver | type T в scope? | Result |
|---|---|---|---|
| — | T | да | OK — метод на named T (D35 status quo) |
| — | T | нет | error E_BARE_TYPEVAR_NEEDS_PREFIX |
[T] | T | нет | OK — generic, T = typevar |
[T] | T | да | error E_PREFIX_SHADOWS_NAMED_TYPE |
| — | []T | да или нет | parse OK — но если есть named T, T = named (silent miscompile risk; см. ниже) |
[T] | []T | да или нет | OK — explicit prefix wins, T = fn-generic |
Critical: fn []T @method без fn[T] префикса и без type T в scope —
type-check error: «T не объявлен ни через carrier-brackets, ни через
fn[T] префикс, ни как named type». Закрывает silent-miscompile gap
(vec.nv pre-Plan-101 поведение).
Bound syntax (через D72)
fn[T Hashable] []T @dedup() -> []T => ...
fn[T A + B] []T @method() => ... // multi-bound (Plan 101.3)
fn[K Hashable, V] (K, V) @key_value() -> (K, V) => @
fn[T From[K], K] T @construct_from(v K) -> T => T.from(v) // parametric protocol
Bound = только protocol-тип (D72). Concrete-type bounds (fn[T int],
fn[T User]) — отдельный open question
Q-representation-bound,
Plan 102 (future).
Protocol composition (Plan 101.4 — закрывает D53 open question)
Protocols composed через use A, B keyword внутри protocol body.
Параллель D39 record-embed (same keyword, разная семантика). Composition
валиден в type-decl и anonymous type-position.
Literal-position — composition ОТВЕРГНУТА (см. ниже).
type Reader protocol { read(buf []u8) -> int }
type Writer protocol { write(buf []u8) -> int }
// 1. Multi-composition в type-decl:
type ReadWriter protocol {
use Reader, Writer // embed
close() -> () // own method
}
// 2. Single-composition (естественно, без ambiguity):
type ReadExt protocol {
use Reader
job() -> ()
}
// 3. Pure composition без own methods:
type Streamable protocol {
use Reader, Writer, Closeable
}
// 4. Mix anywhere в block — order independent:
type Complex protocol {
init() -> ()
use Reader
helper() -> int
use Writer
}
// 5. Anonymous-composition в type-position (extension D53):
fn process(rw protocol { use Reader, Writer }) { ... }
// 6. Использование как bound — composed protocol работает как named:
fn[T ReadWriter] []T @process() => ...
// эквивалентно fn[T Reader + Writer] []T @process() (101.3 multi-bound)
Семантика:
use A, B, C— flatten method-signatures из A, B, C в этот protocol.- Resulting method-set = union(A, B, C, own_methods).
- Multiple
use-statements аккумулируются:use A, B; use C≡use A, B, C. - T satisfies composed-protocol ⟺ T has все methods из union.
Реализация ред. 5 (2026-05-25, Plan 101.4):
- Парсер поддерживает обе формы:
use A, B(comma-list, как в spec) иuse A\n use B(line-per-use, более читаемо в большом protocol’е). - Все
use-items должны идти В НАЧАЛЕ protocol body — interleaving с методами запрещён (E_PROTOCOL_EMBED_AFTER_METHOD). Это упрощает чтение: сначала видишь “состав”, потом “новое”. - Type-check ловит:
- E_PROTOCOL_EMBED_UNKNOWN — embed target не объявлен.
- E_PROTOCOL_EMBED_NOT_PROTOCOL — target существует, но не protocol.
- E_PROTOCOL_EMBED_CYCLE —
A use B↔B use A(или self-embed). - E_PROTOCOL_EMBED_DUPLICATE — после flatten’а ≥2 method из разных embed-источников с тем же (name, arity). Override-механизм отложен.
- E_PROTOCOL_EMBED_NOT_NAMED —
use <complex type>запрещено.
Literal-composition — отвергнута:
// ❌ ОТВЕРГНУТО:
let v = protocol Foo {
use Reader // error: E_LITERAL_COMPOSITION_NOT_ALLOWED
read(buf) => impl1
close() => impl2
}
// Workflow: extract в named type:
type MyRW protocol { use Reader, Writer }
let v = protocol MyRW {
read(buf) => impl1
write(buf) => impl2
}
Почему literal-composition отвергнута: literal — value-construction (impls), composition — type-level operation. Смешивать слои когнитивно нагружено. Industry-aligned — Rust/Go/Java/Kotlin/Scala не разрешают anonymous-composition в literals.
Asymmetry с multi-bound (101.3) [T A + B] оправдана: разные
contexts — multi-bound = use-site intersection при satisfaction-check;
protocol composition = decl-time method-set union. Разные scopes,
разные операторы.
Differences vs D39 (record-embed):
- D39 record
use name Type(field-form, runtime delegation+field). - D53+ protocol
use Type[, Type]*(нет field, compile-time method-set union). - Same keyword
use— same intuition «include this stuff». Parser распознаёт по контексту (record-body vs protocol-body).
Многократное использование одного имени
Одно имя — один generic во всей сигнатуре (existing D119 / D72 convention):
fn[T] (T, T) @duplicate(a T) -> (T, T) => (a, a) // T дважды → один T
fn[T] [][]T @flatten() -> []T => ... // T в receiver и return — один T
Backward-compat
- 100% преserve для existing
fn Option[T] @map[U],fn HashMap[K, V] @keys,fn Result[T, E] @ok,fn HashMap[K Hashable, V] @method— D145 строго аддитивно. std/collections/vec.nvсодержит 7 методов patternfn []T @method[U](написан как-если-бы T дженерик). Это bug — T silently трактуется как named type, codegen падает. Plan 101.1 включает migration vec.nv →fn[T] []T @method[U].
Параллель индустрии — таблица
| Lang | Synтакс для array-method | Bound syntax |
|---|---|---|
| Rust | impl<T> Vec<T> { fn map<U> } | <T: A + B> |
| Go | func (v Vec[T]) Map[U] | [T A | B] (union, не intersection!) |
| TypeScript | function map<T, U>(arr: T[], f) | T extends A & B |
| Kotlin | fun <T, U> Array<T>.map(f) | <T : A> + where T : B |
| Scala 3 | extension [T](arr: Array[T]) def map[U] | T <: A & B |
| Java | <T, U> U[] map(T[] arr, ...) | <T extends A & B> |
| Nova D145 | fn[T] []T @map[U] | [T A + B] (Rust-style +) |
Nova edge:
- Cleanest receiver syntax —
fn[T] []T @mapкороче Rustimpl<T> Vec<T> { fn map<U> }(2 nested blocks → 1 line). - Bound syntax без двоеточия —
[T Hashable](D72) — параллель Novaname typeconvention (params, fields, let). - Multi-bound
+familiar — Rust audience узнаёт. - Protocol composition через
use— параллель D39 record-embed, единое правило. - Loud disambiguation —
E_BARE_TYPEVAR_NEEDS_PREFIX/E_PREFIX_SHADOWS_NAMED_TYPEявные, не silent miscompile. - Future-proof —
Q-representation-boundоткрыт для extension на concrete-type bounds (Plan 102).
Lineage
- Plan 48 / D119 — method-level + receiver-via-carrier generics.
- Plan 72 / D72 — bound syntax
[T Bound](free fn + type-decl). D145 переиспользует в новой позиции (fn[T Bound]prefix). - Plan 88 — static-method-on-typevar.
- Plan 99 — Option/Result closure-applying на Nova-body (paritет).
- D39 —
use Typeembed для records. D145 переиспользует pattern для protocol composition (Plan 101.4). - D53 —
type X protocol { ... }. D145 закрывает open question «Composition protocol’ов» через 101.4.
См. также
- D72 — bound syntax.
- D119.
- D39 —
useдля embed. - D53 — protocol decl.
- Plan 101 master
- 5 sub-plan’ов:
- Q-representation-bound — concrete-type bounds (newtype/embed-aware), Plan 102 future.
D180. Canonical .new() constructors (convention)
Статус: convention (stdlib provides, compiler does NOT auto-generate).
stdlib предоставляет .new() для типов с единственным очевидным
default-значением:
| Тип | .new() возвращает | Файл декларации |
|---|---|---|
int, u8–u64, i8–i64 | 0 | std/runtime/defaults.nv |
f32, f64 | 0.0 | std/runtime/defaults.nv |
bool | false | std/runtime/defaults.nv |
str | "" | std/runtime/string.nv |
[]T (для любого T) | [] (empty array) | builtin (emit_c.rs) |
Также []T.with_capacity(n int) -> Self — empty с pre-allocated capacity
(builtin).
Для своих типов разработчик пишет .new() явно. Компилятор НЕ
автогенерирует для user records / sum types / consume types.
Это design discipline:
- Явный конструктор виден в
nova docи IDE. - Имена кодируют намерение (
User.new(name, email)vsUser.guest()). - Валидация инвариантов в момент создания.
- Эволюция типа: добавление поля заставляет обновить конструктор — good failure (компилятор поймает breaking change).
НЕ имеют canonical .new() (convention — не использовать;
enforcement diagnostic — followup [M-91.7-default-new-enforcement]):
char('\0'сомнителен как «default»)Result[T, E](OkилиErr? ambiguous)Option[T]— каноничен, но codegen ограничение для generic builtin sum static methods откладывает Nova-side декларацию (followup[M-91.7-option-new-static]). До закрытия — использоватьNoneнапрямую.- tuples (
(int, str)etc.) - user-defined records / sum / consume types — по конвенции этого блока
- protocols, fn types, external/opaque
Пример
// stdlib provides:
let x = int.new() // 0
let s = str.new() // ""
let a = []int.new() // []
let buf = []u8.with_capacity(1024)
// User type — explicit:
type User { name str, email str, is_admin bool }
fn User.new(name str, email str) -> Self => { name, email, is_admin: false }
fn User.guest() -> Self => { name: "guest", email: "", is_admin: false }
Связь
- D26 — prelude auto-availability.
- D66 —
Selfв return type. - D131 — consume / fluent.
- D182 —
Selfrequirement. - Plan 91.7.
D181. Array methods — -> @ fluent mut chain + slice syntax
Статус: active (Plan 91.7, 2026-05-28).
-> @ для всех mut-методов []T
Все мутирующие методы массива возвращают @ (receiver pointer)
для fluent chain (D131):
| Метод | Сигнатура |
|---|---|
@push(v T) | -> @ |
@reserve(extra int) | -> @ |
@truncate(n int) | -> @ |
@fill(v T) | -> @ |
@copy_from(src readonly []T) | -> @ |
@extend_from(src readonly []T) | -> @ |
@insert_from(i int, src readonly []T) | -> @ |
@copy_within(src_from, dst_from, len) | -> @ |
@sort() (Nova-side) | -> @ |
@sort_by(cmp) | -> @ |
Non-mut методы (@get(i), @pop()) возвращают Option[T] —
unchanged.
Пример
let mut a = []int.new()
a.push(1).push(2).push(3).reserve(10)
a.sort() // direct call
let r = a.sort_by(|x,y| ...) // can also return into binding
Slice — только bracket syntax (Plan 96)
Метод @slice(from, to) -> []T удалён. Используйте arr[a..b]
(zero-copy view, см. Plan 96 / D-str-slice). Один очевидный путь.
Известные ограничения
- Mixed Nova-method + builtin chain:
a.sort().push(99)— codegen пока эмититa->sort()(struct field access) вместо function call. Followup[M-91.7-mixed-method-chain]. Workaround: разнесите вызовы. - Generic sort/min/max для
[T Ord]— followup[M-91.7-sort-generic]. Текущий MVP — concrete[]int @sort()(Plan 91.3).
Связь
- D131 — fluent API
семантика
-> @. - D177 — Nova-body dispatch механизм.
- Plan 90.1 — extend-family (extend_from, insert_from, reserve).
- Plan 96 —
arr[a..b]slice syntax.
D182. Self в return-type static methods — required form для parametric types
Статус: active (Plan 91.7, 2026-05-28).
Правило
Для static-методов на параметризованных типах (fn Option[T].new(),
fn HashMap[K, V].new(), etc.) return-type должен использовать Self,
а не explicit-form -> Option[T] / -> HashMap[K, V].
Rationale:
- Explicit-form дублирует тип-параметры — redundant.
Selfустойчив к переименованию типа (rename-safe).Selfявно говорит «возврат того же receiver-типа» — semantic clarity.- Single canonical form — D9 «один очевидный путь».
Примеры
// ✅ Correct (canonical):
export fn Option[T].new() -> Self => None
export fn HashMap[K, V].new() -> Self => { ... }
export fn StringBuilder.new() -> Self => { ... }
// ❌ Wrong (explicit redundant form):
export fn Option[T].new() -> Option[T] => None
export fn HashMap[K, V].new() -> HashMap[K, V] => { ... }
Для primitive receiver types
Self тоже рекомендуется для consistency:
export fn int.new() -> Self => 0 // канонично
export fn int.new() -> int => 0 // допустимо, но не canonical
Codegen requirement
Self в return-type корректно resolved через current_receiver_type ⇒
правильный C type:
- primitive receiver → primitive value type (
nova_int,nova_bool, …) - Option/Result → sum repr (
NovaOpt_<T>,NovaRes_<ok>_<err>*) - user record →
Nova_<TypeName>*
См. emit_c.rs::type_ref_to_c "Self" case — делегирует в receiver_c_type.
Enforcement
Validation rule — followup [M-91.7-self-required-parametric]. Текущий
compiler принимает обе формы; canonical форма документирована здесь.
Связь
D183. Canonical comparison protocols + default method bodies (Plan 91.8a)
Статус: active (Plan 91.8a, 2026-05-29).
Канонические протоколы (renames)
| Было | Стало | Файл |
|---|---|---|
Iter[T] | Iterable[T] | std/prelude/collections.nv |
Display | Printable | std/prelude/protocols.nv |
Equatable.eq(other Self) -> bool | Equatable.equals(other Self) -> bool | std/prelude/protocols.nv |
Comparable.cmp(other Self) -> Ordering | Comparable.compare(other Self) -> int | std/prelude/protocols.nv |
Hashable.hash() -> u64 | unchanged | std/prelude/protocols.nv |
Rationale renames:
-ablesuffix convention — unified naming (Iterable/Equatable/Comparable/Hashable/Printable).Comparable.compare -> int— единый стиль сstr.compare()(D178) и Cmemcmp/strcmp.Orderingsum-type удалён.Equatable.equals— явнее чемeq(Java convention).Display→Printable— действие через-able, не имя-noun.
Comparable embeds Equatable
export type Equatable protocol {
equals(other Self) -> bool
}
export type Comparable protocol {
use Equatable
compare(other Self) -> int
equals(other Self) -> bool => @compare(other) == 0 // default body
}
use Equatable (D39 embed) делает каждый Comparable также Equatable.
Локальная декларация equals в Comparable с default body overrides
embedded default — implementer пишет только @compare, @equals
auto-synthesized из default body как @compare(other) == 0.
Default method bodies в protocols
Правило (новое в D183):
Метод в protocol-декларации может иметь тело (
=> exprили{ ... }). Тело используется как default-реализация: если тип-implementer не задаёт свой@method, компилятор использует body из протокола, подставляяSelf= receiver type. Если implementer задал@methodявно — explicit version используется (override).
Семантика:
- Метод без тела = abstract — implementer ОБЯЗАН реализовать.
- Метод с телом = default — implementer МОЖЕТ override.
Пример:
type Comparable protocol {
use Equatable
compare(other Self) -> int // abstract
equals(other Self) -> bool => @compare(other) == 0 // default
}
type MyDate { y int, m int, d int }
fn MyDate @compare(other MyDate) -> int { ... }
// @equals НЕ объявлен — используется default из Comparable.
// Override для perf:
type FastHashed { hash_cache u64, ... }
fn FastHashed @compare(other FastHashed) -> int { ... }
fn FastHashed @equals(other FastHashed) -> bool {
@hash_cache == other.hash_cache && @compare(other) == 0
}
Cleanup
Orderingsum-type удалён изstd/prelude/core.nv.Less/Equal/Greaterexports удалены изstd/prelude.nv.std/sort.nvsort_by(cmp fn(int, int) -> int)— memcmp-style convention.PRELUDE_VERSIONbumped 12 → 13.
Memcmp-compatible int return
compare(other) -> int returns:
- negative if
@ < other - zero if
@ == other - positive if
@ > other
Caller должен использовать только sign (< 0, == 0, > 0), НЕ magnitude.
Совместимо с C memcmp/strcmp convention. Implementer для primitive numerics
рекомендуется использовать safe signum form:
fn int @compare(other int) -> int =>
if @ < other { -1 } else if @ > other { 1 } else { 0 }
Не использовать => @ - other — overflow risk для больших int.
Реализация (части)
- Парсер (
compiler-codegen/src/parser/mod.rs::parse_effect_methods): добавлен parser default body после return_type/contracts. Body ==> exprили{ ... }. ПолеEffectMethod.default_body: Option<Block>в AST. check_protocol_embeds(compiler-codegen/src/types/mod.rs): local override embedded methods разрешён — locally declared метод в protocol с тем же именем что embedded не считается duplicate. Используется дляComparable.equalsoverrides embeddedEquatable.equalsdefault.- Codegen synthesis для defaults: followup
[M-91.8a.2-default-codegen]. Сейчас implementer пишет default-method explicitly для compatibility (как boilerplateequals(o) => @compare(o) == 0).
Известные ограничения / followups
- Codegen synthesis (
[M-91.8a.2-default-codegen]): type T который имеет@compareно не@equalsпока компилируется только если@equalsобъявлен явно. Eager synthesis из default body — отдельный codegen pass. - Operator dispatch (D184, Plan 91.8b):
==всё ещё dispatches к@eq(D46). Renaming@eq→@equalsв operator dispatch — задача Plan 91.8b. До 91.8b implementer пишет оба:@equals(protocol) +@eq(operator). - Generic sort/min/max (D185, Plan 91.8c): generic
fn[T Comparable]array methods — отдельный subplan.
Связь
- D26 — prelude auto-availability.
- D39 —
useembed. - D58 — structural typing.
- D72 — bounds.
- D109 — split policy (Hashable не embeds Equatable; Comparable embeds Equatable в D183).
- D178 —
str.compare -> int. - Plan 91.8a — implementation.
D183 amendment — Plan 91.8a.2 part 1: protocols refactor (orthogonal) + Self в param
Статус: active (Plan 91.8a.2 part 1, 2026-05-29).
Refactor: orthogonal protocols (canonical coercion form)
Было (91.8a part 1):
type Equatable protocol {
equals(other Self) -> bool
}
type Comparable protocol {
use Equatable
compare(other Self) -> int
equals(other Self) -> bool => @compare(other) == 0 // override of embedded default
}
Стало (91.8a.2 part 1) — canonical:
type Equatable protocol {
equals(other Self) -> bool {
let cmp Comparable = @ // coercion-style (explicit dependency)
cmp.compare(other) == 0
}
}
type Comparable protocol {
compare(other Self) -> int
}
Rationale:
- Orthogonal protocols — каждый stand-alone, без embed-зависимости.
- Coercion canonical (Q6 decision): explicit cross-protocol dependency visible при чтении декларации; codegen devirtualizes к direct call когда тип known statically (zero runtime cost).
- Conditional default: T satisfies Equatable если has @equals explicit ИЛИ satisfies Comparable (default body synth via @compare). Type только Equatable (Vector3, Complex, etc.) пишет @equals явно — coercion fails potential потому что @compare отсутствует.
- Direct form
=> @compare(other) == 0тоже валидна — terser; same C output after devirtualization. Coercion form preferred в stdlib для documentation.
Printable.fmt default body
type Printable protocol {
fmt(sb StringBuilder) {
sb.append(str.from(@))
}
}
- Primitives — works via primitive
Nova_int_to_stretc. - User types — implementer пишет @fmt явно (perf) OR provides
fn str.from(MyType) -> stroverload.
From identity blanket (D183 amendment)
export fn[T] T.from(t T) -> T => t
- Аналог Rust
impl<T> From<T> for T. - Override запрещён (Q4 strict decision): попытка
fn Money.from(m Money) -> MoneyдаётE_BLANKET_IDENTITY_OVERRIDE. Identity is identity (D9 single canonical path). - Resolution order для
T.from(value):- Explicit
fn T.from(value_type)→ win - Blanket identity — match только если
value_type == T - D77 auto-derive из From[value_type] chain
- Error E_NO_FROM_IMPL
- Explicit
- Identity Into auto-derived через D77.
- Coexistence: blanket additive с existing
From[T]protocol decl (std/prelude/protocols.nv:81-83) +emit_c.rs::from_targets/into_targetsregistries (D77 4-way derive).
Self в param-type position (М-91.8a-self-in-param closed)
Раньше fn T @method(other Self) -> R давал E7001 «Self type used outside
receiver context». Fix: emit_c.rs::emit_module method overload registration
устанавливает current_receiver_type перед param_c_types calculation
(mirror return-type path). Закрыто Plan 91.8a.2 part 1.
Codegen lazy synthesis + devirtualization — followup (Plan 91.8a.2 part 2)
Часть 1 (текущая) ограничена структурным refactor + Self fix. Часть 2 (отдельный sub-session) реализует:
- Lazy synthesis at use-site:
- Bound contexts (
[T Equatable]etc.) — synth default body для типов которые satisfy abstract methods - Protocol coercion (
let x Equatable = m) - Operator dispatch (Plan 91.8b)
- String interpolation (Plan 91.10)
- NOT triggered: bare method call (
m.equals(other)— direct lookup only)
- Bound contexts (
- Devirtualization pass — coercion form
let cmp Protocol = @становится type ascription + direct call при synthesis для concrete T. Result: same C output что direct form. - Cache per compilation unit:
HashMap<(TypeId, MethodName), SynthFnDecl>. - From blanket mono — extension Plan 101 mono pass на
fn[T] T.methodstatic на generic T. - Error diagnostics: E_SYNTH_CYCLE, E_SYNTH_AMBIGUOUS, W_DEVIRT_FAILED, E_BLANKET_IDENTITY_OVERRIDE.
До части 2 — implementer пишет default body methods явно (boilerplate compatibility). Это работает но дублирует код.
Связь
- D183 (part 1) — base D183.
- D26 — prelude.
- D58 — structural typing.
- D77 — From/Into 4-way auto-derive.
- Plan 91.8a.2.
D186 — #impl(P1 + P2 + ...) opt-in annotation для protocols
Когда: 2026-05-29 (Plan 91.9). Plan: 91.9-impl-annotation.md. Зависит от: D58 (structural protocols), D72 (generic bounds), D183 (canonical protocols Equatable/Comparable/Printable + default body).
Проблема
Nova protocols — structural (D58). Compiler разрешает obj.method()
если у типа есть соответствующий метод, без явного opt-in. С добавлением
default body synthesis (D183) ситуация ухудшилась:
type Greetable protocol {
greet() -> str { "Hello, " + @name() }
}
type User { display_name str }
fn User @name() -> str => @display_name
u.greet() // ??? — без D186 это работало structurally (TypeScript-style)
Проблемы:
- Невидимая мутация behavior: добавление протокола в одном модуле тихо добавляет методы всем типам подходящей сигнатуры.
- Reader-hostile: глядя на
type User, нельзя понять что у него есть методgreet(он синтезирован). - Ambiguity: два протокола с methods одинакового имени и default bodies — порядок resolution не детерминирован.
- Verification: type-author не получает feedback что type соответствует intended protocol.
Решение
#impl(P1 + P2 + ...) annotation перед type declaration. Меняет
два аспекта:
1. Gate semantics (bare-call / interpolation требуют opt-in)
Контексты, где synthesis fires:
| Context | Требует #impl(P)? | Почему |
|---|---|---|
Bare call u.method() | ✅ да | Ambient — type-author opt-in нужен |
Interpolation "${u}" | ✅ да | Ambient — Printable.fmt synthesis |
Generic bound [T P] | ❌ нет | Caller opted in через bound |
Coercion let x P = u | ❌ нет | Caller opted in через annotation |
Cast (u as P).method() | ❌ нет | Caller opted in через cast |
Param func(...args []P) | ❌ нет | Caller opted in (signature) |
Принцип симметрии: хотя бы один из (type-author, use-site) должен
opt’нуться явно. Структура #impl — type-author side; bound/coercion/cast/
param — use-site side.
2. Verification (auto-check соответствия)
При декларации #impl(P) compiler проверяет:
- E_UNKNOWN_PROTOCOL —
Pне найдено как type name. - E_IMPL_NOT_PROTOCOL —
Pнайдено, но не protocol kind. - E_IMPL_MISSING_METHODS — T не provides метод P:
- не имеет explicit
fn T @method(...), - и default body P.method не synthesizable для T (зависит от другого метода которого T не имеет).
- не имеет explicit
Verification работает at type-declaration site — error появляется сразу, не при первом использовании.
Синтаксис
#impl(Equatable + Comparable + Printable)
type Coin { value int }
fn Coin @compare(other Self) -> int => ...
fn str.from(c Coin) -> str => ...
// equals auto-derived через Equatable.equals default (uses @compare)
// fmt auto-derived через Printable.fmt default (uses str.from)
+ separator consistent с multi-bound [T A + B + C] (D72, Plan 101.3).
Order arbitrary: #impl(A + B) ≡ #impl(B + A).
Multiple #impl annotations не разрешены — single annotation with +.
Position
#impl(...) ставится перед type T (рядом с #stable, #from_fields):
#stable(since = "0.1")
#impl(Hashable + Equatable)
type UserId { value u64 }
Семантика
Use-site остаётся structural (D58 preserved). #impl не делает тип
nominal. Он добавляет:
- Gate на ambient synthesis (bare call / interpolation).
- Verification в точке декларации.
Через bound / coercion / cast / param-coercion использование любого
structurally-подходящего типа всё ещё работает — #impl не требуется.
Что НЕ делает
- НЕ создаёт nominal typing (use-site structural preserved).
- НЕ обязателен — opt-in, existing types работают через use-site coercion.
- НЕ меняет runtime —
#implтолько compile-time проверка/gate.
Codegen
emit_c.rs::try_synthesize_default_method_with_gate(t, c, m, gate_on_impl):
gate_on_impl = true— bare call / interpolation; restricts candidates к protocols вtype_impl_protocols[t].gate_on_impl = false— vtable thunk (coercion), bound mono; structural.
type_impl_protocols: HashMap<String, HashSet<String>> populated в
forward-decl pass из TypeDecl.impl_protocols.
Type-checker verification
types/mod.rs::verify_impl_protocols walks каждый Item::Type с
non-empty impl_protocols:
- Each
Plookup вself.types. None → E_UNKNOWN_PROTOCOL. - Kind check — must be
TypeDeclKind::Protocol. Иначе → E_IMPL_NOT_PROTOCOL. - Each required method
mвP.methods:t_provides_method(T, m.name)→ ok (explicit).m.default_body.is_some() && default_body_calls_satisfy_for(body, T)→ ok (synthesizable).- Else → list в missing, emit E_IMPL_MISSING_METHODS с hint.
default_body_calls_satisfy_for — AST walker проверяет body’s referenced
calls resolve for T (через t_provides_method + t_satisfies_str_from для
auto-derive str.from(@) pattern).
Compatibility
- Existing structural use-sites (bound
[T P], coercionlet x P = u, cast(u as P), parameter coercion) continue работать без#impl. - Existing types без
#implмогут потерять bare-call:fn User @name() -> str => ...; u.greet()(Greetable.greet default) — раньше работало, теперь error (без#impl(Greetable)). - Migration trivial: добавить
#impl(Protocol)перед type decl.
Связь
- D58 — structural protocols (use-site preserved).
- D72 — generic bounds (use-site opt-in alternative).
- D183 — canonical protocols + default body synthesis (что gate’ится).
- D109 split policy.
- Plan 101.3 — multi-bound
+syntax.