Managed classes, planned and deliberately separate from structs

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.
This commit is contained in:
Joseph Ferano 2026-09-11 17:34:13 +07:00
parent b34d0d7d59
commit ac8d31ee65
2 changed files with 67 additions and 11 deletions

View File

@ -14,7 +14,7 @@ this plan that contradicts them is out of date.
* What Flan is
A minimal Lisp for game development. Clojure's brackets and a small slice of its
API, C's memory and value model. No GC.
API, C's memory and value model. No mandatory GC.
Not a Common Lisp, not a Clojure. In one line: *Odin with a Lisp frontend and a
live REPL.*
@ -43,7 +43,8 @@ live REPL.*
* Non-goals
- Numeric tower (no bignums, rationals, complex)
- CLOS/MOP, ~format~, pathnames, streams, sequences-over-anything, CL reader
- Full CLOS/MOP (metaclasses, method combination, ~slot-missing~, arbitrary
~change-class~), ~format~, pathnames, streams, sequences-over-anything, CL reader
- Clojure's lazy seqs, JVM interop, ~core.async~
- Persistent collections, and immutable collection types generally (see Data model)
- Live-image development at SBCL's level
@ -70,8 +71,10 @@ Structure sharing destroys clear ownership, which is the only thing that forces
collector. Replaced by value structs that copy on assignment — see Data model.
** The consequence that drives everything
No GC means *no object headers*. Every struct is exactly its C layout, arrays are
C arrays, so there is no marshalling layer and no wrapper allocation.
Plain ~struct~ means *no object headers*. Every struct is exactly its C layout,
arrays are C arrays, so there is no marshalling layer and no wrapper allocation.
The future managed ~class~ facility below is deliberately a separate kind of
value: it has identity, metadata, and an implementation-defined representation.
FFI is still a boundary — ownership, who allocates, string and slice
representation, struct padding, callbacks into Flan, error handling, and the
@ -128,6 +131,54 @@ world.
- Flat and SoA arrays.
- No implicit allocation anywhere in the core.
** Managed classes — planned, deliberately separate from structs
~struct~ remains Flan's default: fixed-layout values suitable for stack storage,
arrays, SIMD/SoA, FFI, and hot paths. It cannot acquire fields while live values
exist. A future ~class~ is for long-lived gameplay/editor objects that need
identity, extensibility, and live schema changes:
#+begin_src lisp
(defclass Enemy
(x float) (y float) (health int))
(defmethod update ((e Enemy) dt) ...)
#+end_src
Classes require a managed allocation strategy and runtime class/shape metadata,
but *not necessarily a tracing GC*. The initial likely choices are a world or
session arena, pool allocation behind generational ~(Handle T)~ values, or an
explicitly owned region. A small tracing GC confined to class instances remains
an option if cyclic graphs prove burdensome; it never changes ~struct~ layout or
the C ABI.
Generic functions attach operations independently of class definitions. This
addresses the expression problem for dynamic game objects: a later module can
add either ~(defmethod draw ((e Enemy)) ...)~ or a new ~Capsule~ class plus its
~draw~ method without editing the original class or a central closed ~match~.
Start with exact-class, single-argument dispatch. Multi-argument dispatch is
the later extension for interactions such as ~(collide Player Enemy)~. Method
specificity/ambiguity rules are required before inheritance or multiple dispatch
is enabled.
Class redefinition has a stable class identity and numbered layouts. Changing
slots leaves existing instances at their old layout until an explicit migration
at a reload/frame boundary. Added fields use declared defaults; removed fields
are discarded; renamed or type-changed fields require user migration code. This
is the small, eager and debuggable analogue of CLOS
~make-instances-obsolete~ / ~update-instance-for-redefined-class~, rather than
surprising lazy mutation on field access:
#+begin_src lisp
(redefine-class Enemy ...)
(defmethod migrate ((old Enemy@1) (new Enemy@2))
(set new.frozen false))
(migrate-instances Enemy)
#+end_src
The precise class syntax, inheritance, storage strategy, and migration API are
not frozen. Do not add classes until ordinary ~struct~, ~Handle~, and reload
semantics are working.
Immutability also serves the optimiser: a value known never to be mutated can be
copied into registers and stack-allocated freely. Mutability is what forces heap
identity.
@ -176,8 +227,10 @@ static typing the tag is paid only where it is asked for, in ~any~.
- Tagged unions, ~distinct~ types, bit sets.
** Consequences
- Multimethods are *compile-time overload resolution*, not runtime dispatch.
Genuine runtime dispatch is on an explicit enum or tagged union.
- For ~struct~s, multimethods are *compile-time overload resolution*, not runtime
dispatch. Genuine runtime dispatch is on an explicit enum or tagged union.
Planned managed classes instead use generic-function dispatch as described in
"Managed classes"; this is an intentionally separate facility.
- Conditions still work: a condition is a struct, the signal channel is a tagged
union, ~handler-case~ type matching resolves at compile time.
- The REPL works — the compiler knows each site's type and emits the right printer,
@ -740,10 +793,11 @@ marked.
6. Hot-reload compatibility rules. /Milestone 7./ A changed function signature
creates a new version: new callers resolve it, old callers keep the old one,
and the session warns at tracked stale caller sites. Still open: a changed
struct layout with live instances, a function pointer already handed to C, a
captured environment, and redefining a ~defvar~. Each needs an answer of the
form "rejected", "accepted with a migration", or "accepted and the old code
keeps running".
*struct* layout with live values is rejected; a managed class layout has an
explicit frame-boundary migration path, as described above. Still open: a
function pointer already handed to C, a captured environment, and redefining
a ~defvar~. Each needs an answer of the form "rejected", "accepted with a
migration", or "accepted and the old code keeps running".
7. Does the interpreter survive milestone 3, or is the compiled path the only
backend? /Milestone 3, on measured numbers./ See Compilation.
8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the

View File

@ -2,7 +2,9 @@
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.
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