Classes and generic functions, written down

Item 6 marked LANDED, and the section under it: the four spellings with
an example each, why the shape tag is a header field and not the
reserved key the queue's note assumed, why the method bodies are inlined
rather than lifted, and what was deferred -- inheritance, multi-argument
dispatch, the qualifier methods, named-slot construction, unknown-slot
checking, computed dispatch values -- each with the reason rather than a
list. Plus the two findings that outlive the lane: the x86 redefinition
module's missing descriptors, and eval-expr never answering a dyn in
:value.
This commit is contained in:
Joseph Ferano 2026-09-20 15:07:34 +07:00
parent 0511cc1a01
commit 90110f525a

174
FIX.org
View File

@ -519,7 +519,10 @@ rename. typed-flan branch freezes the static language pre-dyn.
5. Typed = and != grow strings: bytewise, length + same-pointer fast paths,
both backends, one survey program. Ordering stays refused. — LANDED, daed039
6. defclass = named dyn map + shape tag; CLOS class dispatch AND
Clojure-style arbitrary dispatch functions. After 1.
Clojure-style arbitrary dispatch functions. After 1. — LANDED, 8d2bf2a
(the feature), 5af990e (the daemon proof and an x86 descriptor fix it
turned up) and 6c6024e. Written up below, "Classes and generic
functions, 2026-09-20".
7. dyn if: truthiness (nil/false are false, all else true). Typed stays
strict bool. — LANDED, 264765a
@ -1359,3 +1362,172 @@ test/programs/defvar-dyn.flan is both readings in one program, pinned in
acceptance at the default, -O0 and --x86; and dev-rerun.flan grew a
[(defvar tally 0)] whose line is 4 after three re-runs, which is the claim
that the new spelling goes through the old guard.
* Classes and generic functions, 2026-09-20 — M2 queue item 6
The recorded decision was "defclass = named dyn map + shape tag; CLOS class
dispatch AND Clojure-style arbitrary dispatch functions", and it is built as
written. The two dispatch styles are one mechanism and not two: a class
dispatcher is the shape tag of the first argument used as the dispatch
function, so a method written for the class ~point~ and one written for the
value ~:point~ are the same branch — which is also why the two spellings are
refused as duplicates of each other.
** The surface, as landed
#+begin_src lisp
(defclass point [x y]) ; a class: named slots, no types
(point 3 4) ; the constructor — the class's own name
(class-of p) ; :point, and nil for anything else
(get p :x) (put p :x 10) ; the slots are map keys; nothing new
(defgeneric area [self] dyn) ; CLOS: dispatch on the class
(defmethod area point [p] (* (get p :x) (get p :y)))
(defmulti describe [x] dyn (get x :kind)) ; Clojure: the body is the dispatch
(defmethod describe :square [s] (get s :side))
(defmethod describe :else [s] "something else")
#+end_src
- *A slot is a key.* An instance is a dyn map, so ~get~, ~put~, ~has-key?~
and ~len~ are how one is read and written, and no operation was added for
any of it. ~(len p)~ is the slot count.
- *The constructor is positional*, one argument per slot in the order they
were written, and it is an ordinary ~defn~ — so its arity refusal, its cell
in a dev build and its behaviour under redefinition are the ones every
function already has. The named-slot spelling is deferred; see below.
- *A method has no return slot.* The generic states the return type once, for
every method written for it, which is also what makes the parse
unambiguous: the vector is always the third form.
- *Every parameter of a generic and of a method is dyn*, written or not, and
a slot that is not a bare name is refused. That keeps these forms off the
undecided-pairing path a ~defn~'s vector is on: a vector that may hold only
names can be read by the parser, where a ~defn~'s cannot be read until
every type name is known.
- *A dispatch value is a literal* — a class's name, a keyword, a string, an
integer, ~true~, ~false~, or ~:else~ for the arm everything falls through
to. ~:else~ and not Clojure's ~:default~, because ~match~ already spells
"none of the above" that way and two words for it would be one too many.
~:else~ is the last arm whatever order it was written in.
- *A miss signals.* ~(defstruct NoMethod [generic string value dyn])~ in the
prelude, signalled with ~error~, carrying the name written at the generic
and the value the dispatch actually produced. A condition and not a trap,
because a miss is something a program can be written to answer;
~handler-case~ around the call is the shape, and a ~:else~ method is the
other answer. No restart is established at the miss, which is
BoundsError's decision taken for BoundsError's reason. ~value~ is the first
~dyn~ field in any condition here; the per-type descriptor an item-2 struct
carries is what the collector reaches it by.
** The shape tag: a header field, not a reserved key
This queue item's own note said "named dyn map + shape tag", and the obvious
reading was a reserved entry in the map. It is a field in the object's header
instead — an interned ~kw_entry *~ in the map arm of ~flan_obj~'s union — and
the departure is deliberate.
An entry would be counted by ~len~, walked by both renderers, and compared by
~dyn_equal~'s key loop. Every instance would answer a length one larger than
its slot count, print a key nobody wrote, and be one ~put~ away from having
its own class changed. A header field cannot be reached by ~get~ or ~put~ at
all, so the question of a user key colliding with it does not arise rather
than being answered by picking an unlikely spelling.
It cost nothing. The view arm of that union is 24 bytes, so the map arm
growing from 16 to 24 does not grow the union, and ~sizeof(flan_obj)~ is 48
before and after — checked, not assumed. It needs no marking either: an
interned keyword entry is immortal by construction and is not a collector
object, which ~mark_value~ states by following ~BOX_OBJ~ and nothing else.
The tag is visible in exactly three places: ~class-of~ answers it; equality
compares it, so two instances of one class compare by their slots and an
instance is never equal to a plain map with the same entries (Clojure's
answer for a record beside a map); and both renderers print it —
~#point{ :x 1 :y 2}~, which is Clojure's own spelling for a record.
The tag is built from the *qualified* class name, so two packages' ~point~
classes are two classes and their instances are never equal.
** How it is built: a pass, not a macro
None of the four forms reaches the checker. ~lib/classes.ml~ rewrites the
whole declaration list at the top of ~Check.build_program~, exactly where
~Shim.expand~ rewrites a ~declare-c~: a class becomes its constructor, a
generic becomes one function whose body binds the dispatch value and compares
it down a chain, and a method becomes a branch of that chain.
A macro sees one form and this needs the whole list, because a method may be
written above its generic, below it, or arrive at a reload an hour later.
Running over the flat list is also what makes the dev loop work: a reload
rebuilds every dispatch from the session's whole set of declarations.
*The method bodies are inlined rather than lifted into functions of their
own*, and that is the load-bearing choice. A generic is then exactly one
top-level name, so adding a method to a running program is the ordinary
redefinition of one function, through the cell the call site already goes
through. ~session.ml~ names the generic alongside the method's own
declaration name for that reason — without it a ~C-c C-c~ on a ~defmethod~
would install something no call site reads. A method still declares a name of
its own, ~area@:circle~, which is what makes re-evaluating one a replacement
and evaluating a new one an append; no function is emitted under it. Proved
end to end against a real daemon (~test_dev.ml~, "a method added to a running
program"), not only at the session's report.
The cost, recorded rather than hidden: *a method is not separately callable
and is not a frame of its own*. A break loop under a method shows the
generic.
** Deferred, each with the reason
- *Inheritance.* plan.org's own rule is that method specificity and ambiguity
rules are required before inheritance or multiple dispatch is enabled, and
with single dispatch on literal values there is no specificity question at
all: two methods either answer for the same value, which is refused, or for
different ones. A hierarchy would create the question, and the author never
asked for one.
- *Multi-argument dispatch.* plan.org names it as the later extension, for
~(collide Player Enemy)~. It wants the specificity rules above.
- *~:before~, ~:after~, ~:around~ and ~call-next-method~.* They only mean
something once methods can be ordered by anything but equality, which is
the same gate inheritance is behind.
- *Named-slot construction*, ~(point {:x 1})~ with an omitted slot meaning
nil — the dyn twin of ~(Cursor {.src s})~ with its omitted-is-zeroed rule.
Positional is what a generated ~defn~ gives for free, arity included; the
named form is a checker special case and was not worth one at v1.
- *Unknown-slot checking at ~(get p :z)~.* The one compile-time win a
declared slot set makes possible (docs/SPIKE-DUPLICITY.md §8 names it), and
it needs the checker to know the class of an expression — class-typed
tracking on the dyn side, which dyn deliberately does not have. A class
adds a tag and a dispatch, not a static slot discipline.
- *Computed dispatch values.* Clojure registers a method under any value
because registration there is a run-time call; here it is compile-time, and
the method's declaration name is built from the value.
- *~nil~ and floats as dispatch values.* ~:else~ covers the nil case, which
is the common one (~class-of~ answers nil for anything that is not an
instance); a float compared for equality is a trap waiting to be sprung.
- *Class redefinition and migration* — plan.org's ~redefine-class~ /
~migrate-instances~. A heterogeneous map has no layout to be stale, so
nothing breaks today when a class gains a slot: old instances simply lack
it. Enumerating live instances is the part that is missing, and it is the
pool's question rather than this lane's.
- *The JS backend.* It refuses dyn wholesale, so none of this compiles there.
Same parking as the string-equality hole above.
** Two things found on the way, neither about classes
- *The x86 backend's redefinition module never emitted the per-type dyn
descriptors.* ~Emit.redefinition~ has always emitted them, by going through
~finish~; the x86 twin ended at the rodata section and stopped. Nothing had
reached it, because a redefined body had to construct a struct holding a
dyn to need one, and until ~NoMethod~ there was no such struct a
compiler-written body could build. It is not a bad read at run time — a
descriptor label is local, so ~ld~ refuses the module with an undefined
symbol. Fixed with one line beside the same call in the executable path.
- *A dyn value answered by ~eval-expr~ never reaches the reply's ~:value~.*
It renders to the program's own stdout, which arrives on a *later* reply's
~:output~ — the dyn-global rows already read one that way and say so, and
~(+ 2 3)~ answering "5" beside ~(area (point 3 4))~ answering "" is the
whole of the difference. Left standing: where a dyn expression's value
should surface is a question about the editor protocol, not about this
lane. The dev test works around it by comparing inside the expression, so
what crosses the wire is a typed 1.
** What was run
~dune test --root .~ green (exit 0, no FAIL lines) after each of the three
commits. Three acceptance rows for ~test/programs/dyn-class.flan~ — default,
~-O0~ and ~--x86~ — and a three-way diff of the program's real output across
the same three, captured by hand before the rows were written. It is in
~test_sanitize.ml~'s list; per the sweep policy the sweep itself was not run.