Files
jsrt/docs/demutate_algo.txt
T
2026-09-08 18:52:47 +00:00

265 lines
10 KiB
Plaintext

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 ; ( <value> ; DefineField @F )+
mutation run ( <object> ; <value> ; 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:
<value> // 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 <value> 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 "<create>", 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 "<mutate>", 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.