The struct/class split, written down before anything is built on it. A struct stays a fixed-layout value with C's layout, which is what keeps the FFI, SoA and wasm stories intact; a class is a separate kind with identity, metadata and an implementation-defined representation, for the long-lived gameplay objects that want to change shape while the program is running. The tagline loses "no GC" for "no mandatory GC", because a small collector confined to class instances is now an option rather than a contradiction. CLOS goes from a flat non-goal to a bounded one: the metaclasses, method combination and arbitrary change-class are out; exact-class single dispatch and an explicit frame-boundary migration are in. Migration is eager and explicit rather than CLOS's lazy-on-access, which would put a check on every slot read. Class identity is stable and layouts are numbered, the same shape as the function versions the hot reload section grew. Nothing is frozen and nothing is to be built until struct, Handle and reload semantics are working.
214 lines
10 KiB
Markdown
214 lines
10 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. It governs plain
|
|
fixed-layout `struct` values, not the separately planned managed `class`
|
|
facility (see plan.org, "Managed classes").
|
|
|
|
## 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.
|
|
|
|
## Maps — first implementation
|
|
|
|
Every map is homogeneous: `(Map K V)` has one key type and one value type. The
|
|
first implementation accepts only built-in structural key types: integers,
|
|
enums, strings, fixed arrays, and value structs composed recursively from those
|
|
types. Tuples and triples join that set when they are introduced. `Ptr`, slices,
|
|
`Vec`, and `Map` are not map keys yet.
|
|
|
|
Equality and hashing for those keys are compiler-provided structural operations,
|
|
not type classes and not operations available to an unconstrained type variable.
|
|
An empty map takes its type from its context:
|
|
|
|
```
|
|
(defvar enemies (Map string Enemy) (map-new))
|
|
```
|
|
|
|
`(get m k)` returns `(Option V)`: absence is `None`, not an untyped `nil`.
|
|
`(put m k v)` is the upsert operation and returns `Unit`; it either inserts or
|
|
replaces. `(set (get m k) v)` is not map syntax.
|
|
|
|
The first Map implementation admits copyable keys and values only, so `get`
|
|
returns a copy. Move-aware lookup, removal, and owned entries are deferred until
|
|
`Vec`/`Map` values are supported in maps; the map itself remains an owning,
|
|
move-only container.
|
|
|
|
## 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]`.
|
|
- **The first implementation follows Zig/Odin's explicit model, not Rust's
|
|
borrow checker.** A slice is invalidated by any operation that may reallocate
|
|
its owner (`push`, `put`, `reserve`); its user is responsible for respecting
|
|
that contract. Dev builds carry a generation word on `Vec` and trap on use of
|
|
a stale slice. `Ptr` is the explicit lower-level escape hatch and has the
|
|
same lifetime contract.
|
|
- A future lightweight provenance pass may reject the obvious mistakes (a
|
|
borrow of a local escaping, use after an owner moves, and reallocation with a
|
|
live borrow). It must not require Rust-style lifetime annotations or dictate
|
|
an ECS-shaped object model. Long-lived graph links use `(Handle a)`; temporary
|
|
graphs may use explicitly managed, stable region storage.
|
|
- 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. The first implementation leaves it as an
|
|
explicit lifetime contract; a future provenance pass can check it.
|
|
|
|
`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. In the first implementation its non-escape
|
|
rule is an explicit programmer contract, aided by dev checks; the future
|
|
provenance pass above may enforce it.
|
|
|
|
## 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 (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 `=`, `<`, `+`, or `hash`.
|
|
|
|
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).
|
|
|
|
`println` is the deliberate exception. It is a compiler-provided,
|
|
type-directed intrinsic: monomorphisation selects or emits a structural printer
|
|
for each concrete instantiation, so `(println x)` is legal for `x : a` without
|
|
introducing a `Printable` type class. Structs, fixed arrays, options and,
|
|
eventually, Vecs and Maps print structurally. `Ptr` and `Handle` print their
|
|
address or identity rather than recursively dereferencing, and collection
|
|
printers impose depth and length limits. `any` and `Error` use their runtime type
|
|
metadata. User generic code still passes an explicit function for every other
|
|
operation that depends on a type's structure.
|
|
|
|
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, stable function pointer.** No captured
|
|
environment or allocation. In a dev build, a reference to a top-level `defn`
|
|
is the address of a stable trampoline that loads that function version's
|
|
indirection cell and calls its current body; it is never the address of a
|
|
particular body. Thus stored callbacks and ordinary calls observe a later
|
|
*body* redefinition, as in Common Lisp. Release builds may call the body
|
|
directly because it cannot be redefined.
|
|
|
|
A signature-changing redefinition makes a new internal function version and a
|
|
new trampoline ABI. Newly compiled code resolves the source name to that new
|
|
version. Existing callers and stored `Fn` values keep their old trampoline and
|
|
therefore safely call the old version. The session immediately warns at every
|
|
tracked caller source location that still targets the old signature; recompiling
|
|
one either retargets it successfully or reports an ordinary type error. This is
|
|
what raylib callbacks, hot-reload 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 — deferred.** 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". Do not settle this until
|
|
a concrete use case requires it; revisit it with the optional lightweight
|
|
provenance work.
|
|
|
|
**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. Allocation uses the current
|
|
implicit allocator by default, as in Odin; an operation never falls back to a
|
|
hidden global allocator, and an explicit allocator can override the context.
|