Files
jsrt/docs/model.md
T
2026-10-03 14:06:24 +00:00

219 lines
18 KiB
Markdown

# The core model
This file is the spec for how the compiler represents JS values, objects, bindings and functions in C. Each part of the code must agree with it. A difference between the code and this file is a bug in one of them.
The method:
1. State the JS semantics that the C code must keep (section 1).
2. Define a default representation that keeps all of it with no analysis (section 2).
3. Let types (sections 3 and 4), the value rule (section 5) and analyses (sections 6 and 7) narrow the representation. Each narrowing has one condition. If the condition is not proved, the default stays.
So a hole in an analysis costs speed. It never makes wrong code. Only a wrong rule can make wrong code.
## 1. Semantics
JS has four core concepts.
**Value.** A value is one of: undefined, null, bool, number, string, object. A value cannot change. A value is always copied: an assignment, an argument, a return and a field write each copy it. An object value is a reference to an object, so a copy of it copies the reference.
**Object.** An object is a cell with identity. It has own fields and a prototype (an object or null). A field read looks at the own fields. If the field is absent, the read goes on to the prototype, and at the end of the chain it gives undefined. An absent field and a field that holds undefined are different. Two object values are equal (`===`, `==`) if and only if they refer to the same object.
**Binding.** A binding is the storage of a variable. Each entry into a scope makes new bindings. A loop body is entered once for each iteration. `for (let …)` copies the values of the loop vars into the new bindings of the next iteration. A closure captures bindings, not values. So a write to a captured binding, by the owner or by any closure, is seen by all of them.
**Function.** A function is an object that also has code, and the bindings that it captured.
### When the representation is free
A representation is correct if no program can see a difference from these semantics.
- **A primitive** cannot change and has no identity. A copy of it and a shared pointer to it look the same. Either is correct.
- **An object** can be copied instead of shared only if no program can see the copy. There are two ways to see a copy:
- a write through one holder, which another holder reads later
- an identity test between two holders
- **A binding** must be shared if a closure captures it and a write reaches it after the capture.
## 2. The default representation
With no types and no analysis, this representation is correct for every program that uses only supported ops.
- **Every value is an `any_t`:** a tagged union of the kinds. An object in an `any_t` is a pointer to its cell, so it keeps its identity.
- **Every object is a cell on the heap.** Each object starts with a header: the site that created it. The site tells the object's shape and, for a fn, its code. Each field starts as absent.
- **Every captured binding is a box:** a cell on the heap with one slot.
- The box is made when the fn is entered.
- The owner and every closure that captured the binding read and write the slot.
- A scope that is entered again is a loop body. QuickJS closes each captured binding of such a scope (`close_loc k`) at every way out of it. `close_loc k` puts a new box in `k`, which holds the current value. Closures made before keep the old box. So each iteration has its own bindings.
- A box's slot is never absent: JS gives a binding a value before any read of it that does not throw. A `var` starts as undefined. A `let` or `const` has no value before its declaration, where a read throws (the TDZ, section 8).
- A binding that no closure captures is a C local. Transform knows this from `closure_vars`, so no analysis decides it.
- A module binding has no frame. Its box is made where the fn that declares it is made.
- **A function object holds a pointer to each box that it captured.** The fn that makes the closure gives each box: from its own local if the binding is its own, else from its own env. So every capture is one copy of a pointer.
## 3. Types
A type tells which values a place can hold at runtime. Infer computes one type for each value id in each variant (section 3.5).
### 3.1 The types
A type is one of these:
| type | values |
|---|---|
| `Unknown` | none: no value has reached the place yet |
| `Undefined` | undefined |
| `Null` | null |
| `Bool(v)` | a bool. `v` is the value, if it is known |
| `Int` | an int32 (provisional: the number type is not decided) |
| `Float` | a number |
| `Str(v)` | a string. `v` is the value, if it is known |
| `Obj(site)` | an object that `site` creates. A fn is an object whose site has code |
| `Sum(id)` | a value of one of the types in a list: `Int \| Str`, `Obj(A) \| Null`, `Obj(A) \| Obj(B)` |
| `Any` | any value. No rule gives it now (section 3.4) |
**Join:**
```
Unknown * T = T
Any * T = Any
T * T = T
Bool(a) * Bool(b) = Bool, the value not known. The same for Str
Int * Float = Float
T * U = T | U
```
**Sums.** A sum is a list of the types above, except `Unknown`, `Sum` and `Any`. It has at most one type of each kind, except objects, which have one `Obj` for each site. A join of two sums is the union of their lists, where two types of one kind join by the rules above. So `Int | Str` joined with `Float` is `Float | Str`. Each list is kept once, by number, in the site table (`SiteTable`), which also holds a row for each site. There are finitely many kinds and sites, so there are finitely many sums.
**Members.** The members of a sum are the types in its list. The only member of any other type is the type itself, and `Unknown` has none. Every rule in infer works member by member, and joins the results (section 3.3).
### 3.2 The invariant
At every program point, every runtime value of a place is in the place's type.
Every rule in infer must keep this invariant. Every rule is monotone, and a rule with an `Unknown` operand gives `Unknown`. Infer starts from `Unknown` everywhere and computes a fixed point of the rules.
### 3.3 Objects
- **A site** is an op that creates an object (`object`, `fclosure`), or a built-in fn. All objects of a site share one type for each field, for the whole program. Two sites are never merged: objects of two sites in one place give `Any`.
- **A field's type** is the join of every value written to it. A field can be absent, unless every object of the site gets it when it is created (section 7). A field that no write reaches is only absent.
- **A field read** on `Obj(site)` gives the own field's type. If the field can be absent, it also gives the read on the prototype (the `__proto__` field). The chain ends at a prototype that is absent or not an object, where the read gives `Undefined`. So a field that can be absent reads as its type `| Undefined`.
- **A field write** on `Obj(site)` joins the value's type into the site's field. So every value that holds an object of the site sees the write.
- **A primitive** has no own fields. A read on it reads its kind's prototype, which the stdlib links with `__link_proto_*`.
- **A field read or write on undefined or null** throws (section 8), so it gives `Unknown`.
- **An array** is an object too, of a site whose kind is "array": `array_from` makes it. Besides its fields, the site has an **element type**: the join of every value written to an element. So the type of an array is `Obj(site)`, and its site says what its elements can be (written `[T]` for short).
- `a[i]` gives `T | Undefined`: an index can be a hole, or past the end.
- `a[i] = x` joins the type of `x` into `T`. An index past the end makes the array longer, and the indexes between are holes.
- `a.length` is an `Int`, and it is not a field. A write of it truncates the array, or makes it longer with holes.
- An index is a number. Any other key is a computed field name (section 8).
- An array's prototype is the one that the stdlib links for arrays (`__link_proto_array`), as for a primitive kind. Its methods are JS in the stdlib, on top of the three ops above.
- **On a sum,** a read, a write or a call works on each member, and joins the results. So a read on `Obj(A) | Null` gives the read on `A`: the `Null` member throws, and gives nothing.
### 3.4 `Any`
Since sums, no join gives `Any`, so no value has the type `Any`. The rules for it stay, so that it can be used again (for example, as a limit on the size of a sum).
Code that holds an `Any` does not know the site of an object in it. So infer tracks which objects can be in an `Any`:
- **The mark.** When a value of type `Obj(site)` goes into an `Any`, the site is marked `in_any`. A value goes into an `Any` in a join that gives `Any`, and when it is an operand or a result of a read, write or call on `Any`. The mark spreads to the sites of the objects in the site's fields, because code that holds the `Any` can read them.
- **A read on `Any`** reads the field on every `in_any` site and on every primitive prototype. What it reads goes into the `Any` that it gives.
- **A write on `Any`** writes the field on every `in_any` site, and sets their `written` flag (section 5).
- **A call on `Any`** calls the code of every `in_any` site that has code, in the variant whose args are all `Any`, except the env, which is the fn object itself (`Obj(site)`). It gives `Any`.
So every object that code with an `Any` can reach is of an `in_any` site, and the C can choose the code by the site in the object's header.
### 3.5 Calls
- **A call** of `Obj(site)` runs the site's code, with the fn object itself as its env. A call of a sum calls each `Obj` member with its own env, and joins the results. A call of a value that is not a fn throws (section 8). A call of `Any` is in section 3.4.
- **The program is closed.** Every fn comes from an `fclosure`, or it is a native. So the code of every site is known.
- **A variant** is one walk of a fn with one tuple of arg types. A known value does not cross a call (`Bool(v)` becomes `Bool`), so there are finitely many variants.
- **Rounds:** infer walks every variant from the entry again, until a round changes nothing in the fields, the flags, the prototypes or the returns. A call of a variant that is still being walked (recursion) gives what that variant returned in the round before. When a round changes nothing, these returns are the fixed point.
## 4. Representation
The C representation of a value is a function of its type and of the value rule (section 5) only. Each type has one:
| type | C |
|---|---|
| `Unknown` | the code cannot run |
| `Undefined`, `Null` | `undefined_t`, `null_t`: unit types |
| `Bool` | `bool` |
| `Int` | `int32_t` |
| `Float` | `double` |
| `Str` | `string_t` |
| `Obj(site)`, a value shape (section 5) | `S`, the struct of the site |
| `Obj(site)`, any other shape | `S *`, a pointer to the struct of the site |
| `Str`, plus `Undefined` and/or `Null` | `string_t`. `NULL` is null, and `JSRT_UNDEFINED` is undefined |
| `Obj(site)`, plus `Undefined` and/or `Null` | `S *`, with the same two sentinels |
| `Obj` of several sites, with or without `Undefined` and `Null` | `jsrt_header *`, a pointer to the object's header, with the same two sentinels |
| any other sum | `any_t`. It is the tagged union of every kind, so it holds every sum |
| `Any` | `any_t`, a tagged union. An object in it is the pointer to its cell |
- **An array** is a pointer to the struct of its site, whose header is followed by `length`, `cap` and `items`: an array of `cap` elements, of the representation of `T | Undefined`. A hole holds undefined.
- **Absent** is a field state, not a value. A field that can be absent needs one more immediate for absent: a sentinel address for `string_t` and `S *`, a header tag for `S`, and an `any_t` tag for every other type.
- **Conversions.** A type grows in a join, so most conversions go to a representation that holds more: a member into its sum, or `int32_t` into `double`. These cannot fail. A conversion to a representation that holds less checks the value, and a failed check is a `TypeError` (section 8). For example, a `string_t` that can be undefined, passed to a native's string param.
- **An op on a sum** tests which member the value is: a kind test on an `any_t`, a sentinel test for null and undefined, and a `switch` on the site in the header for objects. Each case then works on one member, as for a single type. A member on which the op throws (a field read on null) has no case, so it throws.
- **A read, a write or a call on `any_t`** of type `Any` is a helper that emit writes: a `switch` on the site in the object's header, with one case for each `in_any` site (section 3.4). Every field name is known at compile time, so no hash map is needed. One comes with computed field names (section 8).
## 5. Values: immutable shapes
**The rule.** Section 1 gives the two ways to see a copy of an object: a write through one holder that another holder reads later, and an identity test. An object that is never written after its creation, and never in an identity test, has neither. So it behaves like a primitive: a copy of it and a shared pointer to it cannot be told apart. It can be a C struct value, copied freely, with any number of holders.
**The flags.** Infer sets three flags on each site. Each flag only becomes true.
- **written:** a field write that is not a creation write reaches an object of the site. A creation write is a `define_field` into the value that a creating op gives, before the object goes anywhere: an object literal's fields, a closure's captured boxes. Regify turns a `define_field` into anything else (a class field on `this`, a static field on the class) into a `put_field`. So every `define_field` is a creation write, and every `put_field` is a write after creation.
- **compared:** an object of the site is an operand of `===`, `!==`, `==` or `!=`, and the other operand can also be an object (`Obj` or `Any`).
- **in_any:** an object of the site can be in an `Any` (section 3.4). Code that does not know the site can write it or compare it.
**Value shapes.** A site has a value shape if no flag is set, it is not an array, it is not a member of a sum (an element of an array is in the sum `T | Undefined`), and it does not contain itself through value fields. A sum is one C type for all its members, so its object members are pointers. A struct cannot contain itself, so a site that does stays a pointer. An object of an immutable site cannot hold itself, but objects of one site can hold each other, as in a list.
**Representation.** An object of a value shape is a struct value (`S`), in its holder: a C local, a param, a return value, or the struct of the object whose field holds it. In a field that can be absent, its header's site is the tag for absent (`JSRT_SITE_ABSENT`). It is not in a sum and not `in_any`, so it is never an `any_t` value. A runtime operation that only reads it (ToString, `<`) gets a heap copy.
**Default.** An object of any other shape is a pointer to a cell (`S *`).
**Soundness.** No write after creation can show a copy, and no identity test can. A creation write happens before the object goes anywhere, so no copy exists yet.
## 6. Escape
**Question.** Where can a cell still be reached: after its frame returns, or when the op that made it runs again?
**Result.** One of three values for each value that a creating op gives:
- **`escapes`** if one of these is true:
- After the frame of its fn returns, the cell can be reached in a way other than as the return value itself. For example, it is stored in a field of a cell that is not `local`, stored in a box that is not `local`, or given to a callee. Every call operand counts as kept, because the analysis does not know the callee.
- A value that can reach the cell is still used when the creating op runs again: the op is in a loop, and the cell reaches a var or a join slot, which can keep it until the next run.
- **`returned`** if it does not escape, the return value is the only way to reach it after the return, it is the only object that a return of the fn can give, and its creating op runs at most once in each call.
- **`local`** otherwise: no holder outlives the frame.
A box is a cell, so the same rules apply to boxes.
**Narrowing.**
- **`local`:** the cell is on the stack of its frame. The value is still a pointer (`S *`) to it, so only the creating op changes. A C compiler can also split such a cell into registers if its address goes nowhere.
- **`returned`:** the caller allocates the cell. The variant gets one more param: a pointer to the cell to build into. The callee builds the object there and does not allocate. In the caller, the call is the creating op of the cell. So the caller's escape result for the call's dst chooses the stack or the heap, and the decision goes up through any number of fns that make and return objects.
**Default.** A cell that escapes is on the heap (`jsrt_alloc`).
**Soundness.**
- A stack cell lives until its frame returns, and its creating op uses the same memory again each time it runs. `local` excludes both cases.
- A `returned` object is in the caller's cell from the start and never moves. So every pointer to it stays valid, including a pointer from the object to itself.
**Where it is used.** Emit chooses the allocation of every cell in one function, `alloc()`. A creating op calls it, and so does a call of a variant whose result is `returned`.
## 7. Other narrowings
| narrowing | condition | if not proved |
|---|---|---|
| no absent in a field | a write to the field comes before every read of it (definite initialization). Object-literal fields have this: QuickJS fills them with `define_field` before the object goes anywhere | the field can be absent (section 3.3) |
| a captured binding as a value | no write reaches the binding after its first capture. Most `const` bindings have this. A `const` that a hoisted fn captures before its declaration does not | a box |
| a direct call | the callee's type is `Obj(site)`: its code is known | a call on `Any`: a `switch` on the site (section 3.4) |
## 8. Out of scope
These are not part of this model, and they need their own design:
- narrowing in branches: in `if (x !== null) { x.f }`, `x` keeps its whole type. This is correct, because the `Null` member of the read throws, but the C still tests for null
- `throw`, `try`, `catch` and `finally`. These include the `TypeError`s above: a call of a value that is not a fn, and a failed check of a conversion from `any_t`.
- the TDZ (a read of a `let` before its declaration)
- fields with computed names (`o[k]`), `delete`, `in`. The absent state is the base that `in` and `delete` need.
- typed arrays, `ArrayBuffer` and `DataView`
- iterators: `for…of`, spread and destructuring
- getters and setters