demutate: object create + in-place mutation become function calls Plain text, same dump format as class_form.txt / proto_form.txt. Goal: every point that builds or mutates an object becomes a call to a function that RETURNS a fresh object. A mutation function copies the input's fields with a spread and re-applies only the writes it was given; it never names the rest. ==================================================================== THE KEY IDEA ==================================================================== Find a maximal contiguous RUN of static-key field writes to one object -- the Object that creates it, the DefineField / PutField writes with an atom key, and the value computations they consume -- and lift the whole run into one function. creation run Object ; ( ; DefineField @F )+ mutation run ( ; ; PutField @F )+ (same object) The function returns the finished object. The call site replaces the run with: load the parameters, make the closure, call it, and put the result where the run's object went (the escaping value for a creation, a store-back for a mutation). Creation and mutation are ONE code path. The only difference is whether the run's object is made inside it (creation -> start from an empty Object) or loaded from outside (mutation -> the object is a parameter, spread in first). Creation is just mutation of a freshly-made empty object; PutField and DefineField both become DefineField on the fresh copy the function owns. ==================================================================== PARAMETERS -- the rule that keeps this general ==================================================================== A run is handed anything it does not make for itself. Everything else moves into the function unchanged. There are two kinds of parameter: FRAME. GetArg, GetLoc, GetVarRef and their numbered forms are frame reads. Each becomes a parameter, and the load stays at the call site. RUN. A nested build inside this run. That run is lifted first, and its call result is passed in. See NESTED BUILDS. Everything else -- constants, arithmetic, field reads off a parameter -- is made by the run and moves in. Parameters are taken in first-use order. A frame slot appears once however many times it is loaded, so a mutation's repeated loads of its object fold into the one parameter, and the object is always parameter 0. So a literal of constants lifts to a 0-parameter function; a mutation of `a` lifts to a 1-parameter function; `a.x = p + 1` lifts to a 2-parameter one. ==================================================================== FUNCTION BODY -- how the run is rebuilt ==================================================================== The lifted function always builds ONE fresh object and returns it. if the run has an incoming object (a parameter), start as a spread copy: [0] = Object None [], [1] = GetArg0 None [], // the incoming object [2] = Null None [], [3, 4, 5] = CopyDataProperties U8(6) [2, 1, 0], // copy ALL its fields [] = Drop None [5], [] = Drop None [4], otherwise (a creation), start empty: [0] = Object None [], then, for each field write in the run, in order: // moved in, or GetArgK for a parameter DefineField Atom(@F) [value, obj] and finally: [] = Return None [obj], Each generated function is appended to the flat function list, gets the next fn id N, and FnById(N) is added to the caller's cpool at some slot K; the call site loads it with Fclosure8 Const(K). ==================================================================== DETECTION (one linear walk) ==================================================================== A run cannot be matched opcode by opcode, the way a class build can, because is any expression at all. FOLLOW THE OBJECT instead: start a run at an Object (creation), or at a load of a slot that a static-key write then writes to (mutation). skip forward to the next instruction that USES the object. carry on only while that use is a static-key write to it. everything passed over on the way is the value computation, and it joins the run. A creation and a mutation differ only in how the object carries from one write to the next: DefineField hands the object back as a NEW value, so a creation run is one value chain. PutField hands nothing back, so a mutation re-loads the object before every write. END the run at any of: the object is READ for anything but a field write -- passed to a call, used in an expression, branched on. a static-key write to a DIFFERENT object. a jump, or an instruction some jump lands on. A run is ONE straight line of code, so both sides of a branch are cut off. Without this an `if` in the middle of a sequence of writes joins the two sides into one run. "Same object": a mutation re-loads the object before each write (each GetArg0 / GetLocCheck is a fresh value), so sameness is judged by the load SOURCE -- the same Arg or Loc -- not the value id. quickjs keeps writes in source order and does not group by object, so interleaved writes to two objects (a.x; b.y; a.z) split into three runs, not two. Ignore computed-key writes (DefineArrayEl / PutArrayEl) -- see extensions. NESTED BUILDS: a run's own instructions are walked AGAIN, to find the object builds inside it. Every Object in the program ends up in exactly one lifted function, so one function describes one object shape. { x: { y: 1 }, z: { w: 2 } } lifts to THREE functions, not one. An inner run is held INSIDE its parent's parameter list, as the Run parameter it becomes. So lifting is depth-first: lift a run's Run parameters, then the run itself, which needs their results. The inner walk looks for BUILDS ONLY. A mutation writes its object over and over, so a walk that looked for anything would match the run's own second write as a run of its own: a.x = 1 ; a.y = 2 one mutation run, NOT a run with one nested ORDER IN THE PASS: demutate runs BEFORE declosure. declosure builds its closure objects out of Object and DefineField Atom(int(k)), which a run cannot tell apart from a user object. Run it after, and every closure gets lifted as if it were an object literal. ==================================================================== WORKED EXAMPLE (test), real instructions ==================================================================== --- the creation run --- DETECT: [1] = Object None [], [2] = Push1 None [], [3] = DefineField Atom(file_atom[7]) [2, 1], [4] = Push0 None [], [5] = DefineField Atom(file_atom[8]) [4, 3], [] = PutLoc0 None [5], nothing is used from outside, so the function takes no parameters. REPLACE with (the run's result stays value [5], so PutLoc0 is untouched): [20] = Fclosure8 Const(Kc) [], [5] = Call0 None [20], [] = PutLoc0 None [5], new FnById(Nc), name "", arg_count 0: [0] = Object None [], [1] = Push1 None [], [2] = DefineField Atom(file_atom[7]) [1, 0], [3] = Push0 None [], [4] = DefineField Atom(file_atom[8]) [3, 2], [] = Return None [4], --- the mutation run (a.field1 = 2 ; a.other = 3) --- DETECT: [6] = GetLocCheck Loc(0) [], [7] = Push2 None [], [] = PutField Atom(file_atom[7]) [7, 6], [8] = GetLocCheck Loc(0) [], [9] = Push3 None [], [] = PutField Atom(file_atom[9]) [9, 8], the object (Loc 0) is used-but-not-produced -> the one parameter; 2 and 3 are produced inside -> they move in. REPLACE with (store the returned object back): [21] = Fclosure8 Const(Km) [], [22] = GetLocCheck Loc(0) [], // the incoming object [23] = Call1 None [22, 21], // a = mutate(a); callee is last [] = PutLoc0 None [23], new FnById(Nm), name "", arg_count 1: [0] = Object None [], [1] = GetArg0 None [], [2] = Null None [], [3, 4, 5] = CopyDataProperties U8(6) [2, 1, 0], [] = Drop None [5], [] = Drop None [4], [6] = Push2 None [], [7] = DefineField Atom(file_atom[7]) [6, 3], [8] = Push3 None [], [9] = DefineField Atom(file_atom[9]) [8, 7], [] = Return None [9], ==================================================================== FIELD VALUE FORMS -- all handled the same way ==================================================================== One rule covers them all: a frame read (GetArg, GetLoc, GetVarRef) becomes a parameter and its load stays at the call site; everything else moves in. a.x = 1 constant Push1 is produced inside the run, so it moves in. { x: p } argument GetArg is a frame read, so p becomes a parameter. a.x = p + 1 computed p is a parameter; the Add is produced inside, so it moves in. a.x = b.y field read b is a parameter; the GetField is produced inside, so it moves in. { x: { y: 1 } } nested object The inner Object is a run of its OWN. It is lifted first, and the outer run takes its result as a parameter. See NESTED BUILDS. ==================================================================== NEEDS A SEPARATE HELPER (extensions) ==================================================================== spread in a literal { ...b, x: 1 } CopyDataProperties of a source object, not just the input copy computed key { [k]: 1 }, a[k] = 1 DefineArrayEl / PutArrayEl, a[0] = 1 keyed by a value, not an atom ==================================================================== GAPS (left for later) ==================================================================== A mutation run's object must live in a local, so the returned object can be written back and the next read sees it. A bare param or a sub-expression has nowhere to write back. Aliasing / identity: the run returns a NEW object, so two names for the same object diverge, and === changes. Ordering: a run must stay in program order; it is cut wherever the object is read for anything but a field write. Methods ({ m() {} } -> DefineMethod), getters/setters, delete, defineProperty: not plain field writes.