diff --git a/FIX.org b/FIX.org index 61d3eea..7c8d20f 100644 --- a/FIX.org +++ b/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.