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:
parent
0511cc1a01
commit
90110f525a
174
FIX.org
174
FIX.org
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user