diff --git a/TODO.org b/TODO.org index 8dc501d0..f395baa5 100644 --- a/TODO.org +++ b/TODO.org @@ -1959,21 +1959,6 @@ CLOSED: [2026-09-25] =put= on an instance still checks a declared slot's type and still inserts an undeclared key; only =set= refuses one, since a slot it writes has to exist. -** NEXT update: change a place by applying a function to it -Decided 2026-09-25: every place evaluates each of its subexpressions once, C's compound-assignment rule, which also fixes =++= and =--=; =update= is built on that. Rules out refusing side effects in a place. -=(set (.velocity g) (inc (.velocity g)))= names the place twice. Clojure's -=update= would be a macro over the same two steps, for a struct field and a -class slot alike. - -Blocked on the double-evaluation question, which =++=, =--= and any -compound assignment share: =(update (at grid (next-index) c) inc)= evaluates -=(next-index)= twice, and a place with a side effect is then wrong rather than -slow. Either places get a general single-evaluation rule — bind every -subexpression of a place to a temp once, which is what C's compound assignment -does — or the language says a place must be side-effect free and refuses -otherwise. The first is the real fix and it is a change to how every place -lowers, not to one macro. - ** TODO A session eval reported (CFn [] ()) does not cross into dyn yet At =sand.flan:46:20=, the =:pause= in =(when (get state :pause) (return))=, where =state= is a =defclass= instance with a =pause= slot. =(CFn [] ())= is diff --git a/docs/BUILT.md b/docs/BUILT.md index 2fc8b761..567337f6 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -3887,7 +3887,7 @@ before this landed, so `macro-unless.flan` is a test written after the feature. ### The line between a special form and a macro A form the prelude itself relies on is built into the parser: `cond`, `when` and `dotimes`. A form only programs use -is a prelude macro: `inc`, `++`, `into`, `unless`, `until` and `comment`. The reason is `Macro.reduce`: a prelude +is a prelude macro: `inc`, `++`, `update`, `into`, `unless`, `until` and `comment`. The reason is `Macro.reduce`: a prelude function that calls a macro is left out of the module that runs macros, so a form the prelude's own functions use cannot be a macro without taking those functions away from every macro body. `until` peels an optional leading label and answers `(while :label (not test) body ...)`. diff --git a/lib/parse.ml b/lib/parse.ml index e5190994..88b824a5 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -449,6 +449,14 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = | [ target; value ] -> mk (Ast.Set (place target, expr value)) | _ -> fail f "set is (set place value)") + (* What [update], [++] and [--] expand into: (update~ PLACE g NEW), where + NEW is written over the name [g]. The name has a [~] in it so no program + can write it; only a prelude macro builds one. See [modify]. *) + | Sym "update~" -> + (match args with + | [ target; { v = Sym g; _ }; value ] -> modify f target g value + | _ -> fail f "internal: update~ is (update~ place name value) — a compiler bug") + (* ── (vec-new [u8]) and (map-new string [u8]) ─────────────────────── The type positions of these two take a type expression. Whether this call is the builtin at all is the checker's to know — a program may define its @@ -1252,6 +1260,65 @@ and place (f : Form.t) : Ast.place = (deref p), or a class slot (get inst :slot)" (Form.to_string f) +(* A read-modify-write of a place, with every subexpression of the place + evaluated once — C's rule for compound assignment. (update (at grid (next) + c) inc) calls [next] once, and the read and the write land on the same + element. + + Each index, key and pointer is bound to a temp first, outermost and + leftmost first. The container a field or an index is taken from is not: it + has to stay a path to the storage, since a temp would be a copy of a struct + or an array and the write would land in the copy. Only a path made of + names, fields, indexes and derefs stays one; anything else in container + position — a call answering a Vec, say — is a value, and is bound like an + index. Then [g] is bound to the place's current value, and [value], written + over [g], is stored back through the same path. *) +and modify f target g value : Ast.expr = + let mk e = { Ast.e; loc = f.loc } in + let binds = ref [] in + let temp (e : Ast.expr) = + match e.Ast.e with + | Ast.Int _ | Ast.UInt _ | Ast.Float _ | Ast.Byte _ | Ast.Str _ | Ast.Kw _ -> e + | _ -> + let t = fresh_temp "place" in + binds := { Ast.bname = t; bty = None; bval = e; bloc = e.Ast.loc } :: !binds; + { e with Ast.e = Ast.Var t } + in + let rec path (e : Ast.expr) = + match e.Ast.e with + | Ast.Var _ -> e + | Ast.Field (t, n) -> { e with Ast.e = Ast.Field (path t, n) } + | Ast.Call (({ Ast.e = Ast.Var "at"; _ } as h), t :: idx) when idx <> [] -> + let t = path t in + { e with Ast.e = Ast.Call (h, t :: List.map temp idx) } + | Ast.Call (({ Ast.e = Ast.Var "deref"; _ } as h), [ p ]) -> + { e with Ast.e = Ast.Call (h, [ temp p ]) } + | _ -> temp e + in + let p = + match place target with + | Ast.Pvar _ as p -> p + | Ast.Pfield (t, n) -> Ast.Pfield (path t, n) + | Ast.Pindex (t, idx) -> + let t = path t in + Ast.Pindex (t, List.map temp idx) + | Ast.Pderef p -> Ast.Pderef (temp p) + | Ast.Pslot (t, k) -> + let t = temp t in + Ast.Pslot (t, temp k) + in + let at e = { Ast.e; loc = target.loc } in + let read = + match p with + | Ast.Pvar n -> at (Ast.Var n) + | Ast.Pfield (t, n) -> at (Ast.Field (t, n)) + | Ast.Pindex (t, idx) -> at (Ast.Call (at (Ast.Var "at"), t :: idx)) + | Ast.Pderef p -> at (Ast.Call (at (Ast.Var "deref"), [ p ])) + | Ast.Pslot (t, k) -> at (Ast.Call (at (Ast.Var "get"), [ t; k ])) + in + let old = { Ast.bname = g; bty = None; bval = read; bloc = target.loc } in + mk (Ast.Let (List.rev (old :: !binds), [ mk (Ast.Set (p, expr value)) ])) + and arms f (items : Form.t list) : Ast.arm list = let rec go = function | [] -> [] diff --git a/lib/prelude.ml b/lib/prelude.ml index c69bf2ca..8d201c41 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -2270,16 +2270,12 @@ let source = {flan| ;; because a macro does not have a type at all; the expansion is checked at the ;; call site as if it had been written there. ;; -;; **++ and -- read the place twice, and that is an accepted cost.** The -;; expansion is (set PLACE (+ PLACE 1)), so PLACE is evaluated once to read -;; and once to write. For a variable, a field or a deref that is free and -;; means nothing. For (at arr (next-index)) — an index with a side effect — -;; it means next-index runs twice and the read and the write land on different -;; elements. That is not a bug to be fixed here: macros are non-hygienic by -;; decision (plan.org, open decision 2), a macro cannot bind a temporary for -;; the *place* without a reference type it does not have, and -;; rl/with-drawing and rl/with-mode-2d already take the same trade on their -;; arguments. Write the index out first if it does anything. +;; **++ and -- evaluate the place once.** Each index, key and pointer in the +;; place is bound to a temp before the read, so (++ (at arr (next-index))) +;; calls next-index once and reads and writes the same element — C's rule for +;; compound assignment. They are update with + and -, spelled as the form +;; update~ that update itself expands into (a prelude macro may not call a +;; macro); lib/parse.ml's [modify] is where the place is taken apart. (defmacro inc [& args] (if (!= (length args) 1) `(inc-takes-one-number) @@ -2293,12 +2289,31 @@ let source = {flan| (defmacro ++ [& args] (if (!= (length args) 1) `(++-takes-one-place) - `(set ~(at args 0) (+ ~(at args 0) 1)))) + (let [g (gensym)] + `(~(Form.Sym {.s "update~"}) ~(at args 0) ~g (+ ~g 1))))) (defmacro -- [& args] (if (!= (length args) 1) `(---takes-one-place) - `(set ~(at args 0) (- ~(at args 0) 1)))) + (let [g (gensym)] + `(~(Form.Sym {.s "update~"}) ~(at args 0) ~g (- ~g 1))))) + +;; ── update: change a place by applying a function to it ──────────────── +;; +;; (update (.velocity g) inc) +;; (update (at grid r c) + 10) +;; +;; (update place f args ...) stores (f old args ...) back into the place, where +;; old is what the place held. f is written as the head of a call, so it may be +;; a function, an operator or a macro such as inc. Every place set takes is a +;; place here too — a name, a field, an element, a deref, a class slot — and +;; the place is evaluated once, as ++ says above. It answers what set answers. +(defmacro update [& args] + (if (< (length args) 2) + `(update-takes-a-place-and-a-function) + (let [g (gensym)] + `(~(Form.Sym {.s "update~"}) ~(at args 0) ~g + (~(at args 1) ~g ~@(form-rest args 2)))))) ;; ── into: a fused transformation, and not a transducer ──────────────── ;; diff --git a/spec-syntax.md b/spec-syntax.md index 4800e773..77639572 100644 --- a/spec-syntax.md +++ b/spec-syntax.md @@ -142,7 +142,7 @@ Each item: the proposal, then the reason in one line. index, field). - **`==` is `=`; `=` is assignment.** `x = v` reads `(set x v)`, `a[i] = v` reads `(set (at a i) v)`, `p.x = v` reads `(set (.x p) v)`. `x += v` reads - `(set x (+ x v))`; like `++` today, the place is evaluated twice. + `(update x + v)`, which evaluates the place once, as `++` does. - **A run of the same operator flattens** (variadics, section 3): `a + b + c` reads `(+ a b c)`, `a < b < c` reads `(< a b c)` (Flan's chain semantics, `test/programs/chain.flan`). This keeps the converter round trip diff --git a/test/programs/update-place.flan b/test/programs/update-place.flan new file mode 100644 index 00000000..8da35858 --- /dev/null +++ b/test/programs/update-place.flan @@ -0,0 +1,55 @@ +;;;; update, ++ and -- evaluate every subexpression of their place once, as +;;;; C's compound assignment does. `calls` counts the index function: one call +;;;; per form, and the read and the write land on the same element. + +(defonce calls i32 0) + +(defn next-index [] i32 + (set calls (+ calls 1)) + (- calls 1)) + +(defstruct Body [velocity i32 hits [3 i32]]) + +(defn add [x i32 y i32] i32 (+ x y)) + +(defclass counter [n i32]) + +(defn which-slot [] dyn + (set calls (+ calls 1)) + :n) + +(defn main [] i32 + (let [xs [10 20 30] + v (vec-new i32) + g (Body {.velocity 5})] + (push v 1) (push v 2) (push v 3) + ;; next-index answers 0, then 1, then 2. + (++ (at xs (next-index))) + (-- (at v (next-index))) + (update (at xs (next-index)) * 3) + (println calls) ; 3 + (println (at xs 0) (at xs 1) (at xs 2)) ; 11 20 90 + (println (at v 0) (at v 1) (at v 2)) ; 1 1 3 + ;; A field, with a macro as the function, and with arguments after it. + (update (.velocity g) inc) + (update (.velocity g) add 10) + (println (.velocity g)) ; 16 + ;; A path through a field into an element: the struct is written in place, + ;; not in a copy. + (set calls 0) + (update (at (.hits g) (next-index)) + 7) + (++ (at (.hits g) (next-index))) + (println calls) ; 2 + (println (at (.hits g) 0) (at (.hits g) 1)) ; 7 1 + ;; Through a pointer. + (let [p (addr (.velocity g))] + (update (deref p) * 2) + (println (.velocity g))) ; 32 + (free v)) + ;; A class slot, with the key computed once. + (let [c (counter 4)] + (set calls 0) + (++ (get c (which-slot))) + (update (get c (which-slot)) * 10) + (println calls (get c :n))) ; 2 50 + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 2a2ba22b..d41fa573 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -432,6 +432,13 @@ let () = match_enum_out; outputs ~dev:true "match over an enum, dev" "programs/match-enum.flan" match_enum_out; + (* update, ++ and -- evaluate their place's subexpressions once: the + counts are the number of calls an index or a key function got. *) + let update_out = "3\n11 20 90\n1 1 3\n16\n2\n7 1\n32\n2 50\n" in + outputs "update evaluates its place once" "programs/update-place.flan" + update_out; + outputs ~x86:true "update evaluates its place once, --x86" + "programs/update-place.flan" update_out; (* The count is [length] so that [len] is left to programs, and this is the claim that it really is one: a local holding a count, a parameter, and a defn the program calls by its bare name, all of @@ -4904,6 +4911,17 @@ level "1" "(defn main [] i32 (++) 0)" "++-takes-one-place"; macro_arity "-- with two arguments" "(defn main [] i32 (let [a 1 b 2] (-- a b)) 0)" "---takes-one-place"; + macro_arity "update with no function" + "(defn main [] i32 (let [a 1] (update a)) 0)" + "update-takes-a-place-and-a-function"; + (* A place update takes is a place set takes, and is refused the same way. *) + macro_arity "update of something that is not a place" + "(defn main [] i32 (update 5 inc) 0)" "5 is not assignable"; + macro_arity "update of a parameter" + "(defn f [a i32] () (update a inc))" "a is a parameter"; + macro_arity "update whose function answers the wrong type" + "(defn yes [x i32] bool true)\n(defn f [] () (let [a 1] (update a yes)))" + "expected i32"; (* unless keeps a guard, and it is now the narrower one: a body may be missing, a test may not. *) macro_arity "unless with no test at all"