What the class decision changes for the next session

plan.org's managed classes arrived after NEXT.md's handoff was written, so a
session reading the plan cold would take them as the next task. They are not:
plan.org's own last line on them says nothing until struct, Handle and reload
semantics work, and that belongs where the next task is named.

Three findings from reviewing it that are not in plan.org. A generic function is
a cell whose body is a dispatch table - adding a method later is the same
problem the indirection cells already solve, so the expensive half of classes is
built. Migration has to enumerate live instances, which makes the pool behind a
generational Handle the only one of the three storage options that obviously
supports it, rather than a free choice. And a numbered layout has to stay
resolvable for migrate to dispatch on, which is the same retention rule as
nothing is ever dlclosed.

Also records the open question the class facility raises for conditions: whether
a condition may be a class, what the hierarchy would buy, and the three costs -
allocation on the signal path being the serious one. It wants answering before
handler-case, since it decides whether handler matching has one path or two.

Plus three nits in the new prose: float/int are not Flan type names, the place
syntax was dotted, and the migration example set a slot the class did not have.
The tag-word sentence said any was the only place one is paid, which Error and
now a class instance both make false.
This commit is contained in:
Joseph Ferano 2026-09-11 17:35:06 +07:00
parent ac8d31ee65
commit 59e9392ec8
2 changed files with 60 additions and 4 deletions

54
NEXT.md
View File

@ -37,6 +37,60 @@ and come back as *unknown name*, which is the house rule's own class of bug.
Read SBCL for what restarts should *mean* and ignore how it moves control: it
transfers with `block`/`return-from`, which §6 rules out.
### Managed classes are planned. Do not start them.
plan.org grew a `class` facility beside `struct`: identity, runtime shape
metadata, an implementation-defined representation, generic-function dispatch,
and live schema change with an explicit migration at a frame boundary. Its own
last line is the rule — nothing until ordinary `struct`, `Handle` and reload
semantics are working. It is here so that a session reading plan.org cold does
not take it as the next task. Three things found while reviewing it, none of
them in plan.org yet:
- **A generic function is a cell.** "A later module can add `(defmethod draw
((e Enemy)) ...)` without editing the original" means every compiled call site
of `draw` has to find the new method — which is the problem the indirection
cells already solve. A generic function is a cell whose body is a dispatch
table and a reload extends the table. The expensive half of classes is
therefore already built and tested.
- **The pool is not one storage option among three.** `migrate-instances` has to
*enumerate* live instances. A pool behind generational `(Handle T)` gives that
by construction; a world arena and an owned region do not obviously. plan.org
presents the three as a free choice and they are not.
- **`Enemy@1` has to stay resolvable** for `migrate` to dispatch on it, so the
session retains every layout version's metadata for as long as any instance
holds it. Same rule as "nothing is ever `dlclose`d", and worth stating as one.
### Open: can a condition be a class?
Unanswered, and it wants answering before `handler-case`, because it decides
whether handler matching has one path or two.
It would buy the thing conditions most lack: a **hierarchy**. §1 says flatly
there is none, which is why nothing can say "any condition" — no catch-all
handler and nothing for a break loop to match on. Class inheritance gives it.
Three costs, one serious:
- **Signalling would allocate.** A struct condition is a stack value and
`signal` takes its address; a class instance needs a pool slot at the signal
site. That is the failure path, sometimes the hot path, and sometimes the
thing that failed is allocation itself. plan.org also says no implicit
allocation anywhere in the core.
- **§5's lifetime inverts.** Today the condition dies with the signalling frame
and a handler that keeps it copies, which is free for a value struct. A class
instance survives the transfer — nicer, but now something owns and frees it.
- **Layout versions meet handler frames.** A struct condition cannot change
layout; it is refused. A class can, and then a frame pushed against
`MyError@1` is on the stack while the signaller builds `MyError@2`.
The shape that probably wins is both: a struct condition stays exactly what it
is — no allocation, matched by name hash, dies with the frame — and a class
condition is allocated, survives, and matches by walking its class chain.
That is two matching paths, which is the same bill the struct/class split
already signs, so it is consistent rather than a new cost. Either way it is an
amendment to a **frozen** `spec-conditions.md`, not a gap in it.
**The dev loop is closed.** `C-c C-c` in Emacs recompiles the top-level form
at point and installs it in a running program, at that program's next frame

View File

@ -139,7 +139,7 @@ identity, extensibility, and live schema changes:
#+begin_src lisp
(defclass Enemy
(x float) (y float) (health int))
(x f32) (y f32) (health i32))
(defmethod update ((e Enemy) dt) ...)
#+end_src
@ -169,9 +169,10 @@ is the small, eager and debuggable analogue of CLOS
surprising lazy mutation on field access:
#+begin_src lisp
(redefine-class Enemy ...)
(redefine-class Enemy
(x f32) (y f32) (health i32) (shield i32))
(defmethod migrate ((old Enemy@1) (new Enemy@2))
(set new.frozen false))
(set (.shield new) (.health old))) ; a default would not have been right
(migrate-instances Enemy)
#+end_src
@ -188,7 +189,8 @@ identity.
collector, as Forth-lineage and refcounted dynamic languages show. It is chosen,
and the reason is that dynamic typing would require paying a tag word on every
value, which is exactly the header cost dropping the GC was meant to avoid. Under
static typing the tag is paid only where it is asked for, in ~any~.
static typing the tag is paid only where it is asked for: in ~any~, in ~Error~,
and on a managed ~class~ instance. An ordinary ~struct~ never carries one.
- Types are mandatory; *inference* makes them feel optional. Annotate function
signatures, infer locals — Odin/Zig/Rust ergonomics.