flan/spec-memory.md
2026-09-10 14:40:34 +07:00

155 lines
7.1 KiB
Markdown

# Spec 1 — Ownership, containers, and copies
Status: **frozen**. Closes plan.org open decisions #6 and #10, and resolves the
contradiction between "value structs copy on assignment" and owning containers.
Everything else in the design references this vocabulary.
## The four container types
| Notation | Layout | Assignment | Owns storage | Allocator |
|-----------|-------------------|------------|--------------|-----------|
| `[n T]` | n contiguous `T` | copies | no (inline) | — |
| `[T]` | ptr + len | copies the *view* | no | — |
| `(Vec T)` | ptr + len + cap | **moves** | yes | stored |
| `(Map K V)` | open-addressed, flat key/value arrays | **moves** | yes | stored |
- `[n T]` is a value. It lives wherever it is declared, copies on assignment and
on pass-by-value, and is what `defconst colors [4 u32] ...` and
`(defvar grid [rows [cols u32]] ...)` are.
- `[T]` is a **non-owning slice**: a borrowed window into a `[n T]`, a `(Vec T)`,
or a literal in read-only memory. Copying a slice copies ptr+len, never the
elements. A slice may be `const`-qualified; freeing through one is not possible
because a slice has no allocator and no `cap`.
- `(Vec T)` and `(Map K V)` are **move-only**. Binding, passing, or returning one
transfers ownership; the source binding is dead afterwards and using it is a
compile error. There is no shallow copy, so there is no double free.
## Copying is always explicit
`(clone x)` produces an independent deep copy of a `Vec`/`Map` using the current
allocator; `(clone x alloc)` names one. Value types (`[n T]`, structs of value
types, primitives) need no `clone` — assignment already copies them.
A struct containing a `Vec` field is itself move-only. Ownership is structural,
not declared: a type is a value type iff all of its fields are.
## Borrowing
- `(as-slice v)` / `(as-slice v lo hi)` view a `Vec` or fixed array as `[T]`.
- A slice is invalidated by any operation that may reallocate the owner (`push`,
`put`, `reserve`). This is **not checked** in the first implementation; dev
builds carry a generation word on `Vec` and trap on use of a stale slice.
- Cross-referencing long-lived objects uses `(Handle a)` into a pool, never a
raw pointer or slice. A stale handle is detectable.
## Taking an address
`(addr x)` yields `(Ptr T)` for any assignable place `x` — a local, a global, a
field, an element. The pointer is non-owning and does not extend anything's
lifetime, so `addr` of a local is only valid while that frame lives. This is the
same escape question as case 3 below and is checked by the same analysis; until
that analysis exists, `addr` of a local may not be stored or returned.
`addr` is how a value struct is shared mutably without an allocator — recursive
descent over a cursor, an entity passed down a call chain — and it is why
milestone 2 needs no heap at all.
## Places — what `set` accepts
A fixed set of assignable forms, not a `setf`-style extensible place mechanism:
```
(set x v) ; a local or a defvar
(set (.field x) v) ; struct field; x may be a struct, (Ptr S) or (Handle S)
(set (at a i ...) v) ; fixed array, slice, or Vec element
(set (get m k) v) ; map entry
(set (deref p) v) ; whole-object store through a pointer
```
`.field` and `at` auto-deref exactly one pointer or handle level, which is what
makes `(set (.hp e) ...)` legal when `e : (Ptr Enemy)` and illegal when
`e : Enemy` bound by value.
**Mutating something you matched.** Pattern bindings bind *values*, so a matched
struct is a copy. To mutate in place, obtain a pointer first — the pointer is
visible in the type:
```
(match (resolve w h) ; (Option (Ptr Enemy))
(Some e) (set (.hp e) ...) ; e : (Ptr Enemy), field access derefs
None ...)
```
`deref` yields a value; `resolve` yields a pointer. Both are overloaded on
`(Ptr a)` and `(Handle a)` and resolve at compile time.
## Generics
Parametric polymorphism is monomorphisation, with **no type classes and no
constraints**. The consequence is a hard rule:
> A type variable `a` supports only what every type supports: move, `clone`,
> field-free storage. It does **not** support `=`, `<`, `+`, `hash`, or `print`.
Anything else is passed in explicitly as a function value:
```
(defn largest [xs [a] gt (Fn [a a] bool)] (Option a) ...)
```
Ordered/arithmetic operators over `a` are therefore rejected, not silently
instantiated. The alternatives — compile-time interfaces, or intrinsics
restricted to primitives — are deliberately deferred until the base checker is
stable (build sequence milestone 4).
Type arguments are **inferred at call sites** from the argument types; there is
no explicit instantiation syntax in the first implementation. A type variable
that appears only in the return type is therefore an error.
## Function values
Three cases, split by whether the value escapes the frame that made it.
**1. `(Fn [T1 T2] R)` — a plain function pointer.** No captured environment, no
allocation, C calling convention plus the implicit allocator argument. This is
what raylib callbacks, hot-reload indirection cells, and function *parameters*
use. A top-level `defn` is one, so `(largest hps >)` passes `>` at `i32`
directly. This is the only function type that may cross an FFI boundary or sit
in a reload cell.
**2. Non-escaping `fn` — captures by value into a stack environment.** A `fn`
whose value provably does not outlive the frame that created it gets an
environment allocated in that frame and captures the named locals **by value**
at the point of creation. No heap, no allocator, no lifetime question. This
covers essentially every lambda in practice:
- callbacks to `reduce` / `filter` / `each` / `map`, which consume them and return
- comparators passed to a function that does not store them
- `handler-bind` handler bodies
That last one is not a convenience. A handler must be able to see the enclosing
locals — `(fn [c] (push errors c) (invoke-restart 'skip-form))` capturing a local
`(Vec ParseError)` *is* the accumulation pattern, and conditions are not worth
building without it. Handlers are strictly non-escaping: the `handler-bind` frame
outlives every call to them.
Captured `Vec`/`Map` are captured **by pointer**, not moved, since the capture
does not outlive the owner. A non-escaping `fn` is therefore not itself an owner.
**3. Escaping closures — still open.** A `fn` stored in a struct, pushed into a
container, or returned needs a heap environment and an answer to "which allocator
owns it, and what happens when the frame arena resets". Not settled; see
plan.org open decisions. Escape analysis (open decision #4) is the same analysis
that classifies cases 2 and 3, so they are decided together.
**Early exit inside a `fn`.** `try`, `some`, and `return` in a `fn` body exit the
`fn`, not the enclosing function — a `fn` is a function. Code that wants to
propagate out of a loop uses an imperative loop form, not a callback.
## Allocators
The allocator is part of the calling convention (`context/allocator`,
`context/temp`). `Vec` and `Map` record the allocator they were created with, so
`free` and `clone` never need it named again. No core operation allocates
implicitly.