diff --git a/TODO.org b/TODO.org index 06379f38..9a98629b 100644 --- a/TODO.org +++ b/TODO.org @@ -587,10 +587,11 @@ method to a running program is an ordinary redefinition. ** CANCELLED Class features deferred, each with its reason CLOSED: [2026-09-20] Inheritance, multi-argument dispatch, =:before=/=:after=/=:around= and -=call-next-method=, named-slot construction, unknown-slot checking, computed -dispatch values. With single dispatch on literal values there is no specificity -question, and inheritance or multiple dispatch would create one. Unknown-slot -checking needs class-typed tracking the dyn side deliberately does not have. +=call-next-method=, named-slot construction, compile-time unknown-slot checking, +computed dispatch values. With single dispatch on literal values there is no +specificity question, and inheritance or multiple dispatch would create one. +Compile-time unknown-slot checking needs class-typed tracking the dyn side +deliberately does not have; the runtime refuses an unknown slot instead. ** DONE update-instance-for-redefined-class, the user hook CLOSED: [2026-09-25] @@ -732,8 +733,8 @@ of !=. ** DONE A dyn value takes .field and [:key] CLOSED: [2026-09-26] -Assigning ~x.name~ or ~m[:k]~ is ~put~, not the stricter ~(set (get x :k) v)~: it adds -a key a plain map or a class lacks. ~m[k]~ on a dyn map takes any key, as ~get~ does. +Assigning ~x.name~ or ~m[:k]~ is ~put~: a plain map gains the key, and a class +instance refuses one its class does not declare, on read too, as ~get~ and ~put~ now do. ** DONE A slice from a C pointer, and a pointer cast CLOSED: [2026-09-25] @@ -1402,9 +1403,8 @@ CLOSED: [2026-09-20] CLHS 4.3.6. Nothing is enumerated and no heap is walked — the redefinition is constant time and each instance pays once, at its next touch. Neither printer migrates, so a stale instance shows its old slots to the editor -until something touches it. The registry is advisory: a key the class never -declared is dropped by the next migration, which is data loss with no enforcement -behind it. +until something touches it. An instance holds only declared slots — get, put and +set refuse any other key — so the migration's drop loses nothing a program wrote. ** WAIT A class registry keeps one slot list per class, not one per layout version Decided 2026-09-25: waits for a case name-matching migration to the current list gets wrong. diff --git a/docs/SBCL-REDEFINITION-NOTES.md b/docs/SBCL-REDEFINITION-NOTES.md index d54b7533..7fb2acec 100644 --- a/docs/SBCL-REDEFINITION-NOTES.md +++ b/docs/SBCL-REDEFINITION-NOTES.md @@ -326,10 +326,9 @@ The CLOS answer would need, concretely: Flan spelling of that hook is a generic function, e.g. `(defmethod update-for-redefined point [p added discarded] ...)`, which fits the dispatch mechanism that already exists. -- A decision on whether `put` of an unknown slot stays legal. Today it is — a - class instance is an open map, and TODO.org, "Class features deferred, each with - its reason", already defers refusing an unknown slot at `(get p :z)`. If unknown slots stay legal, the registry's slot list is - advisory and the whole update protocol is advisory with it. +- A decision on whether `put` of an unknown slot stays legal. Decided + 2026-09-26: it does not; `get`, `put` and `set` refuse an unknown slot on an + instance at run time, so the registry's slot list is enforced. This is a real, SBCL/CLOS-precedented design that Flan's runtime can actually support. It is also a feature with no user yet, since redefinition on the dyn diff --git a/lib/classes.ml b/lib/classes.ml index cc006999..f6ed51a8 100644 --- a/lib/classes.ml +++ b/lib/classes.ml @@ -185,9 +185,9 @@ let collect (decls : Ast.decl list) = is what [class-of] answers and what a generic dispatches on. Named-slot construction — the dyn twin of [(Cursor {.src s})], with an - omitted slot meaning nil — is deferred, and so is refusing an unknown slot - at [(get p :z)]. Both are recorded in TODO.org, "Class features deferred, - each with its reason". *) + omitted slot meaning nil — is deferred (TODO.org, "Class features deferred, + each with its reason"). An unknown slot, [(get p :z)], is refused at run + time by the runtime's [trap_no_slot]. *) let constructor n (slots : Ast.field list) loc : Ast.decl = (* Two slots of one name would write one entry and read one value, and the constructor would take two arguments for it. The duplicate parameter diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index ffad7f19..250f8f37 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -1610,15 +1610,10 @@ flan_dyn flan_dyn_map_new(void) { * * **What the registry constrains.** A store into a slot the class declares * — the constructor's, [put]'s, [set]'s — is checked against the slot's - * type. A key the class does not declare is not refused by [put]: a class - * instance is an open map — TODO.org, "Class features deferred, each with its - * reason", defers unknown-slot checking — so a key nobody declared can be - * written to one, and the migration below will *drop* it at the next - * redefinition, because its rule is that an instance's keys are the class's - * slots. That is real data loss and it is written down as such in TODO.org, - * "A redefined defclass migrates its instances lazily", rather than dressed - * up as enforcement. [set] does refuse an undeclared key, because a slot it - * writes has to exist. + * type. A key the class does not declare is refused by [get], [put] and + * [set] alike ([trap_no_slot]), so an instance's keys are its class's slots + * and the migration below, which keeps only those, drops nothing a program + * wrote. * * **Where a migration happens.** [want_map], so every [get], [put] and * [has-key?]; [flan_dyn_len]'s map arm; and [dyn_equal]'s, so two instances @@ -3206,10 +3201,20 @@ flan_dyn flan_dyn_map_get(flan_dyn m, flan_dyn k) { } /* A program's (get m k) and (.k m): the same, with the site a value that is - * not a map is refused at. */ + * not a map is refused at, and a key an instance's class does not declare + * refused rather than answered nil — [trap_no_slot]. */ +static class_entry *class_sync(flan_obj *o); +static int64_t class_slot(class_entry *e, flan_dyn k); +static _Noreturn void trap_no_slot(const uint8_t *loc, int64_t loclen, + const char *op, flan_obj *o, + class_entry *e, flan_dyn k); flan_dyn flan_dyn_get(flan_dyn m, flan_dyn k, const uint8_t *loc, int64_t loclen) { + class_entry *e; if (!is_map(m)) trap2(loc, loclen, TYPE_TRAP, "get", "only a map answers it", m, k); + e = class_sync(dyn_obj(m)); + if (e != NULL && class_slot(e, k) < 0) + trap_no_slot(loc, loclen, "get", dyn_obj(m), e, k); return flan_dyn_map_get(m, k); } @@ -3294,6 +3299,29 @@ static flan_dyn check_slot(const uint8_t *loc, int64_t loclen, int by, static inline void map_store(flan_obj *o, flan_dyn k, flan_dyn v); +/* A key an instance's class does not declare, read or written. An instance + * has exactly its class's slots — a typo in a slot name is an error at the + * access and not a new key — so get, put and set all refuse one; a plain map + * takes any key. */ +static _Noreturn void trap_no_slot(const uint8_t *loc, int64_t loclen, + const char *op, flan_obj *o, + class_entry *e, flan_dyn k) { + char sk[SAY_MAX]; + kw_entry *c = o->u.v.klass; + int64_t i; + say(sk, SAY_MAX, k); + said_len = 0; + said_add("dyn %s: %.*s has no slot %s. Its slots are", op, (int)c->len, + (const char *)(c + 1), sk); + if (e == NULL || e->nslots == 0) said_add(" none"); + else + for (i = 0; i < e->nslots; i++) + said_add(" :%.*s", (int)e->slots[i]->len, + (const char *)(e->slots[i] + 1)); + flan_say(loc, loclen, "%s", said_buf); + flan_trap((const uint8_t *)"DynType", 7); +} + /* A constructor's stores: [flan_dyn_map_set]'s, with the refusal worded for * the constructor call it happened inside rather than for a [put] nobody * wrote, and placed at the slot's declaration. */ @@ -3308,8 +3336,7 @@ void flan_dyn_slot_init(flan_dyn m, flan_dyn k, flan_dyn v, * they are three different mistakes: the value is not a class instance at * all (a map's entries are written with [put], which is where inserting a * key is real); the key is not a slot the class declares; the value does not - * fit the slot's type. The first two are why this is not [put]: a declared - * slot always exists, so writing one is a store and never an insertion. */ + * fit the slot's type. The first is why this is not [put]. */ void flan_dyn_slot_set(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, int64_t loclen) { flan_obj *o; @@ -3329,43 +3356,37 @@ void flan_dyn_slot_set(flan_dyn m, flan_dyn k, flan_dyn v, o = dyn_obj(m); e = class_sync(o); j = class_slot(e, k); - if (j < 0) { - char sk[SAY_MAX]; - kw_entry *c = o->u.v.klass; - int64_t i; - say(sk, SAY_MAX, k); - said_len = 0; - said_add("dyn set: %.*s has no slot %s. Its slots are", - (int)c->len, (const char *)(c + 1), sk); - if (e == NULL || e->nslots == 0) said_add(" none"); - else - for (i = 0; i < e->nslots; i++) - said_add(" :%.*s", (int)e->slots[i]->len, - (const char *)(e->slots[i] + 1)); - said_add("; a key the class does not declare is added with put, not set"); - flan_say(loc, loclen, "%s", said_buf); - flan_trap((const uint8_t *)"DynType", 7); - } + if (j < 0) trap_no_slot(loc, loclen, "set", o, e, k); if (!slot_admit(&e->types[j], v, &out)) trap_slot_type(loc, loclen, BY_SET, o, e, j, m, v); map_store(o, k, out); } -void flan_dyn_map_put(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, - int64_t loclen) { +static void map_put(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, + int64_t loclen, int any_key) { flan_obj *o; class_entry *e; if (!is_map(m)) trap2(loc, loclen, TYPE_TRAP, "put", "only a map answers it", m, k); o = dyn_obj(m); e = class_sync(o); + if (!any_key && e != NULL && class_slot(e, k) < 0) + trap_no_slot(loc, loclen, "put", o, e, k); /* A map with no class, and a class with no typed slot, stop at the test. */ if (e != NULL && e->typed) v = check_slot(loc, loclen, BY_PUT, o, e, m, k, v); map_store(o, k, v); } -/* The same with no site: a map literal's stores, and test/dyn_ops.c. */ +/* A program's put, and the store under a dyn's [.k] and [:k]. */ +void flan_dyn_map_put(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, + int64_t loclen) { + map_put(m, k, v, loc, loclen, 0); +} + +/* With no site and any key: an untagged map literal's stores, and + * test/dyn_ops.c, which builds instances' odd states by hand. No program + * reaches an instance through it. */ void flan_dyn_map_set(flan_dyn m, flan_dyn k, flan_dyn v) { - flan_dyn_map_put(m, k, v, NULL, 0); + map_put(m, k, v, NULL, 0, 1); } /* The store under all three, with the instance already brought up to date. */ diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 7e8538a6..2a5b1bd5 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -156,10 +156,8 @@ flan_dyn flan_dyn_type_of(flan_dyn v); * declares are dropped. The instance's identity is preserved throughout; * this is CLHS 4.3.6, and [flan_dyn_class_hook] is its user hook. * - * The drop is unconditional, which is the honest cost of a class instance - * being an open map: a key written by a raw [put] that the class never - * declared is dropped by the next migration too. The registry describes the - * class's intention and does not enforce it. */ + * No key the class never declared can be there to drop: [get], [put] and + * [set] refuse one on an instance. */ void flan_dyn_class_def(flan_dyn name, const uint8_t *slots, int64_t n); /* The body update-instance-for-redefined-class dispatches through, as a diff --git a/test/programs/dyn-class-slots.flan b/test/programs/dyn-class-slots.flan index 662d1ade..d5bb62d3 100644 --- a/test/programs/dyn-class-slots.flan +++ b/test/programs/dyn-class-slots.flan @@ -28,12 +28,9 @@ (println (get s :step)) (set (get s :tag) [1 2]) (println (get s :tag)) - ;; put reaches the same check for a declared slot, and still inserts a - ;; key the class does not declare -- an instance is an open map to put. + ;; put reaches the same check for a declared slot. (put s :speed 2.5) - (put s :scratch 9) (println (get s :speed)) - (println (get s :scratch)) (println (length s)) ;; A typed caller boxes into the dyn parameter as any call does. (set (get s :step) (twelve)) diff --git a/test/programs/dyn-class.flan b/test/programs/dyn-class.flan index 87f9eeb4..9a5991d1 100644 --- a/test/programs/dyn-class.flan +++ b/test/programs/dyn-class.flan @@ -64,7 +64,9 @@ (println (get p :x)) (println (has-key? p :x)) (println (has-key? p :nothing)) - (println (get p :nothing)) + ;; A key its class does not declare is refused on an instance; a plain + ;; map answers nil for one it lacks. + (println (get {:x 1} :nothing)) ;; The shape tag, as a value. Every value can be asked; only an instance ;; answers with a name. diff --git a/test/programs/dyn-field-trap.flan b/test/programs/dyn-field-trap.flan index 499f3bf3..9ae1150e 100644 --- a/test/programs/dyn-field-trap.flan +++ b/test/programs/dyn-field-trap.flan @@ -4,7 +4,7 @@ (defn as-dyn [d dyn] dyn d) -(defn main [args [string]] i32 +(defn main [args [str]] i32 (let [which (if (> (length args) 1) (i32 (bytes->i64 (bytes-view (at args 1)))) 0) s (State false false) n (as-dyn 3)] @@ -14,5 +14,7 @@ (= which 1) (set (.paused s) 1) (= which 2) (set (.paused n) true) (= which 3) (set (at s :step) 2) + (= which 5) (set (.pasued s) true) + (= which 6) (println (.pasued s)) :else (println (at n :paused)))) 0) diff --git a/test/programs/dyn-field-trap.fln b/test/programs/dyn-field-trap.fln index dd9c64ea..bed59b9f 100644 --- a/test/programs/dyn-field-trap.fln +++ b/test/programs/dyn-field-trap.fln @@ -4,7 +4,7 @@ defclass(State, [paused bool step bool]) fn as-dyn(d) -> dyn = d -fn main(args: [string]) -> i32 +fn main(args: [str]) -> i32 let which = if length(args) > 1 then i32(bytes->i64(bytes-view(args[1]))) else 0 let s = State(false, false) @@ -18,6 +18,10 @@ fn main(args: [string]) -> i32 n.paused = true elif which == 3 s[:step] = 2 + elif which == 5 + s.pasued = true + elif which == 6 + println(s.pasued) else println(n[:paused]) 0 diff --git a/test/programs/dyn-fields.flan b/test/programs/dyn-fields.flan index e1c83ad5..8f48a9b0 100644 --- a/test/programs/dyn-fields.flan +++ b/test/programs/dyn-fields.flan @@ -25,7 +25,7 @@ (set (at (.items b) 0) 11) (update (at (.items b) 1) + 5) (println (.count b) (.x (.pos b)) (.y (.pos b)) (at (.items b) 0) (at (.items b) 1)) - (println (.missing b) (= (.missing b) (get b :missing)))) + (println (.missing {:a 1}) (= (.missing {:a 1}) (get {:a 1} :missing)))) (let [m {:hp 3}] (set (.hp m) (- (.hp m) 1)) (set (at m :mp) 9) diff --git a/test/syntax/handwritten/dynfields.fln b/test/syntax/handwritten/dynfields.fln index 49d2956d..e36d11b8 100644 --- a/test/syntax/handwritten/dynfields.fln +++ b/test/syntax/handwritten/dynfields.fln @@ -23,7 +23,8 @@ fn main() -> i32 b.items[0] = 11 b.items[1] += 5 println(b.count, b.pos.x, b.pos.y, b.items[0], b.items[1]) - println(b.missing) + let plain = {:a 1} + println(plain.missing) let m = {:hp 3} m.hp -= 1 m[:mp] = 9 diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index cc897c87..ab9f246b 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -5652,13 +5652,12 @@ level "1" (* Typed class slots and set on a slot: the stores that fit, then one run per refusal. The constructor, put and set each check a declared - slot's type, set refuses a slot the class does not declare and a - value that is not an instance, and put still inserts an undeclared - key. On both backends, because every one of these is a runtime call + slot's type, and set refuses a slot the class does not declare and a + value that is not an instance. On both backends, because every one of these is a runtime call whose arguments the two emit separately. *) let slots_out = "#state{:pause false :step 3 :speed 1.5 :name \"sand\" :tag :x}\n\ - true\n-7\n[1 2]\n2.5\n9\n6\n12\n3.5\ntrue\n2\n:state\n" + true\n-7\n[1 2]\n2.5\n5\n12\n3.5\ntrue\n2\n:state\n" in outputs "dyn: typed class slots" "programs/dyn-class-slots.flan" slots_out; outputs ~x86:true "dyn: typed class slots, --x86" @@ -5688,8 +5687,7 @@ level "1" is declared i32, and 5000000000 is not a value it holds \ exactly"); ("3", "dyn-slot-trap.flan:17:19: dyn set: state has no slot :paws. \ - Its slots are :pause :step :tag; a key the class does not \ - declare is added with put, not set"); + Its slots are :pause :step :tag"); ("4", "dyn-slot-trap.flan:18:19: dyn set: (get m k) is a place only \ on a class instance, and this is a map with no class"); ("5", "dyn-slot-trap.flan:19:28: dyn construct: the slot :owner of \ @@ -5733,7 +5731,9 @@ level "1" rows; (try Sys.remove exe with Sys_error _ -> ()) in - let field_rows (l0, l1, l2, l3, l4) = + (* 5 and 6 are a misspelt slot on a class instance, written and read: + refused naming the class and its slots, never a new key or a nil. *) + let field_rows (l0, l1, l2, l3, l4, l5, l6) = [ ("0", l0 ^ ": dyn get: int and keyword, and only a map answers it — \ (get 3 :paused)"); ("1", l1 ^ ": dyn put: the slot :paused of State is declared bool, \ @@ -5744,19 +5744,27 @@ level "1" ("3", l3 ^ ": dyn put: the slot :step of State is declared bool, and \ this is int"); ("4", l4 ^ ": dyn at: int and keyword, and only a text, a vec or a \ - map is indexed — (at 3 :paused)") ] + map is indexed — (at 3 :paused)"); + ("5", l5 ^ ": dyn put: State has no slot :pasued. Its slots are \ + :paused :step"); + ("6", l6 ^ ": dyn get: State has no slot :pasued. Its slots are \ + :paused :step") ] in let flan_rows = field_rows ("dyn-field-trap.flan:13:28", "dyn-field-trap.flan:14:19", "dyn-field-trap.flan:15:19", "dyn-field-trap.flan:16:19", - "dyn-field-trap.flan:17:22") + "dyn-field-trap.flan:19:22", "dyn-field-trap.flan:17:19", + "dyn-field-trap.flan:18:28") + and fln_rows = + field_rows ("dyn-field-trap.fln:14:13", "dyn-field-trap.fln:16:5", + "dyn-field-trap.fln:18:5", "dyn-field-trap.fln:20:5", + "dyn-field-trap.fln:26:13", "dyn-field-trap.fln:22:5", + "dyn-field-trap.fln:24:13") in field_trap "programs/dyn-field-trap.flan" flan_rows; field_trap ~x86:true "programs/dyn-field-trap.flan" flan_rows; - field_trap "programs/dyn-field-trap.fln" - (field_rows ("dyn-field-trap.fln:14:13", "dyn-field-trap.fln:16:5", - "dyn-field-trap.fln:18:5", "dyn-field-trap.fln:20:5", - "dyn-field-trap.fln:22:13")); + field_trap "programs/dyn-field-trap.fln" fln_rows; + field_trap ~x86:true "programs/dyn-field-trap.fln" fln_rows; (* A numeric cast opening a dyn box — TODO.org, "A numeric cast opens a dyn box". programs/dyn-cast.flan is one program because the three diff --git a/test/test_dev.ml b/test/test_dev.ml index 02b4c0ff..c97466da 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -9346,13 +9346,13 @@ let () = (* ── A slot lost, and a second generation ── [:y] goes. Nothing calls [area] after this: its method reads :y, - which is now nil, and a generic that traps on a slot its class no + which is now refused, and a generic that traps on a slot its class no longer has is the program being wrong rather than the migration. *) let r = redefine "(defclass point [x z])" in if status r <> "ok" then fail "removing a slot from a class: %s" (said r) else begin - holds "a lost slot reads as absent" - "(if (= (get (at instances 0) :y) nil) 1 0)"; + holds "a lost slot is absent" + "(if (has-key? (at instances 0) :y) 0 1)"; holds "a lost slot is gone from the count" "(if (= (length (at instances 0)) 2) 1 0)"; holds "the slots either side of it are untouched" @@ -9385,31 +9385,13 @@ let () = end; (* ── A definition that did not change ── - Every C-c C-k re-runs a file's class definitions, and a generation - bumped per registration rather than per *change* would migrate - every instance in the program on every save. Here that would be - visible: the value written below is put into a slot the class - declares, and a spurious migration would keep it — so the - discriminating half is the raw key on the line after, which a real - migration drops and an ignored re-registration leaves alone. *) - holds "a key written straight into an instance" - "(do (put (at instances 0) :scratch 7) 1)"; + Every C-c C-k re-runs a file's class definitions, and re-running + an unchanged one has to leave its instances' values alone. *) let r = redefine "(defclass point [x z w])" in if status r <> "ok" then fail "re-evaluating an unchanged class: %s" (said r) else - holds "an unchanged definition migrates nothing" - "(if (= (get (at instances 0) :scratch) 7) 1 0)"; - (* And the same key after a definition that *did* change, which is - the advisory registry stated as a test rather than as a hope: a - class instance is an open map, [put] accepts any key, and the next - migration drops the ones the class does not declare. TODO.org, "A - redefined defclass migrates its instances lazily", says so in as - many words. *) - let r = redefine "(defclass point [x z w q])" in - if status r <> "ok" then fail "a fourth redefinition: %s" (said r) - else - holds "a migration drops a key the class never declared" - "(if (= (get (at instances 0) :scratch) nil) 1 0)" + holds "an unchanged definition keeps the values" + "(if (= (get (at instances 1) :x) 5) 1 0)" end; (try Unix.close c with Unix.Unix_error _ -> ()); (try Unix.kill mpid Sys.sigkill with Unix.Unix_error _ -> ()); diff --git a/web/index.html b/web/index.html index b506108b..8fa15b52 100644 --- a/web/index.html +++ b/web/index.html @@ -728,8 +728,9 @@ kind as a keyword — :nil, :bool, :int, :keyword — and an instance's class name, so a class cannot be named after one of those kinds. The slots are map keys: (.pause s) reads one, and (set (.pause s) true) writes one, checking its -type. get and put do the same, and put -is also how a key the class does not declare is added.

+type. get and put do the same. A key the class does +not declare is refused, so a misspelled slot stops the program at the line that +misspelled it.

Dispatch comes in the two styles and they are one mechanism. defgeneric dispatches on the class of the first argument, which is