Nova — открытые вопросы дизайна
Что обсуждали, но не зафиксировали как решение. Когда вернёшься к работе над языком — сначала закрой эти вопросы.
Q1. Унификация методов типов и эффектов
Контекст. Сейчас (после D35):
fn User @greet() -> str => ... // метод инстанса: неявный self через @
fn list_users() Db -> []User => ... // функция с эффектом: handler в скоупе
D35 ввёл @-методы (неявный self), но только для типов данных.
Для эффектов остался отдельный синтаксис «обычная функция + эффект в
сигнатуре». Это два разных способа объявить «функцию, ассоциированную
с типом/эффектом».
Предложение. Распространить @ на эффекты:
fn User @greet() -> str => ...
fn Db @list_users() -> []User => ... // self = активный handler в скоупе
Один синтаксис, два способа доступа к self:
- Для data-типа — экземпляр через
u.greet() - Для эффект-роли — активный handler через
Db.list_users()(илиdb.list_users()если есть локальное имя)
Эффект в сигнатуре автоматически — через @ (не нужно дублировать).
Статус. D35 закрыл часть про data-типы. На эффекты не распространён — вопрос остаётся открытым.
Тонкие места:
- Неявный
selfдля эффектов — это «магия» или унификация? - Куда отнести функции с несколькими эффектами (
Net Db Log Fail)?fn (Net, Db, Log) @method— некрасиво, иselfне один. - Переписывание stdlib — но stdlib ещё нет, дешёвое время.
Q2. Tuple-namespace (Db, Log).method
Контекст. Был вопрос: «можно ли (Db, Log).list_users() как
сокращённый with?»
Решение в обсуждении. Отвергнуто (не даёт ничего сверх with-блока,
дублирует информацию из сигнатуры).
Не зафиксировано в decisions/ явно. Если возникнет снова — сослаться на это обсуждение.
Q3. Реализация fiber stack для Async
Bootstrap-status (2026-05-06): временное решение — minicoro (mco) с fixed-size growable stacks. Размер по умолчанию — то, что minicoro даёт ас default’ом. Этого достаточно для bootstrap- сценариев (тесты до 64 fiber’ов в
NOVA_SCOPE_CAP). Production-выбор остаётся открытым — список ниже.
D14 говорит «открытый вопрос»:
- Segmented stacks (как старый Go)?
- Cactus stacks (как Cilk)?
- On-demand growable (как новый Go)?
Каждый имеет цену:
- Segmented: дешёвый старт, hot-spot на границе сегмента
- Cactus: хороший для work-stealing, сложнее реализация
- Growable: выделяет много vsmem заранее, копирование при росте
Дефолтный размер fiber stack тоже не определён. Erlang начинает с 233 слов (~2KB), Go — с 8KB. Для Nova нужно решить, ориентируясь на основной use-case (серверы? embedded? AI-генерация?).
Q4. Семантика Alloc[Cycle] collector’а — ✅ ЗАКРЫТО
Закрыто D6. D21 (~T/~&T opt-in
cycle collection) отменён в пользу tracing GC по умолчанию. Эффект
Alloc[Cycle] снят, префиксы ~T/~&T удалены из языка.
Управление collector’ом — runtime-параметр, не часть языка.
Q5. Точная граница Panic (частично закрыто)
D13 определил Panic как «аппаратные/математические сбои»:
- Деление на ноль ✓
- Целочисленное переполнение (debug — panic, release — wrapping) ✓ закрыто D13
- Выход за границы массива ✓
- Переполнение стека — открыт (Q5.2 ниже)
- OOM ✓ закрыто D13 (Panic, fiber умирает; supervisor может рестартовать)
Остаются открытыми:
Q5.2. Stack overflow recoverable? Может ли быть не-panic,
обрабатываемая ошибка? Или всегда смерть fiber’а? Erlang restart,
Java StackOverflowError. Nova должна заявить позицию — скорее всего
«fiber умирает, supervisor рестартует» по аналогии с OOM, но не
зафиксировано явно.
Q5.4. Assertion failures в debug. Это Panic или обычные Fail?
Если Panic — нельзя поймать в обычном коде (только supervisor видит).
Если Fail[AssertionError] — нужно везде декларировать. Скорее
всего Panic (это «сбой инварианта», не бизнес-ошибка), но не
зафиксировано.
Q6. Effect polymorphism — синтаксис + rank-2 семантика
Текущий пример:
fn map_eff[T, U, E](xs [T], f (T) E -> U) E -> [U]
E — параметр-эффект. Не определён точный синтаксис:
- Можно ли
E1, E2?[E1, E2]? - Как ограничивать (bound) эффект-параметры?
- Как стирать (
erase) для разнородных задач — D12 коснулся, но не всё детализировано.
Rank-2 polymorphism в handler-method’ах
D42 модель B (через D53) допускает generic в методах protocol/effect:
type Db effect {
in_transaction[T](body fn() Db Fail -> T) Fail -> T
}
Здесь T — generic метода (rank-2): один и тот же handler Db
вызывает in_transaction с разными T для каждого вызова. После
D61 (tail-only семантика, без resume)
не специфицировано, как handler управляет произвольным T:
bodyвозвращаетT— handler-method получаетTчерез вызовbody(). Может ли handler решить «вернуть T или другое»?- Если handler делает
interrupt v— типыvиTдолжны быть совместимы. Как это проверяется при rank-2? - SMT-проверка контрактов на
body()— возможна ли она на rank-2-T?
Использовано в orm_demo.nv и stdlib_sql.nv — компилятор должен поддержать раньше чем self-hosted production.
Связь: D42 (REVISED), D53, D61, D12, Q-bounds.
Q7. Macros / comptime
В overview.md сказано: «typed compile-time функции (как Zig comptime)». Но конкретный синтаксис, мощь, ограничения — не описаны.
Вопросы:
- Comptime-функции имеют доступ к типам как первый класс?
- Можно ли генерировать код во время компиляции?
- Reflection во время компиляции — да или нет?
- Custom DSL через comptime — допускается?
Q8. Обновить paradigm.md и revolutionary.md
Технический долг. Эти файлы содержат устаревший синтаксис из ранних этапов:
trait/impl Trait for Type— отменено в D15type X = { ... }с=для record — отменено в D17type X = { методы }для интерфейсов — заменено наprotocol X { методы }в D42effect X { ... }— отменено в D18- match с
->— отменено в D19 mut self,:в аннотациях типа,throwsбезFail[]— устарели
Что делать: обновить под текущие D-решения. Это просто переписать все примеры с новым синтаксисом, без изменения смысла.
syntax.md, effects.md, decisions/01-philosophy.md (D9) актуализированы. audit.nv
тоже актуален. paradigm.md помечен как устаревший до полной переписи.
Q9. Стандартная библиотека
Не описана структура. Что есть в stdlib:
String,HashMap,HashSet,Option,Result— очевидно (Vec нет —[]Tвстроенный, см. D58)LinkedList,Tree,Graph— какие именно типы?Json,Sql.builder— упоминаются вaudit.nv, не описаныTime,Random,Net,Db— стандартные эффекты, не определены их операции- HTTP, WebSocket, gRPC — что в core, что в external?
Конкретные пробелы методов
String API не зафиксирован. В nova_tests/ и examples/
используются s.len, s.contains(sub), s.starts_with(p),
s.ends_with(p), s.to_lower(), s.to_upper(), s.trim() — все
работают в compiler-codegen runtime, но это implementation-факт,
не часть спеки. Production-компилятор должен ориентироваться на
формальный список.
Escape sequences в string literals ("\n", "\t", "\"",
"\\") — в bootstrap’е поддержаны, в спеке упоминаются только
для tagged templates (D48:1510),
не для обычных "..."-литералов. Нужно явно зафиксировать список
поддерживаемых escapes для обычных string-литералов.
Array API — xs.len, xs.push(v), xs.pop(), xs.get(i)
работают в bootstrap’е, не описаны в спеке. xs.map(f),
xs.filter(p), xs.reduce(...), xs.find(p) — НЕ работают
(в bootstrap’е) и НЕ описаны.
Это большая работа на отдельный документ.
Q10. Tooling детали
Заявлены в overview.md:
nova run,nova build,nova fmt,nova lint,nova testnova check --fragmentnova run --record/nova replay(time-travel)
Но не описаны:
- Формат
.nrecфайлов трасс - Структура package manager (content-addressed как Deno)
- Hot reload — как именно работает?
- LSP протокол — расширения для эффектов? Для контрактов?
Q11. Имя языка
«Nova» — рабочее имя. Конфликтует с:
- Battle.net Nova (игровой движок Activision)
- Various JS/Python/Ruby библиотеки
- Команда Linux
nova(OpenStack compute) - Nova Networks (компания)
Если язык будет реально публиковаться — нужно другое имя.
Q12. Модель concurrency — ЗАКРЫТО (D50)
D50 закрыл основные пункты Q12:
- Q12.1 (spawn-семантика) →
spawnтолько в structured-scope; fire-and-forget — черезdetach { ... }с эффектомDetach. - Q12.2 (Async vs Par) → один эффект
Async,Parне вводится. - Q12.6 (C interop) →
blocking { ... }примитив + эффектBlocking(несовместим сRealtime). await/маркер на call site → нет, эффектAsyncв сигнатуре единственная декларация (подтверждение D14).
Двухэтапный план реализации (Go-style v1.0, Erlang-style v2.0+) — там же.
Остаётся открытым в Q9 (stdlib):
Channel[T]API — ✅ закрыто D79.Mutex/RwLock/AtomicSized— ✅ закрыто D169 / D169 / D168; доступны какruntime.syncstdlib, не prelude (D167-D173).Atomic[T]generic — ⏳ отложено (M2 reject в Plan 103.2; future generic-codegen plan; не входит в Plan 103.x scope).- Размер blocking-pool по умолчанию (runtime-конфиг).
- Q12.7 —
Domain-примитив (явная граница ОС-потока) для real-time embedded use-case, отложено до user-feedback.
Исторический контекст подвопросов сохранён ниже на случай возврата.
Q12.1. Семантика spawn
В examples/audit.nv:256 используется spawn write_audit(...) как
fire-and-forget вне supervised/parallel блока. Это противоречит
structured concurrency: задача переживает родителя, отмена не
прорастает. Варианты:
spawnвсегда внутри scope’а (supervised,parallel,nursery-блок). Unstructured нет вообще.spawnструктурный,detachотдельный для долгоживущих задач.detachтребует явного эффектаDetachили capability.- Текущее поведение
audit.nv—spawnfire-and-forget, scope неявно «модуль/процесс». Это удобно, но ломает structured concurrency.
Нужно решение. Влияет на cancellation, на семантику ошибок, на supervision.
Q12.2. Граница Async vs Par
Сейчас оба эффекта в стандартном наборе, но границы не описаны:
Async= «функция может уступить fiber-scheduler’у»?Par= «функция запускает несколько fiber’ов параллельно»?- Должны ли они комбинироваться (
Net Async Parдляparallel forс сетью)? parallel forтребуетParили достаточноAsync?- Может ли быть
ParбезAsync(например, чисто CPU-bound параллелизм)?
Текущие примеры противоречат: audit.nv:222 пишет Async без Par
для middleware, который spawn’ит задачу — то есть эффект параллелизма
не виден в сигнатуре. Это дыра в «сигнатура = полное описание».
Q12.3. Multithreading vs concurrency
Не зафиксировано: fiber’ы работают на одном ОС-потоке, на M-потоках с work-stealing (Go/Tokio), или на отдельных Domain’ах (OCaml 5)? Это влияет на:
- Производительность CPU-bound кода (один поток — bottleneck)
- Сложность реализации (M:N сложнее)
- Семантику shared state (см. Q12.4)
OCaml 5 разделяет Domain (ОС-поток с собственной кучей) и Fiber
(легковесная задача внутри Domain). Это явное двухуровневое разделение.
Nova могла бы:
- Один scheduler на процесс, M-потоков (Go-style) — простая модель, но shared state требует синхронизации.
Domainкак явный примитив — изоляция кучи, передача данных через каналы. Сложнее, но безопаснее.- Single-threaded по умолчанию, multi-thread opt-in — упрощает
~Tи~&T, но ограничивает параллелизм.
Q12.4. Shared state между fiber’ами
Статус: ✅ partial closed (Plan 103.x, 2026-05-26).
- Channels → ✅ D79 (capability-split, send/recv, select).
- Actor mailbox pattern → зафиксирован как предпочтительный idiom в D79 §«Что отвергнуто» + §1531 rewrite.
- Mutex/RwLock → ✅ D169 (Mutex/RwLock/ReentrantMutex,
runtime.syncstdlib, не prelude).- Atomic-операции → ✅ sized types D168 (12 типов × 13 ops);
Atomic[T]generic — ⏳ отложено (M2 reject).- STM → отвергнуто (не в scope Nova v1.x).
- Semaphore/Barrier/CountDownLatch/Condvar → ✅ D170.
- Once/OnceCell/Lazy → ✅ D171.
- Memory ordering → ✅ D167 (
MemOrderingenum +fence()).- AI-first guidance → ✅ D173 (decision tree + 5 patterns).
Остаётся:
Atomic[T]generic (future plan);Domain-примитив (Q12.7, отложено).
Исторический контекст (до Plan 103.x):
Если два fiber’а должны делить состояние, рассматривались варианты: Channels (D79 ✅), Actor mailbox (D79 ✅ idiom), Mutex/RwLock (D169 ✅), Atomic[T] (D168 ✅ sized; generic ⏳), STM (отвергнуто).
Q12.5. Thread-safety ~T и ~&T — СНЯТО
~T и ~&TСтатус: снято. D21 отменён в пользу managed memory
(D6 пересмотрен). Префиксов ~T/~&T нет, atomic
refcount не нужен. GC сам обеспечивает thread-safe управление памятью.
Заменяющий вопрос — thread-safety GC: как concurrent collector синхронизируется с приложением? Это runtime-implementation deal, не language design. Современные решения (ZGC, Shenandoah, MMTk) уже дают ответы — выбирается на этапе реализации.
Q12.6. C interop для блокирующих вызовов
D14:665 упоминает «механизм detach to OS thread», но не описывает.
Если fiber вызывает синхронный C-код (например, read(2) без
O_NONBLOCK), он блокирует весь scheduler. Нужен механизм:
- Явный
detach { c_call() }блок, runtime передаёт fiber на отдельный ОС-поток? - Эффект
Blockingв сигнатуре C-обёртки? - Pool блокирующих потоков для всех
detach?
Что блокируется без Q12
- Q9 (stdlib). Нельзя описать
Channel,Mutex,Atomic,Mailboxбез модели shared state. - AI-first тезис. Без
Parв сигнатуреaudit_middlewareLLM не видит, что функция параллельна. Сигнатура не полна. - Реализация GC (D6). Concurrent collector работает на отдельном потоке параллельно с приложением — выбор реализации зависит от Q12.3.
- Эффекты на границах (D12). Очереди и планировщики типизированы, но «передача задачи между потоками» — это та же граница, что «передача через процесс»? Не описано.
Приоритет
Высокий. Это структурный вопрос уровня D6/D14, не «деталь runtime». Влияет на сигнатуры stdlib, AI-first тезис и память.
Прагматичный план: Go-style v1.0, Erlang-style v2.0
После обсуждения — выбран двухэтапный план:
v1.0 — Go-style fibers + shared memory:
- Fiber’ы как goroutine: shared heap, передача данных по указателю
- Каналы как stdlib (
Channel[T]сsend/recv) Mutex,RwLock,Atomic[T]как stdlib-типы (Q12.4)- Один scheduler на процесс, M ОС-потоков work-stealing (Q12.3.1)
- Atomic refcount для
~T/~&Tвсегда (Q12.5.1) — простая модель, цена ~10ns - Preemptive scheduling — runtime прерывает fiber’ы по таймеру или через compiler-вставленные точки (как Go 1.14+)
- Supervisor как библиотечный паттерн поверх panic = exit fiber (D13)
v2.0+ — Erlang-style isolation (опционально, если докажет ценность):
- Per-fiber heap (изолированная куча, как Erlang process)
- Per-fiber GC (микросекундные паузы локально)
- Передача между fiber’ами только через каналы с копированием
- Полная изоляция падений
- Hot reload, distributed-by-default
Erlang-модель сильнее (изоляция, supervision, distributed бесплатно), но сложнее реализовать (per-process heap + per-process GC). Для v1.0 Nova не берёт эту планку — Go-модель достаточна для backend-серверов и CLI-приложений, и хорошо сочетается с D6 (managed memory с concurrent GC).
Что зафиксировано из v1.0-плана
- Preemptive fiber-runtime — обязательно (исключает «cycle in plugin останавливает весь сервер»)
- Shared heap с concurrent GC — единая куча на процесс, GC снимает вопрос refcount/atomicity для shared ownership (D6 пересмотрен)
- Channels как stdlib — описание в Q9 (stdlib)
- C interop через
detach— Q12.6, фиксируется как stdlib-функция с эффектомBlockingв сигнатуре - Supervisor — библиотечный паттерн — поверх runtime-границы fiber’а
Что остаётся открытым
- Точные API
Channel[T],Mutex,Atomic[T]— Q9 - Граница
AsyncvsPar(Q12.2) — Q9 stdlib опишет - Семантика
spawnструктурный/unstructured (Q12.1) — Q9 определит - Q12.7 (новый): следует ли вводить
Domain-примитив (явная граница ОС-потока) для real-time embedded use-case — отложено до user-feedback
Q13. Версионирование типов данных как stdlib-паттерн
Идея. Эволюция типов через sum-type вариантов + методы преобразования
- handler
Db/Fs, знающий о версиях:
type Account
| V1 { id u64, balance money }
| V2 { id u64, balance money, frozen bool }
| V3 { id u64, balance money, frozen bool, currency Currency }
fn Account.to_latest(self) -> Account => match self {
V1 { id, balance } => V2 { id, balance, frozen: false }.to_latest()
V2 { id, balance, frozen } => V3 { id, balance, frozen, currency: USD }
v3 => v3
}
Handler Db при чтении применяет миграцию прозрачно, возвращая latest
версию.
Почему не D-решение. Реальная сложность миграций (DDL, блокировки,
конкурентные writers, big-rollback) — снаружи кода. Любой язык с sum-types
даёт ту же выразительность. Ввод evolution как ключевого слова
противоречил бы D18 («не плодить специальные сущности») и не давал бы
ничего сверх библиотечного решения.
Когда вернуться. При работе над Q9 (stdlib) — описать как рекомендуемый
паттерн для типов, читаемых из persistent storage, вместе с операциями
Db handler’а.
Q14. Cost-types — resource bounds в сигнатуре
Идея. Опциональный контракт о сложности:
fn sort[T](xs [T]) -> [T]
requires bounded(time: O(n log n), space: O(1))
=> ...
Проверяется статически где можно (RAML / AARA подходы).
Почему отложить. Research-уровень. Nova и так признаёт высокую планку реализации эффектов (decisions/01-philosophy.md → D10). Брать ещё одну рискованную ставку до v1.0 — превышение допустимого риска.
Когда вернуться. После стабилизации D10/D14/D21 и реализации R5 в проде.
Связь. R4 (контракты), R5 (AI-first), D10.
Q15. Enum с числовыми значениями ✅ ЗАКРЫТО (D52)
D52 ввёл sum-варианты с числовыми discriminants и auto-increment:
type ExitStatus | Ok | Failure | Critical // 0, 1, 2 (auto)
type FileMode | Read = 1 | Write | Execute // 1, 2, 3
type ErrorCode
| NotFound = 404
| Unauthorized = 401
| InternalError = 500
type Bit u8 | Off = 0 | On = 1 // явный базовый тип
⚠ Явный базовый тип (
type X u8 | …) пока не реализован — parser drift, см. Plan 105.
Cast Sum → int безопасный (c as int); cast int → Sum через
pattern match с Fail[InvalidVariant]. Конфликт значений запрещён
компилятором.
Закрывает все use-case’ы исходного Q15:
- Привязка численного значения — через
= n. - Автонумерация — через auto-increment от первого варианта.
- Сериализация в wire-формат — через
as int(стабильный discriminant).
Q16. Bitflags ✅ ЗАКРЫТО (D46)
С введением D46 (operator overloading) вопрос закрывается Вариантом C (newtype над int с методами
@or,@and):type Permission(int) const READ Permission = Permission(1) const WRITE Permission = Permission(2) const EXECUTE Permission = Permission(4) fn Permission @or(other Permission) -> Permission => Permission(@.0 | other.0) fn Permission @and(other Permission) -> Permission => Permission(@.0 & other.0) fn Permission @contains(flag Permission) => (@ & flag).0 != 0 let p = READ | WRITE if p.contains(READ) { ... }Типобезопасность сохранена, оператор
|работает через@or. StdlibBitflags[T](Вариант A) не нужен.
Контекст. Permissions, capabilities, set-of-options — частый паттерн:
Read | Write | Execute
HTTP_GET | HTTP_POST
INTR_HOLD | INTR_LATCH
Это не sum-type (sum-type = один из вариантов, bitflags = комбинация).
Sum-type для них не подходит. Нужен либо int с константами и битовыми
операциями (как в C), либо специальный тип Bitflags[T].
В Nova никак — нет ни int-констант с битовыми операторами как
идиомы, ни Bitflags-типа.
Варианты
A. Stdlib Bitflags[T] — generic-тип над enum-подобным sum-type:
type Permission | Read | Write | Execute
let p Bitflags[Permission] = Permission.Read | Permission.Write
if p.contains(Permission.Read) { ... }
Требует перегрузки операторов |, & для конкретного типа. См. D1
— перегрузка операторов «только для стандартных traits» (намёк, что для
custom-типов нельзя). Нужно явно расширить.
B. Goлевой стиль int + константы:
let PERM_READ = 1
let PERM_WRITE = 2
let PERM_EXECUTE = 4
let p = PERM_READ | PERM_WRITE
Работает уже сейчас (int + битовые операторы), но теряется типобезопасность —
p имеет тип int, не Permission.
C. Newtype над int — type Permission(int), методы .has(...),
.with(...). Безопасно, но многословно, и | не работает без
operator overloading.
Приоритет
Средний. Нужно для backend (HTTP, БД, ОС-вызовы). Решение зависит от того, есть ли в Nova operator overloading для custom-типов.
Связь. Q15 (если оба решаются через derive-макросы), D1 (перегрузка операторов).
Q17. Bootstrap-язык компилятора — Rust
Решение из обсуждения: первый компилятор Nova (v0.1–v1.0) пишется на Rust. После self-hosting (v2.0+) переписывается на Nova.
Почему Rust:
- Лучшая экосистема для компиляторов (LLVM через
inkwell, парсеры черезchumsky/logos, AST через native sum-types) - Pattern matching, sum-types, traits — естественны для PL-кода
- LLM знает Rust очень хорошо — AI-codegen качество максимальное
- Прецедент массовый: Roc, Gleam, Carbon, Mojo, Grain — все на Rust
- Концептуальная близость к Nova (
~T/&T/мономорфизация/sum-types идейно срисованы с Rust)
Почему не C:
- AST на C через
enum + union— в 3-5 раз больше кода + ручная память - Нет exhaustiveness check для switch
- LLM хуже на C-компиляторных задачах
- Memory bugs съедят 30% времени разработки
Почему не OCaml:
- В 1.5x короче Rust для компилятора, но LLM знает хуже
- LLVM bindings слабее
- Меньше потенциальных контрибьюторов
Это не D-решение. Это выбор реализации, как SMT-движок в D24. В дизайн-документе языка не фиксируется — может измениться в зависимости от инструментов и команды.
Связь. D24 (по аналогии — выбор реализации, не дизайна).
Q19. ЗАКРЫТО (2026-05-10) — Trailing-block синтаксис expr { x => body }
Статус: закрыто. Решено ревизией Closure-rev (Plan 19, D43 rev):
- Trailing-block (
f(args) { block }) — разрешён только для callback’ов без параметров (DSL-форма:with_timeout,retry,transaction,region,supervised). - Trailing-fn (
f(args) fn(p) body) — для callback’ов с params, идентично closure-full без имени. - Старая форма
f(args) { x => body }(с параметрами через=>) отменена. - Ответ на исходный вопрос Q19: «общий механизм», но в двух чётких
формах для разных случаев —
{ block }для no-params,fn(p) bodyдля with-params.
Вопрос остаётся в файле как исторический контекст — показывает
эволюцию от Kotlin-style { x => body } к Rust-style разделению.
Q19 (исторический контекст). Trailing-block синтаксис expr { x => body } — общий механизм или фиксированные примитивы?
Контекст. В коде Nova используется паттерн «функция/конструкция + блок в качестве последнего аргумента»:
race {
body(),
sleep(dur).then { throw Timeout } // .then { ... } — trailing block
}
with_timeout(2.seconds) { // trailing block
Db.exec(sql`UPDATE counters SET v = v + 1`)
}
supervised { // trailing block
spawn handle_requests()
}
with Db = real_db { // блок после with
transfer(alice, bob, 100)
}
region { // блок region (D6)
let buf = []f32.with_capacity(1024)
buf.to_owned()
}
Не зафиксировано: это специальный синтаксис языка для structured concurrency / scope primitives, или общий механизм «вызов функции с блоком как последним аргументом» (как Swift/Kotlin trailing closure)?
Варианты
A. Только зафиксированные примитивы языка. Список конструкций с
блоком фиксирован: with, supervised, region, parallel for,
race, select, with_timeout, try_panic (отменён) — каждая
описана в D-решениях. Программист не может делать expr { x => body } для своих функций.
Плюсы:
- Парсер однозначен —
{после имени = блок только для известных конструкций - AI-first: LLM видит конкретные конструкции, не путает с лямбдами
- Минимум синтаксической поверхности
Минусы:
- Расширение языка требует D-решения
- Меньше гибкости для библиотек
B. Общий trailing-closure механизм (Swift/Kotlin/Ruby стиль). Любая функция, последний параметр которой — функция, может быть вызвана с блоком вместо круглых скобок:
fn with_lock[T](lock Mutex, body fn() -> T) -> T => ...
with_lock(my_mutex) { // trailing block
do_work()
}
Плюсы:
- Унифицирует язык:
parallel for,with_timeout, customwith_lock— всё одна форма - Библиотеки могут создавать DSL-подобные конструкции
- Прецедент Swift, Kotlin, Ruby
Минусы:
- Парсер сложнее —
{после идентификатора может быть и блок-литерал, и trailing closure - AI-first: дублирование с обычной лямбдой
f((x) => ...) - Скрытое поведение: что значит
obj.method { x => ... }?
C. Гибрид — ключевые слова и whitelist’ed функции. Зафиксированные
примитивы (вариант A) + явный список stdlib-функций, которые принимают
trailing block (with_lock, with_resource, with_timeout).
Пользовательские функции — только через обычный вызов с лямбдой.
Плюсы:
- Гибкость для stdlib без обобщения
- Парсер всё ещё знает, что является блоком
Минусы:
- Список нужно поддерживать
- Грамматика менее однородна
Моё предложение
Вариант A — только зафиксированные примитивы языка. Причины:
- AI-first: один способ передать closure — обычный аргумент
f((x) => body). Не два способа делать одно. - Парсер однозначен:
{послеwith/supervised/region/race/parallel/select/with_timeout/try— это блок, не record/ handler-литерал. Иначе грамматика становится контекстно-зависимой. - Принцип «не плодить специальные сущности» (D17,
D18, D22) — trailing-closure это
ещё одна синтаксическая роль
{, которая увеличивает поверхность.
.recover { err => ... } в examples/audit.nv был
ошибкой — заменён на handler with Fail[E] = |err| ... { ... }.
Приоритет
Низкий. Сейчас все нужные конструкции (with, supervised,
region, parallel for, race, select, with_timeout) зафиксированы
как примитивы языка. Если возникнет реальный use-case для общего
trailing-closure — пересмотреть. Пока — нет.
Связь. D14 (structured concurrency primitives), D22 (лямбды).
Q18. ЗАКРЫТО — Cycle-detection больше не актуален
Статус: закрыто. D21 отменён в пользу managed memory (D6 пересмотрен). Циклы освобождаются автоматически concurrent GC, никаких compile-time ошибок цикла, никаких suggestion’ов о weak-направлении не нужно.
Вопрос остаётся в файле как исторический контекст — показывает, почему мы отказались от opt-in cycle collection. Дальнейшее тело сохранено, но не актуально для текущего дизайна.
См. D6 (новая версия) и discussion-log этап 13.
Историческое тело (не актуально)
Контекст. D21 фиксирует: цикл через ~T без
~weak — compile error с suggestion. Это уже работает на уровне
дизайна. Открытый вопрос — качество этих сообщений и наличие
lint-режима для поиска циклов в большом проекте.
Зачем нужно
Программисты регулярно создают потенциальные циклы — особенно при рефакторинге. Сейчас в D21 (отменено) зафиксирован формат ошибки:
error: cycle possible in `~T` references between Node and Edge
suggestion: use `~weak` for one direction, or `~&T` to enable cycle collection
Этого недостаточно для AI-first языка. LLM получает короткое сообщение, не видит варианты, не знает какую сторону цикла сделать слабой. Человек тоже теряется.
Что улучшить
1. Расширенный формат ошибки с тремя вариантами:
error: cycle in `~T` references: Tree → children → Tree → parent → Tree
options:
1. Make `parent` weak (typical for trees, leaves owned by root):
parent ~weak Tree
2. Make `children` weak (rare, used when leaves outlive parent):
children []~weak Tree
3. Use `~&T` for both (enables cycle collection, runtime cost):
children []~&Tree, parent ~&Tree
see: docs/memory/cycles.md#trees
LLM или человек видит все варианты с пояснением, делает осознанный выбор. Это AI-first (R5.3) — обучающий сигнал.
2. Lint-режим nova lint --memory-graph:
Анализирует граф типов всего проекта, находит возможные циклы и неоптимальные паттерны. Полезно при рефакторинге крупных систем.
3. Документация docs/memory/cycles.md:
Каталог типичных паттернов с готовыми решениями:
- Деревья (parent → child) —
parent ~weak - Doubly linked list —
tail ~weakилиprev ~weak - Observer / Subscription (publisher ↔ subscriber) —
subscribers []~weak - DOM-like (parent ↔ children + listeners) —
~&T(подходит для cycle collector) - Graph (произвольные циклы) —
~&T
Каждый паттерн со ссылкой на ошибку компилятора, чтобы LLM могла переходить от ошибки к docs автоматически.
4. Stdlib-defaults с явным комментарием:
// в stdlib
type Tree[T] {
value T
children []~Tree[T]
parent ~weak Tree[T] // weak: tree owned top-down, leaves don't outlive root
}
Комментарий объясняет почему именно эта сторона weak — для обучения программиста, использующего stdlib.
Что отвергнуто
Авто-вставка ~weak. Компилятор не может выбрать сторону цикла
без знания домена. Контрпример: дерево vs subscription/publisher —
weak’ом помечается противоположная сторона. Авто-выбор приведёт к
тихим багам с жизнью объектов.
Авто-конверсия ~T → ~&T при цикле. Скрывает performance-импликацию
(cycle collector работает в этой зоне), нарушает real-time гарантии
тихо. D21 опт-ин, не автомат.
Приоритет
Средний. Не блокирует язык, но критично для UX. Без хороших сообщений compile-time проверка циклов превратится в раздражитель, а не помощника. Делается одновременно с реализацией type checker’а (этап 2 roadmap).
Связь. D21, R5.3, roadmap этап 2.
Приоритет
Если возвращаться к работе:
Сначала (закрыть, чтобы продолжать):
- Q1 (унификация методов) — структурный вопрос
- Q8 (обновить документы) — технический долг
- Q12 (concurrency model) — блокирует Q9 и ломает AI-first тезис
Потом (важно для v0.1):
- Q5 (граница Panic)
- Q6 (effect polymorphism)
- Q9 (stdlib) — зависит от Q12, Q13
- Q15 (enum с числами) — частая боль для wire-протоколов
- Q16 (bitflags) — нужно для permissions/options
Можно отложить (детали реализации):
- Q3, Q4 (runtime детали)
- Q7 (macros) — но блокирует Q15 D-вариант
- Q10 (tooling)
- Q11 (имя)
- Q13 (schema evolution как stdlib-паттерн) — описать вместе с Q9
- Q14 (cost-types) — после v1.0
Q20. Нужен ли defer? ✅ ЗАКРЫТО (2026-05-10) D90
Закрыт D90 (2026-05-10): добавлены
deferиerrdeferкак scope-level cleanup statement’ы. Семантика — Zig-style: scope-level (не function-level), LIFO, eager arguments, infallible body, no-suspend.errdeferзапускается только на throw/panic-exit. Решение мотивировано отсутствием RAII в Nova (D6 managed heap) — безdeferresource cleanup пишется через handler-блоки, что многословно.
Контекст. Слово defer присутствует в подсветке VSCode-расширения
(editors/vscode/syntaxes/nova.tmLanguage.json,
editors/vscode/README.md) как зарезервированное
ключевое слово, но семантика в spec/decisions/ не определена. До
формального решения defer использовать нельзя.
Семантика, обсуждавшаяся ранее (Zig/Swift-style):
- Block-scoped (а не function-scoped как Go).
- Срабатывает при
throw, не приpanic(D13/D25). - LIFO.
- Две формы:
defer exprиdefer { ... }. - Эффекты внутри тела defer должны быть в сигнатуре enclosing-функции.
Главный вопрос — нужен ли defer в Nova вообще.
В Nova уже есть два механизма cleanup’а без отдельного defer:
-
Handler-обёртки. Защита ресурса оформляется как функция, принимающая lambda-тело. Cleanup — на выходе из обёртки:
fn with_file[T](path str, body fn(File) Fail[IoError] -> T) Fail[IoError] -> T { let f = File.open(path)!! // Result[File, IoError] → throw IoError let result = body(f) // если throw — handler ловит выше f.close() // обычное закрытие result } with_file("data.txt") { f => f.write(data) }Это более общий механизм — handler видит throw, может откатить транзакцию, обработать как угодно. Согласовано с D10 «всё — handler».
-
region { ... }(D6) — арена освобождается en-masse на выходе из блока, безdefer.
Открытые подвопросы:
- Q20.1. Покрывают ли handler-обёртки реальные use-case’ы, или
остаются практичные сценарии, где
defer x.close()рядом с открытием — заметно эргономичнее? Нужны примеры из реальных программ (придёт с MVP-stdlib и первыми пользователями). - Q20.2. Если
deferвсё-таки вводится — взаимодействие сsupervised(cancel:)(срабатывает ли defer при отмене fiber’а?), с region (порядок: defer’ы сначала, арена потом, или наоборот?), с «двойным throw» (defer в процессе throw сам делает throw — что выигрывает?). - Q20.3. Альтернатива — RAII через protocol-метод
@drop()на типе ресурса. ТипFileопределяетfn File @drop() Io -> (), компилятор вставляет вызов на выходе из скоупа, как в Rust/C++. Это встроеннее в систему типов, чемdefer, и не требует ручного вызова. Рассмотреть как третий вариант.
Статус. До решения — defer не используется в коде/документации
языка. Подсветка VSCode оставлена как форвард-резерв (если решение в
пользу defer’а — менять не придётся; если против — будет небольшое
изменение в syntaxes/).
Связь: D10 (всё — handler), D13 (panic), D25 (throw), D6 (region).
Q21. Управление proliferation эффектов в публичных сигнатурах
Контекст. Типичная backend-функция после нескольких слоёв архитектуры (router → middleware → controller → service → repository) накапливает 5-8 эффектов в публичной сигнатуре:
export fn create_order(req CreateOrderReq)
Db Net Log Trace Fail[OrderError] Time Random
-> OrderResponse
Любое расширение в нижнем слое (например, добавили Cache в
репозиторий) поднимается через все public-функции вверх. Без механизма
группировки это работает как «N-вирусов одновременно» — хуже одиночного
async-вируса в Rust. Автор языка считает это критическим вопросом:
ожидается, что эффектов в реальных программах будут десятки или сотни.
С другой стороны — D28 («public — обязательно явно») и D10 (AI-first, «сигнатура = полное описание») явно требуют, чтобы ничего не пряталось от читателя. Любой механизм группировки балансирует на этом ребре.
Три альтернативы.
Вариант A. Effect aliases через alias-keyword
alias StandardWeb = Db Net Log Trace Fail Time
export fn create_order(req Req) StandardWeb Random -> Resp
StandardWeb — синтаксический сахар, раскрывается компилятором в union
эффектов. Видимость через export alias/private как у других деклараций
(D47). Может ссылаться на другие алиасы.
Плюсы:
- Лаконично в сигнатуре.
- Локально для проекта (каждый проект свои алиасы).
- Расширяется обычным redeclaration алиаса.
Минусы / открытые вопросы:
- Семантика «контракта»: если функция объявила
StandardWeb, но реально использует толькоDb Fail— компилятор разрешает (алиас как ≤-подмножество, но это ослабляет D28 «гарантия чистоты — проверенный факт») или требует все эффекты алиаса использовать (тогда алиас бесполезен)? - Параметризованные эффекты в алиасе (
Fail[E]): что значитalias A = Fail[OrderError],alias B = Fail[UserError], использованиеA B— конфликт, объединение, или ошибка? - AI-first риск: LLM, видя
StandardWeb, не знает её состав без чтения определения — это «−1 файл к локальности контекста». Для пользовательского кода допустимо, для prelude-алиасов опасно. - Стандартные алиасы в prelude (
Web,Cli,BatchJob) — привлекательно, но: устаревает (нужноCache?), не угадать «правильный» набор (REST vs GraphQL vs WebSocket — разные), все программы Nova зависят от выбора. Лучше не делать prelude-алиасов; алиасы — проектный механизм.
Вариант B. Алиас как protocol с композицией
После D18 (эффект = protocol) естественно использовать тот же
механизм:
export protocol StandardWeb : Db, Net, Log, Trace, Fail, Async, Time
export fn create_order(req Req) StandardWeb Random -> Resp
StandardWeb — обычный протокол, расширяющий 7 других. Composition
(A : B, C) — единый механизм для эффектов и для структурных
интерфейсов.
Плюсы:
- Один механизм с D18, не отдельная фича. Меньше концепций.
- Не вводит новой грамматики (только composition между протоколами).
- Hover/doc показывают композицию естественно.
Минусы / открытые вопросы:
- Требует решить семантику protocol composition — это отдельный D, не покрытый D18 в текущей форме. Inheritance vs flat union vs mixin — выбор не сделан.
- Композиция протоколов влияет и на data-протоколы (структурные контракты для типов), не только на эффекты. Большая семантическая поверхность.
- Те же AI-first и параметризационные вопросы, что в варианте A.
Вариант C. Effect rows / row polymorphism
export fn create_order[E](req Req) Db Net | E -> Resp
E — переменная для «остальных эффектов». Caller подставляет конкретные
эффекты под E. Прецедент — Koka, Effekt (где это академически
проверено).
Плюсы:
- Не пакует имена в группы — каждый эффект остаётся явным в той функции, которая его реально использует.
- Решает полиморфные случаи:
map_eff[T, U, E](xs [T], f (T) E -> U) E -> [U]— функция-комбинатор работает с любым эффектом вызываемой лямбды. - AI-first сохраняется:
E— это «остальное», не магическое имя группы.
Минусы / открытые вопросы:
- Сложнее — это полноценный полиморфизм. Реализация дороже.
- Уже частично есть в Q6 «Effect polymorphism — синтаксис». Нужно свести Q6 + Q21.C в одно решение.
- Не решает именования в публичных API — там 7 эффектов всё равно пишутся явно. Подходит больше для библиотечных комбинаторов, чем для backend-сигнатур.
Что не делается (для всех вариантов)
- Subtraction (
Alias \ Effect) — сложная row-typing семантика, не для MVP. - Default-эффекты на модуль — нарушает «сигнатура = полное описание» (D10).
- Effect categories в стиле Helium — сложнее, чем алиасы, без выигрыша.
- Опт-ин на effect inference в public — D28 остаётся, public явный.
Когда решать
Откладываю до первой стадии stdlib (Q9) и первых реальных программ. Сейчас «proliferation» — это прогноз, не измеренная проблема. До MVP неясно:
- Сколько эффектов реально в типичной сигнатуре?
- Какие пакуются в стандартные группы?
- Будут ли row-polymorphic комбинаторы доминировать в stdlib?
Принимать решение в текущем виде — риск ввести неправильную семантику (см. открытые вопросы каждого варианта) или избыточный механизм. Когда начнёт писаться реальный код на Nova, картина прояснится: либо proliferation действительно болит и алиас/protocol-композиция — очевидная победа, либо row-polymorphism в stdlib + 5-7 эффектов в public-сигнатуре оказываются нормой.
Действие сейчас: ничего в спеке не вводить, не использовать ни в примерах, ни в подсветке. Если автор хочет «лёгкое решение прямо сейчас» — рекомендуемый вариант B (protocol composition): дешевле по новой грамматике, опирается на уже принятый D18.
Связь: D18 (эффект = protocol), D28 (public явно), D10 (AI-first), D47 (видимость), Q6 (effect polymorphism — пересекается с вариантом C), Q9 (stdlib — проявит реальную картину proliferation).
Q22. Унификация type / protocol в один keyword? ✅ ЗАКРЫТО (D53, D61)
Финальное решение — гибрид:
- Declaration syntax объединён под единым
type-keyword’ом по D53. Все объявления идут черезtype, с kind-токеном для категории. - Семантика расщеплена по D61:
effectиprotocol— разные kind-токены с разным поведением.effect— поддерживаетwith-substitution (mock в тестах) и continuation-capture (interrupt, throw).protocol— структурный контракт, безwith-substitution.
type Hashable protocol { hash() -> u64 } // структурный контракт
type Logger effect { log(msg str) -> () } // эффект (with-substitution)
type Db effect { query(q Sql) -> []DbRow } // эффект
type any protocol { } // top-type
Анонимный protocol-тип в позиции параметра — protocol { ... } (с
обязательным префиксом, симметрично []T, (A, B), fn() -> T).
D42 помечен revised → D53. Выбор между effect и protocol —
программистский, через два sniff-вопроса (см. D62 правило 4).
Контекст ниже сохранён как историческая справка.
Исходный контекст Q22
Контекст (до D53). После D18-revised и D42 в Nova два keyword’а:
type— данные (record, sum-type, alias).protocol— поведение (эффекты + структурные контракты).
Различаются позицией в репо и формой литералов: у type литерал —
Name { field: value }, у protocol — handler-литерал
Name { op(args) => body }.
Гипотеза. Возможно, достаточно одного type, разрешающего
либо поля, либо методы (но не одновременно). Парсер уже различает
содержимое {...} для литералов (двоеточие vs стрелка) — то же самое
правило могло бы работать и для объявлений.
// data — type с полями
type User { id u64, name str }
// behavior — type только с сигнатурами методов
type Logger { log(msg str) -> () }
type Db {
query(q Sql) -> []DbRow
exec(q Sql) -> ()
}
Аргументы за единый type:
- Один keyword вместо двух — проще грамматика.
- Прецеденты: TypeScript (
interfaceи для полей и для методов), Common Lisp CLOS (defclass). - Содержимое
{...}уже различимо парсером, можно поднять это правило на уровень объявления.
Аргументы против (как сейчас в D18-revised):
- Декларация категории заранее. Данные и поведение — разные категории (значение существует/нет, сериализуемо/нет, расширение = +поле или +метод, подтипирование = по форме или по контракту). Keyword фиксирует категорию явно.
- Прецеденты статически типизированных языков идут в обратную
сторону: Rust
struct/trait, Swiftstruct/protocol, Gostruct/interface. Единый keyword — у структурных/динамических языков (TypeScript runtime — duck typing). - Handler-литералы. Сейчас форма литерала однозначно связана с
keyword объявления: у
type X—X { field: value }, уprotocol X—X { op(args) => body }. С единымtypeLLM может смешать формы в одном литерале (синтаксически корректно для разных имён, семантически бессмыслица). - D42 уже принят — концептуально разделил данные и поведение. Откат к единому keyword требует пересмотра D42.
Тонкие места при унификации:
- Запретить ли смешивание полей и методов в одном
{...}? (Иначе получится «класс» — нежелательно, противоречит «protocols + data».) - Что с пустым
type X { }— данные без полей или поведение без операций? Сейчасtype X = ()для unit,protocol X { }теоретически пустой контракт. - Параметризация
Fail[E]— она у data-типа или у protocol’а? (Сейчасprotocol Fail[E], и это согласовано с другими параметрическими протоколами.)
Решение пока: не трогать. D18-revised только что прошёл по spec/ и примерам, аргументы в пользу унификации риторические («одного keyword’а хватит»), без измеренной болевой точки. Оставить как открытый вопрос — если в реальном коде Nova появится систематическое неудобство от двух keyword’ов, вернуться.
Связь: D18 (эффект через protocol),
D42 (разделение данные/поведение),
D17 (объявление типов без =).
Q23. Группировка методов: methods Type { ... }-блок (Rust impl-style)
Контекст. Сейчас методы типа объявляются через
D35 — отдельные fn Type @method(...)
декларации:
type Account { readonly id u64, balance money, closed bool }
fn Account.new(owner str) -> Account => ...
fn Account @balance_pct(of money) -> f64 => @balance / of * 100.0
fn Account @is_solvent() -> bool => !@closed && @balance > 0
fn Account mut @deposit(amount money) => @balance += amount
fn Account mut @withdraw(amount money) Fail[Overdraft] => ...
Имя типа повторяется на каждом методе. Для типа с 15 методами — 16 повторений. Локальность теряется: тип и его поведение визуально разнесены, особенно когда методы рассеяны по файлу.
Предложение. Ввести methods Type { ... }-блок (как impl Type
в Rust):
type Account { readonly id u64, balance money, closed bool }
methods Account {
fn new(owner str) -> Account =>
Account { id: ids.next(), balance: 0, closed: false }
fn @balance_pct(of money) -> f64 =>
@balance / of * 100.0
fn @is_solvent() -> bool =>
!@closed && @balance > 0
fn mut @deposit(amount money) =>
@balance += amount
fn mut @withdraw(amount money) Fail[Overdraft] =>
if amount > @balance { throw Overdraft }
@balance -= amount
}
Имя типа задаётся блоком. Внутри — fn name(...) для static-функций,
fn @name(...) для методов инстанса, fn mut @name(...) для
мутирующих. Это тот же @-синтаксис D35, просто сгруппирован.
Альтернативы (расширенный контекст).
- (a) Оставить как сейчас — раздельные
fn Type @method. Минимум концепций, но плохая локальность. - (b)
methods Type { ... }-блок — это предложение. - (c) Методы внутри
typeблока (Java/Kotlin/Swift-стиль) — сильнее всего ломает текущую модель:typeстановится «данные + методы» (полу-класс), grow-pressure на наследование, путаницаtype/protocolдля эффектов.
Вариант (c) явно отвергнут: возврат методов в type размывает D1
(«protocols + data, без классов»), создаёт два механизма для
поведения (type с методами и protocol), и порождает семантические
дыры (что значит «эффект внутри метода type?»).
Вариант (b) — компромисс, дающий локальность без слома модели.
Что даёт (b):
-
Локальность. Тип и методы в одном месте файла.
-
Группировка по теме. Несколько
methods-блоков для одного типа — конструкторы отдельно, базовые операции отдельно, conditional methods (если введут bounds) отдельно. Прецедент Rust. -
Extension methods из чужого модуля видны явно.
import HashMap from std methods HashMap[K, V] where K: Json, V: Json { fn @to_json() -> str => ... }Сейчас в Nova расширение чужого типа делается через
fn ForeignType @methodгде-нибудь в своём модуле — визуально неотличимо от «своих» методов. С блоком — заявка явная. -
Совместимость с
protocol.type/protocolостаются раздельными. Структурная совместимость работает как сейчас: компилятор смотрит, какие методы определены у типа (черезmethodsили старый стиль), и сравнивает с протоколом. -
D1, D17, D42 не меняются.
typeостаётся чистым (только данные),protocol— чистым контрактом. Меняется только D35: методы переезжают вmethods-блок.
Тонкие места:
-
Один способ или два? Текущий стиль
fn Type @methodостаётся валидным или нет?- Только блок — чище, но breaking change для существующих spec/examples (кода мало, миграция дешёвая).
- Оба разрешены — совместимо, но «два способа одного» нарушает AI-first.
Рекомендация: только
methods-блок, миграция один раз. -
Static vs instance в блоке. Та же разметка:
fn name(...)— static (конструктор/factory),fn @name(...)— instance,fn mut @name(...)— mutating instance. Без новых правил. -
Множественные блоки для одного типа. Разрешены (как Rust многократный
impl). Программист сам группирует по теме. -
where-clauses для conditional methods — зависит от Q-bounds. Если bounds в MVP нет,whereтоже нет, conditional откладывается. -
Visibility. Каждый метод сам декларирует
export/private (D47). Блокmethodsсвою видимость не имеет. -
Embed/delegation (D39). Прокси- методы при
use Typeгенерируются на основе всехmethods-блоков типа. Override метода — отдельный метод во внешнемmethods-блоке обёртки, явный вызов@Inner.method()для делегации.
Прецеденты:
- Rust
impl Type { ... }— ровно эта идиома, 10+ лет, любят. - Swift
extension Type { ... }— то же для своих и чужих типов. - Kotlin — методы внутри
class+ extension functions снаружи (два способа, что у нас неприемлемо). - Go —
func (r Type) method()отдельно, как Nova сейчас. Жалоба сообщества — нет визуальной группировки.
Цена:
- Новый keyword
methods. Короткий, читаемый, семантически точен. - Миграция всех существующих
fn Type @methodвmethods-блоки — делается раз, кода пока мало. - Семантика множественных блоков для одного типа должна быть зафиксирована.
Решение пока: не трогать. Текущий стиль (D35) рабочий, breaking
change без измеренного болевого опыта рискован. Когда появится первый
средний по размеру тип (например, Vec[T] или HashMap[K, V] в
stdlib) с 20+ методами, локальность станет реальной проблемой —
тогда вернуться к выбору (b) vs текущее.
Связь: D1 (protocols + data, без
классов — Q23 это сохраняет), D17
(type для данных), D35 (методы через
@ — Q23 переселяет их в блок), D39
(embed/delegation на основе методов), D42
(protocol остаётся раздельным), D47
(видимость), Q22 (унификация type/protocol — связанный, но
ортогональный).
Q-bounds. Синтаксис bounds на дженериках (если будут)
✅ CLOSED by D72. Bounds приняты, синтаксис
[T Hashable]без двоеточия, единое правило «name type». Текст ниже — историческая справка.
Контекст. В MVP bounds на дженерики отвергнуты (02-types.md → D42, open-questions D42: «сейчас параметр без bound, компилятор полагается на структурное соответствие при использовании»; history/rejected.md: «[T: Bound] отвергнут в MVP»). Если/когда bounds будут вводиться, нужно зафиксировать синтаксис.
Главное правило, которое уже есть в языке. Двоеточие в Nova —
только разделитель ключ-значение (record-литералы, dict-литералы,
02-types.md → D17). В аннотациях типов
двоеточия нет: u User, не u: User. Параметр T с указанным
контрактом — это аннотация типа, не key-value.
Рекомендуемый синтаксис, если bounds появятся.
fn all[T FromRow]() Db Fail -> []T
fn map[K Hashable, V](m HashMap[K, V]) -> ...
Без двоеточия, по правилу «имя тип» — единый стиль с параметрами
функции (x int), полями record (id u64), let-bindings
(let x int = 5), for-loops (for x int in xs), embed
(use w HashMapIter[K, V]).
Что отвергается заранее:
[T: FromRow](Rust/Scala/Kotlin) — конфликтует с D17.[T where FromRow](C#-style) — многословно.[T impl FromRow](Swiftsome-style) — нестандартно.
Тонкие места:
- Несколько bounds на один параметр —
[T FromRow & Hashable]?[T (FromRow, Hashable)]? Лучше — анонимный structural type или композиция протоколов. - Связь с эффектами в bounds — может ли protocol-bound включать
эффект-операции? (Эффекты — это
protocol, D18, так что технически да.) - Что бывает с уже принятыми решениями про anonymous structural
type в позиции параметра (D42):
fn f(x { show() -> str })— можно ли это перенести в bound:fn f[T { show() -> str }]()?
Статус. Открыт как «предзафиксированная форма» — если bounds
будут, использовать [T Bound] без двоеточия. Целиком решение о
вводе bounds откладывается до post-MVP.
Q-self-mandatory. Обязательное использование Self где валидно
Контекст. D66 разрешает Self в любом
type-контексте (методы, static-функции, protocol, effect). Сейчас
программист может писать либо Self, либо явное имя типа:
fn Box[T].of(v T) -> Self => ... // Self
fn Box[T].of(v T) -> Box[T] => ... // явное имя — тоже валидно
Предложение. Сделать Self обязательным там где он валиден.
Использование явного имени типа — compile error или линт-warning.
Аргументы за:
- DRY жёстче. Имя типа никогда не повторяется → переименование
Box → Containerточечное. - AI-консистентность. LLM не «забывает» дописать generic-
параметры (
Box[T]vsBox). - Один способ (D40).
Аргументы против:
- Имя типа читается лучше для коротких типов.
User,Box— нагляднее чемSelf. Self экономит для generic’ов с параметрами. - Прецедентов нет. Rust, Swift, Scala —
Selfвсегда опционален. Strict-Self нет ни в одном языке. - Конфликт с существующим стилем. Большая часть spec/examples написана с явными именами типов. Миграция значительная.
- AI-training data. LLM обучен на языках без обязательного
Self— генерирует имена типов. Hard-rule вызовет постоянные compile errors при first generation. - Гибче через линтер. Если хочется единообразия — это linter-warning, не language-rule. Программист отключает локально при необходимости.
Варианты решения:
A. Hard-rule в языке. fn Box.of(v T) -> Box[T] — compile error
«use Self». Жёстко, но единообразно.
B. Линт-warning по умолчанию. Линтер предупреждает «здесь можно
Self», программист игнорирует/исправляет. Гибко.
C. Style-guide рекомендация. Прописать в convention’ах: «для
методов с явным receiver’ом и static-конструкторов используй
Self», без linter enforcement.
D. Оставить как есть. Обе формы валидны без рекомендации.
Тонкие места:
- Свободные функции с generic-параметрами не имеют receiver’а —
Selfзапрещён по D66. Hard-rule не применим в свободных функциях. - Bound
[T From[Self]]в свободной функции —Selfзапрещён, нужно явное имя или другой generic. Это уже исключение. - Sum-варианты —
fn Tree @clone() -> Selfвалиден, но в телеmatch @ { Leaf => Leaf, ... }нельзяLeaf => Self.Leaf(конструктор). Self применим только в типовых позициях. - Миграция существующих файлов — большая часть spec/decisions/, examples/, nova_tests/ написаны с явными именами. Hard-rule потребует масштабного sweep.
Статус. Не зафиксировано. Склонность — (B) линт-warning: сохраняет гибкость, даёт DRY-win опционально, не ломает существующий код. Решение откладывается до появления линтера в toolchain.
Связь: D66 (Self universal),
D40 (один способ), D72
(bounds, где Self запрещён в свободных функциях).
Q-anon-effect. Анонимный эффект в позиции эффекта
Контекст. D42 разрешает анонимный структурный тип в позиции параметра:
fn log_one(x { show() -> str }) Log -> () =>
Log.info(x.show())
После D18-revised
эффект — это protocol. По симметрии:
fn log_one(s str) { log(msg str) -> () } -> () =>
log("...") // анонимный эффект между ) и ->
Допускать ли это? Удобно при «одноразовом» эффекте без отдельного
объявления. Но границы между параметрами и анонимным эффектом могут
читаться плохо: fn f(x { ... }) { ... } -> () — два {...} подряд,
парсер должен различить «структурный параметр» и «анонимный эффект».
Статус. Решение отложено. На MVP — анонимные эффекты запрещены,
эффект всегда именованный protocol.
Q-field-tags. Метаданные на полях типов (аналог Go struct tags)
Контекст. В Go теги вида `json:"id"` решают связку «имя поля
в коде ↔ имя в wire-формате» для JSON, БД, валидации, и доступны через
runtime reflection. В Nova сейчас такого механизма нет — маппинг
делается ручными функциями (fn User.from_row(r) -> User => ...,
fn User @to_json() -> Json => ...) или handler-литералами.
Для record с 5-10 полями ручной маппинг — приемлемая цена и AI-friendly прозрачность. Для record с 30+ полями (типичный backend domain-model) — заметный boilerplate, который накапливается на каждом формате (Json/Sql/Validate/Yaml/Protobuf).
Что в других языках:
| Язык | Механизм | Где живёт |
|---|---|---|
| Go | `json:"id"` после поля | runtime reflection |
| Rust | #[serde(rename = "id")] | compile-time macros (derive) |
| Swift | enum CodingKeys: String, CodingKey | compile-time через protocol |
| Kotlin | @SerialName("id") + serialization plugin | compile-time |
| OCaml | [@@deriving yojson] | compile-time PPX |
| C# / Java | [JsonPropertyName("id")] / @JsonProperty | runtime reflection |
Современные типизированные языки в подавляющем большинстве идут по пути compile-time derive, не runtime reflection. Это согласовано с направлением Nova (всё видно в типах, никакой невидимой runtime-магии).
Варианты:
A. Не вводить, оставить ручной маппинг. Прозрачно, никакой магии, полный контроль. Цена — boilerplate для больших record’ов и дублирование при многоформатной сериализации.
B. Compile-time атрибуты + derive-макросы (Rust serde-style). Атрибут на поле и/или типе:
@derive(Json, FromRow)
type User {
readonly id u64 @json("id") @sql("user_id")
name str @json("name") @sql("full_name")
email str @json("email") @sql("email")
internal_token str @json(skip) @sql(skip)
}
Атрибуты — compile-time литералы, доступные только comptime-функциям.
Сами по себе ничего не делают; @derive(Json) запускает
comptime-функцию, которая читает атрибуты и генерирует
fn User @to_json() -> Json, fn User.from_json(j Json) -> User.
Раскрытие генерации обязано быть видимым через тулинг
(nova check --show-derive User) — это сохраняет AI-first прозрачность,
LLM может посмотреть «что на самом деле вызывается».
Зависит от Q7 (macros/comptime) — без него вариант B нереализуем.
C. Schema-объект как first-class value. Программист руками объявляет схему рядом с типом:
type User {
readonly id u64
name str
email str
}
let user_json_schema = JsonSchema[User] {
field("id", (u) => u.id, (u, v) => User { ..u, id: v })
field("name", (u) => u.name, (u, v) => User { ..u, name: v })
field("email", (u) => u.email, (u, v) => User { ..u, email: v })
}
Никаких атрибутов на полях. Schema — обычный Nova-объект, читается
функциями Json.encode_with(user, user_json_schema). Цена —
multiline-объявление вместо одного-двух тегов на поле, дублирование
имён полей в schema.
Не зависит от других open-questions, можно зафиксировать сейчас.
D. Runtime reflection (как Go, Java, C#). Отвергается заранее:
- Противоречит AI-first: тег
json:"id"ничего не делает «сам по себе», нужно знать какую-то библиотеку «снаружи кода». LLM, читая поле, не видит активную конструкцию. - Несовместимо с принципом «всё видно в типах» — теги это out-of-band метаданные, которые компилятор не валидирует.
- Несовместимо с capability-режимом и effect-видимостью — reflection обходит эффект-систему.
Тонкие места:
-
Несколько потребителей одного поля (
json+sql+validate). В B — несколько атрибутов рядом с полем, шумно но локально. В C — несколько отдельных schema-объектов, поле повторяется по числу форматов. Что лучше — открыто. -
Skip-семантика. Поля, которые не сериализуем: отдельный atom
@json(skip), отсутствие атрибута → не включать, или маркер_prefix(D47 convention) → не включать? -
Default-имя. Если 95% полей сохраняют имя один-к-одному (snake_case в коде ↔ snake_case в wire), не нужно ли по умолчанию маппить, отмечать атрибутом только исключения? Это сильно срезает boilerplate.
-
Имена derive’ов.
@derive(Json)или@derive(Encoder[Json])или@derive(SerializableTo[Json])? Упирается в дизайн stdlib (Q9). -
Composability с эффектами. Если
Json.encodeимеет эффекты (например,Fail[EncodeError]) — генерируемая функция должна правильно их пробрасывать. Это согласуется с D28 (вывод эффектов в private), но требует, чтобы comptime-генератор корректно вычислял эффект-сигнатуру. -
Совместимость с
readonly/mut-полями (D36). При decode’е из wire-формата нужно создавать новыйUser(потому чтоid—readonly), не мутировать существующий. Генератор должен это учитывать.
Связь.
- Q7 (macros/comptime) — блокирует вариант B.
- Q9 (стандартная библиотека) — определяет
Json,FromRow,Validateи т.п. - Q15 (representation tags для enum’ов) — смежная задача, тоже про сериализацию, может решаться тем же механизмом.
Статус. Открыт. Рекомендуемый путь:
- Сейчас — оставить вариант A (ручной маппинг). Это работает, у Nova нет реализации, спешить некуда.
- После Q7 — вернуться, рассмотреть B+C: атрибуты для частых случаев, schema для сложных. Это не взаимоисключающие варианты.
- Никогда — вариант D (runtime reflection).
Q-anonymous-union. Anonymous unions (TS-style string | number) без обёрток
Контекст. Сейчас sum-тип в Nova (D52) требует именованные конструкторы:
type StrOrInt | S(str) | I(int)
let x StrOrInt = S("alice")
С D55 literal coercion программист пишет
просто let x StrOrInt = "alice" (компилятор оборачивает в S). Но
тип всё равно остаётся sum-обёрткой, а не «string или number».
TypeScript/Scala 3 имеют anonymous unions без обёрток:
type StrOrInt = string | number; // tip = string ИЛИ number
let x: StrOrInt = "alice"; // тип x — string, не обёртка
Здесь string — подтип string | number. Это subtyping,
которого в Nova сейчас нет (только структурная типизация для
protocol’ов).
Альтернативы синтаксиса для Nova (если когда-то введём):
A. С маркером type для existing types:
type IntOrStr | type int | type str
type Maybe[T] | type T | None
Парсер однозначен — type X = «existing type», без type = «новый
конструктор». Не ломает текущий синтаксис sum’ов.
B. Со скобками:
type IntOrStr | (int) | (str)
Двусмысленно с tuple-конструкторами одного поля.
C. Не вводить. Использовать D55 coercion + named sum’ы как
сейчас (type StrOrInt | S(str) | I(int)). Громоздко при объявлении,
но coercion убирает шум при использовании.
Главные минусы введения:
- Subtyping — серьёзное расширение системы типов. Нужно решить variance, type inference, exhaustiveness, dispatch. Сейчас Nova эти концепции не имеет.
- Runtime-cost. Каждое значение
IntOrStrнесёт runtime-tag (иначеis-проверка не работает). Boxing на границах. Для статически типизированного языка — реальная цена. - Прецедентов мало. TS (бесплатно в JS-runtime), Scala 3 (с cost). Большинство строго типизированных языков (Rust, Swift, Kotlin, F#, OCaml, Haskell) не вводят anonymous unions — используют named variants.
Решение пока: не вводить. D55 coercion + named sum покрывают большинство use-case’ов. Если в реальном Nova-коде накопится измеренная боль от обёрток — вернуться.
Связь: D52,
D55, D54
(is-pattern уже даёт runtime type-check для any).
Q-stdlib-data-types. SqlValue, JsonValue, Sql, теги sql/json в stdlib
SQL-часть — эталонная реализация в examples/stdlib_sql.nv
и применение в examples/orm_demo.nv.
Окончательная фиксация в prelude (D26) — отдельным D-блоком после
v1.0-stdlib.
JSON-часть — открыта. Number representation и Object representation не зафиксированы (см. подвопросы ниже).
Контекст. D48 фиксирует tagged
template literals и стандартные теги json, sql, regex, bytes.
Но возвращаемые типы этих тегов и их структура — не определены.
С введением D55 (literal coercion) типизация SQL-аргументов через closed sum становится практичной:
// Кандидат для prelude:
type SqlValue
| I(i64)
| F(f64)
| S(str)
| B(bool)
| Bytes([]byte)
| Null
type Sql {
template str // "SELECT * FROM users WHERE id = ?"
args []SqlValue // [I(42)]
}
// Tag-функция:
fn sql(parts []str, args []SqlValue) -> Sql =>
Sql {
template: parts.join("?"),
args
}
// Использование (через D55 coercion):
let q = sql`SELECT * FROM users WHERE id = ${user_id}`
let users = Db.query(q)
Db.query(sql`... ${42} ... ${"alice"}`) // безопасно, без injection
Аналогично для JSON:
type JsonValue
| Null
| Bool(bool)
| Number(...) // f64? i64+f64? opaque Number?
| String(str)
| Array([]JsonValue)
| Object(HashMap[str, JsonValue])
let j JsonValue = json`{"name": "${user}", "age": ${age}}`
Открытые вопросы:
- Number representation в JsonValue.
Number(f64)теряет int-precision.Int(i64) | Float(f64)точнее, но coercion42ambiguous (i64 или f64?).Number(NumberKind)гдеNumberKind | I(i64) | F(f64)— двухуровневое, гибко но громоздко. Прецеденты: Rust serde_json — opaqueNumberс методамиas_i64()/as_f64(). - Object representation.
HashMap[str, JsonValue]теряет порядок ключей. Альтернатива —[]Field. Большинство JSON-парсеров используют HashMap. - Compile-time JSON-парсинг через
json\…“. Нужен Q7 (macros/comptime), без него — runtime. Db.queryсигнатура. ✓ Решено: черезSql-тег.fn Db.query(q Sql) Fail[DbError] -> []DbRow,fn Db.exec(q Sql) Fail[DbError] -> int. Это единая публичная сигнатура — все usage’и черезsql\…`илиSql.builder().build()(динамические запросы). Прямого пути с raw-string'ом и отдельным[]SqlValue-массивом не осталось. Эталон —examples/stdlib_sql.nv`.- Где разместить. В prelude (D26)
или в stdlib-модулях (
std.sql,std.json)? Гибрид:Option/Result— prelude,SqlValue/JsonValue— модули?
Решение пока: не вводить в prelude. Зафиксировать как часть Q9 (stdlib). При работе над Q9 решить все 5 пунктов.
Связь: D48 (tagged templates), D55 (coercion делает это эргономичным), D26 (prelude), Q9.
Q-numeric-coercion. Coercion числовых литералов через D55
⏸ DEFERRED — ждёт JsonValue (Plan 17 Ф.3, 2026-05-08). Rationale: сквозная coercion (D44 numeric + D55 sum) полезна для
JsonValue | Number(f64) | ...и подобных, но без зафиксированногоJsonValue-типа в stdlib главного use-case’а нет. Текущий exact-match строже, не ломает existing код. Trigger: добавлениеJsonValueилиConfigvalueв stdlib (Q-stdlib-data-types) — первый случай, где coerce даст реальное упрощение ({ "k": 42 }безNumber(42.0)).
Контекст. D55 ввёл literal coercion для sum-конструкторов с exact match типа значения. Тонкость возникает с числовыми литералами:
type Wrapper | F(f64)
let w Wrapper = 1.5 // ok — F(1.5), exact match (f64)
let w Wrapper = 42 // ??? 42 это int, не f64
D44 разрешает literal coercion для числовых типов в позиции с явной аннотацией:
let x u8 = 200 // 200 как u8 (D44)
let y f64 = 42 // 42 как f64 (D44)
Вопрос: работает ли эта литеральная coercion сквозь D55?
let w Wrapper = 42 // ⇒ w = F(42 as f64)? или ОШИБКА?
Альтернативы:
A. Сквозная coercion (D44 + D55 комбинируются). Литерал 42
подгоняется под f64 в позиции F(f64)-параметра. Эргономично,
но добавляет цепочку конверсий — D55 говорит «exact match».
B. Только exact match. Программист пишет 42 as f64 или
42.0:
let w Wrapper = 42 as f64 // явно
let w Wrapper = 42.0 // float-литерал
Строже, без неожиданностей. Но громоздче.
C. Только для числовых литералов. Literal coercion (D44) работает для int↔int, int↔float; D55 видит уже «адаптированный» литерал. Это частный случай A, но ограниченный литералами (не переменными).
Проблема для JsonValue:
type JsonValue | ... | Number(f64) | ...
let j JsonValue = 42 // что: Number(42 as f64) или ОШИБКА?
Без сквозной coercion JsonValue неэргономичен — каждое целое
требует 42.0 или 42 as f64. С coercion — 42 работает.
Решение пока: отложено до решения по JsonValue (Q-stdlib-data-types).
Сейчас D55 строго требует exact match. Если JSON/SQL покажет реальную
боль — расширить D55 до варианта C (literal-only через D44).
Связь: D44, D55, Q-stdlib-data-types.
Q-style-coercion. Когда применять D55 coercion, когда писать явно?
✅ CLOSED by D55 → «Style-guide» (Plan 17 Ф.1, 2026-05-08): permissive (вариант A) с формализованными рекомендациями (вариант B для линтера).
nova fmtне переписывает между формами — выбор стилистический. Сводка-таблица «coerce / явный» добавлена в D55.
Контекст. D55 ввёл literal coercion в позиции с явным целевым типом. Это opt-in эргономика — старая форма (явные конструкторы и имена типов) тоже валидна и работает. Получаются два равнозначных написания одного и того же:
// Sum-coercion
let m Maybe[int] = 42 // ✓ coercion
let m Maybe[int] = Just(42) // ✓ явный, тоже валидно
// Record-coercion
let u User = { id: 2, name: "Bob" } // ✓ coercion
let u User = User { id: 2, name: "Bob" } // ✓ явный, тоже валидно
fn make() -> Duration => { nanos: 100 } // ✓ coercion
fn make() -> Duration => Duration { nanos: 100 } // ✓ явный
Это classical style-vs-mandate вопрос, не зафиксированный в D55.
Реальные case’ы из миграции examples/:
-
Однозначно лучше с coercion:
export fn Duration.from_secs(n i64) -> Duration => { nanos: n * 1e9 } // ^^^ имя из аннотации, чище -
Явный конструктор лучше из-за визуальной симметрии в match:
match @buckets[idx] { Occupied { value } => Some(value) // лучше Some(value) _ => None // ← unit, не coerce'ится } // С coercion: `value` слева, `None` справа — асимметрично, читать сложнее. -
Явный конструктор лучше в
letбез аннотации:let ip_value = if e.ip != "" { Some(e.ip) } else { None } // нет аннотации — coercion не работает; нужны явные Some/None. -
Спорно —
{ {...} }после else:else { Money { amount: a + b, currency: c } } // явный else { { amount: a + b, currency: c } } // coerce — `{ {...}}` шумно
Альтернативы политики:
A. Permissive (текущее). Программист сам выбирает — coercion или явно. D55 разрешает оба.
- Плюс: гибкость, читаемость per-case.
- Минус: inconsistency — в кодовой базе одно и то же пишется по-разному. Code review устаёт. LLM генерирует то так, то так.
B. Style guide (рекомендация). D55 разрешает оба, но стиль рекомендует одну форму:
- expression-body return: предпочитать coercion (короче).
- match-веточки с unit-альтернативой (Some/None): явные конструкторы (visual symmetry).
letбез аннотации: явный конструктор (других вариантов нет).- Сложные nested-литералы (
{ {...} }): явный для ясности.
Это не правило компилятора, а guideline для nova fmt/линтера и
code review.
C. Mandatory coercion. Запретить явный конструктор там, где coercion применим — компилятор ругается «излишняя обёртка». Жёсткая форма, единая.
- Плюс: zero ambiguity, единый стиль везде.
- Минус: ломает практичность (case 3 выше — нет аннотации, нельзя
без Some), требует разрешения для
letбез аннотации.
D. Mandatory explicit. Запретить coercion — программист всегда пишет имя.
- Плюс: explicit always.
- Минус: убивает D55 целиком, теряем эргономику prelude-типов.
Решение пока: A (permissive). При работе над nova fmt/style
guide вернуться к B — формализовать рекомендации. C/D — слишком
жёстко, ограничивает практический код.
Тонкости для guideline (если введём B):
- expression-body с явным
-> T: предпочитать coercion (короче). let x T = ...с аннотацией: предпочитать coercion.let x = ...без аннотации: явный конструктор обязателен.- match-arms: unit-варианты (None, Empty) не coerce’ятся, для визуальной симметрии писать все ветки с явным конструктором.
{ {...} }(block + record-литерал): писать явный имя для ясности (избегать визуально шумного{ {...}}).- call-site аргументы коллекций:
[42, "alice"]для[]SqlValue— coercion лучше (нет[I(42), S("alice")]-шума). - nested coercion:
let r Result[User, str] = { id: 2, name: "Bob" }— двойная coercion (record + sum), явный был быOk(User { ... }). Coercion значительно короче.
Связь: D55, Q-anonymous-union, Q-numeric-coercion (связаны), Q9 (style guide как часть tooling в v1.0).
Q-array-api. Формальный API []T — что встроено, что расширяется
✅ CLOSED by D38 → «Built-in API для
[]T» (Plan 17 Ф.1, 2026-05-08): зафиксирован минимальный built-in API (len/cap/is_empty,[i]/get,push/pop,iter/for, static-конструкторыnew/with_capacity/filled) и список текущих stdlib extensions (map/filter/fold/any/all/first/last). Slicingxs[a..b]остаётся отложенным (Q-array-slicing). Embeduse []T— разрешён по D39.Update 2026-05-28 (Plan 91.7, D181): Все mut-методы (push/reserve/truncate/fill/copy_from/extend_from/ insert_from/copy_within) теперь возвращают
@(fluent chain, D131).@slice(from, to)удалён (Plan 96.1 —arr[a..b]единственный путь).[]T.new()/[]T.with_capacity(n)подтверждены как canonical (D180). Generic[T Ord] @sort()/@min/@max/@binary_search— followup[M-91.7-sort-generic].
Контекст. []T — встроенная конструкция языка (D27).
По D32 runtime-представление —
(ptr, len, cap)-структура. В примерах spec/ и examples/
используются: xs.len, xs.push(x), []T.with_capacity(n),
xs.iter(), и т.д. Но формального D-решения про API []T
нет — это используется «по умолчанию», без зафиксированного списка.
Вопросы:
Q-array-api.1. Что входит в API []T
Из практики и примеров видны следующие операции — нужно зафиксировать полный список:
Геттеры:
xs.len— количество элементов (поле или метод? сейчас как поле).xs.cap— capacity (выделенная память).xs.is_empty—len == 0(для удобства).
Конструкторы (static-функции на типе []T):
[]T.with_capacity(n int) -> []T— выделить с capacity n, len 0.[]T.alloc(n int) -> []T— выделить с len n (заполнено default-T). Не уверен, что зафиксировано. См. Q-array-api.4.[]T.from(other []T) -> []T— копия (shallow clone).
Мутирующие:
mut xs.push(item T) -> ()— добавить в конец, grow при переполнении.mut xs.pop() -> Option[T]— удалить с конца.mut xs.clear() -> ()— обнулить len, capacity сохранить.mut xs.insert(i int, item T) -> ()— вставить по индексу.mut xs.remove(i int) -> Option[T]— удалить по индексу.
Итерация:
xs.iter() -> Iter[T]— итератор по элементам.for x in xs { ... }— синтаксический сахар надiter().
Доступ:
xs[i]— индексирование, panic при out-of-bounds (D13).xs.get(i int) -> Option[T]— безопасный доступ.
Slicing (если есть):
xs[a..b]— slice. Возвращает[]Tбез копирования (zero-cost). Не зафиксировано.
Q-array-api.2. Можно ли расширять []T методами через fn []T @custom()
Да — программист может объявить собственный метод на []T, как на
любом типе:
fn []T @sum_int() -> int where T = int => // bound пока нет, см. Q-bounds
@fold(0) { (acc, x) => acc + x }
fn []f64 @average() -> f64 =>
@fold(0.0) { (a, x) => a + x } / (@len as f64)
Это валидно по D35 — методы на типе
через fn Type @method. []T — тип, расширение работает. Нужно
зафиксировать формально, что встроенные конструкции (массивы,
tuples) подлежат расширению так же, как именованные типы.
Q-array-api.3. use []T в record (D39 на встроенные типы)
Может ли record-тип использовать use []T для прокси-делегации?
type Holder[T] {
use data []T
extra str
}
let h = Holder[int] { data: [1, 2, 3], extra: "info" }
let n = h.len // прокси к data.len через D39
h.push(42) // прокси к data.push
D39 написан под именованные типы
(use Account). Распространение на встроенные конструкции ([]T,
tuples) — естественное расширение, но не зафиксировано формально.
См. clarification в D39.
Q-array-api.4. []T.alloc(n) vs []T.with_capacity(n) — разница
Из текущих примеров используются оба:
[]Slot[K, V].with_capacity(cap)(decisions/03-syntax.md → D38).[]T.alloc(n)(мой usage вexamples/stdlib_vec.nv).
Если оба существуют:
with_capacity(n)— len=0, cap=n. Push не реаллокирует, пока len < cap.alloc(n)— len=n, cap=n. Все элементы инициализированы default-T.
Для default-T нужен механизм default-значения. Либо Default-protocol,
либо требование bound’а на T. Не зафиксировано.
Возможно, alloc(n) вообще не нужен — пользовательские структуры
(HashMap, Vec) делают with_capacity и заполняют сами.
Q-array-api.5. Slicing — есть ли xs[a..b] ✅ ЗАКРЫТО Plan 96 / D144
Range-индексирование xs[a..b] реализовано Plan 96 (2026-05-23) —
sub-slice view, без копии backing. 5 форм Range (Rust RangeBounds
parity): a..b/a..=b/a../..b/... Также str[a..b]
(codepoint-indexed, panic при OOB). Полная семантика —
D144.
Решение пока
Не вводить отдельный D — это часть Q9 (stdlib). При работе над
stdlib уточнить полный API []T и зафиксировать одним D-блоком.
Сейчас: считать API []T де-факто включающим
len/cap/push/pop/with_capacity/iter/get/[i] (по
текущим примерам). Расширение через fn []T @method разрешено по
D35. Embed use []T в record — clarification в D39.
Связь: D27 (синтаксис массивов),
D32 (runtime-представление),
D35 (методы на типе),
D38 (turbofish для конструкторов),
D39 (use-delegation), Q9 (stdlib),
Q-bounds (где []T методы используют T-constraint’ы).
Q-embed-syntax. Embed-keyword — use vs альтернативы
Контекст. D39 (revised) фиксирует
embed через use name Type — alias-имя поля обязательно:
type AuditedAccount {
use account Account // имя поля = "account"
audit_log []AuditEntry
}
Этот вопрос — про выбор keyword’а (use), не про обязательность
имени (она зафиксирована в D39).
use — multi-purpose: и для embed здесь, и потенциально для
импортов/локальных алиасов в будущем (D29 использует import, но
use тоже рассматривался — например, как Rust use std::io). Это
создаёт перегрузку семантики keyword’а.
Альтернативы
A. Текущий D39 — use name Type
type AuditedAccount {
use account Account
audit_log []AuditEntry
}
type Wrapper { use w HashMapIter[K, V] }
За: проверенный, кода уже написано.
Против: use многозначен (embed, потенциально импорты, scope-
локальные aliases).
B. Go-style — голый тип без keyword (с обязательным alias)
После нового D39 имя поля обязательно везде, поэтому Go-style без keyword’а выглядел бы так:
type AuditedAccount {
account Account // обычная запись поля!
audit_log []AuditEntry
}
Но это превращает embed в обычное поле — синтаксически неотличимо. Для активации delegation нужен специальный токен. Голый тип (Go-style без keyword’а и без alias’а) несовместим с обязательным alias’ом из D39 — теряется единственный синтаксический маркер «это embed, а не обычное поле».
Чтобы спасти этот вариант, нужен какой-то маркер:
type AuditedAccount {
account = Account // `=` как маркер embed?
audit_log []AuditEntry
}
Но = уже занят (присваивание в let, alias в type X alias Y).
Нет хорошего символа.
Против: обязательность alias’а из D39 сделала Go-style не применимым без явного keyword’а. Голый embed теряет различие с обычным полем.
C. embed name Type — отдельный keyword
type AuditedAccount {
embed account Account
audit_log []AuditEntry
}
type Wrapper { embed w HashMapIter[K, V] }
За:
- Keyword точно описывает намерение.
embedоднозначен,use— общий. - Освобождает
useдля других целей (scope-aliases, импорты в блоке). - AI-locality высокая.
Против:
- Ещё один keyword в языке.
- Очень похоже на A синтаксически — выигрыш только в семантической точности слова (одна роль вместо потенциально нескольких).
Отвергнутые альтернативы
name : Typeчерез:— конфликт с D17 (Nova явно отвергла:в type annotations).Type + {...}(intersection) — конфликт с D46 operator overloading (+=@plus).extends Type— обещает наследование (Java/C# семантика), а D39 — delegation, не наследование. Вводит в заблуждение.~Type— конфликт с removed memory prefix (~T/~&Tиз отменённого D21), путает ветеранов.@embed Type—@уже значит self-method/field в D35, перегрузка значения.+Type— конфликт с унарным+, не принято в mainstream.
Сравнение топ-3
| Аспект | A. use Type | B. голый Type | C. embed Type |
|---|---|---|---|
| Keyword | use (multi-purpose) | (нет) | embed (специфичный) |
| Длина | средняя | короткая | средняя |
| Прецедент | D mixin, partial Rust | Go | OCaml include (схожая идея) |
| AI-locality | высокая | средняя | высокая |
| Парсер | прямолинейный | lookahead по case | прямолинейный |
use нужен для импортов? | да, занят | свободен | свободен |
Решение пока
Не менять. D39 принят, кода с use написано (примеры, decisions).
Менять синтаксис без сильного триггера — лишний breaking change.
Update 2026-05-08: D39 расширен формой use _ Type (anonymous
embed) для simple wrappers где явный alias бессмысленный. Это
снимает часть давления на keyword use — программист не
вынужден придумывать имя поля каждый раз. Q-embed-syntax по-прежнему
открыт про выбор use vs embed keyword’а, но anonymous form
закрыла главный pain-point обязательного alias’а в simple cases.
Реализация anonymous embed — Plan 11 Ф.9 (через override-precedence в общем overload-resolution, lazy mechanism).
Если возвращаться — мой собственный голос за C (embed Type):
- Точная семантика keyword’а («embed» однозначно говорит «встроить»,
тогда как
useэто и многое другое). - Освобождает
useдля других целей (потенциально — local-aliases типов, импорты в скоупе функции). - Совместимо с alias-формой через
asв Go-style — при желании программиста.
Триггеры для пересмотра:
- Если в реальном Nova-коде накопится боль от перегрузки
use(программист путает embed и импорт). - Если
useпотребуется для другой семантики (например, using-statement из C# для эффект-handler’ов вwith-альтернативе).
Связь: D39,
D29 (импорты — потенциальный второй
пользователь use), D17 (record-форма),
D52 (kind-токены — embed встал бы
наряду с alias).
Q-positional-partial-pattern. .. для позиционных конструкторов sum ✅ ЗАКРЫТО (D59)
D59 формализовал partial-pattern ..
для трёх контекстов одновременно — record ({ field, .. },
наследие D17/D52), позиционные конструкторы sum (Cons(..),
Move(x, ..)) и массивы ([head, ..], [a, .., z]). Единый ..
маркер «остальные элементы игнорируются».
Также формализованы array-patterns ([], [r], [a, b], slice-
bind [head, ..rest]) и tuple-patterns ((a, b), (a, _, c),
destructuring let).
Контекст ниже сохранён как историческая справка.
Исходный контекст Q-positional-partial-pattern
Контекст. D17 фиксирует partial pattern matching только для record-формы:
type Shape | Circle { radius f64 } | Square { side f64 }
match shape {
Circle { radius, .. } => 3.14 * radius * radius // .. — остальные поля
Circle { radius } => 3.14 * radius * radius // эквивалент
}
Для позиционных конструкторов (Cons(T, LinkedList[T]),
Click(int, int), etc.) текущий синтаксис требует placeholder для
каждого поля:
type LinkedList[T] | Empty | Cons(T, LinkedList[T])
match list {
Empty => true
Cons(_, _) => false // два `_` для двух полей
}
При большем числе полей растёт шум: Click(int, int) | Move(int, int, int) | Scroll(int) — Click(_, _), Move(_, _, _),
Scroll(_). Программист пишет «не интересуют поля» N раз.
Предложение
Расширить .. partial-pattern на позиционные конструкторы:
match list {
Empty => true
Cons(..) => false // partial: все поля игнорируются
}
match event {
Click(..) => "click" // не важны координаты
Move(x, ..) => "move at ${x}" // важна только первая
_ => "other"
}
Правила (предлагаемые):
Cons(..)— все поля игнорируются (какCons(_, _)сейчас).Move(x, ..)— первое поле в bind, остальные игнорируются.Move(.., z)— последнее в bind, начальные игнорируются.Move(x, .., z)— первое и последнее, среднее игнорируется.
Прецеденты
- Rust:
..работает в обеих формах (Variant(_, _)иVariant(..)),Variant(x, ..)/Variant(.., x)тоже. - Swift:
case .variant(_, _, _)явно,..нет — все поля прописываются. - OCaml:
Cons (_, _)явный wildcard, нет..для tuple. - Haskell: wildcard
_для каждого поля.
Rust — единственный mainstream-прецедент. Но Rust-сообщество
любит .. — частая идиома.
Цена
- Новая форма pattern. Парсер должен различать
Variant(..),Variant(x, ..),Variant(.., x),Variant(x, .., y). - Конфликт с record-формой
... В{ field, .. }..стоит после,. В позиционной(x, ..)тоже после,. Парсер различает по виду внешних скобок ({}vs()), что согласовано с D17. - Тонкость с одним полем:
Variant(..)для конструктора с одним полем эквивалентноVariant(_). Скорее всего разрешено.
Решение пока
Не вводить формально. Текущий код использует Cons(..) идиому
неформально (examples/stdlib_linkedlist.nv)
— ожидая, что D17/D52 будет расширен. До формализации Cons(..)
фактически работает по интуитивному правилу «.. означает
“остальное игнорируется”», но компилятор может потребовать
строгую форму D17. При работе над парсером — зафиксировать в
revision к D17 или D52.
Связь: D17 (partial pattern для
record), D52 (sum-варианты), D19
(match-arms через =>).
Q-static-method-protocol. Static-методы в protocol через .name()-префикс
✅ РЕШЕНО 2026-05-22 (Plan 97). Принято предложение из этого вопроса: static-методы в
type X protocol { ... }маркируются точка-префиксом.name(), по симметрии с D35 (fn Type.name(...)в реализации). Формализовано в D143 (parse rule, matching rules, backwards-compat: bare имя — instance, как было). Реализовано в Plan 97 Ф.1 (parser + ASTEffectMethod.is_static). Применено в prelude:From[T] { .from(t T) },TryFrom[T, E] { .try_from(t T) -> Result[Self, E] }(см.std/prelude/protocols.nv).Note on hard runtime-enforcement: парсер принимает
.name(), AST хранитis_static: bool. Type-checker строгое сопоставление static↔instance при satisfaction-проверке — deferred (Plan 15 Ф.5+ или отдельный follow-up). См.docs/simplifications.md#m-protocol-static-enforcement-deferred.Историческое DEFER (предыдущее) — снято: Plan 15 закрыт, Plan 59 мономорфизировал Result, что покрывает основные use-case’ы (
From/Into/TryFrom/TryInto).
Контекст. D42/D53 описывают protocol с instance-методами (без префикса):
type Hashable protocol {
hash() -> u64
eq(other Self) -> bool
}
Реализация — через D35 @-методы.
Для static-функций (конструкторов, factory-функций) protocol
сейчас не предусмотрен. Это блокирует, например, generic collect:
type FromIter[T] protocol {
.from_iter(it Iter[T]) -> Self // static-функция-конструктор
}
fn Iter[T] @collect[Out: FromIter[T]]() -> Out =>
Out.from_iter(@)
Предложение
Расширить protocol-синтаксис: точка-префикс (.name()) маркирует
static-функцию (по симметрии с D35
fn Type.name(...) — точка в реализации).
type FromIter[T] protocol {
.from_iter(it Iter[T]) -> Self // static — через точку
@count() -> int // instance (если нужен @ symmetry,
// Q-protocol-method-prefix)
method() -> bool // instance (текущее, без префикса)
}
Реализация (структурно):
type Vec[T] { data []T }
fn Vec[T].from_iter(it Iter[T]) -> Vec[T] => ...
// Vec[T] автоматически удовлетворяет FromIter[T]
Минусы
- Тонкость грамматики: точка в protocol-блоке как маркер.
- Связано с Q-collect-mechanism — без bound’ов на дженериках (Q-bounds) generic-collect не работает даже со static-protocol.
Selfв protocol — концепция уже есть, но в static-контексте означает «конкретный реализующий тип» (как SwiftSelfв protocol).
Решение пока
Не вводить. Когда понадобится collect/from_iter-style generic-
конструкторы — вернуться. Связано с Q-bounds, Q-collect-mechanism.
Связь: D35 (точка для static), D42 (protocol), D53, Q-bounds, Q-collect-mechanism, Q-protocol-method-prefix.
Q-protocol-method-prefix. @method() vs голое method() в protocol-объявлении
✅ CLOSED by D53 → «Method-prefix в protocol-блоке» (Plan 17 Ф.1, 2026-05-08): обе формы валидны и эквивалентны.
@method()для визуальной симметрии с реализацией; голоеmethod()для краткости.mut @method()обязательно с@. Bootstrap парсит обе формы; std/testing/property.nv использует голую.
Update 2026-05-29 (Plan 91.8a, D183): Default body в protocol method — новая фича: метод с телом (
=> exprили{ ... }) — default impl, implementer может override. Метод без тела — abstract (обязательно реализовать). Также renames:Iter→Iterable,Display→Printable,Equatable.eq→equals,Comparable.cmp→compare -> int. Ordering sum-type удалён.
Контекст. Сейчас в protocol-блоке instance-методы пишутся без префикса:
type Hashable protocol {
hash() -> u64 // instance, без префикса
equals(other Self) -> bool // (D183: было `eq`)
}
В реализации — с @:
fn User @hash() -> u64 => ...
Асимметрия: declaration без @, definition с @. Программист
мысленно сопоставляет.
Предложение
@ обязателен и в protocol-объявлении — для полной симметрии:
type Hashable protocol {
@hash() -> u64 // instance — @, как в реализации
@eq(other Self) -> bool
.new() -> Self // static — точка (Q-static-method-protocol)
mut @push(item T) -> () // mut instance
}
За
- Полная симметрия declaration ↔ definition.
- Меньше неявности — программист не помнит, что без префикса в protocol = instance.
- AI-friendly — точно как реализация.
Против
- Breaking change — все 16+ protocol-объявлений переписать.
- Шум —
@hash()чуть длиннееhash(). - В существующих языках (Swift, Rust, Kotlin) protocol/trait не использует self-маркер в declaration — convention.
Решение пока
Не менять. Текущая асимметрия живёт. Программист привыкает.
Возможен пересмотр вместе с Q-static-method-protocol — если вводим
точку для static, можно добавить @ для instance ради консистентности.
Связь: D42, D35, Q-static-method-protocol.
Q-collect-mechanism. Generic collection construction
Контекст. В Rust:
let v: Vec<i32> = (0..5).collect();
let s: HashSet<i32> = (0..5).collect();
Один метод collect, целевой тип выводится из контекста или
передаётся через turbofish (collect::<Vec<_>>()). Универсальный
collection-builder.
В Nova через D58 Iter[T] есть, но универсальный collect не
работает без:
- Bound’ов на дженериках —
Out: FromIter[T](Q-bounds, отвергнуты в MVP). - Static-method в protocol —
FromIter[T] { .from_iter(...) -> Self }(Q-static-method-protocol). - Type-as-value или turbofish для передачи целевого типа.
Альтернативы
A. Конкретные методы — без collect
fn Range @to_vec() -> []int
fn Range @to_set() -> Set[int]
fn Range @to_linked_list() -> LinkedList[int]
N методов для N целей. Простой, рабочий, без bound’ов.
B. Turbofish + bound (Rust-style)
fn Iter[T] @collect[Out]() -> Out where Out: FromIter[T] => ...
let v = (0..5).collect[[]int]()
Требует Q-bounds + Q-static-method-protocol.
C. Type-as-value (Swift-style)
let v = (0..5).collect([]int) // []int как «type-callable»
Тип в позиции аргумента вызывает type’s from_iter. Требует Q-type-as-value.
D. Передача функции явно
let v = (0..5).collect(([]int).from_iter)
Длинно, но без новых концепций.
Решение пока
A в MVP — конкретные to_vec, to_set, etc. на каждом типе-
итераторе. B/C/D — после Q-bounds/Q-static-method-protocol/Q-type-
as-value.
Связь: Q-bounds, Q-static-method-protocol, Q-type-as-value, D58.
Q-type-as-value. Передача типа как значения (xs.collect([]int))
Контекст. В Swift:
let v = Array(0..<5) // Array — «type как callable»
Тип-имя в позиции функции — вызывает соответствующий init.
В Nova сейчас типы — compile-time сущности. Передавать []int
как значение в ()-аргументе не работает:
fn collect[Out](ctor SomeProtocol) -> Out => ...
collect([]int) // []int это тип, не значение — ошибка
Turbofish работает (collect[[]int]()), но передача в
()-аргументе требует механизма «type as callable».
Предложение
Type в позиции выражения вызывает соответствующий конструктор по
convention:
[]int= type-callable, эквивалентно[]int.from_iterили[]int.new(выбор по сигнатуре).User= type-callable, эквивалентноUser.newили общему конструктору.
Минусы
- Type-resolution полнее. Какой конструктор выбирается —
from_iter?new? Зависит от target-типа в позиции? Сложно. - Прецеденты ограничены — Swift, Python (
list(...)), но не Rust/ Kotlin/Go. - Type-as-value в runtime — требует runtime-tag типа (как Swift Mirror, Java reflection).
Решение пока
Не вводить. Если когда-то понадобится для эргономики collect —
вернуться вместе с Q-collect-mechanism.
Связь: Q-collect-mechanism, D38 (turbofish — текущая альтернатива).
Q-range-extras. Reverse и step для Range
Контекст. D58 ввёл базовый Range
(a..b, a..=b). Не зафиксировано:
- Reverse range —
5..0(start > end). Что значит:- Пустой range (Rust-style — для прямого направления)?
- Идущий назад (5, 4, 3, 2, 1, 0)?
- Step — итерация с шагом,
(0..100).step(10)или0..100..10?
Прецеденты
- Rust:
5..0— пустой; reverse через(0..5).rev(). Step черезstep_by(n). - Python:
range(5, 0)— пустой.range(5, 0, -1)— обратный. Step через третий аргумент. - Kotlin:
5 downTo 0— отдельный keyword. Step черезstep(n). - Swift:
stride(from: 0, to: 100, by: 10)— отдельная функция.
Решение пока
Не зафиксировано. Реализуется в examples/stdlib_range.nv как
методы:
fn Range @reverse() -> Range
fn Range @step(n int) -> StepIter
Конкретный синтаксис — после первой версии Range (см. examples).
Связь: D58.
Q-resume-semantics. Семантика resume в handler-method’е ✅ ЗАКРЫТО (D61)
Закрыт через D61 в варианте (II) tail-only:
resumeкак keyword отвергнут, заменён на комбинациюreturn v(нормальное завершение, continuation возобновляется) +interrupt v(досрочное прерывание with-блока). Линейность — one-shot. Multi-shot отложен под Q-multishot-resume (если когда-нибудь потребуется backtracking-эффект). Для never-операций разрешён толькоinterrupt. См. D61 «Алгоритм компиляции/интерпретации эффектов» — пошаговое тех-задание имплементатору.Оригинальный текст ниже сохранён для истории.
Контекст. Все handler-литералы в spec’е и examples массово
используют resume(value) для возобновления континуации операции
(query(q) => resume(real.query(q)), log(msg) { ... ; resume(()) }).
Но формального D-блока про resume не существует — только
фрагментарные упоминания в D10 и
D31. Программист, читающий spec, не
знает:
- Что формально означает
resume(v)? Возвращает ли он что-то? - One-shot или multi-shot? Можно ли вызвать
resumeдважды? Что произойдёт? - Тип
resume.fn(R) -> ()илиfn(R) -> T_with_block? - Что если
resumeне вызван? handler возвращает за весьwith-блок? Какое значение? - Запрещён ли
resumeдляnever-операций (Fail.throw)? resume()без аргумента для unit-операций — сахар или обязательная форма?
Ключевые design choices
One-shot vs multi-shot
| Модель | Прецедент | Стоимость | Use-cases |
|---|---|---|---|
| One-shot | Koka, OCaml 5, Eff | дёшево, stack-based | Fail, Db, Log, Time, Random — 95% реальных эффектов |
| Multi-shot | Multicore-OCaml лаб. | дорого, копирование континуации | backtracking, недетерминизм, choose-effect |
Nova — backend-язык, фокус на надёжности и производительности.
Склонность — one-shot: вызвал resume дважды → runtime panic
(или compile-time error, если static-анализ позволит).
Тип resume
(I) fn(R) -> () — handler-method заканчивается, значение v
становится результатом операции в бизнес-коде.
(II) fn(R) -> T_with_block — resume возвращает финальное
значение всего with-блока, позволяя писать «после resume» (например,
log время выполнения).
(II) мощнее, (I) проще. Koka использует (II).
Без resume
Если handler-method не вызвал resume — handler возвращает за
весь with-блок. Это типичный паттерн для Fail:
fn try_parse(s str) -> Option[int] =>
with Fail[ParseError] = |_| interrupt None {
Some(parse(s)!!)
}
Здесь |_| interrupt None — handler-лямбда. Если бизнес-код
бросает — handler возвращает None, и весь with-блок даёт
None.
never-операции
Fail.throw имеет тип never (нет возвратного значения). resume
для неё запрещён — нечего возобновлять. Линтер/тайпчекер должен
запретить.
Unit-аргумент
Для операции () -> () (log(msg) -> ()) resume принимает ():
log(msg) { println(msg); resume(()) }
Можно сделать resume() без аргумента — синтаксический сахар. Решение
зависит от того, насколько часто такое встречается (Log.log,
Time.sleep — частые).
Что нужно зафиксировать в D-блоке
- Линейность (one-shot recommended).
- Точный тип
resume. - Поведение если не вызван.
- Запрет для never.
- Поведение для unit-операций.
- Что происходит при второй попытке вызова (panic vs compile-error).
- Пример каждой формы.
Решение пока
Не зафиксировано. Все примеры используют resume(...) интуитивно
по принципу «возвращает значение в место операции». До D-блока — это
имплицитная семантика, нужная для понимания handler’ов. Обсудить и
записать в виде D-блока (вероятно, D61 после D60).
Связь: D10, D31, 04-effects.md.
Q-handler-method-param-inference. Тип параметра handler-method’а ✅ ЗАКРЫТО (D61)
Закрыт через D61 в варианте (A) inference обязателен по умолчанию, явные типы разрешены опционально. Параметры handler-method’а биндятся по позиции к параметрам декларации операции; типы автоматически выводятся из effect-декларации. Можно писать
query(q Sql) => ...для документации, но это избыточно.Оригинальный текст ниже сохранён для истории.
Контекст. Сейчас в handler-литералах параметры пишутся без типа:
with Db = Db {
query(q) => resume(real.query(q)) // q: Sql выводится из protocol
exec(q) { staged.push(q); resume(()) }
}
Тип q (а также sql, args в старой форме (sql, args)) выводится
из сигнатуры protocol’а Db.query(q Sql) -> []DbRow. То же самое
делает лямбда: (req) => handle(req) получает тип req из
контекста-параметра.
Вопрос: должна ли спека разрешать инференс, или требовать явный тип в handler-method’е?
Аргументы
За инференс (текущая практика во всех ~20 примерах):
- handler-method всегда вызывается через protocol — типы фиксированы. Дублировать в каждом литерале — шум.
- Симметрия с лямбдой.
- Все примеры в spec/examples написаны без типов; требование явных типов — большой sweep.
Против:
- Локальное чтение хуже:
query(q) { use(q) }— непонятноq : ?. - AI-first: LLM проще генерирует с явными типами (меньше неочевидного контекста).
- D45 inferred return type — там inference только для return, параметры всегда явные. Handler-method был бы исключением.
Возможные варианты
(A) Инференс обязателен — типы из protocol-сигнатуры всегда
выводятся, явные типы запрещены (избыточны).
(B) Инференс опционален — query(q) и query(q Sql) оба валидны.
Линтер может предлагать опускать.
(C) Явные типы обязательны — query(q Sql) всегда. Sweep всех
примеров.
(B) самый гибкий, но создаёт «два пути»; (A) самый компактный; (C) самый локально-читаемый.
Решение пока
Не зафиксировано. Все примеры используют (A)-форму неявно. До формального решения работает «инференс из protocol-сигнатуры», но это нужно явно зафиксировать в D-блоке (вероятно, в составе D31 или отдельным расширением).
Связь: D31, D45 (inferred return type — прецедент, но только для return).
Q-fail-coercion. Auto-coercion типов ошибок при ?-операторе
⏸ DEFERRED — нужен дизайн (Plan 17 Ф.3, 2026-05-08). Rationale: проблема known (без auto-coerce каждое разнотипное
?требует ручной.map_err(AppError.Variant)?), но дизайн нетривиален: вариант через unary-конструктор «один подходящий wrap» — близкий аналог RustFrom<E>через D73, но взаимодействие с D55 sum-coercion требует точной формализации (что приоритетнее: явный.map_errили auto-wrap?). Trigger: реальная боль в stdlib — когда ≥5 функций требуют.map_err(...)?boilerplate и pattern регулярен (один wrap-конструктор). Варианты решения перечислены ниже (auto-deriveFromчерез#[from]-маркер; явный?.into(); sum-type AppError сOr<A, B>).
Контекст. D65 фиксирует семантику
Fail[E]. При транзитивном пробросе через ? если callee бросает
E', а caller декларировал Fail[E] (E ≠ E’) — программист обязан
явно использовать .map_err(...) или multi-Fail в row.
type AppError
| Parse(ParseError)
| Db(DbError)
fn process(s str) Fail[AppError] -> int {
let n = parse(s).map_err(AppError.Parse)? // явный wrap
Db.query(...).map_err(AppError.Db)? // явный wrap
}
В Rust есть From<E>-trait, через который ? автоматически конвертирует
тип ошибки если есть имплементация. Для Nova аналогичное правило могло
бы быть:
Если
E(caller’s Fail) имеет ровно один sum-вариант с типомE'(callee’s Fail),?автоматически вызывает этот вариант-конструктор:parse(s)? // вместо parse(s).map_err(AppError.Parse)?compile-time проверка: вариантов с типом E’ должно быть ровно один. Если несколько — ambiguous, compile error.
За
- Убирает boilerplate
.map_err(...)для типичных wrap’ов. - Прецедент Rust — программисты знают.
- Compile-time проверка остаётся (один вариант — однозначно, иначе ошибка).
Против
- Магия. По месту вызова
parse(s)?неочевидно что происходит wrap. - AI-friendly? LLM может не знать про auto-coercion и путаться.
- D40-style «один способ». Auto-coercion + явный
.map_err— два способа, неоднозначность. - Локальное reasoning. С явным
.map_err(AppError.Parse)сразу видно как ошибка маппится. С?— нужно смотреть на типAppError.
Решение пока
Не зафиксировано. Оставляется как потенциальная будущая фича.
В текущем D65 — всегда явный .map_err(...)? или multi-Fail в row.
Q-pipe-operator. Pipe-оператор |>
⏸ DEFERRED to v0.5+ (Plan 17 Ф.3, 2026-05-08). Rationale: trailing-block (D43) + method-chain через
@-методы (D35) покрывают паттерн «data flow» без|>. Добавление оператора требует решения о приоритете и ассоциативности, конкуренции сbitwise OR, и effect-row inference на partial-application — complexity без ясного выигрыша. Trigger для пересмотра: ≥3 use-case’а в реальной кодовой базе, где method chain или free-function call дают объективно худший читаемость, и где pipe был бы естественнее.
Контекст. Во многих функциональных языках (Elixir, F#, Elm,
Hack, OCaml) есть pipe-оператор x |> f |> g, эквивалент
g(f(x)). Делает цепочки трансформаций линейными слева-направо.
let result = users
|> filter(active)
|> map(format_name)
|> join(", ")
vs. без pipe:
let result = join(map(filter(users, active), format_name), ", ")
или через method chaining (если каждая функция — метод):
let result = users.filter(active).map(format_name).join(", ")
Статус. В bootstrap-парсере токена |> нет. В спеке тоже не
упомянут. Был ли намеренно отвергнут или просто не дошли руки —
не зафиксировано.
За
- Линейная читаемость для трансформаций («data flow»).
- Не требует чтобы функция была методом типа.
- Прецедент в FP-сообществе.
Против
- D40 «один способ». Уже есть method chaining — pipe это альтернатива, дублирующая тот же data-flow паттерн.
- AI-friendly? Method chaining
x.f().g()гораздо более распространён в LLM-обучении (Java/Python/JS/Rust).|>редкий, повышает cognitive load. - Эффекты + pipe.
users |> get_users() |> ...— где effect-row? Pipe скрывает callee, осложняет вывод эффектов. - Партикулярно для Nova. Все типы могут иметь
@-методы (D35), поэтому method chaining — universal pattern. Pipe не добавляет выразительности.
Решение пока
Не зафиксировано. Скорее всего не добавлять — есть method
chaining через @-методы, а D40 призывает не дублировать паттерны.
Если решим зафиксировать как «no» — отдельный D-блок «No pipe».
Связь: D35 (@-методы),
D40 (один способ).
Q-string-interpolation. Интерполяция строк "hello ${name}"
✅ CLOSED by D44 → «Строковые литералы и интерполяция» (Plan 17 Ф.4, 2026-05-08): синтаксис
${expr}(JS-style), escape\${для буквального${. Codegen эмитит StringBuilder цепочку с pre-size estimate (одна аллокация, без O(N²) от+). Реализован весь стек: lexer (sentinel \x01$ для escape), parser (sub-lex/parse каждого${expr}), AST (ExprKind::InterpolatedStr { parts }), codegen (StringBuilder.append + into), interp.Тесты:
nova_tests/types/string_interpolation.nv(13 тестов: int / negative / str / bool / f64 / char literal / multi / expression in${}/ escape / большие строки).Const-инициализатор: интерполяция запрещена (требует runtime StringBuilder); compile error.
Контекст. Многие современные языки имеют интерполяцию строк:
Контекст. Многие современные языки имеют интерполяцию строк:
JS: `Hello ${name}, you are ${age}`
Python f"Hello {name}, you are {age}"
Kotlin "Hello $name, you are $age"
Swift "Hello \(name)"
Rust println!("{name}")
В bootstrap-парсере Nova — конкатенация через +:
let s = "Hello " + name + ", you are " + str.from(age)
Статус. Не зафиксировано в спеке. Bootstrap не парсит интерполяцию.
За
- Читаемость, особенно для длинных строк.
- Меньше boilerplate (
+иstr.from(...)). - Универсальная фича — все программисты ожидают.
Против
- Сложность парсера/лексера. Интерполяция — это string-mode + embedded expression mode. Усложняет грамматику.
- Tagged templates (D-?) — есть планы на
tag...` с runtime’ом. Интерполяция и tagged templates — пересекаются (sql tag = Sql.eval с интерполированными частями?). - AI-friendly формат.
${...}— популярно (JS), но\(...)(Swift),{...}(Python) — все разные. Нужно выбрать один синтаксис. - Type coercion.
"${n}"для int — implicitstr.from(n)(D73)? Или требовать явный? (Plan 17 закрыл это:${expr}— sugar надstr.from(expr).)
Альтернативы синтаксиса
"${expr}"— JS-style (привычно большинству)"\(expr)"— Swift (без $-конфликта со shell)"{expr}"— Python f-string (без префикса) илиf"{expr}"
Решение пока
Не зафиксировано. Скорее всего нужно — современный язык без интерполяции выглядит архаично. Вопрос — какой синтаксис и как взаимодействует с tagged templates.
Связь: Tagged templates (нет D-блока пока), D40.
Q-coercion-order. Порядок применения coercion: sum vs record vs spread vs punning
Контекст. В Nova есть несколько форм неявных трансформаций литералов в позиции с явным типом:
- D55 literal coercion — sum-конструкторы (
E.Variant(...)) и record-литералы. - D60 spread —
[...arr, x]для массивов и{ ...rec, k: v }для записей. - D52 field punning —
{ x, y }≡{ x: x, y: y }когда идентификаторы совпадают с именами полей.
Композиция этих правил в одном литерале может давать неоднозначность порядка применения:
let p = { ...base, x } // что сделать первым:
// (a) spread → record { ...base.fields, x: x }?
// (b) punning x → x: x → record { ...base.fields, x: x }?
// обычно эквивалентно, но не всегда
let r RecordType = { ...base, value: 5 + 10 }
// 1. coerce 5 + 10 в тип поля value (sum-coercion если value: SomeOption)?
// 2. spread base?
// 3. record-construct?
Открытые пункты
- Формальный порядок — нужно зафиксировать sequence: spread → punning → field-coercion → sum-coercion (или другой).
- Многошаговая coercion:
let r SomeRecord = { x: 5 }гдеSomeRecord.x: SomeSumType. Двушаговая: int → SomeVariant → field. Зафиксировано ли «не более одного уровня coercion»? - Type checker order: bottom-up vs top-down — на какой стадии применяется coercion?
За
- Без формального порядка LLM может генерировать «работает в одном направлении, не в другом».
- D55 в большом примере (
audit.nv,orm_demo.nv) активно использует comlex coercion — нужно правило.
Против
- Может оказаться что в реальном коде эти кейсы редки.
- Решение требует expert-внимания к type-checker дизайну (production компилятор).
Решение пока
Не зафиксировано. Bootstrap применяет правила «по одному за раз» (сначала spread, потом field-resolution, потом coercion внутри полей) — это implementation-факт. Production должен дать явное правило.
Связь: D55, D60, D52, Q-numeric-coercion, Q-style-coercion.
Q-pure-view. Семантика pure_view для handler-state в контрактах
Контекст. D24 упоминает pure_view
как механизм ссылки на handler-state в контрактах:
fn transfer(...) Db -> ()
ensures Db.balance(to) == old(Db.balance(to)) + amount
=> ...
«В v1.0 поддержка частичная — только для эффектов с явным
pure_view (чистая проекция состояния handler’а). Полная поддержка —
research, отдельный D-пункт после v1.0.»
Но что такое pure_view формально нигде не зафиксировано:
- Декларация:
pure_view— это атрибут метода эффекта? Отдельная декларация? Свойство handler’а? - Семантика: какие операции можно использовать в pure_view? Только
чтение — нельзя
Db.exec(...)? - SMT-кодировка: как решатель переводит
Db.balance(...)в uninterpreted function + axioms? - Проверка: handler обязан реализовать
pure_viewсоответствующим методом?
Используется в
- revolutionary.md R5.7 — обратимость spec ↔ impl,
ссылается на handler-state в
ensures. - revolutionary.md R4 — пример с
Db.balanceв ensures. - 09-tooling.md D24 — упоминание.
За
- Без этого ключевая фича spec ↔ impl не работает на effect-методах.
- Db/Net/Time/Random — типичные handler’ы, контракты на них — естественны.
Против
- Большой scope: формализация SMT-кодировки + axioms + проверка pure’ности.
- Связь с D62: handler — обычное значение,
Db.balance(x)это вызов через handler-стек, который может меняться. Что значит «pure» для такого вызова?
Решение пока
Открыто. До формализации контракты с Db.X(...) принимаются
грамматикой, но SMT их не доказывает → ошибка @must_verify или
runtime check. Production-компилятор должен дать формальное
определение.
Связь: D24, Q-contract-dsl, R5.7, D62.
Q-contract-dsl. Формальный contract-DSL: result, old(...), .is_ok, .is_err
Контекст. В D24 «Контракты как обычная часть языка» приведены
примеры requires/ensures с использованием:
result— выражение «значение, которое функция возвращает»old(expr)— значениеexprдо вызова функцииresult.is_ok,result.is_err— для функций сFailэффектомresult.value,result.error— для Result-типов?
Используется в revolutionary.md:175,365-366,386-388 и 09-tooling.md → D24, но формальная семантика этих ключевых слов не зафиксирована.
Открытые вопросы
-
Что такое
resultдля функции сFail? В сценарииfn withdraw() Fail[Overdraft] -> ()функция формально возвращает().result.is_okозначает «функция завершилась без throw». Но тогдаresult.is_okдля() -> ()всегда true (ничего не бросает) — что отличает отFail[E] -> ()где результат может бытьis_err? -
Семантика
resultдля Result-типа. Если функция возвращаетResult[T, E],result.is_okэто вызов method’а на возвращённом Result, аresult.value/result.error— извлечение payload’а? Тогда два механизма (Fail и Result) дают разные значенияresult.is_ok. -
old(expr)— глубина копии.old(acc.balance)копирует поле,old(arr)копирует массив или ссылку? -
Композиция контрактов. Можно ли использовать другие функции в
requires/ensures(requires is_valid(input))? Если да — что с эффектами этой функции (она должна бытьpure)?
За
- Контракты — ключевая фича spec ↔ impl (R5.7).
- Без формализации SMT-checker и LLM-генератор не могут проверять.
Против
- Большой scope: D24 + новый D-блок про contract DSL.
- Связь с handler-state (Q-pure-view):
Db.balanceвensures— как handler-зависимое значение проверяется?
Решение пока
Не зафиксировано. В примерах используется неформально. До формализации контракты являются рекомендацией для LLM, не проверяемой гарантией.
Связь: D24, R5.7, Q-pure-view (handler-state в контрактах).
Q-alloc-region. Полная семантика Alloc[R] и связь с region { }
Контекст. В spec упомянут эффект Alloc[R] — аллокация в named-
региона R (overview.md, effects.md,
D26 prelude, 04-effects.md → D2).
Параллельно 05-memory.md → D6 и
06-concurrency.md → D14 объявляют
region { ... } как примитив языка (как parallel for / race).
Связь между Alloc[R] (эффект) и region { } (блок-примитив) явно
не зафиксирована.
Что есть сейчас:
fn alloc_in(buf []u8) Alloc[r] -> Buffer // r — имя региона
// (как параметр?)
fn map_audio(samples []f32, gain f32) -> []f32 =>
realtime nogc {
region { // примитив языка
samples.map() { x => x * gain }
}
}
Открытые подвопросы:
- Объявление
Alloc[R]. Эффект параметризуется именем региона. Откуда берётсяR? Это compile-time имя (как lifetime в Rust), тип-параметр функции, или identifier из enclosingregion { }? - Handler для
Alloc[R]. Кто его ставит? Блокregion { }автоматически? Или программист пишетwith Alloc[r] = arena_handler { ... }? - Сигнатура
region { body }. Что body может делать с эффектом? Body — лямбдаfn() Alloc[r] -> T? ТипTуезжает из региона как — копируется, references запрещены? - Multi-region. Можно ли вложить
region { region { ... } }? ПолучитсяAlloc[outer],Alloc[inner]— две арены. Как escape между ними? Сейчас D6 показывает sequentiallet scratch = region { ... }; region { finalize(scratch) }, но не вложенный случай. - Связь с
realtime nogc { }. D64 говорит «внутриrealtime nogc— только region-allocations и стек». То естьAlloc[R]в сигнатуре функции = «эта функция совместима сrealtime nogc { }контекстом». Нужна формальная связь. - Coercion / inference. Если функция
f() Alloc[r] -> Tвызывается внутриregion { }, должен ли компилятор автоматически связатьrс регионом блока? Или программист пишет явно? - Сравнение с прецедентами:
- Rust — lifetimes
'aчерез borrow checker. - Koka —
<alloc<r>>эффект в effect row. - Encore — capabilities + parallel regions.
- Cyclone — region-based memory с явными аннотациями.
- Rust — lifetimes
Статус. Alloc[R] оставлен в prelude как зарезервированное
имя, концептуально упомянут в декларациях, но полная семантика
откладывается до v1.0+. Реализация regions — пост-MVP вместе с
realtime nogc enforcement.
Что делаем сейчас (MVP):
Alloc[R]остаётся в prelude-списке как заявленный эффект.region { ... }в spec упомянут, но в bootstrap/codegen не имплементирован (managed GC покрывает 99% backend-сценариев).realtime { }блок (D64) парсится, без compile-time enforcement.
Решение об активации: когда появится первый real-time use case (audio-обработка, embedded), или когда будет реализован concurrent GC и понадобится strict no-GC escape hatch.
Связь: D6 (managed GC + region opt-in),
D14 (region как примитив языка),
D62 (эффекты прямые, ambient runtime),
D64 (realtime { } / realtime nogc { }),
D26 (prelude — Alloc[R] зарезервирован).
Q-record-spread-args. Spread record-литерала в позиции аргументов функции
Контекст. Сейчас named arguments в Nova нет. Опциональные параметры выражаются через паттерн «опции-record + spread» (syntax.md → «Опциональные параметры»). Но это требует отдельного record-типа для каждого набора опций, что иногда избыточно.
Предложение. Разрешить f(...{field1: v1, field2: v2}) —
spread record-литерала в позиции аргументов. Компилятор
раскладывает поля по именам параметров функции:
fn name(x int, s str) -> ()
name(...{x: 2, s: "test"}) // эквивалент name(2, "test")
name(...{s: "test", x: 2}) // порядок полей не важен (spread по именам)
let opts = { x: 2, s: "test" }
name(...opts) // spread существующего record'а
name(2, ...{s: "test"}) // позиционный + spread остальных
Семантика:
...record-exprв позиции аргумента раскладывается по именам параметров функции.- Несоответствие имён поля и параметра — compile error.
- Непокрытые параметры — compile error («missing field»).
- Можно комбинировать с обычным spread (D60) внутри:
name(...{ ...base, s: "override" }).
Преимущества:
...— явный маркер. Парсер однозначен без type-directed parsing (в отличие отname({...})который мог бы быть либо одним record-аргументом, либо named-form).- Согласовано с D60. Spread уже расширяет литералы — теперь расширяет и call-site.
- Закрывает named arguments без отдельной фичи.
- Refactoring безопасен — добавил параметр, spread-вызовы получают compile error «missing field».
Тонкости:
- Конфликт с variadic spread (D69).
f(...xs)для variadic = «развернуть массив[T]».f(...rec)для record = «развернуть по именам». Различимы по типу spread-выражения (массив vs record), но требует type-check для resolution — мягкий type-directed step. - Дублирует паттерн опций-record в простых случаях. Если уже
есть
fn connect(opts ConnArgs), тоconnect(...opts_record)≈connect(opts_record). Преимущество только когда не хочется заводить отдельный record-тип под опции (т.е. для разнородных параметров). - Композиция с D60:
name(...{ ...defaults, x: 9 })— nested spread внутри record-литерала, потом spread record’а в аргументы. Двухуровневый spread. - Парсер vs type-checker. Сейчас D60 spread — чисто синтаксический. Здесь — семантический: раскладка на этапе type-check’а. Шаг к усложнению, но не критичный.
mut-параметры.fn deposit(mut acc Account, amount money)— spread...{acc, amount}должен сохранятьmut-семантику. Технически тот же self-resolution, что и при обычном вызове.- Default-значения (если когда-то будут — отвергнуты сейчас, см. history/rejected.md). Spread мог бы пропускать поля без override — но без default’ов compile error.
Альтернативы рассмотрены:
f({x: 1, s: "test"})без...(named arguments через D55). Отвергнуто: требует type-directed parsing для различения «один record-аргумент vs named-form».- Python-style
f(x=1, s="test")— отдельный синтаксис named-аргументов. Новая грамматика, конфликтует с lambda-параметрами. - Не вводить ничего — паттерн опций-record уже работает. Цена — отдельный record-тип под каждый набор опций.
Прецеденты:
- JavaScript —
f(...obj)spread для массивов, для объектов — только в литералах ({...obj}). Разворачивания в args нет. - Python —
f(**kwargs)разворачивает dict в named-args. Прямой прецедент семантики. - Ruby —
f(**hash)аналогично. - OCaml — labeled arguments
f ~x:1 ~s:"test"— отдельный механизм.
Статус. Не зафиксировано. Склонность — принять (хорошо ложится на существующие D60/D69, явный синтаксис, закрывает named arguments без отдельной фичи). Решение откладывается до появления конкретного use case (когда D55+D60 паттерн опций-record окажется многословным в реальном коде).
Связь: D55 (record-coercion), D60 (spread в литералах), D69 (variadic spread на call-site, для массивов), history/rejected.md «Default-значения параметров».
Q-math-protocol. Float / Numeric protocol для generic числового кода
Контекст. D74 объявляет
математические операции (@sqrt, @sin, @cos, @atan2, @hypot,
…) как instance-методы на конкретных числовых типах
(f64, f32, int). Generic-код, желающий работать с «любым
числом» (например, Complex[T], Vector[T], Matrix[T]), упирается
в отсутствие protocol-bound — без него theta.cos() на абстрактном
T не скомпилируется.
В D74 было прямо отвергнуто:
Trait-style
Floatprotocol сsin/cos/...(HaskellFloating, Rustnum_traits::Float). Лишняя indirection, generics с bounds для каждой математической функции усложняют сигнатуры.
Решение принято для stdlib: математика на конкретных типах, не через protocol. Но это блокирует пользовательский generic-код.
Вопрос: ввести ли Float (и Numeric) protocol для generic-кода?
Варианты:
A. Не вводить (текущее). Generic числовой код — невозможен без
дублирования ComplexF32 / ComplexF64, VectorF32 / VectorF64.
Цена — ~2x кода для каждого generic числового типа. Для stdlib
терпимо (сделано один раз). Для прикладного — программист пишет
type ComplexF32 { ... } отдельно.
// Текущий подход: дублирование
type Complex { re f64, im f64 }
type ComplexF32 { re f32, im f32 } // отдельный тип
// Дублированная алгебра, дублированные методы
B. Ввести только Float protocol для generic-кода. Stdlib-реализация
на f32/f64 остаётся как в D74 (instance-методы), но дополнительно
объявляется protocol с теми же сигнатурами. Generic-код использует
protocol-bound:
type Float protocol {
@sqrt() -> Self
@sin() -> Self
@cos() -> Self
@atan2(other Self) -> Self
@hypot(other Self) -> Self
@abs() -> Self
@is_finite() -> bool
@is_nan() -> bool
// ...
}
// Теперь generic Complex работает:
type Complex[T Float] { re T, im T }
export fn Complex[T].from_polar(r T, theta T) -> Self =>
{ re: r * theta.cos(), im: r * theta.sin() }
Структурно f32 и f64 автоматически удовлетворяют Float —
никаких impl не нужно (D53). Бесплатно для stdlib, разблокирует
пользовательский generic-код.
C. Ввести иерархию Numeric ⊂ Float ⊂ … (как Haskell Num /
Floating / Real). Гранулярные bounds — функция использующая
только +/* требует Numeric, использующая sin/cos — Float.
Лучше типобезопасность, дороже сложность.
type Numeric protocol {
@plus(other Self) -> Self
@times(other Self) -> Self
@neg() -> Self
// ... только арифметика
}
type Float protocol {
@sqrt() -> Self
@sin() -> Self
// ... тригонометрия
// Float наследует Numeric? — открытый подвопрос protocol-наследования
}
Тонкости:
- Конфликт с D74 «отвергнут Float protocol». Формулировка D74 касалась stdlib-реализации. Можно уточнить: stdlib пишет instance-методы напрямую (никакой indirection), а дополнительно объявленный protocol существует только для типизации generic- bound’ов — без runtime-overhead через мономорфизацию.
- Какие методы включить в
Float? Полный набор D74 (~25 методов) делает protocol тяжёлым. Минимум:@sqrt,@sin,@cos,@abs,@hypot,@atan2— что нужно дляComplex/Vector. Остальное — расширения (FloatExtra,Hyperbolic). - Protocol-наследование (Q-protocol-inheritance) — если хочется
Float : Numeric, нужно решение про composition protocol’ов (открытый вопрос D42). Numericдляint? int не имеет sqrt/sin, но имеет +/*/-. Generic-функцияsum[T Numeric](xs []T) -> Tхочет работать и с int, и с f64. Нужна отдельная иерархия.- Прецеденты:
- Rust —
num_traits::Float,num_traits::Num, целая иерархия. Работает, но сложно для новичков. - Haskell —
Num,Floating,Real— каноническая иерархия. Известная сложность для обучения. - Swift —
BinaryFloatingPoint,BinaryInteger,Numeric— протоколы в stdlib. Используется в обобщённой математике. - Go — нет (generics с bounds появились поздно). Дублирование.
- Julia —
AbstractFloat, через duck-typing. Просто, но слабо проверяемо.
- Rust —
Связанные открытые вопросы:
- Q-bounds — закрыто D72, bounds разрешены.
- Q-default-generic ниже — для
Complex[T = f64], чтобы старый не-generic вызовComplex.from(2.0)остался работать. - Q-protocol-inheritance — для
Float : Numeric.
Статус. Не зафиксировано. Текущее (A) работает для stdlib через
дублирование. Если/когда понадобится generic числовой код в проде —
ввести B (минимальный Float protocol с 6-8 ключевыми методами).
C (иерархия) — отложено до накопления реальных use-case’ов.
Связь: D72 (bounds), D74 (math на числовых типах), D53 (protocol = тип), D42 (protocol-наследование — открытый подвопрос).
Q-default-generic. Default-значения generic-параметров ✅ ЗАКРЫТО (2026-05-10)
✅ ЗАКРЫТО → D88 (2026-05-10). Триггер — D87
Effect[E, IRT = never]: тип handler’а должен сообщать о возможностиinterrupt, но обратная совместимость требуетEffect[E] ≡ Effect[E, never]через default. Это и есть real consumer, которого ждали.Принят синтаксис из текущего раздела as-is:
[T = f64],[T Bound = Default], обязательные параметры до опциональных. Содержимое ниже — историческое описание перед закрытием.
⏸ DEFERRED — nice-to-have (Plan 17 Ф.3, 2026-05-08). Rationale: прецеденты есть (Rust, C++, TS), синтаксис чистый (
[T = f64]), но в Nova сейчас нет generic Complex / Vector / Matrix — главного use-case’а. Без real consumer’а добавлять сложность вывода типов (default vs inference из аргументов) — overengineering. Trigger: появление generic math-типов (Q-math-protocol) или первый кейс «добавить параметр к существующему типу не ломая caller’ов» в stdlib.
Контекст. Сейчас generic-параметры объявляются без default-значений
(D16: [T]). Если добавить generic к существующему типу
(Complex → Complex[T]), все существующие вызовы ломаются —
программист обязан указать [T] явно.
Предложение. Разрешить default-значение в generic-объявлении, чтобы можно было параметризовать тип/функцию без breaking change’а для существующих использований:
type Complex[T = f64] {
re T
im T
}
// Старые вызовы продолжают работать без [T]:
let z = Complex.from(2.0) // T выводится как f64 из default
let z Complex = Complex.new(1.0, 2.0) // тип Complex (без скобок) → Complex[f64]
// Новые — с явным параметром:
let z32 Complex[f32] = Complex.new(1.0_f32, 2.0_f32)
Синтаксис: [T = f64] — без двоеточия (стиль Nova).
Альтернатива [T default f64] — длиннее, без выгоды.
Семантика:
Complexбез скобок ≡Complex[f64](default подставляется).Complex[f32]— явная инстанциация.- Default — тип, должен быть уже определён (никаких forward references).
Использование с алиасами:
type Complex32 alias Complex[f32]
type Complex64 alias Complex[f64] // эквивалент `Complex` без скобок
Тонкости:
- Парсер.
[T = f64]— после имени параметра идёт=потом тип. Грамматически чисто (нет конфликтов с другими=в Nova, потому что generic-список окружён[]). - Несколько параметров с default.
[K = str, V = int]— все опциональны, можно не указывать ни одного. Если только часть с default — обязательные должны идти до опциональных ([T, U = f64]✅,[T = f64, U]❌). - Default через bound.
[T Float = f64]— boundFloat+ defaultf64. Парсер:name bound = default. - Inference vs default. Если компилятор может вывести
Tиз аргументов — default не нужен:
Default — это «когда не выводится и не указан».fn first[T = int](xs []T) -> Option[T] first([1, 2, 3]) // T = int (вывод из []int, не default) first[]([]) // []? — пусто, default не помогает // (тип []T неизвестен) - Прецеденты:
- Rust —
struct Vec<T, A: Allocator = Global>. Работает. - C++ —
template<typename T = int>. Работает. - TypeScript —
interface Foo<T = string>. Работает. - Swift, Kotlin — нет default-параметров для generic’ов.
- Java — нет.
- Rust —
Конфликт с D9 «один очевидный путь»:
Default делает Complex и Complex[f64] эквивалентами — это нарушает
«один путь». Но это не выбор для программиста (как с default
arguments — там программист может опустить или передать), это
сокращённая запись. Аналогично D58 implicit iter в for-loop
(for x in xs ≡ for x in xs.iter()) — формально два пути, но
семантически тот же.
Решает реальную проблему:
Без default — добавление generic к существующему типу = breaking change. С default — backward-compatible эволюция API. Это важно для долгоживущей stdlib.
Тонкость с D52 (newtype):
type Complex Complex[f64] // ОШИБКА: парсер не знает, это newtype
// или alias-без-keyword
type Complex alias Complex[f64] // ok: явный alias
Default-параметры дополняют alias-механику, не заменяют. Алиасы
полезны для конкретных инстанций (Complex32, ResultStr),
default — для самой частой инстанции «по умолчанию».
Статус. Не зафиксировано. Полезно для:
- Backward-compat при добавлении generic к существующим типам.
Complex[T = f64],HashMap[K, V, S = DefaultHasher](если будет hasher-параметр),Result[T, E = Error](если хочется упростить частый случай).
Решение — отложено до появления конкретного use case (например, generic Complex / Vector / Matrix через Q-math-protocol).
Связь: D16 (generic [T]),
D52 (newtype, alias),
D72 (bounds — комбинация
[T Bound = Default]),
Q-math-protocol — главный use case.
Q-char-literals. Синтаксис char-литералов ✅ ЗАКРЫТО (2026-05-07)
Реализовано предложенное в Q-char-literals: lexer recognizes
'a'/'\n'/'\u{HEX}'→ TokenKind::Char(u32) (codepoint). AST: ExprKind::CharLit + Literal::Char (для match-pattern’ов). Codegen: char как nova_int в bootstrap (codepoint напрямую). См. nova_tests/types/char_literals.nv (16 тестов).Оригинальный текст ниже сохранён для истории.
Контекст. В prelude есть тип char (D26),
он используется в сигнатурах: fn s @chars() -> Iter[char],
fn s @char_at(i int) -> Option[char]. Однако синтаксис
char-литерала ('a', '\n', 'é') в спеке не описан.
Это пробел: код вида match c { '0'..='9' => ... } или
if c == '"' нужен в практических парсерах (std/encoding/json.nv,
std/math/complex.nv и т.д.),
но формально не определён.
Предложение. Добавить char-литералы стандартного вида:
let c char = 'a'
let nl = '\n'
let backslash = '\\'
let quote = '\''
let unicode = 'é' // é
let emoji = '\u{1F600}' // 😀, escape с {} для codepoint > 0xFFFF
Грамматика:
char-literal = "'" ( raw-char | escape ) "'"
escape = '\\' ( "'" | '"' | '\\' | 'n' | 'r' | 't' | 'b' | 'f' | '0'
| 'u' hex4
| 'u{' hex+ '}' )
hex4 = hex hex hex hex
Тонкости:
- Конфликт с tuple-индексом?
t.0,t.1(D37) использует точку и цифру. Char-литерал начинается с'— нет конфликта. - Конфликт со str-литералом?
"a"этоstr,'a'этоchar. Чёткое разделение: одинарные = char, двойные = string. - Single-quote string как в Python? Нет — Python разрешает оба варианта для строк. Nova использует одинарные только для char.
charэто codepoint или byte? Codepoint (как Rust, Swift). Размер 4 байта (Unicode scalar). Не байт-char как в C.- Range patterns (
'0'..='9'в match) — отдельный вопрос (Q-range-patterns), часто связан с char-литералами.
Прецеденты:
| Язык | Char-литерал | Тип |
|---|---|---|
| Rust | 'a' | char (Unicode scalar, 4 байта) |
| Swift | "a" as Character или Character("a") | Character |
| Go | 'a' | rune (int32) |
| C/C++ | 'a' | char (byte) или int |
| Java | 'a' | char (UTF-16 code unit) |
| Python | нет (всё — str) | — |
| OCaml | 'a' | char (byte) |
Большинство языков используют одинарные кавычки. Nova следует этому прецеденту.
Статус. Не зафиксировано. Текущая нужда — парсеры в stdlib
(json, complex, sql) используют char-литералы в кодекс-стиле. Bootstrap
parser char-литералы не поддерживает — это блокирует прогон таких
файлов. Предложенный синтаксис согласован с прецедентами и Nova-style
(escape через \\, \u{...} для extended codepoint’ов).
Связь: D44 (числовые литералы —
другой класс литералов), D26
(char в prelude как тип), D48
(tagged template literals — соседняя категория), Q-range-patterns
('0'..='9').
Q-string-indexing. Семантика s[i] для str ✅ ЗАКРЫТО (2026-05-07)
Решение: вариант B (Codepoint). В соответствии с школой B codepoint-indexed API (D26 пересмотрен 2026-05-07),
s[i]— codepoint at index,Option[char]. O(n) cost — это явная цена школы B; для hot-path есть explicits.bytes()→ byte-level access.
Контекст. D26 фиксирует str как UTF-8 byte slice внутри, но
все public operations работают на codepoint-уровне. Что означает
s[i]? Три варианта были рассмотрены:
| Вариант | Семантика | Cost | Прецедент |
|---|---|---|---|
| A. Byte | s[i]: byte (u8) | O(1) | Go |
| B. Codepoint ✅ | s[i]: Option[char] | O(i) | Python |
| C. Запрещено | Только s.bytes()[i] / s.chars().nth(i) | n/a | Rust |
Принят вариант B — consistent со всем остальным D26 API (s.len, s.slice, s.find, etc — всё codepoint-indexed). См. D26 «Почему codepoint-indexing (школа B) выбрана для Nova».
Связь: D26, Q-char-literals, D27.
Q-cstring. Гарантия nul-termination для nova_str.ptr
Контекст. Bootstrap-runtime сейчас:
nova_str_concat— аллоцируетlen + 1, кладёт\0после данных.- Литералы (
(nova_str){.ptr="...", .len=N}) — nul-terminated (C.rodata). nova_str_slice— НЕ добавляет\0, просто view.
Это полу-гарантия: пользователь не может надёжно передать
s.ptr в C-функцию без копирования.
Варианты:
- Always nul-terminated.
sliceкопирует с\0. Простой C-interop ценой O(n) на slice. Прецедент: Java Native Interface (черезGetStringUTFChars). - Никогда не гарантировано. Удалить
\0из concat, литералы — честный slice указатель. Любой C-interop — через явныйs.as_cstr() -> *const c_char(копирует если нужно). Прецедент: Rust (&str→CString— явная аллокация). - Текущее inconsistent. Документировать что
\0есть после литералов и concat, но не slice. Программист сам знает контекст. Не рекомендуется — путает.
Решение (2026-05-07): вариант 2 — Rust-style. Согласуется с
принципом «нет скрытых аллокаций» и упрощает nova_str_slice (zero-
copy). C-interop через явный Buffer.from(s).add_byte(0).into() (или
будущий s.as_cstring() -> []byte если станет частым use-case).
Текущий bootstrap всё ещё inconsistent (concat/литералы — terminated,
slice — нет); fix — отдельная задача рантайма.
Связь: D26, Q-ffi (FFI-механизм
ещё не зафиксирован), Q-buffer (Buffer.add_byte для явного \0).
Q-string-interning. Опциональное interning через Atom-тип
Контекст. D26 фиксирует str как не интернируемый. Это даёт
предсказуемый perf и совпадает с Rust/Go. Но кейсы AST identifiers,
JSON keys, log fields — это много дубликатов одной строки, где
interning экономит память и даёт O(1) ==.
Варианты:
- Auto-intern для коротких строк. Python-style — runtime автоматически интернирует строки до N байт. Скрытая cost.
- Явный
Atomтип. Erlang-style —Atomэто always-interned immutable identifier. Создаётся черезAtom("foo"), сравнение O(1) pointer equality. Sym[T]newtype. Тоньше — пользователь сам объявляет какие строки интернируются (type UserId = Sym[str]). Управление pool’ом через runtime API.- Просто
Rc[str]/Arc[str]. Ручная дедупликация через reference counting + хеш-таблицу. Самый простой вариант — нет нового типа, но программист сам управляет pool.
Предложение: отложить, по умолчанию 2 (Atom-type) как наиболее
expressive (Erlang/Elixir опыт показывает что Atoms полезны вне
строк — для tag-types, error-codes). До решения — Nova-программист
использует HashMap если нужна явная дедупликация.
Связь: D26, D6 (GC managed heap), Q-stdlib-data-types.
Q-buffer. Buffer — mutable byte accumulator ❌ REMOVED (2026-05-08)
Buffer удалён из языка полностью в Plan 04 Этап 6 (2026-05-08). Заменён split’ом на StringBuilder/WriteBuffer/ReadBuffer. Никакой backward compatibility — Nova не в production, революционный язык важнее обратной совместимости.
Удаление одним коммитом:
- codegen dispatch удалён (record_schemas + 5 групп special-case’ов в emit_call/infer).
nova_rt/buffer.hудалён.nova_tests/runtime/buffer.nvудалён.nova_rt/nova_rt.h—#include "buffer.h"удалён.- 14 std/ файлов мигрированы на StringBuilder (text-only sweep); url.nv decode_query — на WriteBuffer +
str.try_from([]byte)?.Замены:
- text accumulation →
StringBuilder(Q-string-builder)- binary accumulation →
WriteBuffer(Q-write-buffer)- binary reading →
ReadBuffer(Q-read-buffer)- mixed text+binary →
WriteBuffer+str.try_from([]byte)?(D77 — UTF-8 validate + конверсия).WriteBuffer @write_char(c)/@write_str(s)добавлены для UTF-8 encode chars/strings в byte buffer (Plan 04 Этап 6.1).Buffer — неудачное решение (попытка унифицировать text+binary в одном типе). Правильно заменено split’ом со специализированной семантикой. История unified
Buffer(Q-buffer 2026-05-07 → ⚠️ REPLACED 2026-05-08 → ❌ REMOVED 2026-05-08) сохранена ниже для понимания эволюции; не использовать ни при каких условиях — компилятор Buffer не знает.См. также: D82 (
externalkeyword), D26 (prelude добавляет три новых типа), Plan 04 Этап 6 (docs/plans/04-buffer-split-and-external.md).
Контекст. D26 фиксирует что s1 + s2 — O(a+b) per call, новая
аллокация. В hot loop s = s + x × N → O(N²). Это норма для
immutable strings, но требует builder для production кода.
Также для бинарных протоколов (network, serialization) нужен
аккумулятор []byte с capacity-grow. Это та же задача: растущий
байт-буфер. Различие только в финализации: с UTF-8 валидацией → str,
без проверки → []byte.
Решение (2026-05-07): один тип Buffer для обоих случаев. Это
шаг вперёд относительно прецедентов (которые имеют два типа —
bytes.Buffer + strings.Builder в Go, Vec<u8> + String в Rust).
Унификация даёт меньше API surface и одну mental model для AI-генерации.
Реализовано (2026-05-07): runtime nova_rt/buffer.h, codegen
special-case dispatch для Buffer.new() / Buffer.with_capacity(n) /
Buffer.from(s/b) (Path-form static) и для buf.add_*() / buf.into() /
buf.try_into() / buf.into_str_unchecked() / buf.len() / buf.capacity()
/ buf.clone() (Member-form instance методов на receiver-type
Nova_Buffer*). Tests: nova_tests/runtime/buffer.nv (16 passing) — basic
ops, capacity-grow, clone independence, UTF-8 add_char (1/2/4-byte),
hot-loop 1000-add accumulation. UTF-8 валидация в try_into
реализована вручную (overlong/surrogate detection). После consume
mutating-method даёт nova_assert("buffer consumed: ...") panic.
API (финализирован, не реализован)
// Создание
Buffer.new() -> Buffer
Buffer.with_capacity(n int) -> Buffer
Buffer.from(s str) -> Buffer // copy UTF-8 bytes
Buffer.from(b []byte) -> Buffer // copy bytes
// Аккумуляция (mutating, @-методы; bootstrap-limit: разные имена,
// см. ниже про overload)
fn Buffer mut @add_str(s str) -> () // O(s.len) memcpy + grow
fn Buffer mut @add_bytes(b []byte) -> () // O(b.len) memcpy + grow
fn Buffer mut @add_byte(b byte) -> () // одна byte
fn Buffer mut @add_char(c char) -> () // UTF-8 encode 1-4 bytes
// Финализация (consume — после неё mutating-методы → runtime panic
// "buffer consumed")
fn Buffer @into() -> []byte // infallible — через D73
fn Buffer @into() Fail[Utf8Error] -> str // fallible — D73 + Fail (target str)
fn Buffer @try_into() -> Result[str, Utf8Error] // D77 sugar — equivalent
fn Buffer @into_str_unchecked() -> str // escape hatch без проверки
// Note: D73 уточнение (2026-05-07) — fallible from/into через Fail-effect
// в сигнатуре. То есть `buf.into()` для target `str` декларирует
// Fail[Utf8Error] и throw'ает на невалидный UTF-8; для target `[]byte` —
// infallible. Compiler разрешает overload по target-type через D73 dispatch.
// `try_into()` остаётся как D77 convenience-sugar (Result-стиль).
// Read (без consume)
fn Buffer @len() -> int
fn Buffer @capacity() -> int
fn Buffer @clone() -> Buffer // для snapshot'ов
Семантика
@add_*копируют контент в internal[]byte(heap-allocated, 2x capacity grow).@into() -> []byteчерез D73 ([]byte.from(buf Buffer)авто- выводится изBuffer @into). Перевод ownership: после@into()любой mutating-метод → runtime panic.@try_into() -> Result[str, Utf8Error]через D77. Walks buffer, валидирует UTF-8, на успехе — zero-copy переход вstr. Тоже consume.@into_str_unchecked()— escape hatch для случаев когда buffer доказуемо валидный UTF-8 (например, аккумуляция только через@add_strи@add_charбез@add_byte/@add_bytes). Без runtime check, дешевле.@clone()возвращает независимую копию buffer; для snapshot’а без consume.
Bootstrap-limitation: overload по типу аргумента
Текущий codegen использует method_receivers: HashMap<name, (recv_ty, is_instance)> — ключ это только имя метода. Это значит overload
@add(s str) и @add(b []byte) на одном receiver Buffer дадут
last-wins (второй перепишет первого). Поэтому в API разные имена:
@add_str / @add_bytes / @add_byte / @add_char.
Когда Q-overloading закроется (overload by argument type) — можно
будет уплотнить в @add(...). Текущий API forward-compatible: добавить
overload-ed @add потом не сломает существующие @add_str etc.
Конверсии str ↔ []byte (через D73)
Без Buffer есть простая граница:
fn []byte.from(s str) -> []byte =>
// copy s.ptr..s.ptr+s.len
fn str.try_from(b []byte) -> Result[str, Utf8Error] =>
// validate UTF-8, return Ok(str) или Err(Utf8Error)
D73/D77 авто-синтезируют s.into() / b.try_into(). Это для
одноразовой конверсии. Buffer нужен когда конкатенаций много.
Прецеденты
| Язык | Builder/Buffer | Финализация | Особенности |
|---|---|---|---|
| Go | bytes.Buffer + strings.Builder | разные методы | разделены: bytes vs UTF-8 |
| Rust | Vec<u8> + String | String::from_utf8 | Vec |
| Java | ByteArrayOutputStream + StringBuilder | .toString() | разделены |
| Python | bytearray + ''.join | manual | разделены |
Nova унифицирует в один Buffer — меньше API surface, единая
mental model. Это design-выбор согласованный с principle «одна идиома
для одной задачи» (D9).
Связь: D26, D73
(From/Into для финализации), D77
(TryFrom/TryInto для UTF-8 валидации), Q-overloading (overload
по типу аргумента в @add), Q-array-api ([]T.from/@push general API),
Q-clone-semantics (@clone() deep vs shallow), Q-readonly-types
(TS-style Readonly<T> / DeepReadonly<T>).
Q-string-builder. StringBuilder — UTF-8 string accumulator ✅ ЗАКРЫТО (2026-05-08)
Обновление 2026-05-28 (D179, Plan 91.6):
StringBuilderпереведён сexternal typeна чистый Nova consume-типtype StringBuilder consume { mut buf []u8 }. Все методы Nova-body (кромеbuf.push(u8)— builtin array op). API изменения:@into() → @to_str(),@byte_len/@peekудалены,@char_lenновый. Подробности — см. D179 в08-runtime.md. Старые API ниже — историческая декларация на момент закрытия Q-string-builder в 2026-05-08.
Контекст. Replaces text-side унифицированного Buffer (Q-buffer).
s1 + s2 — O(a+b) per call; в hot loop O(N²). Требуется builder.
Split на StringBuilder (text-only) + WriteBuffer (binary-only)
выявился при добавлении endianness-методов в Q-buffer — text+binary
смешение ломает API.
Решение (2026-05-08): отдельный тип StringBuilder с append-only
text-семантикой. @into() -> str infallible (UTF-8 invariant
поддерживается каждым @append). Декларации API — через
external fn (D82), реализация — nova_rt/string_builder.h.
API (Plan 04 Этап 3)
export external fn StringBuilder.new() -> Self
export external fn StringBuilder.with_capacity(n int) -> Self
export external fn StringBuilder.from(s str) -> Self
export external fn StringBuilder.from(c char) -> Self
export external fn StringBuilder mut @append(s str) -> ()
export external fn StringBuilder mut @append(c char) -> ()
export external fn StringBuilder @len() -> int
export external fn StringBuilder @capacity() -> int
export external fn StringBuilder @clone() -> Self
export external fn StringBuilder @into() -> str // infallible
Семантика
- Append-only.
@append(s)копирует UTF-8-байты;@append(c)— encode codepoint в 1-4 байта. @into()infallible. UTF-8 валиден по построению (str/charвходы) — invariant держится без runtime-check на финализации.- Consume. После
@into()любой mutating-метод → runtime panic. @clone()— deep копия internal byte storage.- 2x capacity grow — стандартное удвоение.
Что отвергнуто
- Объединение с
WriteBufferобратно (унифицированный Buffer). Q-buffer закрылся как REPLACED — split лучше: type-safety + infallible@into() -> str. @append(b []byte)— нарушит UTF-8 invariant. Сырые байты → WriteBuffer.@into_str_unchecked()escape hatch (был в Q-buffer). Не нужен — построение через@append(s|c)уже гарантирует UTF-8.
Прецеденты
| Язык | Тип | Финализация |
|---|---|---|
| Java | StringBuilder | .toString() infallible |
| Go | strings.Builder | .String() infallible |
| Rust | String (с push_str/push) | identity (уже String) |
Все three разделяют builder для строк и для байтов. Nova согласована с этим mainstream’ом.
Связь: D26 (prelude),
D82 (external keyword),
D73 (from(c char) через D73),
Q-buffer (REPLACED), Q-write-buffer, Q-read-buffer.
Q-write-buffer. WriteBuffer — binary serialization buffer ✅ ЗАКРЫТО (2026-05-08)
Контекст. Replaces binary-side унифицированного Buffer (Q-buffer).
Бинарные протоколы (network, serialization) требуют endianness-методы
(write_u32_le, write_i64_be, …) — 18 числовых типов × LE/BE.
В унифицированном Buffer’е такие методы не вписывались рядом с text
(add_str, add_char).
Решение (2026-05-08): отдельный тип WriteBuffer с endianness-aware
write-методами. @into() -> []byte infallible. Декларации API —
через external fn (D82), реализация — nova_rt/write_buffer.h.
API (Plan 04 Этап 3)
export external fn WriteBuffer.new() -> Self
export external fn WriteBuffer.with_capacity(n int) -> Self
export external fn WriteBuffer.from(b []byte) -> Self
// Bytes
export external fn WriteBuffer mut @write_byte(v byte) -> ()
export external fn WriteBuffer mut @write_bytes(src []byte) -> ()
// 18 числовых × LE/BE (write_u8/i8 без endianness — 1 byte):
export external fn WriteBuffer mut @write_u8(v u8) -> ()
export external fn WriteBuffer mut @write_i8(v i8) -> ()
export external fn WriteBuffer mut @write_u16_le(v u16) -> ()
export external fn WriteBuffer mut @write_u16_be(v u16) -> ()
export external fn WriteBuffer mut @write_u32_le(v u32) -> ()
export external fn WriteBuffer mut @write_u32_be(v u32) -> ()
export external fn WriteBuffer mut @write_u64_le(v u64) -> ()
export external fn WriteBuffer mut @write_u64_be(v u64) -> ()
export external fn WriteBuffer mut @write_i16_le(v i16) -> ()
export external fn WriteBuffer mut @write_i16_be(v i16) -> ()
export external fn WriteBuffer mut @write_i32_le(v i32) -> ()
export external fn WriteBuffer mut @write_i32_be(v i32) -> ()
export external fn WriteBuffer mut @write_i64_le(v i64) -> ()
export external fn WriteBuffer mut @write_i64_be(v i64) -> ()
export external fn WriteBuffer mut @write_f32_le(v f32) -> ()
export external fn WriteBuffer mut @write_f32_be(v f32) -> ()
export external fn WriteBuffer mut @write_f64_le(v f64) -> ()
export external fn WriteBuffer mut @write_f64_be(v f64) -> ()
export external fn WriteBuffer @len() -> int
export external fn WriteBuffer @capacity() -> int
export external fn WriteBuffer @clone() -> Self
export external fn WriteBuffer @into() -> []byte
Семантика
@write_uN_le/be— endianness-explicit. Программист обязан выбрать LE/BE; нет «default-endian». Это безопасно: bug-class «забыл endianness» исключён на API-уровне.@write_u8/@write_i8— 1 byte, endianness не нужен.@into() -> []byteinfallible (любые байты валидны как[]byte).- Consume. После
@into()mutating → runtime panic. - 2x capacity grow — как StringBuilder.
Что отвергнуто
WriteBuffer.from(s str)— не вводим в MVP. Программист пишетwb.write_bytes(s.bytes())илиWriteBuffer.from(s.bytes()). Future вопрос: добавить как convenience.- Default-endian (
write_u32без суффикса). Bug-class. Программист забывает что default может быть LE на одной системе и BE на другой; network-protocol code тогда ломается тихо. - Объединение с
StringBuilderобратно — split победил, см. Q-string-builder. write_strметод — нарушает binary-only семантику. Программист пишетwb.write_bytes(s.bytes())— explicit конверсия str→bytes.
Прецеденты
| Язык | Тип | Endianness |
|---|---|---|
Rust byteorder crate | WriteBytesExt trait | LE/BE explicit |
Go encoding/binary | binary.LittleEndian.PutUint32 | namespace per endian |
Java ByteBuffer | .order(ByteOrder.LITTLE_ENDIAN) | mode-per-buffer |
Nova выбирает Rust-style explicit per-method — самый безопасный (нет hidden state).
Связь: D26, D82, Q-buffer (REPLACED), Q-string-builder, Q-read-buffer, Q-overloading.
Q-read-buffer. ReadBuffer — cursor-style binary reader ✅ ЗАКРЫТО (2026-05-08)
Контекст. Pair к WriteBuffer для читающей стороны бинарных
протоколов. View над []byte с position-cursor; @read_* advance’ит
position. Pair @read_* (Fail-form, throw на end-of-buffer) /
@try_read_* (Result-form) — auto-derive на C-runtime уровне.
Решение (2026-05-08): отдельный тип ReadBuffer. View, не value
(нет @into() — явный throw блокирует D73 auto-derive). Декларации —
через external fn (D82), реализация — nova_rt/read_buffer.h.
API (Plan 04 Этап 3)
export external fn ReadBuffer.from(b []byte) -> Self // view, no copy
export external fn ReadBuffer @position() -> int
export external fn ReadBuffer @remaining() -> int
export external fn ReadBuffer @has_remaining(n int) -> bool
export external fn ReadBuffer @remaining_bytes() -> []byte // copy of remaining
// Throwing form (Fail[ReadBufferError])
export external fn ReadBuffer mut @read_byte() Fail[ReadBufferError] -> byte
export external fn ReadBuffer mut @read_bytes(n int) Fail[ReadBufferError] -> []byte
export external fn ReadBuffer mut @read_u8() Fail[ReadBufferError] -> u8
export external fn ReadBuffer mut @read_i8() Fail[ReadBufferError] -> i8
export external fn ReadBuffer mut @read_u16_le() Fail[ReadBufferError] -> u16
export external fn ReadBuffer mut @read_u16_be() Fail[ReadBufferError] -> u16
// ... все 18 числовых × LE/BE
// Try form (Result[T, ReadBufferError]) — auto-derived на C-runtime уровне
export external fn ReadBuffer mut @try_read_byte() -> Result[byte, ReadBufferError]
export external fn ReadBuffer mut @try_read_bytes(n int) -> Result[[]byte, ReadBufferError]
export external fn ReadBuffer mut @try_read_u8() -> Result[u8, ReadBufferError]
// ... все 18 числовых × LE/BE
// Block D73 auto-derive of @into() — ReadBuffer is a view, not a value
fn ReadBuffer @into() Fail[Error] -> () =>
throw Error.new("ReadBuffer.@into() is not supported; use @remaining_bytes()")
ReadBufferError
export type ReadBufferError
| UnexpectedEnd { wanted int, available int }
Будущие варианты (InvalidFormat, InvalidUtf8) — добавлять по мере
появления read-методов с этими failure modes.
Auto-derive read/try_read на C-runtime уровне
Программист stdlib не пишет @read_* и @try_read_* отдельно.
Одна C-функция на каждый числовой × LE/BE возвращает
result-структуру:
typedef struct ReadResult_u32 {
nova_bool ok; // 1 = success, 0 = UnexpectedEnd
uint32_t value;
int64_t wanted; // для error
int64_t available;
} ReadResult_u32;
Codegen эмитит обе Nova-сигнатуры на одну C-функцию:
@read_u32_be(Fail-form): проверяетok, throw’ит черезNova_Fail_failсReadBufferError.UnexpectedEnd { wanted, available }.@try_read_u32_be(Result-form): wrapper упаковывает вResult.Ok(value)/Result.Err(UnexpectedEnd {wanted, available}).
Минимизирует C-код в 2x (~18 functions вместо 36) и поддерживает D77 «программист пишет одну форму, обе доступны».
Семантика
- View, no copy —
ReadBuffer.from(b)хранит указатель + len + pos, не копирует input. Срок жизни[]byteдолжен пережитьReadBuffer(managed heap GC обеспечивает). @position/@remaining/@has_remaining— read-only cursor metadata.@remaining_bytes()— копирует оставшиеся байты в новый[]byte. Это compromise: zero-copy view над slice потребовал бы Q-readonly-types.@into()явный throw — блокирует D73 auto-derive@into()для ReadBuffer. ReadBuffer — view, не value-to-convert.
Что отвергнуто
- Только
@try_read_*(без Fail-формы). Программист часто хочетtry— early-exit через?operator. Но Fail-форма короче для «just read it, throw on error» паттерна. Обе нужны. - Только
@read_*(Fail-only). Result-форма необходима для graceful-recovery в protocol parser’ах. ReadBuffer.@into() -> []byte— нарушает view-семантику. Какой bytes возвращать — все? Только remaining? Двусмысленно. Лучше явный@remaining_bytes().- Default-endian — bug-class, как и в WriteBuffer.
Прецеденты
| Язык | Тип | Read API |
|---|---|---|
Rust byteorder | ReadBytesExt trait | read_u32::<LE>(...) Result |
Go binary.Read | binary.LittleEndian.Uint32(b) | panic on short read |
Java ByteBuffer | .getInt() | BufferUnderflowException |
Nova auto-derive read/try_read — оригинальная фича (закрепляется плотно D77 pattern).
Связь: D26,
D77 (TryFrom/TryInto параллель),
D82, Q-buffer (REPLACED), Q-write-buffer.
Q-codegen-builtins-cleanup. Удаление hard-coded external-таблиц из codegen ✅ CLOSED (2026-05-08)
Plan 12 закрыт (2026-05-08). Ф.1-Ф.5 + Ф.7 acceptance.
std/runtime/builtins.nv— single source of truth; codegen читает AST черезExternalRegistry(include_str!-embedded в binary). Hard-coded dispatch удалён в emit_call (Member-form instance + Member-form static + Path-form static). Acceptance: добавлениеWriteBuffer @write_zero(n int)в builtins.nv + runtime impl работает БЕЗ правки Rust-codegen’а.Не сделано (отложено): Ф.6 type-checker gate для unknown methods на opaque types. Сейчас unknown method даёт linker error (late-stage); idealем early-stage type error. Отдельный refactor
types/mod.rs, не блокер для main goal’а.
Контекст. D82 (расширен 2026-05-08)
фиксирует: std/runtime/builtins.nv — единственный источник истины
для сигнатур external-функций. Codegen знает только правила mangling
и Nova→C type mapping, но не хранит список самих функций.
Сейчас codegen этому ещё не соответствует. В compiler-codegen/ есть:
record_schemas.insert("StringBuilder", ...)/"WriteBuffer"/"ReadBuffer"— hard-coded layout/method tables.- Method dispatch таблицы — special-case
Nova_StringBuilder_method_*emit’ы в emit_c.rs. - Старый
record_schemas.insert("Buffer", ...)(Plan 04 Этап 6 удалит).
Проблема. Любое расхождение между builtins.nv и Rust-таблицей — silent. Если в builtins.nv:
export external fn WriteBuffer mut @write_u32_be(v u32) -> ()
а в codegen Rust hard-coded Nova_WriteBuffer_method_write_u32_be(buf, v) где v имеет тип int (не uint32_t) — компилируется, но
runtime UB: codegen эмитит call с nova_int (64-bit), runtime ждёт
uint32_t, ABI ломается.
Что нужно сделать. Codegen читает AST builtins.nv (как обычный
Nova-модуль) и для каждой external fn декларации:
- Применяет mangling rules → C-name.
- Применяет Nova→C type mapping → C-prototype.
- Эмитит prototype в сгенерированный header.
- При встрече вызова
wb.@write_u32_be(v)— lookup’ит декларацию в builtins.nv AST, не в Rust-таблице.
После этой миграции hard-coded таблицы удаляются. Расхождение между
.nv-декларацией и runtime-реализацией ловится линкером (undefined
reference / type mismatch при включённом -Wstrict-prototypes).
Объём работы.
- AST-walker для builtins.nv (можно переиспользовать существующий parser).
- Mangling rules вынесены в один модуль (сейчас разбросано).
- Type mapper Nova→C централизован.
- Удаление
record_schemas.insert(...)для StringBuilder/WriteBuffer/ ReadBuffer (после Plan 04 Этап 6 — и для Buffer, до — оставить).
Зависимости.
- Plan 04 Этапы 1-5 (runtime типы реализованы) — закрыты.
- Plan 04 Этап 6 (удаление Buffer) — pending.
- Этот cleanup — после Этапа 6 или параллельно.
Связь: D82 (single source of truth правило), Plan 12 (этот cleanup — план), Plan 04 Этап 6 (предшествует), Q-overloading (overload-resolution тоже читает из AST builtins.nv).
Q-match-unit-arms-in-expr. Bootstrap-codegen: match в expression-position с unit-arms
Контекст. Когда match-выражение стоит в expression-position
(let r = match expr { ... }), а тело какой-то arm’ы содержит
только unit-возвращающие statements (например assert(...),
println(...)), bootstrap-codegen эмитит C-код с void mismatch —
функция nova_assert возвращает unit (void), но codegen ожидает
nova_int или nova_str value.
Симптом. При компиляции:
error C2440: невозможно преобразование "void" в "nova_int"
Где встречается. Обнаружено реальной работой со stdlib:
nova_tests/runtime/error_runtime_error.nv— 4 места (IndexOutOfBounds / TypeMismatch / AssertFailed / NoHandler).std/collections/hashmap.nv— 4 места (проверки Occupied/Some).
Workaround. Переписать на if let Pattern = expr { stmts; assert(...) } — if let это statement-form, нет mismatch.
Что нужно для закрытия. Codegen должен распознавать unit-result match-arms и:
- Либо завернуть в block-expr с явным
()return. - Либо detect’ить void в C и эмитить как statement, а не expression.
- Либо в type-checker’е требовать всем arm’ам совпадающий не-unit тип, если match в expr-position (более строгая семантика).
Status. Не закрыто. Workaround достаточен для bootstrap, но ограничивает идиоматический Nova-код. После полного codegen rewrite (Plan 02) — закрыть.
Связь: Plan 02 (codegen-c-backend), D19
(match-arms =>), Q-pattern-mut (ниже — связанное ограничение).
Q-pattern-mut. Bootstrap-codegen: mut в pattern не парсится
Контекст. В match/let-pattern’ах нельзя использовать mut-модификатор:
match result.get("section") {
Some(mut section_map) => { // ✗ parse error
section_map.insert("k", "v")
}
None => ()
}
Парсер не распознаёт Some(mut x) — ожидает identifier, а не keyword.
Workaround. Переписать на if let с отдельным let mut:
if let Some(section_map_immut) = result.get("section") {
let mut section_map = section_map_immut
section_map.insert("k", "v")
}
Где встречается. Реально нужно при работе с Option/Result
обёртками над mutable коллекциями. В std/encoding/ini.nv
переписано workaround’ом при миграции.
Что нужно для закрытия. Расширить parser pattern-grammar:
pattern = ['mut'] (identifier | constructor-pattern | record-pattern
| tuple-pattern | wildcard | literal)
mut в pattern должен создавать mutable binding (как let mut x = expr) — это compile-time annotation, runtime-поведение не меняется.
Status. Не закрыто. Workaround (отдельный let mut) рабочий, но
многословный. Низкий приоритет — после Plan 02 (codegen rewrite) или
при type-checker rewrite.
Связь: Q-match-unit-arms-in-expr (родственное bootstrap- ограничение), D33 (let/const/mut/readonly), Plan 02.
Q-overloading. Перегрузка функций / методов по типу аргументов ✅ CLOSED by D84
Закрыт D84 (2026-05-10). Полная семантика: четыре оси (receiver-тип, типы аргументов, тип результата, арность); правила резолва — самый специфичный матч, concrete > generic, non-variadic > variadic, args-фильтр перед result-фильтром, ambiguity → compile error с hint’ом. Распространяется на свободные функции, методы и static-функции на типе.
Реализация в bootstrap-codegen:
- Methods (с receiver’ом) — ✅ работает (Plan 11): multi-overload registry + strict resolution + C-name mangling.
- Free-functions (без receiver’а) — ✅ разрешено по D84, codegen будет расширен через тот же mangling-механизм.
- Method values + disambiguation через
as fn(...)— ✅ Plan 11 Ф.4/Ф.5.Variant 4 (protocol-based dispatch) — параллельный путь, не отменяющий D84: используется когда расширяемость через protocol предпочтительнее ad-hoc перегрузки. Описан как идиоматичный путь в D84 «Что отвергнуто».
Контекст. D46 фиксирует operator overloading через @plus/
@times etc — это перегрузка по operator-кейсу. Но ad-hoc overload
обычных функций / методов по типу аргументов — не описан.
Текущее состояние bootstrap-codegen:
| Ось перегрузки | Bootstrap | Прецеденты |
|---|---|---|
По receiver-типу (fn int @m() vs fn str @m()) | ✅ Работает | Rust impl блоки |
| По типу результата (через D73/D77 dispatch) | ✅ Работает | Haskell type classes |
По типу аргумента на одном receiver (fn T @m(s str) vs fn T @m(b []byte)) | ❌ Last-wins | Java, C++, Swift |
| По arity (разное число аргументов) | ❌ Last-wins | C# optional params |
Причина: method_receivers: HashMap<name, (recv_ty, is_instance)> —
ключ это только имя метода. Insert по тому же имени переписывает.
Use-cases требующие arg-type overload:
Buffer.add(s str)/Buffer.add(b []byte)— Q-buffer.Logger.log(msg str)/Logger.log(level int, msg str).- Coercive constructors:
Money.from(int)/Money.from(f64)/Money.from(str)— частично решается D73 (несколькоfromс разными типами параметра).
Варианты:
- Полная ad-hoc overload (Java/Swift-style). Компилятор резолвит
по статическим типам аргументов. Требует переделки
method_receiversвHashMap<name, Vec<Sig>>+ dispatch-логику. - Только D73-style dispatch. Программист пишет несколько
T.from(V1),T.from(V2)— это уже работает (D73 specifically дляfrom). Расширить same-name multiple-defines на любые статические методы — но с явной семантикой “разные параметры значит разные dispatch-ключи”. - Запретить overload, требовать разные имена. Текущее состояние
bootstrap.
add_str/add_bytesetc. Lower expressiveness, но очень предсказуемо. - Generic functions (
fn add[T](v T)с trait-bound). ЕслиBuffer @add[T Encodable](v T)— один метод, dispatch через protocol. Требует Encodable protocol с методом encode_to_buffer. Сложнее объявить, но extensible (новые типы могут implement Encodable).
Предложение: на bootstrap-уровне 3 (разные имена — что и делается в Q-buffer). На production-уровне — 4 (protocol-based) как идиоматичный Nova-путь, с fallback на 1 для редких случаев где protocol не подходит (overload по числу аргументов).
Связь: D46 (operator overloading,
specific case), D53 (protocols как
основной механизм абстракции), D73
(From уже допускает multiple T.from(V1)/T.from(V2) — частный
случай), Q-buffer (motivating use-case).
Q-overload-result-type. Result-type overload (ось 3 D84) — отложено
⏸ DEFERRED (2026-05-10). Производная от D84 — ось 3 (по типу результата) частично реализована: type-checker регистрирует overloads с разным return-type, но codegen на call-site не делает expected-type propagation. Trigger: реальный use-case в stdlib, где single-target
Into[T]через D73 + ось 1 не покрывает (напримерT.@into() -> XvsT.@into() -> Y— multi-target конверсии для одного receiver’а).
Контекст. D84 заявляет четыре оси перегрузки. Оси 1 (receiver-type), 2 (arg-types), 4 (arity) — реализованы в bootstrap-codegen (Plan 11 + 2026-05-10 free-fn extension). Ось 3 (result-type) — частично:
fn Celsius @into() -> Fahrenheit => ...
fn Celsius @into() -> Kelvin => ...
let f Fahrenheit = c.into() // должно резолвиться в первый
let k Kelvin = c.into() // должно резолвиться во второй
Что работает:
- Type-checker допускает обе декларации (overload по возврату — валидно по D84).
- Mangling даёт уникальные C-имена.
Что не работает:
- При
c.into()codegen не смотрит на ожидаемый тип из контекста (let-аннотация, return-position, тип параметра, поле record-литерала). - Если кандидатов несколько с одинаковыми arg-types и разными return- type — ambiguity error, даже когда контекст однозначно задаёт тип.
Что нужно для реализации.
Codegen на каждом call-site должен:
- Вытащить expected type из контекста выражения (let-annotation, return-position, argument-type вызывающей функции, поле record-литерала).
- Применить как фильтр 3 в D84 resolve: отбросить кандидатов с несовместимым return-type.
- Если после фильтра остался один — выбрать его.
- Если несколько / ноль — fallback на текущую ambiguity error.
Это требует bidirectional type inference через выражения: типы текут не только bottom-up (из аргументов), но и top-down (из контекста).
Workaround сейчас. Вместо instance-method overload по возврату — static-функции с разными именами:
fn Fahrenheit.from(c Celsius) -> Self => ...
fn Kelvin.from(c Celsius) -> Self => ...
// Вместо `c.into()`:
let f = Fahrenheit.from(c)
let k = Kelvin.from(c)
Это работает потому что T.from(...) overload’ится по receiver-типу
(ось 1), которая полностью реализована.
Альтернативно — Into[T] (D73)
работает в bootstrap’е через single-target конверсию + контекст из
let-аннотации. Multi-target Into — пока не покрывается.
Когда разморозить. Реальный use-case в stdlib, где обходной путь (static-функции / single-target Into) не работает или требует много дублирования. Например:
Vec[T] @into() -> List[T]vsVec[T] @into() -> Set[T].Json @into() -> UservsJson @into() -> Order(но это уже из плохого дизайна — лучшеUser.from_json(j)).
Связь: D84
(основное определение четырёх осей), D73
(Into[T] — частный случай через single-target), Plan 11 (bootstrap
для осей 1, 2, 4).
Q-clone-semantics. @clone() — shallow или deep / рекурсивно?
✅ CLOSED by D26 → «
@clone()— shallow по умолчанию» (Plan 17 Ф.1, 2026-05-08):@clone()— shallow для record и коллекций (поля копируются, managed-references share’ятся). Для deep-копии программист пишет вручную (@deep_clone()не в prelude). Исключение — opaque accumulator-типы (StringBuilder,WriteBuffer), для которых@clone()deep по семантике типа (mutable internal buffer не должен share’иться между копиями).Прецедент Rust (Clone shallow, DeepClone руками), Java (Object.clone shallow), Go (slice/map share по assign).
Регрессия:
nova_tests/runtime/clone_semantics.nv.
Контекст. В Nova нет & borrowing (D6 — managed heap), поэтому
все ссылки value-shared через GC. Это значит let b = a копирует
указатель, не контент. Когда нужна независимая копия, программист
вызывает a.clone().
Вопрос: что именно делает @clone()?
Прецеденты:
| Язык | Default | Расширения |
|---|---|---|
| Rust | per-type (Clone trait) | derive(Clone) рекурсивно поля |
| Java | shallow (Object.clone) | override для deep |
| Go | value-types (assignment копирует поля) | references — share |
| Python | copy.copy shallow, copy.deepcopy recursive | разные функции |
| JS/TS | Object.assign({}, x) shallow | structuredClone(x) deep |
Варианты для Nova:
- Auto-derived deep clone (Rust-style). Компилятор синтезирует
@clone()для record/sum-типа: для каждого поля вызываетfield.clone(). Программист может override для специальных случаев.int @clone()→ value copy (тривиально)str @clone()→ тот же ptr (immutable, нет смысла копировать)Buffer @clone()→ копия internal[]byte(mutable, требует независимости)Cache @clone()→ пустой cache (override — зависит от business semantics)- Циклы: runtime-detection (set уже-клонированных) или запрет.
- Shallow по умолчанию, явный
@deep_clone(). Дешевле default, но программист должен помнить про share-mut между clone’ами. - Не вводить
@clone()в prelude. Каждый тип сам определяет что клонировать значит. Минимум surprise, максимум ad-hoc work.
Предложение: 1 (auto-derived deep) — Rust-style. Это:
- Безопасный default (после clone независимы).
- Auto-derive снимает boilerplate для типичных типов.
- Override доступен где нужна другая семантика.
- Циклы — отдельная задача (Q-cycle-detection); пока Nova не имеет явных reference-циклов в data-types (D6: GC может collect cycles но user-code их не создаёт идиоматически).
Тонкости:
Buffer @clone()— deep копия[]byte(vital для buffer’а; shared buffer между clone’ами = data races).str @clone()— тот же ptr (str immutable, копия эквивалентна).[]T @clone()— auto-derived: новый array, для каждого элемента вызываетсяelement.clone(). O(n).- Записи с handler-фунциями / closures — closure clone что значит? Открытый sub-вопрос.
Связь: D6 (managed heap), Q-buffer
(Buffer @clone() — конкретный mutable use-case), Q-cycle-detection
(когда это станет актуально).
Q-readonly-types. TypeScript-style Readonly<T> / DeepReadonly<T>
Контекст. TS позволяет помечать тип как иммутабельный на любой глубине через mapped types:
type Readonly<T> = { readonly [K in keyof T]: T[K] }
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
В Nova сейчас:
- D36 даёт
readonlymodifier на отдельных полях record. - НЕТ
readonly Tкак type-modifier для целого типа. - НЕТ
keyof T/ mapped types.
Use-cases:
s.bytes()хочет вернуть[]byteбез mutate-возможности — сейчас нет способа. Workaround: копировать (что и делает D26).- API возвращает «config», который не должен меняться вызывающим — сейчас только конвенция «не меняй».
- Snapshot vs live view — сейчас выражается копированием.
Варианты:
- Полный TS-style.
keyof T, mapped types,readonly T,DeepReadonly<T>. Большая type-system фича. Compile-time only (нет runtime enforcement в managed heap без borrow-checker). Read[T]effect-marker. Маркируем функции «читает только» через эффект — runtime может проверять (через GC marker?) или просто compile-time hint. Согласовано с Nova effect-system.const Tnewtype. Простой type-flag.const []byte— это отдельный тип,[]byte→const []byteчерезas, mutate methods compile-time error.- Не вводить. Полагаться на конвенцию + копирование там где надо независимость.
Тонкость: D6 (managed heap, без borrow-checker) ограничивает
runtime enforcement. Любая readonly-проверка может быть только
compile-time (как в TS). Это значит Nova-код может через as cast’ом
обойти readonly — это soft guarantee, не hard. TS живёт с
этим, но это тонкость.
Предложение: отложить до созревания. Сейчас — 4 (конвенция +
копия) через Buffer.clone(), s.into() -> []byte (копия) и т.д.
Когда будет 5+ конкретных use-cases где readonly нужен — выбрать
вариант 2 (Read[T] как effect) или 3 (const T newtype) в
зависимости от того, нужно ли это compile-time only или runtime.
Связь: D6, D36
(readonly поля), D62 (effects как
runtime-marker pattern), Q-effect-polymorphism.
Q-keywords-as-fields. Можно ли использовать keyword как имя поля?
✅ CLOSED by D83 (2026-05-08) вариантом 1 — keywords строго запрещены как identifier’ы. Без escape-механизма (Rust
r#, Swift backticks отвергнуты как overkill для bootstrap’а; могут быть добавлены после v1.0 если накопится FFI-боль).Sweep задача:
std/collections/queue.nv— полеin []Tпереименовать вinputилиinputs.
Контекст. std/collections/queue.nv использует in как имя поля:
export type Queue[T] {
mut in []T // ⛔ in — keyword (for x in iter)
mut out []T // ✅ out — обычный ident
}
Bootstrap-парсер падает на in field-declaration:
expected identifier, got 'in'.
Варианты (на момент обсуждения):
- Запретить keywords как identifiers вообще. Все keywords —
зарезервированы. Программист переименовывает (
input,inq). Самый простой, согласован с большинством языков (Rust/Go/Java). - Контекстно-чувствительные keywords.
inkeyword только вfor x in iter-конструкции, везде ещё — обычный ident. Сложнее парсер, но эргономичнее. Прецедент: Swift, C# (contextual keywords). - Raw-identifier escape.
r#in— keyword как ident через префикс. Прецедент: Rustr#fn,r#move.
Принятое решение: Вариант 1, зафиксирован в D83.
Связь: D30 (naming convention), D83 (closing decision).
Q-effect-type-anonymous. Anonymous effect types в позиции type
✅ CLOSED by Variant 3 (2026-05-08): использовать
Iter[T]protocol из prelude. Нет нужды в anonymous effect types в позиции типа — D58 даётIter[T]явно, D53 protocol-as-type работает для структурного match’а.Sweep done:
std/collections/linkedlist.nv:from_iterиstd/collections/set.nv:from_iterмигрированы сit effect { mut next() -> Option[T] }(некорректный синтаксис) →it Iter[T](корректный, prelude).
Контекст. std/collections/linkedlist.nv использовал effect { ... }
inline в параметре функции:
fn LinkedList[T].from_iter(it effect { mut next() -> Option[T] }) -> Self {
while let Some(x) = it.next() { ... }
}
Это anonymous effect type — структурный effect, объявленный в
позиции type-аннотации. Bootstrap-парсер не поддерживал: expected type, got 'effect'. Также синтаксис некорректен по двум
причинам:
effect— kind-token при declaration of named type (D18/D61), не используется в позиции типа значения inline.mutна operation в effect-declaration не описан spec’ом — effects описывают operations безmut(D61).
Варианты (на момент обсуждения):
- Поддержать anonymous effect types. Парсер видит
effect {...}в type-position, парсит method-block, создаёт anonymous effect. Согласовано с D53 (protocol как type). - Только named effects. Программист объявляет
effect Iter[T] { mut next() -> Option[T] }отдельно, потом использует имя. Простой, less expressive. Iter[T]protocol в prelude. Стандартный protocol для итераторов; пользователь принимаетit Iter[T]без объявления. Прецедент: RustIntoIterator, SwiftIteratorProtocol.
Принятое решение: Variant 3 — Iter[T] protocol уже есть
в D26 prelude (см. D58/08-runtime.md строки 332-336):
type Iter[T] protocol {
mut next() -> Option[T]
}
Программист пишет it Iter[T] для drain-параметра. Структурно любой
тип с mut @next() -> Option[T] удовлетворяет. Anonymous effects
в позиции типа не нужны — D53 protocol-as-type решает то же самое.
Связь: D58 (Iter[T] protocol), D53, D26 (prelude содержит Iter[T]).
Q-generic-receiver-method. fn []T @method[U](...) — generic methods на slice
✅ ЧАСТИЧНО ЗАКРЫТО (2026-05-17) для user-defined generic типов через D119
Generic method с method-level type-param теперь работает на user generic types:
Wrapper[T] @map[U](f fn(T) -> U) -> Wrapper[U]— compiler emit’ит mono’d instance per (T, U) pair, bidirectional inference из closure-typed args, return type корректно substituted.Остаётся OPEN для built-in
[]T(slice receiver) — требует отдельной parser-side работы ([]Tв receiver position). Q-array-api всё ещё open.
Контекст. std/collections/vec.nv хочет writing extension methods
на встроенный []T:
export fn []T @map[U](f fn(T) -> U) -> []U { ... }
export fn []T @filter(pred fn(T) -> bool) -> []T { ... }
Bootstrap не парсит []T как receiver type. Это требует:
- Парсер:
[]Tв receiver position — type с inferred type-parameter. - Codegen: ✅ DONE (D119) — generation специализированных функций
для каждой комбинации
(T, U)через mono pass.
Варианты:
- Полная поддержка generic methods на built-in типах. Codegen ✅ готов
(D119). Остался parser-side работа:
[]Tв receiver position. - Free functions с TYpe parameters.
fn map[T, U](xs []T, f fn(T) -> U) -> []U. Просто, но теряется method-syntax (xs.map(f)). - Prelude-методы только. Compiler знает фиксированный набор
[]T.map/.filter/.foldи т.п., user не расширяет. Простой bootstrap-уровень.
Предложение: 3 на bootstrap (text status), 1 на production
(codegen уже готов через D119 — нужен только parser pass для []T
receiver). User generic types — уже работают.
Связь: D27, D35, D72, D119, Q-array-api.
Q-assert-without-parens. assert cond без parentheses?
Контекст. std/data/sql.nv писал assert n == 42 как
keyword-style assert (без скобок), но Nova assert это обычная
функция, требует assert(n == 42).
Варианты:
assert— функция (текущее).assert(cond)обязательны parens. Nova-консистентно (println("..."),Mem.live()— все функции с parens).assert— keyword.assert cond— отдельный statement. Прецедент: Rustassert!(cond)(макрос); Javaassert cond(statement).- Trailing-block style.
assert { cond }? Нелепо для assertions.
Предложение: 1 — keep current. Nova не имеет macros, и
выделять assert как special-form нет причин. Обновить файлы где
было assert ... без скобок.
Связь: D40 (function call syntax).
Q-source-annotations. CLI --no-annotate-source (default-on) ✅ ЗАКРЫТО (2026-05-07)
Реализованы annotations
/* SRC: <Nova-исходник> */перед каждым statement’ом / fn-body / trailing-expr сгенерированного.cфайла. По умолчанию включены (user-driven решение 2026-05-07): полезно настолько часто (отладка, code-review, понимание codegen), что должно быть default-on. Off — через явный--no-annotate-sourceдля CI-friendly стабильных diff’ов.Прецеденты: Cython (по умолчанию вставляет Python-исходник), Crystal (
--emit-line-numbers), Rust LLVM (#lineдирективы). Nova выбрал opt-in — потому что (a) Nova-исходник может содержать non-ASCII (UTF-8 в строках/идентификаторах), MSVC иногда хочет/utf-8flag; (b) тестовая сюита диффает.c, аннотации дают разные diffs.Реализация:
CEmitter::set_source_for_annotations(src: String),emit_source_annotation_for_stmt(stmt)hook в началеemit_stmt. Snippet берётся из span’а statement’а, первая строка, escaped*//*, truncated до 120 символов.
Q-stdlib-minimal-api. Минимальный stdlib API surface, выявленный из practical libraries
Контекст. Реализация пяти практических stdlib-либ (math/complex.nv, data/semver.nv, encoding/json.nv, identifiers/uuid.nv, encoding/base64.nv, encoding/url.nv, checksums/crc32.nv, identifiers/ulid.nv) выявила минимальный набор API, без которого парсеры и сериализаторы не пишутся. Этот набор — ориентир для bootstrap stdlib implementation.
Каждая API ниже используется по крайней мере в двух из перечисленных файлов. Это не пожелания, а измеренные требования.
Эффекты
// Random — детерминизм через handler-substitution в тестах
type Random effect {
u64() -> u64 // 64 случайных бита
bytes(n int) -> []byte // массовая генерация
}
// Pre-defined handlers
fn seeded(seed u64) -> Effect[Random] // PRNG с фиксированным seed
fn secure() -> Effect[Random] // CSPRNG для production
// Time — Unix-timestamp + sleep
type Time effect {
now_ms() -> u64 // Unix timestamp в миллисекундах
now_ns() -> u64 // наносекунды (для high-precision)
sleep(d Duration) -> ()
}
// Pre-defined handlers
fn fixed_ms(ms u64) -> Effect[Time] // время заморожено
fn system_clock() -> Effect[Time] // реальные часы OS
Use cases:
Random— uuid.nv (v4), ulid.nv, любая криптографияTime— uuid.nv (v7), ulid.nv, expiration, retry backoff
Парсинг чисел из строки
fn int.try_from(s str) -> Result[int, ParseIntError]
fn u64.try_from(s str) -> Result[u64, ParseIntError]
fn i64.try_from(s str) -> Result[i64, ParseIntError]
fn f64.try_from(s str) -> Result[f64, ParseFloatError]
// ... для всех числовых типов
D77 даёт обе формы: int.from(s) Fail[ParseIntError] -> int синтезируется.
ParseIntError / ParseFloatError — отдельные типы (D30 convention).
Базовые str-методы (предполагаются в prelude)
fn str @len() -> int // длина в байтах (или codepoint'ах? — Q-string-len)
fn str @char_at(i int) -> Option[char] // codepoint на позиции i
fn str @chars() -> Iter[char] // итератор codepoint'ов
fn str @bytes() -> []byte // UTF-8 байты
fn str @slice(from int, to int) -> str // подстрока (D78 — открытый Q что значит i)
fn str @starts_with(prefix str) -> bool
fn str @ends_with(suffix str) -> bool
fn str @contains(needle str) -> bool
fn str @find(needle str) -> Option[int] // позиция или None
fn str @replace(from str, to str) -> str
fn str @to_lower() -> str
fn str @to_upper() -> str
fn str @trim() -> str
fn str @split(sep str) -> []str
fn str @strip_prefix(p str) -> Option[str] // None если не starts_with
fn str @strip_suffix(s str) -> Option[str]
Static-методы str (конструкторы)
fn str.from(c char) -> str // 1-char string
fn str.from(n int) -> str // через D74 conversion
fn str.from(b bool) -> str // "true" / "false"
fn str.from(f f64) -> str // лучше через format spec
fn str.from_codepoint(code int) Fail[InvalidCodepoint] -> str // 1 codepoint → str
fn str.from_bytes(b []byte) Fail[Utf8Error] -> str // UTF-8 validate
fn str.from_bytes_unchecked(b []byte) -> str // escape hatch
[]T API
fn []T.new() -> []T // empty с capacity 0
fn []T.with_capacity(n int) -> []T // empty с зарезервированной памятью
fn []T mut @push(item T)
fn []T @len() -> int
fn []T @is_empty() -> bool
fn []T @get(i int) -> Option[T] // safe indexing
// arr[i] — panic on bounds (D13), arr.get(i) — Option
fn []T mut @clear()
fn []T mut @remove(i int) -> Option[T]
fn []T @first() -> Option[T]
fn []T @last() -> Option[T]
fn []T @contains(value T) -> bool // требует @eq на T
fn []T @iter() -> Iter[T]
Buffer API (Q-buffer закрыто, реализовано)
fn Buffer.new() -> Buffer
fn Buffer.with_capacity(n int) -> Buffer
fn Buffer.from(s str) -> Buffer
fn Buffer.from(b []byte) -> Buffer
fn Buffer mut @add_str(s str)
fn Buffer mut @add_bytes(b []byte)
fn Buffer mut @add_byte(b byte)
fn Buffer mut @add_char(c char) // UTF-8 encode 1-4 bytes
fn Buffer @into() -> []byte // consume → bytes (infallible)
fn Buffer @into() Fail[Utf8Error] -> str // consume → str (UTF-8 validate)
fn Buffer @try_into() -> Result[str, Utf8Error]
fn Buffer @into_str_unchecked() -> str // escape hatch
fn Buffer @len() -> int
fn Buffer @capacity() -> int
fn Buffer @clone() -> Buffer
Option[T] методы
fn Option[T] @is_some() -> bool
fn Option[T] @is_none() -> bool
fn Option[T] @unwrap() -> T // panic on None
fn Option[T] @unwrap_or(default T) -> T
fn Option[T] @unwrap_or_else(f fn() -> T) -> T
fn Option[T] @map[U](f fn(T) -> U) -> Option[U]
fn Option[T] @and_then[U](f fn(T) -> Option[U]) -> Option[U]
fn Option[T] @ok_or[E](err E) -> Result[T, E]
Result[T, E] методы
fn Result[T, E] @is_ok() -> bool
fn Result[T, E] @is_err() -> bool
fn Result[T, E] @unwrap() -> T // panic on Err
fn Result[T, E] @unwrap_err() -> E // panic on Ok
fn Result[T, E] @ok() -> Option[T] // Result → Option (D77)
fn Result[T, E] @err() -> Option[E]
fn Result[T, E] @map[U](f fn(T) -> U) -> Result[U, E]
fn Result[T, E] @map_err[F](f fn(E) -> F) -> Result[T, F]
fn Result[T, E] @and_then[U](f fn(T) -> Result[U, E]) -> Result[U, E]
HashMap[K, V] API (для json.nv)
fn HashMap[K Hashable, V].new() -> HashMap[K, V]
fn HashMap[K, V].with_capacity(n int) -> HashMap[K, V]
fn HashMap[K, V] mut @insert(key K, value V) -> Option[V]
fn HashMap[K, V] @get(key K) -> Option[V]
fn HashMap[K, V] @contains(key K) -> bool
fn HashMap[K, V] mut @remove(key K) -> Option[V]
fn HashMap[K, V] @len() -> int
fn HashMap[K, V] @entries() -> Iter[(K, V)]
fn HashMap[K, V] @keys() -> Iter[K]
fn HashMap[K, V] @values() -> Iter[V]
Iter[T] composers
fn Iter[T] @map[U](f fn(T) -> U) -> Iter[U]
fn Iter[T] @filter(pred fn(T) -> bool) -> Iter[T]
fn Iter[T] @fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc
fn Iter[T] @count() -> int
fn Iter[T] @collect() -> []T // в массив; collect[Out]() — Q-collect-mechanism
Числовые операции (D74 instance methods)
Уже зафиксировано в D74. Минимум для парсеров и математики:
fn f64 @sqrt() / @cbrt() / @sqr()
fn f64 @sin() / @cos() / @atan2(other) / @hypot(other)
fn f64 @abs()
fn f64 @is_finite() / @is_nan() / @is_infinite()
fn f64 @floor() / @ceil() / @round() / @trunc()
fn f64 @min(other) / @max(other)
fn int @abs()
fn int @pow(n int)
fn int @signum()
fn int @min(other) / @max(other)
Static константы:
f64.PI / f64.E / f64.NAN / f64.INFINITY / f64.MAX / f64.EPSILON
int.MAX / int.MIN
Ошибки парсинга — стандартные типы в prelude
По D30 convention Parse<TypeName>Error:
type ParseIntError { value str, reason str }
type ParseFloatError { value str, reason str }
type Utf8Error { position int, byte byte }
type InvalidCodepoint { value int }
Что отсутствует (намеренно — не для MVP)
- Regex — отдельная либа, не prelude. Q-regex.
- Date/Time formatting — кроме
Time.now_ms(), format/parse сложен. - JSON parser — пользовательская либа (
std.json), не prelude. - Crypto primitives — отдельная либа.
- Async I/O — через эффекты Net/Fs, не prelude API.
Приоритеты реализации
Tier 1 (без них stdlib не пишется):
int.try_from(s)/u64.try_from(s)/f64.try_from(s)strметоды (@len,@char_at,@chars,@slice,@find,@contains,@starts_with)[]Tбазовые (new,with_capacity,push,len,get,iter)Buffer(уже реализован 2026-05-07)Option/Resultосновные методы
Tier 2 (для production):
Random/Timehandler’ы (включаяseeded/fixed_ms)HashMap- Iter composers
- str manipulation (
@to_lower,@to_upper,@trim,@replace)
Tier 3 (nice-to-have):
- Regex
- Format spec
- Расширенные числовые (
f64 @atan2, etc.)
Что уже реализовано в bootstrap (по состоянию на 2026-05-07)
После раундов 4–5 codegen+runtime закрыли часть Tier 1/2:
- str:
@bytes()/@chars()/@split(sep)— раунд 4 (eager, []byte / []int / []str) - Pattern alternation
Some(A) | Some(B) => bodyв match-arms — раунд 4 - Buffer API (Q-buffer закрыто) — реализован
- Channel[T] base API (D79):
Channel.new(cap),@send/@recv/@try_send/@try_recv/@close/@is_closed/@len/@capacity, drain-семантика — раунд 5- Tier 1+ (для concurrent stdlib);
select { ... }— pending до spawn-block fix
- Tier 1+ (для concurrent stdlib);
- D28 effect inference — private fn с throw авто-получает Fail
- char-литералы (Q-char-literals закрыто) —
'a' / '\n' / '\u{...}'
Pending Tier 1/2:
int.try_from(s)/u64.try_from(s)/f64.try_from(s)— D77 spec есть, runtime нетRandom/Timehandler’ы — нужны для AI-first тестов через handler-substitutionHashMap[K, V]— base нет в runtimeIter[T]composers (@map,@filter,@fold,@count,@collect)
Связь
- D26 — prelude содержит часть этого набора; D26 нужно расширить под этот список.
- D74 — math instance methods.
- D77 — TryFrom для парсинга.
- Q-buffer — закрыто.
- Q-char-literals — закрыто.
- Q-string-indexing — open: что значит
iвstr @char_at(i int)(байты или codepoint’ы). - Q-collect-mechanism — open:
collect[Out](). - std/ — все либы используют этот набор.
Статус. Open question — не decision потому что это накопительный список, не финальный. Каждая новая stdlib-либа может выявить что-то ещё. Но текущий набор уже измерен на 8 практических файлах и является обязательным минимумом для bootstrap stdlib implementation.
Q-parallel-tuple. parallel { ... } блок с typed tuple-result
Контекст. D50 рекомендует
mut-захваты для гетерогенного fan-out:
let mut a = 0
let mut b = 0
spawn { a = compute_a() }
spawn { b = compute_b() }
Это race-prone в production-runtime (D14 с preemption) и допустимо только в D71 single-threaded bootstrap. После принятия D79 (channels) есть safe-альтернатива для streaming/pipelines, но для 2-N разнородных задач channel тяжеловат.
Предложение. parallel { ... } блок с typed tuple-result:
let (a, b) = parallel {
compute_a(), // → A
compute_b() // → B
}
let (users, posts, count) = parallel {
fetch_users(), // []User
fetch_posts(), // []Post
count_active() // int
}
Семантика:
- Каждое выражение запускается в отдельном fiber’е параллельно.
- Блок ждёт завершения всех.
- Результат — tuple типов выражений в порядке объявления.
- При throw в любом fiber — отмена остальных через cancel-propagation
(как
parallel forсегодня). - Никакого shared
mut— программист не пишет race-prone захваты.
Преимущества:
- Safe by construction. Нет shared state, только структурное агрегирование результатов.
- Типизировано. Compiler знает типы каждого выражения, собирает правильный tuple-тип.
- AI-friendly. Один паттерн вместо двух (
mut-захват vs channels) для типичного fan-out. - Композиция с D75. Если нужен kill-switch снаружи — используем
supervised(cancel: tok).
Implementation hint — overload-семья (bootstrap)
В Nova нет variadic generics (есть только variadic для одного
типа []T через D69). Для bootstrap-
времени реализация — explicit overload-семья N=2..8:
fn parallel[A, B](
a fn() -> A,
b fn() -> B,
) -> (A, B)
fn parallel[A, B, C](
a fn() -> A,
b fn() -> B,
c fn() -> C,
) -> (A, B, C)
// ...до N=8 (стандартный лимит, как Rust tuple impls)
Особенности:
parallel— library function, не language keyword. Это упрощает парсер.- Overloading по arity (число параметров) — D46 разрешает overloading по типу аргумента, по arity тоже работает.
- Generic-параметры выводятся из типов lambda-выражений в позиции аргумента.
Использование как блок-выражение возможно благодаря trailing-block-стилю (D43):
let (a, b) = parallel(
|| compute_a(),
|| compute_b(),
)
Долгосрочная цель — variadic generics
В будущем (отдельный Q-variadic-generics) вместо overload-семьи — один generic:
fn parallel[T...](fns ...fn() -> T) -> (T...)
Где T... — variadic generic-параметр (как Rust tuple[T...] или
TypeScript [...T]). Это отдельный Q, не блокер для
parallel-tuple сейчас.
Тонкости
-
Cancellation на throw. Если первая задача throw’ит, остальные должны отмениться. Реализация через
supervised-style scope под капотом. Если нужен «keep going on error» — программист пишетResult[T, E]в каждой ветке явно. -
Empty / 1-arg parallel.
parallel()илиparallel(f)— тривиальные случаи.parallel(f)≡(f(),)(single-element tuple) — runtime overhead не оправдан, лучше compile-warning. -
Effect-row.
parallel(f, g)имеет union эффектов f и g. В bootstrap при overload-семье — статическая union. С variadic generics — динамическая. -
Async-context.
parallelиспользует suspension (ambient, D62) — сигнатуры fn() -> T чистые, suspension implicit.
Прецеденты
| Язык | Конструкция | Notes |
|---|---|---|
| Rust | tokio::join!(f, g) | macro, возвращает tuple |
| OCaml 5 | Domain.spawn + manual sync | без tuple-builder |
| Erlang | rpc:multicall | для distributed |
| Go | errgroup.Group{}.Go(...) | через group, не tuple |
| Swift | async let a = ... | per-binding async |
| Kotlin | awaitAll(deferred1, deferred2) | возвращает list |
Nova parallel(...) -> (T1, T2, ...) — гетерогенный typed tuple,
самая близкая аналогия — Rust tokio::join!.
Статус
Не зафиксировано. После принятия D79 (channels) parallel-tuple — естественное дополнение для гетерогенного fan-out. Решение:
- Bootstrap path: overload-семья
parallel[A,B],parallel[A,B,C], …,parallel[A,B,…,H]в prelude. - v2 path: variadic generics + единая
parallel[T...]функция.
Связь: D14 (suspension ambient), D50 (concurrency model), D69 (variadic для одного типа), D79 (channels — solution для streaming, parallel-tuple — для fan-out 2..N).
Q-build-pgo. Profile-Guided Optimization для C-backend
Контекст. Nova C-backend через compiler-codegen сейчас не
интегрирован с PGO. Прирост от PGO в production-компиляторах
обычно 15-30% на hot path’ах (rustc bootstrap +12-14%, Chrome
core rendering +15-25%). Это самая большая «бесплатная»
оптимизация после LTO для backend-программ.
Зависит от:
- Plan 09 (Clang migration) должен быть завершён до PGO
работы. На MSVC PGO существует, но слабее: Clang/LLVM имеет
IR-based профили (более точные), instrumentation flags
(
-fprofile-generate,-fprofile-use), tooling (llvm-profdata).
Открытые вопросы:
-
AutoFDO vs обычный PGO?
- Обычный PGO (
-fprofile-generate) — инструментирует binary счётчиками, training run медленнее обычного, но точнее. - AutoFDO (
-fprofile-sample-use) — используетperfsampling, training run без overhead’а, но менее точные профили (sampling на ~100kHz). AutoFDO проще для CI (не нужен инструментированный binary), обычный PGO даёт чуть больше прироста. Не решено.
- Обычный PGO (
-
Profile в репо или нет?
- За хранение: training run может занимать минуты, удобно закоммитить готовый профиль.
- Против: профили платформо-специфичные (x86-64-v3 vs ARM), устаревают при изменении кода, blow up репозиторий.
- Cargo (Rust) — рекомендует не коммитить. Программист сам делает training run.
- Решается при написании полного плана.
-
PGO как часть
nova buildили отдельный workflow?- Integrated:
nova build --pgoделает three-step pipeline автоматически (instrument → user training run → use). Удобно, но скрывает магию. - Manual: программист сам пишет три команды
(
--pgo-instrument, run,--pgo-use). Гибче для CI. - Скорее всего оба (default — manual,
--pgoshorthand для стандартного workflow).
- Integrated:
-
Каков канонический training workload?
- User-defined — программист передаёт свой workload
(
nova build --pgo-train="./bench/representative.sh"). - Auto-generated — Nova prepare’ит из tests/benchmarks автоматически.
- Рекомендация: оба пути; для stdlib-разработки используем
bench/suite (план 09 Ф.6).
- User-defined — программист передаёт свой workload
(
-
PGO для stdlib и user-кода — раздельно или один профиль?
- Один профиль — проще, но если stdlib обновляется чаще user-кода, профиль устаревает.
- Раздельно — stdlib имеет свой PGO профиль (один раз обновляется при release), user-код имеет свой.
- Скорее всего сначала один профиль (простота),
cargo pgo-style refinement позже.
Не open question: само решение «использовать PGO» — да, очевидно полезно. Open это как интегрировать.
Связь:
- Plan 09 — Clang migration, prerequisite.
- Plan 10 — stub для PGO работы. Полный план будет написан после плана 09.
- [docs/simplifications.md] →
[P-no-pgo-integration]— пометка про текущее отсутствие.
Когда закроется: после реализации Plan 10 (PGO integration)
с benchmark’ами показывающими ≥10% прирост vs --release без PGO.
Q-keyword-symmetry. Симметрия keyword’ов в declaration и literal: effect/protocol vs handler
✅ РЕШЕНО 2026-05-22 (Plan 97). Вариант 4 (полная симметрия) принят и зафиксирован в D142:
- (B) keyword
handlerснят, литерал эффекта пишется черезeffect X { ops }. Builtin типEffect[E, IRT]переименован вEffect[E, IRT](см. D87 «Plan 97 amendment»).- (D) анонимный protocol-литерал введён —
protocol X { ops }в expression-position для one-off implementations (Channel-style capability-split factory pattern). Type-position также получилprotocol { sig* }(анонимный protocol-тип в bound’ах / параметрах), см. D53 §628 и Plan 15[P-15-anon-protocol-bound](снят).Реализовано в Plan 97 Ф.2 (anon-protocol type-position), Ф.3 (handler→effect rename, lexer/parser/prelude/sweep), Ф.4 (protocol-literal expression). Clean break — backwards-compat намеренно не сохраняется.
Решающий аргумент: capability-split factory pattern окупает вариант 4, а симметрия declaration↔literal согласована с D52/D53 (kind-token система) и D61/D87 (effect позиционная dispatch’ация).
Историческое обсуждение оставлено ниже как справка.
Контекст. Сейчас Nova использует разные keyword’ы для declaration и literal-формы одной сущности:
// Declaration:
type Cron effect { run() -> () }
type Fan protocol { run() -> () }
// Literal (только для effect):
let h = effect Cron { run() => () } // keyword `handler`, не `effect`
let p = ??? // для protocol — нет literal-формы вообще
Возникает вопрос: унифицировать ли keyword’ы — использовать
effect/protocol и в declaration, и в literal-position?
// Предложение:
let h = effect Cron { run() => () } // keyword `effect`
let p = protocol Fan { run() => () } // новый — anonymous protocol-литерал
Развилка 1 — effect vs handler для литерала эффекта:
- (A) Оставить
handler(текущее). Точнее в expression-position: чтениеlet h = effect Logger { ... }сразу говорит «это обработчик эффекта, не сам эффект».effect Logger { ... }может вводить в заблуждение: «это значение типа эффекта Logger?» - (B) Переименовать на
effect. Симметрия с declaration (effect≡effect). Breaking change, нужен migration sweep по всем тестам и spec’у.
Развилка 2 — anonymous protocol-литералы:
- (C) Не делать (текущее). Protocol реализуется через типы с методами. Идиома Rust/Go/Swift. Аргумент: для reusable протоколов (Hashable, Iter) named-форма естественна; anonymous- форма экономит мало в этих случаях.
- (D) Делать
protocol Fan { run() => () }. Аналог Kotlinobject : Runnable { ... }/ Java anonymous classes / TS object-literal. Удобно для one-off реализаций без объявления отдельного типа.
Уточнение: protocols в Nova бывают двух типов use-case:
- Reusable (Hashable, Iter, From, Into) — лучше named-форма. Тип нужен в bound’ах, generic-сигнатурах, документации.
- One-off (Channel-style factory results, see use-case ниже) — выигрывают anonymous-форму. Тип нужен только как return-type factory-функции.
Old assumption «protocols обычно reusable» — частично верна. Для большинства protocols (~80%) да. Но structural-pattern «factory возвращает interface-implementations» делает one-off случай частым в concurrency- и I/O-API.
Важная аналогия: Nova уже имеет anonymous protocol-impl —
это effect Logger { ... } для эффектов. Эффект структурно тот же
контракт (набор методов с сигнатурами) что и protocol. Различия:
| Effect | Protocol | |
|---|---|---|
| Структура контракта | методы (operations) | методы |
| Anonymous literal | effect X { ... } ✅ есть | нет (текущее) |
| Применяется в | with X = h { ... } | parameter / generic-bound |
| Типичный use-case | one-off (mock в тесте, transaction) | reusable (Hashable, Iter) |
Реальная причина разной идиомы — частота one-off vs reusable
использования, не философское различие. Для эффектов anonymous-
форма окупается, потому что handler’ы почти всегда одноразовые.
Для протоколов экономия меньше — программист один раз пишет
type MyIter и переиспользует.
Слабые аргументы (отвергнутые при анализе):
«Реализации спрятаны, не находятся grep’ом»— найдутся черезgrep "protocol Fan". То же что дляhandler X.«Размывает AI-first locality»— closures уже приняты в Nova (D22 closure-light/full); anonymous protocol — обобщение closure на multi-method, та же категория.«Captures complexity»— managed heap (D6) разрешает капчуры для closures, для protocol-литералов та же семантика.
Реальный аргумент против (D):
- D40 «один очевидный путь». Если protocol чаще reusable (named-type идиома лучше), anonymous-форма добавляет второй путь без существенной новой выразительности — анти-паттерн Nova.
- Прецедент Swift. Языки с extension-системой (Swift)
обходятся без anonymous-impl. Nova-методы (
fn Type @method) работают как extensions — Swift-подобная модель.
Реальный аргумент за (D):
- Симметрия с handler-литералом — оба «inline implementation of a method-contract». Текущая асимметрия неестественна.
- Multi-method ad-hoc удобен для случаев когда нужно реализовать protocol с 2-3 методами разово (closures покрывают только single-method case).
- Прецеденты Kotlin/Java/TS — устоявшийся паттерн.
Прецеденты:
| Язык | Effect-literal/handler | Anonymous protocol/interface |
|---|---|---|
| Nova (current) | effect X { ... } | нет |
| Koka | with effect X { ... } | нет |
| Eff | handler { ... } | нет |
| Java | — (нет effect system) | new Runnable() { ... } ✓ |
| Kotlin | — (нет effect system) | object : Runnable { ... } ✓ |
| Rust | — (нет effect system) | нет (только impl Trait for Type) |
| Go | — (нет effect system) | нет (только конкретные типы) |
| Swift | — (нет effect system) | нет (только extension Type: Protocol) |
| TypeScript | — | object-literal удовлетворяет interface структурно ✓ |
| OCaml | — | нет (только functor/module) |
Для effect-литералов прецеденты не помогают — Koka/Eff используют
свой keyword handler, как Nova сейчас. Для anonymous protocol
картина расколота: Kotlin/Java/TS — за, Rust/Go/Swift/OCaml — против.
Почему мейнстрим без anonymous protocol-impl — разные причины, не один отвергнутый аргумент:
- Rust — невозможно из-за ownership/borrow-checker (нужен concrete type на стадии анализа). К Nova не применимо (нет ownership).
- Go — методы требуют named receiver-типа (Go-специфика). Можно
через
var r Runner = (myStruct{}).func()обходные пути. К Nova применимо частично (метод привязан к типу черезfn Type @m). - Swift —
extension Type: Protocol { ... }достаточно идиоматичен, нет потребности в anonymous. Nova близка к Swift (extension-style методы). - OCaml — functor/module system покрывает похожие use-cases.
Почему мейнстрим с anonymous (Kotlin/Java):
- Java — исторически (до 1.8 нет lambdas, anonymous classes были единственным способом передать callback).
- Kotlin — унаследовал, оставил для multi-method контрактов.
- TypeScript — структурная типизация делает любой object-литерал потенциальной impl интерфейса автоматически.
Аргументы в Nova-контексте:
- AI-first locality (R5.1). Anonymous protocol-impl затрудняет поиск реализаций — программист (или LLM) не может grep’ом найти все impls protocol’а если часть из них в expression-position.
- Цена в symbols. Anonymous-impl экономит ~2-3 строки vs
type X {} + fn X @m(). Это малая выгода. - Симметрия в declaration↔literal — слабый аргумент. В Rust
struct X { f: int }объявление иX { f: 42 }литерал тоже не имеют symmetric keyword’ов (нетstruct X { f: 42 }в expression-position). Так делают большинство языков. handlerkeyword — узкий и точный. Не пересекается ни с чем, парсер прост.
Объём работ:
- (B) переименование
handler→effectв literal: правка lexer, парсер, ~30+ тестов вnova_tests/, ~10 spec-документов, AST-узел, codegen, interp. Среднее изменение. - (D) добавление
protocol X { ... }литерала: новый AST-узелProtocolLit, парсер, type-checker (структурная проверка соответствия protocol’у), codegen (синтез anonymous-типа + методов). Большое изменение.
Варианты комбинаций:
| # | Effect-literal | Anon protocol | Объём | Net |
|---|---|---|---|---|
| 1 | handler (A) | нет (C) | 0 | статус-кво |
| 2 | effect (B) | нет (C) | средний | симметрия без новой фичи |
| 3 | handler (A) | protocol (D) | большой | новая фича без переименования |
| 4 | effect (B) | protocol (D) | большой+ | полная симметрия |
Конкретный use-case — capability-split factory pattern (обнаружен 2026-05-10):
Канонический паттерн «factory возвращает несколько связанных interface’ов с общим скрытым state», каждый interface — отдельный capability на одну сущность.
Пример (гипотетический):
// Гипотетический Lock с capability-split:
type Locker protocol { lock() -> () }
type Unlocker protocol { unlock() -> () }
fn Lock.new() -> (Locker, Unlocker) {
let state = MutexState { ... }
let l = protocol Locker {
lock() -> () => state.lock()
}
let u = protocol Unlocker {
unlock() -> () => state.unlock()
}
(l, u)
}
Без anonymous protocol-литерала нужно объявить два named-типа
(LockerImpl, UnlockerImpl) с явными методами + обернуть. Цена —
~3-4 лишних строки и два типа в namespace которые больше нигде
не используются (полностью one-off).
Сравнение с другими языками для этого use-case:
| Язык | Boilerplate | Эквивалент |
|---|---|---|
| Nova-named (текущее) | средний | два named-типа + методы + constructor |
| Nova-anonymous (D) | минимальный | как в примере выше |
| Kotlin | минимальный | object : Locker { override fun lock() = ... } |
| TypeScript | минимальный | object-literal удовлетворяет structurally |
| Rust | большой | внутренние struct LockerImpl + impl Trait |
| Go | большой | named-типы lockerImpl, unlockerImpl |
| Swift | большой | type-erasing wrapper или внешние structs |
Use-case — прямое противоречие аргументу «D40 один путь»: named-path работает, но дороже на каждый capability-split API.
Замечание о текущем Channel в Nova: Nova уже имеет Channel[T]
(D79), но по Go-модели — один
объект, у которого есть и send, и recv. Это другая модель,
не capability-split.
Capability-split (вторая модель из Rust/Python/TS) — отдельная
дизайн-задача. Если когда-нибудь в Nova появится отдельный
split_channel() API (по образцу tokio::sync::mpsc,
MessageChannel JS, multiprocessing.Pipe Python) — anon protocol
будет идиомой.
Реалистичные кандидаты в Plan 18 stdlib:
Process.spawn(cmd) -> (Stdin, Stdout, Stderr)— child-process с тремя capabilities.HttpServer.bind() -> (Acceptor, ShutdownHandle)— слушатель + capability для graceful shutdown.Db.transaction() -> (TxReader, TxWriter, Commit)— три role’а в транзакции.
Эти API точно появятся в зрелой stdlib. Тогда anon protocol — естественная идиома.
Предложение (обновлено). Use-case есть, не «когда-нибудь появится». Текущая дилемма:
- Если приоритет — минимальный bootstrap — статус-кво (named-типы), документировать через guide «как писать Channel- style API в Nova». Стоимость в каждом stdlib-API — 3-4 строки.
- Если приоритет — идиоматический stdlib — реализовать (D) до начала Plan 18 (stdlib roadmap). Channel-API и другие sync-primitives получают чистый идиом.
Решение между (1) и (2) зависит от того, когда начнётся реальная stdlib работа. Если она через 2-3 сессии — (2) разумно сделать сейчас. Если откладывается — статус-кво до v1.0-аудита.
До решения — статус-кво (вариант 1: handler + no anon protocol).
Anonymous-форма для протоколов не запрещена принципиально —
просто пока не реализована. Use-case Channel-style зафиксирован
как сильный аргумент для приоритезации.
Связь:
- D42, D53 — protocol как структурный контракт.
- D61 —
handlerkeyword в литерале. - D10, R5.1 — AI-first locality.
- Q23 — группировка методов (
methods Type { ... }-блок) — другая related фича про синтаксис методов.
Q-mn-*. M:N runtime — открытые вопросы
Источник: Plan 23 — M:N runtime roadmap. Эти вопросы выявлены при проработке архитектуры перехода с N:1 (single-thread bootstrap) на M:N (work-stealing scheduler на пуле OS-thread’ов). Закрываются D-блоками до старта реализации M:N.
Q-mn-1. Memory model для shared mut при M:N
Что происходит когда fiber A на worker’е 1 и fiber B на worker’е 2 оба пишут в одно managed-heap поле без synchronization?
Варианты:
- (a) UB (Rust-style): запрещено компилятором через ownership analysis.
- (b) Atomic-required: shared mut между fiber’ами требует
Atomic[T]обёртки. Type-checker enforce’ит. - (c) Channel-only: shared mut между fiber’ами в принципе запрещён;
общение только через
Channel. Owner-actor pattern obligatorily.
Влияет на: type-checker rules, std.sync API surface, generic-bounds.
Связь: D6, D79, D91, Plan 18 std.sync.
Q-mn-2. Fiber-migration boundary для realtime nogc
realtime nogc { body } (D64) обещает
«no GC pauses, no suspension». При M:N — fiber может мигрировать
между worker’ами; миграция через GC safepoint = pause. Решения:
- (a) Pin fiber’а к worker’у на время блока.
- (b) Запрет миграции через атрибут fiber’а (
no_migrate). - (c) Запрет M:N для fiber’ов, прошедших через realtime-блок (downgrade в N:1 на время).
Связь: D64.
Q-mn-3. Concurrent GC choice
BDW-GC (libgc) drop-in vs свой Go-style concurrent mark-and-sweep
с write-barrier’ами. Trade-off в Plan 23 «Слой 6».
Рекомендация Plan 23: BDW-GC сначала (быстрый путь к работающему M:N), свой GC — отдельный milestone после v1.0 при необходимости.
Решение фиксируется отдельным D-блоком (D-mn-gc) до старта реализации.
Связь: D6.
Q-mn-4. Worker count auto-tuning
По умолчанию nproc? Через NOVA_THREADS env? Через nova.toml?
Configurable runtime через Runtime.set_workers(n) API?
Прецеденты: Go — GOMAXPROCS env + runtime API; Tokio — worker_threads
в build’е; Erlang — +S N флаг.
Q-mn-5. Blocking-effect — pool size
Plan M:N добавляет honest blocking-pool thread’ов для Blocking
operations (D50). Размер пула —
fixed, auto-grow, либо bounded?
Прецеденты: Go’s blocking-pool unbounded (один thread per blocking
call); Tokio spawn_blocking — bounded (default 512); JVM
ManagedBlocker — fork-join адаптивный.
Q-mn-6. Effect handler stack — concurrent access
with X = h { spawn { ... } } — после spawn fiber имеет snapshot
handler-stack’а (D80). Под M:N —
handler-объект может быть shared между worker’ами. Если handler
stateful (captures let mut через closure) — UB.
Spec должен явно описать:
- Handler — immutable после capture? Mutable но требует internal synchronization?
- Если handler-method вызывается одновременно на двух worker’ах — это race? Или sequential consistency гарантируется?
Q-mn-7. SIGINT / signal handling в multi-thread runtime
Какой thread получает SIGINT при Ctrl+C? libuv даёт uv_signal_t
— на какой uv_loop_t его прибивать?
Сценарии:
- (a) Dedicated signal-thread, шлёт
uv_async_sendна main-worker. - (b) Signal handler на main-worker’е напрямую (libuv позволяет на одном thread’е).
- (c) Каждый worker регистрирует — broadcast, идемпотентность через
nova_cancel_token_cancel(main_scope_token).
Связь: D92 (implicit main-scope, Plan 22 Ф.5).
Q-cancel_scope-lambda-syntax. ЗАКРЫТО (2026-05-14) — cancel_scope keyword удалён, отмена через supervised(cancel:)
✅ ЗАКРЫТО (2026-05-14). Решение: не превращать
cancel_scope(и прочие keyword-scope’ы) в trailing-fn функции. Вместо этого keywordcancel_scopeудалён полностью, а внешняя отмена выражается именованным аргументомcancel:уsupervised:supervised(cancel: tok) { body }(ревизия D75, зависит от D102 «именованные аргументы»).Почему не trailing-fn-функция:
supervised— неустранимый keyword (точка регистрацииspawn-fiber’ов, D14/D50). Делать его функцией не выгодно — компилятор всё равно спецкейсит. Аcancel:как именованный аргумент keyword-конструкции не требует ни нового keyword’а, ни scope-introducedtok =>binding.Token-scope enforcement: проблема не escape, а aliasing. Токен теперь caller-owned by construction (создаётся вне scope’а), поэтому «no escape» нечего защищать. Защищается double-bind — runtime bind-check «один токен → один живой scope», без affine/linear-типов.
tok.cancel()на завершённом scope’е — безвредный no-op.Реализация / миграция bootstrap’а — Plan 47. Ниже — исходный анализ вопроса (сохранён для контекста).
Контекст. Сейчас cancel_scope — keyword-конструкция со специальным
синтаксисом (D75):
cancel_scope { tok =>
spawn { do_thing(tok) }
spawn { do_other(tok) }
}
Здесь tok => — это не lambda-arrow, а scope-introduced token binding.
Token tok имеет тип CancelToken и доступен внутри блока для
передачи в spawn / для последующего tok.cancel() снаружи.
Это согласовано с другими keyword-scope конструкциями в Nova:
supervised { ... }— keyword-block без tokenparallel for x in iter { ... }— keyword-forforbid X { ... }— keyword-capabilitywith_timeout(5.s) { ... }— keyword-block с paramcancel_scope { tok => ... }— keyword-block с token
Предложение: унификация на trailing-fn (D43)
cancel_scope мог бы быть обычной функцией prelude с handler-param:
fn cancel_scope[T](body fn(CancelToken) Fail -> T) Fail -> T
Вызов через trailing-fn (D43):
cancel_scope() fn(tok) {
spawn { do_thing(tok) }
spawn { do_other(tok) }
}
Плюсы
- Единообразие. Один pattern «trailing-fn с params» вместо
special-cased keyword-syntax для
cancel_scope. - AI-генерация safer. LLM знает trailing-fn pattern из
list.map(...) fn(x) { ... }, не нужно учить ещё один edge-case. - First-class.
cancel_scopeможно передавать как value (let cs = cancel_scope), что невозможно с keyword’ом. - Парсер проще. Нет special-casing для
cancel_scopetoken — обычный fn-call.
Минусы
- Token leak.
tokтеперь lambda-параметр, не scope-binding — его можно сохранить в outerlet mut t = ...; cancel_scope() fn(tok) { t = tok }и использовать после exit’а из scope’а. Compile-time enforce «tok доступен только внутри scope’а» теряется. Mitigation — либо runtime-check (cancel’нуть уже мёртвый scope = error), либо ownership-rules (token не Copyable, потребитель owns). - Codegen overhead. Сейчас
cancel_scope— keyword с custom codegen (token-init + scope-bind за один шаг). При обычной fn-call нужно либо inline’ить body (compiler optimization), либо overhead от dynamic dispatch closure’а. - Асимметрия со
supervised/parallel for/forbid. Если унифицировать толькоcancel_scope— оно станет outlier’ом среди keyword’ов. Если унифицировать все — это большой refactor structured concurrency (отдельный D-блок).
Связанные конструкции для унификации
Если идти этим путём, тот же rationale применяется к:
// Текущий → trailing-fn:
supervised { ... } → supervised() fn() { ... }
parallel for x in iter { ... } → parallel_for(iter) fn(x) { ... }
forbid Net { ... } → forbid([Net]) fn() { ... } // принимает list of effects?
with_timeout(5.s) { ... } → with_timeout(5.s) fn() { ... } // уже совместимо!
race { a, b } → race([a, b]) // values, не block
with_timeout уже близок к trailing-block paradigm — он принимает
duration param. cancel_scope идёт следующим natural candidate’ом
для унификации.
Что не решено
- Compile-time token-scope enforcement — есть ли способ сохранить «tok недоступен вне scope’а» без keyword-syntax? Возможно через linear type / borrow checker аналог.
- Codegen efficiency — компилятор должен inline’ить trailing-fn тело чтобы избежать closure overhead. Это требует guarantee от спеки.
- Breaking change для existing code. Текущий
cancel_scope { tok => ... }используется вnova_tests/concurrency/cancel_scope_test.nvиcancel_stress_test.nv— нужна миграция либо backward-compat period. - Все keyword-scope конструкции одновременно или только
cancel_scope? Симметрия требует все, но это significant breaking change для всей structured-concurrency surface.
Прецеденты
- Kotlin —
synchronized(lock) { body }это обычная функцияinline fun synchronized(lock: Any, block: () -> R): R. Trailing lambda is the norm. - Swift —
withCheckedContinuation { continuation in ... }— обычная функция, trailing closure with param. - Scala —
Using.resource(r) { res => ... }— function call. - Rust —
thread::scope(|s| { ... })— function call с closure-param. - Go — нет analog’а (нет structured concurrency primitives).
Все прецеденты используют function + closure-param, не keyword. Это аргумент в пользу унификации Nova’ы.
Статус
ЗАКРЫТО (2026-05-14). Итоговое решение — в выноске вверху секции.
Кратко: ни cancel_scope, ни остальные keyword-scope’ы не становятся
trailing-fn функциями. cancel_scope удаляется, отмена выражается
supervised(cancel: tok) (именованный аргумент, D102). Унификации
всех keyword-scope’ов в функции не происходит — supervised
остаётся keyword’ом из-за spawn-registration магии.
Что из исходных «что не решено» как разрешилось:
- Compile-time token-scope enforcement — признано ненужным: токен caller-owned, escape = no-op; защищается только aliasing, через runtime bind-check.
- Codegen efficiency —
supervised(cancel:)остаётся keyword’ом с custom codegen, closure-overhead вопрос снят. - Breaking change для existing code —
cancel_scope_test.nvиcancel_stress_test.nvмигрируются в Plan 47. - Все keyword-scope’ы или только
cancel_scope— толькоcancel_scope(удаляется);supervised/parallel for/selectостаются keyword’ами. Асимметрии нет —cancel_scopeне «становится функцией», а схлопывается в аргумент существующего keyword’а.
Связь:
- D43 — trailing-fn syntax.
- D102
— именованные аргументы;
supervised(cancel:)опирается на D102. - D75
— ревизованный:
cancel_scopeудалён, отмена черезsupervised(cancel:). - D50 —
supervised/parallel for/spawn. - Plan 47 — реализация / миграция. keyword’ы (одна team — либо все унифицируются, либо все остаются).
- Q-keyword-symmetry — related concern про symmetry keyword’ов declaration/literal.
Q-D93-sync-async-stop. Sync-vs-async stop_cb contract в D93 API ✅ ЗАКРЫТО
Закрыто: Plan 22 Ф.8 (2026-05-11) —
NovaStopModeenum{SYNC, ASYNC}добавлен в D93 API.nova_sched_cancel_all_pendingразличает: SYNC → unpark immediate, ASYNC → ждёт backend wake. Sleep stop_cb стал ASYNC, close-wait busy-loop удалён из_nova_sleep_via_libuv. Подробно — D93
- Plan 22 Ф.8 секция. Историческая запись сохранена ниже.
Контекст. D93 park/wake API определяет NovaCancelStopCb —
callback, который вызывается из nova_cancel_token_cancel через
nova_sched_cancel_all_pending. После stop_cb idempotent loop
делает parked[i] = false (synchronous unpark) — fiber resumes
immediate, видит cancel_requested = true, throw’ает.
Текущее предположение: stop_cb выполняется synchronously —
после возврата из stop_cb всё необходимое для cleanup’а handle’а
уже сделано. Под этим предположением parked[i] = false
сразу после stop_cb безопасен.
Это верно для sleep handle с текущей Ф.4/Ф.6 реализацией:
stop_cb делает uv_timer_stop + uv_close(close_cb), но fiber всё
равно делает busy-wait через uv_run NOWAIT пока close_cb не
выполнится — поэтому handle уже фактически освобождён к моменту
возврата stop_cb.
Проблема — Plan 22 Ф.8 (close-cb state machine):
Production-grade refactor хотел убрать busy-loop wait и сделать park ждать close_cb напрямую (без второго park’а). Архитектура: timer_cb инициирует close (НЕ wake), close_cb делает wake. Один park на весь lifecycle. Stop_cb (для cancel) тоже только initiates close — не делает synchronous wait.
Это сломало контракт с cancel_all_pending: после stop_cb он
делает parked[i] = false, fiber resume’ится до close_cb, и
sanity-check (stage == CLOSED) abort’ит. Откат к Ф.6 версии.
Что нужно: D93 должен формализовать sync-vs-async stop_cb semantic, чтобы cancel_all_pending мог различать:
typedef enum {
NOVA_STOP_SYNC, /* handle полностью freed после stop_cb return */
NOVA_STOP_ASYNC, /* stop_cb лишь инициировал close; ждём wake'а от backend */
} NovaStopMode;
typedef NovaStopMode (*NovaCancelStopCb)(void* handle);
cancel_all_pending для SYNC делает parked[i] = false сразу;
для ASYNC — оставляет parked, полагается на backend wake (uv close_cb).
Use-cases:
- Sleep handle (Plan 22 Ф.8) — ASYNC: stop_cb инициирует
uv_close, wake придёт из close_cb. - Channel waitlist (Plan 21 Ф.1) — SYNC: stop_cb отвязывает waitlist node, handle (waitlist node) полностью убран immediate.
- Socket read (Plan 23+
std.net) — ASYNC: stop_cb делаетuv_read_stop+uv_close, wake из close_cb. - File read (Plan 23+
std.fs) — ASYNC: stop_cb делаетuv_cancelна in-flightuv_fs_t, wake из request callback.
Status (2026-05-11). Plan 22 Ф.8 — DEFERRED. Прототип
выявил проблему, откат к Ф.6 семантике. Текущая ms-busy-loop на
close_cb (через uv_run NOWAIT) — pragmatically acceptable (1-2
iterations typical), не блокер production deployment.
Когда фиксировать. Перед Plan 21 (Channel) реализацией. Каналы требуют чёткого SYNC контракта; sleep и socket — ASYNC. Без формального enum смешать оба в одном API — UB.
Связь: Plan 22 Ф.8 (deferred), Plan 21 channel waitlist, Plan 23 socket-read/file-read, D93 spec.
Q-axiom-binder-type. Тип binder в axiom: Option<TypeRef> vs отдельный enum
Контекст. EffectAxiom.binders: Vec<(String, Option<TypeRef>)> — None означает
“тип не указан явно”. В SMT encoding при None делается inference из usage в формуле,
а если inference не находит — дефолт SortRef::Int.
Проблема. None читается как “отсутствует/нет значения”, хотя семантически это
“untyped” — совсем другое намерение. Скрывает смысл на call-сайтах.
Предлагаемое именование:
pub enum BinderType {
Untyped, // axiom name(id) => ... — inference + дефолт Int
Typed(TypeRef), // axiom name(id int) => ... — явный sort
Generic, // axiom name[T](id T) => ... — T из generics (V2)
}
Тогда match на call-сайтах читается как документация, а не загадка None.
Когда менять. При добавлении третьего варианта (Generic как отдельный BinderType,
а не флаг is_generic на уровне аксиомы) — тогда рефактор окупится. Пока два варианта
(Option<TypeRef>) работает корректно, это техдолг читаемости.
Связь: Plan 33.3 Ф.9 (contracts), compiler-codegen/src/ast/mod.rs EffectAxiom,
verify/pipeline.rs encode_axiom.
Q-with-deadline-vs-within. with_deadline[T](deadline_ms, body) — отмена по точке времени
Контекст. В stdlib std/concurrency/cancellation.nv есть within[T](timeout_ms, body)
— отмена через duration от вызова. В distributed системах (Go context, gRPC, Tower)
принято передавать deadline (абсолютный timestamp) между сервисами, чтобы каждый
hop подсчитывал свой timeout как min(local_timeout, deadline - now).
Предложение. Добавить with_deadline[T](deadline_ms_unix, body fn() -> T) -> Option[T]:
let deadline = parent_request_deadline_ms() // unix-ms, например, 1716470000000
let r = with_deadline(deadline, || fetch_data())
Реализация — тривиальная обёртка над within:
fn with_deadline[T](deadline_ms int, body fn() -> T) -> Option[T] {
let now = Time.now() // unix-ms (через Time.now_ms когда будет)
let remaining = if deadline_ms > now { deadline_ms - now } else { 0 }
within(remaining, body)
}
Зависимости: Time.now() возвращает unix-ms (или новый Time.now_ms() с правильной
semantics — runtime сейчас now() returns monotonic ms, не unix).
Trade-off. with_deadline удобен для RPC-цепочек, но Time.now_ms() vs Time.monotonic
— два разных concept’а (wall vs monotonic clock). within работает на monotonic; deadline
обычно на wall. Нужна decision про какие часы используем.
Связь: std/concurrency/cancellation.nv, Time effect (std/time/duration.nv §289 comment).
Q-tok-checked. tok.checked() — cooperative yield + cancel-check одной операцией
Контекст. Сейчас в CPU-bound loop’е без Time.sleep / Channel.recv fiber может
не yield’нуть scheduler’у долго → отмена «не успевает»:
for i in 0..10_000_000 {
if tok.is_cancelled() { return } // флаг проверяем, scheduler не дёргаем
heavy_computation(i)
}
Если tok.cancel() из другого fiber’а — этот fiber может НИКОГДА не yield’нуть,
и cancel не сработает до конца loop’а.
Предложение. Метод tok.checked() — explicit yield + cancel-throw одной операцией:
for i in 0..10_000_000 {
tok.checked() // 1) yield; 2) if cancel → throw CANCEL
heavy_computation(i)
}
Аналоги: Go runtime.Gosched() + ctx.Err(), Rust tokio::task::yield_now().
Реализация: nova_cancel_token_checked — wrapper над nova_fiber_yield() +
nova_throw_cancel_reason(scope.cancel_reason_ptr) если cancelled.
Trade-off. Может быть путаница с is_cancelled() (non-throwing bool). API surface
ширится. Зато явный pattern для CPU-loops.
Связь: Plan 49 Ф.2 (cooperative cancel), nova_fiber_yield (fibers.h).
Q-cancel-token-with-timeout. CancelToken.with_timeout(ms) factory — auto-cancelling token
Контекст. Хочется factory который создаёт CancelToken и сам отменяет его через
заданное время — как AbortSignal.timeout(5000) в TC39 (WHATWG DOM standard).
Желаемое API:
let tok = CancelToken.with_timeout(5000) // через 5 сек авто-cancel
do_long_work(tok)
Проблема — design choice. Кто-то должен в фоне ждать 5 секунд и вызвать cancel().
Варианты:
-
Background fiber outside structured scope — нарушает structured concurrency (Plan 47 D50/D75: spawn только внутри supervised). Token живёт каллер-side, fiber’у нужен parent-scope для drop’а. Сложно без leak’а.
-
OS timer callback (libuv
uv_timer_t) — callback в event-loop thread вызываетnova_cancel_token_cancel(). Обходит fiber-runtime, нужен thread-safe path. -
Lazy timer queue — separate fire-and-forget queue для таких timer’ов с GC ownership. Новая infrastructure.
Текущий workaround (works today, чуть более многословно):
let tok = CancelToken.new()
supervised(cancel: tok) {
spawn { Time.sleep(5000); tok.cancel("timeout") }
spawn { do_long_work(tok) }
}
Это уже within[T] pattern в stdlib (std/concurrency/cancellation.nv).
Trade-off. Factory удобнее (один-liner для async patterns), но требует или нарушения structured-concurrency (background fiber outside scope), или новой timer-queue infrastructure. Решение влияет на rest of cancellation design.
Связь: Plan 49 (cancellation), Plan 22 (libuv timers), TC39 AbortSignal.timeout proposal.
Q-context-value-equivalent. Go context.Value (typed request-scoped values) для Nova
Контекст. В distributed системах request-scoped values (trace ID, user ID, locale, deadline) пробрасываются через всё дерево вызовов. Передавать каждый параметром → много boilerplate; глобальные variables → не thread-safe / не fiber-scoped.
Go-style (минусы):
ctx := context.WithValue(parent, traceKey, "abc-123")
trace := ctx.Value(traceKey).(string) // type-assert, no compile-time safety
Key — interface{}, value — interface{}. Нет type safety. Конвенция использовать
private types для key чтобы избежать collision’ов — ad hoc.
Желаемое API (typed):
context.set[TraceId]("abc-123")
context.set[UserId](42)
let trace = context.get[TraceId]() // -> Option[TraceId], typed
let user = context.get[UserId]() // -> Option[UserId]
Альтернатива — Nova effects (уже есть):
type Trace effect { current() -> str }
with Trace = trace_handler("abc-123") {
do_work() // внутри: Trace.current() → "abc-123"
}
Effects дают тот же use-case с typed dispatch + handler swap.
Trade-off. Два пути:
- Использовать existing effects (не вводить новый API) — паритет с Go context, но handler-ceremony более многословно.
- Ввести typed
context.set/get[T]()API — короче на use-site, но new infrastructure (где живут values: TLS / fiber-locals / scope-stack?), propagation rules через spawn / handler boundary не определены.
Решение: Нужен полноценный design plan (Plan 51 tentative). Use-cases собрать, сравнить effects-based vs typed-context API, решить storage model. Не в текущем sprint.
Связь: Plan 47/49 (cancellation), spec/decisions/04-effects.md (effects design),
Go context package, Rust tokio::task_local!, TC39 AsyncContext.
Q-multi-bound. Intersection-multi-bound syntax [T A + B]
🟡 PROPOSED — Plan 101.3 (closes this). Закрывается через
+-syntax в[T Bound1 + Bound2 + ...]. Параллель Rust<T: A + B>.
Контекст. D72 §«Multiple bounds» сейчас требует anonymous-protocol или named-composition:
fn cache[T protocol { hash() -> u64, eq(other Self) -> bool }](xs []T) -> ...
// или
type HashableEq protocol { hash() -> u64, eq(other Self) -> bool }
fn cache[T HashableEq](xs []T) -> ...
Q-bounds §«Тонкие места 1» оставило открытым inline-syntax для multi-bound.
Решение (Plan 101.3 proposal): [T A + B] — intersection-bounds
через +, параллель Rust. + выбран vs & (TS) потому что:
- Familiar для Rust-программистов (Nova target audience overlaps).
- Не конфликтует с
,(multi-param separator) и&(bitwise). […]— pure type context, no arithmetic possible → unambiguous.
Применим везде где D72 bound допустим: free fn (fn dedup[T A + B]),
type-decl (type Cache[K A + B, V]), fn[T] prefix (fn[T A + B] []T @method).
Параллель индустрии: Rust +, TS &, Kotlin where clause, Go
embedded composition. Nova + совпадает с Rust.
Status: proposed в Plan 101.3 (P3, ~1 dev-day). Закроется как часть Plan 101 closure.
См. также: D72, D145, Plan 101.3.
Q-representation-bound. Concrete-type bounds (fn[T int], fn[T User])
🟡 PROPOSED — Plan 102 future (out of scope Plan 101).
Контекст. D72 фиксирует «bound — это
protocol-тип». Concrete types — newtype’ы (type UserId u64 per D52),
records (type User { ... }) — не могут быть bound’ами.
type UserId u64 // D52 newtype
fn[T u64] []T @method // currently ❌ — u64 не protocol
type User { id u64, name str }
type Profile { use user User, avatar Url } // D39 embed (not subtype)
fn[T User] T @method // currently ❌
Use cases:
- Newtype-aware bounds: UserId/SessionId/OrderId все —
u64newtype’ы; хочется generic method для всех «u64-representable»:fn[T : repr u64] []T @sum() -> u64. - Embed-aware bounds: Profile embeds User (D39); хочется generic
method для всех «User-embedding» record’ов:
fn[T : has User] T @greet() -> str.
Дизайн options:
- Auto-derived protocol from concrete-type shape:
- Record
type User { id u64, name str }auto-derives protocolUser-shape { id() -> u64, name() -> str }. fn[T User]≡fn[T User-shape](structural conformance).- Profile (via D39 embed) auto-satisfies через delegated accessors.
- Record
- Explicit representation-bound:
fn[T : repr u64]— T’s runtime representation = u64. - Explicit embed-bound:
fn[T : has User]— T contains User field (record-only). - Combination: все три.
Cross-language precedent: Nova-уникальная — Rust/Go/TS/Kotlin/Scala все требуют explicit trait/protocol/interface declaration. Auto-derive from record-shape — это Nova edge opportunity.
Risks:
- Compiler must walk type-tree для shape-matching.
- Может ввести silent matches («user думал что не satisfies, а satisfies»).
- Дизайн compatibility с D17 «no inheritance».
Status: proposed для Plan 102 (post Plan 101). Отдельная design phase Ф.0 нужна — это значительное расширение semantic model. P3 (polish over correctness).
Источник: обсуждение 2026-05-24 во время Plan 101 design.
См. также: D72, D145, D52 (newtype), D39 (record-embed).
Q-memory-model. Memory model между fiber’ами в production-runtime
Статус: ✅ ЗАКРЫТО D167 (Plan 103.1, 2026-05-25).
Исходный вопрос (из D79 §«Что отложено»): в preemptive M:N runtime нужно явно зафиксировать memory ordering — в bootstrap’е single-threaded scheduler делает все ordering’и semantically equivalent.
Решение D167:
MemOrderingenum:Relaxed | Acquire | Release | AcqRel | SeqCstfence(MemOrdering)module function- Default ordering для simple-overload methods —
SeqCst - Bootstrap-runtime (single-threaded): orderings reduce к sequenced-before
- M:N runtime (Plan 23/83.4.5): полный C11
__atomic_*memory model; happens-before через paired Acquire/Release - Channel send/recv — happens-before (Go-style, D79)
Связь: D167, Plan 103.1.
Q-send-timeout. send с timeout у Channel
Статус: 🟡 ОТКРЫТО — Channel-specific, не sync scope. За пределами Plan 103.x.
Исходный вопрос (из D79 §«Что отложено»): @send_timeout(v T, d Duration) —
нужна ли отдельная вариация API. Текущая идиома: select с timeout arm,
но громоздкая.
Не входит в Plan 103.x scope (sync primitives). Отложено до stdlib-фазы работы над Channel API эволюцией.
Q-char-arithmetic. Арифметика на char — advance/retreat by codepoint count
Статус: 🟡 ОТКРЫТО — discussed 2026-05-29, дизайн не зафиксирован.
Контекст. В Plan 91.8a.2 part 3 добавлены comparison operators на char
(codepoint-based, через native operators + char.@compare(other char) -> int).
Открыт вопрос про арифметику.
User asked про возможность:
char + n -> char(advance codepoint by N)char - n -> char(retreat)char - char -> int(distance, useful для ranges)
Issues:
-
Overflow / invalid codepoints. Unicode max = U+10FFFF.
'a' + 1000000даёт invalid codepoint. Поведение?- Panic при invalid → runtime check overhead
- Wrap-around → silent corruption
- Return Option[char] → typed safety, чуть громоздко
-
Surrogate gap U+D800-DFFF. Invalid Unicode чтобы char туда попал. Arithmetic должна пропускать surrogates? Или ошибаться?
-
Semantic surprise.
'9' + 1 == ':'(не'10'!). Программисты могут ожидать decimal increment. -
Implicit promotion (Java style) —
'a' + 1 == 98(int), теряя char type. Nova избегает silent type changes — отвергнуто как anti-pattern.
Предлагаемые варианты
Variant A. Explicit fallible API (Rust-style):
fn char @try_advance(n int) -> Option[char] {
let cp = (@ as int) + n
if cp < 0 || cp > 0x10FFFF { return None }
if cp >= 0xD800 && cp <= 0xDFFF { return None }
Some(cp as char)
}
fn char @minus(other char) -> int =>
(@ as int) - (other as int)
// `c + n` operator — НЕ добавляем (forces explicit `c.try_advance(n)`)
Pros: explicit, safe, no silent corruption. Cons: verbose.
Variant B. Operator overload с panic:
fn char @plus(n int) -> char Fail[OverflowError] {
let cp = (@ as int) + n
if cp < 0 || cp > 0x10FFFF { panic }
cp as char
}
Pros: ergonomic. Cons: hidden panics в hot loop.
Variant C. Operator overload с wrap-around:
fn char @plus(n int) -> char =>
(((@ as int) + n) & 0x10FFFF) as char
Pros: no runtime check. Cons: silent invalid codepoints.
Рекомендация
Variant A (explicit fallible API) — соответствует Nova design philosophy
(D9 «один очевидный путь», D131 safety > convenience, аналогично Result-based
try_* convention в std).
Когда решать
Не блокирует release 0.1 — workaround (c as int) + n доступен для arithmetic.
Решать в рамках dedicated подплана Plan 91.X char-ops или string processing
followup.
Связь
- Plan 91.8a.2 part 3 — char @compare добавлен.
- D183 amendment — Comparable + synthesis chain.
Финальное напоминание
Прежде чем продолжать дизайн, прочитай:
- README.md — главные тезисы
- decisions/ — все принятые решения с обоснованием
- discussion-log (личный, в отдельной репе) — путь к этим решениям
Прежде чем менять решение — прочитай его обоснование. Многие решения поддерживают друг друга. Изменение одного может потребовать пересмотра нескольких.