Auto-derive Guide (Plan 126, D109 amend + D230)

Status: ✅ landed 2026-06-05. D-blocks: D109 amend + D230 NEW.

Nova поддерживает auto-derive для пяти built-in протоколов через #impl(P) annotation на пользовательском типе. Аналог Rust #[derive(...)] без отдельного keyword’а — переиспользуется единый mechanism #impl(P) (D186).

TL;DR

#impl(Equal + Hash + Clone + Compare + Display)
type Vec3 {
    x f64
    y f64
    z f64
}

ro a = Vec3 { x: 1.0, y: 2.0, z: 3.0 }
ro b = Vec3 { x: 1.0, y: 2.0, z: 3.0 }
assert(a == b)             // auto-derived @equal
ro c = a.clone()           // auto-derived @clone
ro h = a.hash()            // auto-derived @hash
ro cmp = a.compare(b)      // auto-derived @compare

Компилятор синтезирует тела методов memberwise рекурсивно на основе полей типа.

Поддерживаемые протоколы

ProtocolМетодСтратегия synth
Equal@equal(other) -> boolmemberwise && chain
Hash@hash() -> u64XOR + rotate FxHash-style combine
Clone@clone() -> Self (D230)record literal с .clone() per field
Compare@compare(other) -> intlexicographic if-chain (memcmp-style)
Display@display(sb) -> ()sb.append("TypeName { f: v, ... }") chain

Все 5 — single-method built-in protocols, объявлены в std/prelude/protocols.nv.

Когда compiler synthesize’ит

  1. Type помечен #impl(P) где P — один из 5 built-in protocols.
  2. Type не предоставляет explicit fn T @method(...) — иначе user wins.
  3. Все поля type’а eligible — primitive ИЛИ имеют #impl(P) ИЛИ имеют explicit fn FieldType @method.

Если хотя бы одно условие нарушено — diagnostic из E_AUTO_DERIVE_* family (см. ниже).

Когда compiler НЕ synthesize’ит

  • Protocol не built-in (user-defined protocol) — auto-derive только для 5 known built-in. User-defined protocols → user пишет body вручную.
  • Type provides explicit methodfn T @equal(other) -> bool => ... wins над auto-derive (manual override).
  • Field type не implement требуемый protocol → E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL.

Field eligibility

Каждое поле type’а должно быть одним из:

Категория поляЧто делает synthesizer
Primitive (int/f64/bool/char/byte/str/u*/i*)Inline copy/compare/hash через built-in routines
#impl(P) annotated record/tupleRecursive call @field.method(...)
Explicit fn FieldType @methodDirect dispatch к user-provided method
[]T arrayRecursive по T
Tuple (A, B, ...)Recursive по element types

Что не eligiblefn(...) types, pointers *T, opaque types, protocol types (требуют explicit user impl).

Примеры

Простой record

#impl(Equal)
type Money {
    cents int
}

ro a = Money { cents: 100 }
ro b = Money { cents: 100 }
assert(a == b)  // → @a.cents == b.cents → true

Рекурсивный auto-derive

#impl(Clone)
type Inner {
    name str
    code int
}

#impl(Clone)
type Outer {
    inner Inner       // ← Inner has #impl(Clone) — eligible
    count int
}

ro o = Outer { inner: Inner { name: "x", code: 1 }, count: 5 }
ro p = o.clone()
// synthesized:
//   Outer { inner: @inner.clone(), count: @count }
// → Outer { inner: Inner { name: @name, code: @code }, count: 5 }

Manual override (user wins)

#impl(Equal)
type CaseInsensitive {
    text str
}

// User implements @equal — wins over auto-derive.
fn CaseInsensitive @equal(other CaseInsensitive) -> bool =>
    @text.to_lower() == other.text.to_lower()

ro a = CaseInsensitive { text: "Hello" }
ro b = CaseInsensitive { text: "HELLO" }
assert(a == b)  // → user-defined logic

Named tuple (Plan 120 D215)

#impl(Equal + Clone)
type Pair(left int, right int)

ro p = Pair(1, 2)
ro q = Pair(1, 2)
assert(p == q)
ro r = p.clone()

Heap-record == override

До Plan 126 на heap-record a == b был identity-eq (pointer comparison). После Plan 126:

// Without #impl(Equal) — identity-eq preserved (backward compat).
type Account {
    id int
    balance f64
}
ro a = Account { id: 1, balance: 100.0 }
ro b = Account { id: 1, balance: 100.0 }
assert(a != b)  // ← different allocations, identity doesn't match

// With #impl(Equal) — structural eq.
#impl(Equal)
type AccountStruct {
    id int
    balance f64
}
ro x = AccountStruct { id: 1, balance: 100.0 }
ro y = AccountStruct { id: 1, balance: 100.0 }
assert(x == y)  // ← memberwise structural eq

Диагностики (Plan 126)

КодКогда триггерится
E_AUTO_DERIVE_CYCLECyclic recursion через fields не терминируется
E_AUTO_DERIVE_FIELD_LACKS_PROTOCOLField type не implement требуемый protocol
E_AUTO_DERIVE_UNKNOWN_PROTOCOLProtocol не в built-in list (Equal/Hash/Clone/Compare/Display)
E_AUTO_DERIVE_UNSUPPORTED_KINDType kind (Newtype/Alias/Effect/Protocol/Opaque) не поддерживает derive

Пример E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL

type Plain {
    n int
}

