A place in update, ++ and -- evaluates each of its subexpressions once, and (update place f args ...) stores (f old args ...) back into it

This commit is contained in:
Joseph Ferano 2026-09-25 15:24:32 +07:00
parent b8c81d94ee
commit 8cc63ed9f6
7 changed files with 169 additions and 29 deletions

View File

@ -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

View File

@ -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 ...)`.

View File

@ -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
| [] -> []

View File

@ -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 ────────────────
;;

View File

@ -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

View File

@ -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)

View File

@ -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"