265 lines
10 KiB
Plaintext
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.
|