New direction

This commit is contained in:
2026-09-03 20:20:58 +00:00
parent 92451e9cdc
commit af85244587
12 changed files with 1095 additions and 998 deletions
+193
View File
@@ -0,0 +1,193 @@
declass: class form -> prototype form
Plain text. Read it next to class_form.txt (the input) and proto_form.txt (the target).
Every instruction below is copied in the same dump format those files use.
The class in function "test" has this cpool:
cpool: [ FnById(2), FnById(3), FnById(4) ]
Const(0) = FnById(2) = the m1 method
Const(1) = FnById(3) = the constructor
Const(2) = FnById(4) = the fields initializer
====================================================================
STEP 1 in function "test": rewrite how the class is built
====================================================================
DETECT, abstractly:
push_const8 #ctor ; define_class @Name
( fclosure8 #method ; define_method @meth )*
( fclosure8 #fields ; set_home_object ; put_loc ; drop )?
which in the example is:
[5] = PushConst8 Const(1) [],
[6, 7] = DefineClass AtomU8 { atom: file_atom[2], arg: 0 } [5, 4],
[8] = Fclosure8 Const(0) [],
[9] = DefineMethod AtomU8 { atom: file_atom[5], arg: 0 } [8, 7],
[10] = Fclosure8 Const(2) [],
[11, 12] = SetHomeObject None [10, 9],
[] = PutLoc1 None [12],
[] = Drop None [11],
REPLACE it with:
[6] = Fclosure8 Const(1) [],
[8] = Fclosure8 Const(0) [],
[18] = GetField Atom(atom("prototype")) [6],
[] = PutField Atom(file_atom[5]) [8, 18],
INSTRUCTION BY INSTRUCTION:
PushConst8 Const(1) + DefineClass [5,4] -> Fclosure8 Const(1), keep dst [6]
Fclosure8 Const(0) -> unchanged (the method closure)
DefineMethod atom=A [8,7] -> GetField "prototype" [6]
then PutField atom=A [8,18]
Fclosure8 Const(2) -> deleted
SetHomeObject -> deleted
PutLoc1 [12] -> deleted
Drop [11] -> deleted
[18] is any fresh value id.
Repeat the DefineMethod line for each method in the class.
REMEMBER for step 2 and 3:
constructor function = Const(1) = FnById(3)
fields function = Const(2) = FnById(4)
class name = file_atom[2]
====================================================================
STEP 2 splice the field initialiser into the constructor FnById(3)
====================================================================
The constructor does not set its own fields. quickjs hoisted them into a
separate initialiser function (FnById(4)) that the constructor reaches through
the captured <class_fields_init> and runs with a call. Splice that function's
field writes straight into the constructor, in place of the call, so the
constructor initialises its fields inline and the separate initialiser is left
unused.
DETECT the call block, abstractly (from the get_var_ref_check of
<class_fields_init> through the next call_method):
get_var_ref_check <class_fields_init> ; dup ; if_false
; get_loc ; swap ; call_method
which in the example is:
[2] = GetVarRefCheck VarRef(0) [],
[3, 4] = Dup None [2],
[] = IfFalse8 Label { target: 14 } [4],
[5] = GetLoc0 None [],
[6, 7] = Swap None [5, 3],
[8] = CallMethod Npop(0) [7, 6],
REPLACE the call block by splicing in the field writes from the initialiser
FnById(4):
[9] = GetLoc0 None [],
[10] = Push1 None [],
[11] = DefineField Atom(file_atom[4]) [10, 9],
The initialiser FnById(4) in full looks like this:
[2] = SpecialObject U8(4) [],
[] = PutLoc1 None [2],
[3] = PushThis None [],
[] = PutLoc0 None [3],
[] = Goto8 Label { target: 7 } [],
[4] = GetLoc0 None [],
[5] = Push1 None [],
[6] = DefineField Atom(file_atom[4]) [5, 4],
[] = ReturnUndef None [],
HOW THE SPLICE IS BUILT:
Take the initialiser FnById(4). Drop these opcodes:
SpecialObject, PushThis, PutLoc0, PutLoc1, PutLoc2, PutLoc3,
Goto8, Goto, ReturnUndef
What is left is the field writes; these are what gets spliced in.
Renumber their value ids to fresh ids in the constructor: 4,5,6 -> 9,10,11.
The constructor's own PushThis / PutLoc0 / CheckCtor prologue and its
final ReturnUndef stay as they were, wrapping the spliced-in writes.
====================================================================
STEP 3 in the constructor FnById(3): fix its header
====================================================================
DETECT, abstractly:
a constructor (name "<null>") that captures <class_fields_init>
which in the example is:
name: "<null>",
captures: [ Capture { name: "<class_fields_init>", ... } ],
REPLACE with:
name: "Counter",
captures: [],
name becomes the class name from step 1.
Remove the <class_fields_init> capture.
If other captures remain after it, subtract 1 from every VarRef(n)
whose n is greater than the removed capture index.
====================================================================
ELIMINATED (opcodes this pass removes from live code)
====================================================================
Step 1 removes these from the class build:
DefineClass replaced by Fclosure8
DefineMethod replaced by GetField + PutField
SetHomeObject deleted
PushConst8 the class-template one, folded into Fclosure8
Fclosure8 the fields-init one, deleted
PutLoc1 the SetHomeObject leftover store, deleted
Drop the SetHomeObject leftover drop, deleted
Step 2 removes these from the constructor (the field-init call block):
GetVarRefCheck of the <class_fields_init> capture
Dup
IfFalse8
GetLoc0 the one feeding the call
Swap
CallMethod
After the pass, these three class-only opcodes never appear again anywhere:
DefineClass
DefineMethod
SetHomeObject
The rest (PushConst8, Fclosure8, GetVarRefCheck, Dup, IfFalse8, GetLoc0,
Swap, CallMethod, PutLoc1, Drop) still occur elsewhere for ordinary reasons.
Only the specific class-pattern instances listed above are removed.
====================================================================
NOT TOUCHED (passed through unchanged)
====================================================================
DefineClass AtomU8 { arg: 1 } derived class
DefineMethod AtomU8 { arg: 1 } getter
DefineMethod AtomU8 { arg: 2 } setter
DefineMethodComputed ... computed method name
DefineClassComputed ... computed class name
====================================================================
LEFT BEHIND (removed by a later pass)
====================================================================
FnById(4), the fields function. Nothing references it anymore.
+187
View File
@@ -0,0 +1,187 @@
declosure: closures become plain objects
Plain text, same dump format as class_form.txt / proto_form.txt.
A closure becomes an object. Field 0 holds the function. Fields 1.. hold the
captured variables, in capture order. The object is also the environment, so
there is no separate env value.
Field names are plain integers, so a field operand reads Atom(int(k)):
field 0 the function
field k+1 capture number k
Running example:
function outer(a) {
function inner() { return a; } // inner captures a
return inner();
}
FnById(1) = outer arg_count 1 (a is arg 0)
FnById(2) = inner outer's cpool Const(0)
captures: [ Capture { name: "a", from: ParentArg(0) } ]
Three steps, one property each.
====================================================================
STEP 1 every function takes the env as its LAST parameter
====================================================================
Runs on every function. Capture reads/writes become field access on the env.
arg_count grows by one; existing args keep their numbers. The env is loaded
once at entry, but only if the function touches a capture. Then the capture
list is cleared.
DETECT, abstractly (any function; each capture access, one at a time):
capture read get_var_ref | get_var_ref_check | get_var_ref0..3
capture write put_var_ref | put_var_ref_check | put_var_ref_check_init
| put_var_ref0..3
inner, which in the example is:
captures: [ Capture { name: "a", from: ParentArg(0) } ],
arg_count: 0,
[0] = GetVarRefCheck VarRef(0) [],
[] = Return None [0],
REPLACE with:
captures: [],
arg_count: 1,
[1] = GetArg Arg(0) [], // env, the new last parameter
[0] = GetField Atom(int(1)) [1], // capture 0 -> field 1
[] = Return None [0],
INSTRUCTION BY INSTRUCTION:
load env once at entry -> GetArg Arg(<new last arg>) [env]
GetVarRef / GetVarRefCheck k -> GetField Atom(int(k+1)) [env]
GetVarRef0..3 -> GetField Atom(int(1..4)) [env]
PutVarRef / PutVarRefCheck k -> PutField Atom(int(k+1)) [value, env]
PutVarRef0..3 -> PutField Atom(int(1..4)) [value, env]
A function that touches no capture still gains the env parameter, but no
GetArg is emitted for it. The uniform parameter is what lets step 3 append
the env at every call site.
====================================================================
STEP 2 every fclosure builds the object, in the PARENT
====================================================================
Each fclosure becomes: load the function as a plain constant, make the object,
store the function in field 0, then one field per capture loaded from the
parent frame. The DefineField results chain; the LAST store must land in the
value id the rest of the code already uses for the closure.
DETECT, abstractly:
fclosure #child (#child resolves to a FnById in cpool)
outer, which in the example is:
[3] = Fclosure8 Const(0) [], // Const(0) = FnById(2) = inner
REPLACE with:
[10] = PushConst8 Const(0) [], // the function, as a plain constant
[11] = Object None [], // the closure object
[12] = DefineField Atom(int(0)) [10, 11], // field 0 = function
[13] = GetArg Arg(0) [], // capture 0 source: outer's arg 0 (a)
[3] = DefineField Atom(int(1)) [13, 12], // field 1 = capture a; last store -> [3]
A closure with NO captures is just field 0, and that store lands in [3]:
[3] = Fclosure8 Const(0) [],
->
[10] = PushConst8 Const(0) [],
[11] = Object None [],
[3] = DefineField Atom(int(0)) [10, 11],
CAPTURE SOURCE -> how it loads (from the parent frame):
ParentLocal(s) -> GetLoc Loc(s) [value]
ParentArg(s) -> GetArg Arg(s) [value]
anything else -> Undefined None [value] // gap, see below
DefineField args are [value, obj], and it returns the object, so the obj
of each store is the result of the store before it.
====================================================================
STEP 3 every plain call goes through the object, in the CALLER
====================================================================
A call names a closure object now. Read the function out of field 0, call it,
and pass the object itself as the trailing env argument. The callee is the
last argument, so it is args.last().
DETECT, abstractly:
plain call call | call0..3 (callee = last arg; not call_method,
call_constructor, or apply)
outer, which in the example is:
[5] = Call Npop(0) [4], // [4] is the closure object
REPLACE with:
[14] = GetField Atom(int(0)) [4], // the function, out of field 0
[5] = Call Npop(1) [4, 14], // call it; env (the object) appended
Npop grows by one, for the appended env. Args become:
[ env, <original call args>, func ]
env = the object value (the old callee)
func = field 0 of the object (the new callee, still last)
====================================================================
ELIMINATED (opcodes this pass removes from live code)
====================================================================
Step 1 removes:
GetVarRef, GetVarRefCheck, GetVarRef0..3 replaced by GetField on env
PutVarRef, PutVarRefCheck,
PutVarRefCheckInit, PutVarRef0..3 replaced by PutField on env
Step 2 removes:
Fclosure, Fclosure8 replaced by PushConst8 + Object
+ DefineField(s)
After the pass, these never appear again anywhere:
Fclosure, Fclosure8
every GetVarRef* and PutVarRef*
====================================================================
NOT TOUCHED (passed through unchanged)
====================================================================
CallMethod carries a receiver; the class passes own it
CallConstructor carries a receiver; the class passes own it
Apply carries a receiver
GetVarRef* / PutVarRef* that name a source this pass does not model
(see gaps) still become field access, but load
Undefined
====================================================================
GAPS (known, left for later)
====================================================================
A captured var the parent later writes: the write makes a new value, so
the closure never sees it. A capture aliases the value at capture time.
Capture source is ParentCapture / module binding / global: step 2 has no
parent frame slot to read, so it loads Undefined.
Foreign callee (e.g. Math.sqrt): step 3 reads field 0, but a host builtin
has no field 0.
Entry points (module root, host-invoked functions): step 1 gives them an
env parameter, but no caller of ours supplies one.
+210
View File
@@ -0,0 +1,210 @@
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 value the run USES but does not itself PRODUCE becomes a parameter, in
first-use order. Everything produced inside the run (Object, constants,
arithmetic that is wholly inside) moves into the function unchanged.
the object being mutated is used-but-not-produced -> it is a parameter.
a constant field value (Push1) is produced inside -> it moves in.
a field value from the enclosing frame (GetArg p) -> a parameter.
So a pure 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)
====================================================================
start a run at an Object, or at the first static-key write to an object.
extend it while the next instruction is one of:
Object (a nested object is its own run)
a value computation feeding a write in this run
DefineField, imm == Atom(F) (same object)
PutField, imm == Atom(F) (same object)
a (re)load of the same object
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.
"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.
====================================================================
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
====================================================================
constant a.x = 1 Push1 moves into the function
argument { x: p } GetArg becomes a parameter
computed a.x = p + 1 p is a parameter; the Add moves in
field read a.x = b.y b is a parameter; the GetField moves in
nested object { x: { y: 1 } } the inner Object is its own run, lifted
first; its result is a value the outer
run uses -> a parameter of the outer
====================================================================
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.