7.1 KiB
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 whatdefconst 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 beconst-qualified; freeing through one is not possible because a slice has no allocator and nocap.(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 aVecor 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 onVecand 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
asupports only what every type supports: move,clone, field-free storage. It does not support=,<,+,hash, or
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-bindhandler 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.