#impl(Equal)
type Wrapper {
    inner Plain    // ← Plain doesn't #impl(Equal)
}
// ❌ E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL:
//   type `Wrapper` claims `#impl(Equal)` but field `inner`
//   (type `Plain`) does not implement `Equal`.
//   Either add `#impl(Equal)` to `Plain`, or provide explicit
//   `fn Wrapper @equal(...)`.

Fix: добавить #impl(Equal) на Plain:

#impl(Equal)   // ← Fix: now Plain eligible
type Plain {
    n int
}

#impl(Equal)
type Wrapper {
    inner Plain
}

Cycle detection

Compiler ведёт visited set (type, protocol) во время synthesis. Если synthesis для типа T уже идёт, и встречается рекурсивный путь обратно к TE_AUTO_DERIVE_CYCLE:

#impl(Clone)
type A { b B }

#impl(Clone)
type B { a A }
// ❌ E_AUTO_DERIVE_CYCLE: cyclic recursion through fields doesn't terminate.
//    Provide explicit `fn A @clone(...)` or `fn B @clone(...)`.

Fix: явный impl на одном из типов разрывает рекурсию:

#impl(Clone)
type A { b B }

fn A @clone() -> A => A { b: @b }   // ← manual; the synthesizer for B will keep working

Композиция с Plan 124.x семантикой

Auto-derive совместим с:

  • priv field modifier (Plan 124.1/D220 §3.3.1): synthesizer работает в type-method scope — имеет доступ к priv-полям.
  • mut field modifier (D33): mut-fields копируются как обычные fields, mutability preserve’ится в new value.
  • ro binding (D33, D175): synthesized methods receive ro Self receiver — only-read access.
  • Value-record type X value { ... } (Plan 124.8 D228): full support, synthesis работает идентично heap-record.
  • Named tuple type X(a int, b str) (Plan 120 D215): fields обрабатываются через NamedTupleField ровно как RecordField.

Sum-type rich synthesis (Plan 180, D345 — ✅ landed)

Все шесть built-in-протоколов синтезируются для sum-типов через match @ { … } с одной arm на variant (SumVariantKind::Unit/Tuple/Record). Payload-элементы биндятся в arm-паттерне и рекурсятся ровно как record-поля.

MarkerФормаСтатус
[M-126-sum-equal-rich]same-variant + payload-wise == (nested match, cross-variant → false)✅ CLOSED
[M-126-sum-hash-rich]variant-index seed ⊕ payload-hash (rotate-XOR combine)✅ CLOSED
[M-126-sum-clone-rich]match-arm-per-variant reconstruction (payload primitives shallow, composites .clone())✅ CLOSED
[M-126-sum-compare-rich]variant-index order, then payload lexicographic✅ CLOSED
[M-126-sum-fmt-rich]variant-aware @display/@debug (V / V(x, y) / V { f: x })✅ CLOSED

Ergonomics-примечание: метод на bare-unit-варианте (Nought.hash()) мис-инферится в тип-варианта; аннотируйте через локал ro n Colour = Nought (та же bidirectional-инференс-граница, что для Empty-коллизий D141).

Serialize/Deserialize (Plan 180, externally-tagged — ✅ landed). #impl(Serialize + Deserialize) на sum → externally-tagged wire (Q4): unit → "V"; single-payload → {"V": x}; tuple → {"V": [a, b]}; record → {"V": {fields}}. Deser читает тег (is_str → bare string / single object-key), unknown-tag → DeError{UnknownVariant}. Internal/adjacent/untagged tagging → followup [M-180-serde-tagging-modes] (гейт на #serde-атрибутах). Пример: nova_tests/serde/sum_autoderive.nv.

Что НЕ supported V1 (followup)

MarkerОписание
[M-126-codegen-method-table]V1: synthesized FnDecl не register’ится в method_table. Codegen wiring для full a == b runtime semantics — V2 expansion

V1 fokuses на type-check level — auto-derive suppresses E_IMPL_MISSING_METHODS корректно, что разблокирует pattern usage в downstream type-checked code. Полное == wiring через method_table — Plan 126 V2 (когда понадобится в production stdlib).

Метод-уровень #impl(P) — opt-in конформность (D268, Plan 154.1)

#impl(P) как ведущий атрибут работает не только на типе (auto-derive выше), но и на отдельной метод-декларации — это необязательная пометка «этот метод реализует метод протокола P»:

#impl(Display)
fn int @display(mut sb StringBuilder) -> () { sb.append(@) }
  • Opt-in, не required. Конформность остаётся структурной — тип с подходящим методом удовлетворяет бонд [T Display] и без #impl. #impl лишь добавляет проверку подписи против P + явно привязывает P к receiver-типу (type_impl_protocols), как если бы P был перечислен на type-декларации.
  • Три кода ошибок (checker): E_IMPL_UNKNOWN_PROTOCOL (P не протокол), E_IMPL_NOT_A_PROTOCOL_METHOD (@m не объявлен в P), E_IMPL_SIGNATURE_MISMATCH (подпись/receiver-mut не совпадает).
  • Где применяется в stdlib: все 6 примитивов (int/f64/bool/char/str/f32) получили конкретные #impl(Display) + #impl(Debug) в protocols.nv — это чинит мис-диспатч Vec[T].debug(sb) на примитивном элементе (Plan 154.1 / D269).

Подробности — D268 и Plan 154.1.

См. также