Bind elements and inlined arguments read from assigned variables
Why
A local ref that does not escape compiles to a JavaScript variable that is assigned with =, and a @val external reads a JS global that JS code may assign. Two JS passes replace a read of a value taken from such a variable with a read of the variable itself, which gives the value of a later assignment (#9227). Js_pass_flatten_and_mark_dead replaces t[i] and t.field of an immutable tuple, record or constructor with two or more elements by the element when it is a variable, and binds the other elements before the block, so such a variable was also read after the call of a later element. Js_pass_tailcall_inline replaces a parameter of a function inlined at a tail call by its variable argument; since #8617 turns a ref captured by a closure into a variable too, that argument can be a ref that the body assigns. Js_pass_record_rest then read a field of such an argument after the assignment from the rest pattern's destructured fields. Both passes now bind an element or argument that is a variable some Lassign assigns (Lam_stats.assignable) or a JS global, as they bind other expressions. That is the rule Lam_compile.delay_safe applies to a variable so as not to delay reading it, now shared by the three as Js_analyzer.var_may_change; other variables are still read in place.
modifiedJS tuple and record elements
type point = {x: int, label: string} let f = y => { let x = ref(1) let t = (x.contents, y) x := 2 let (a, b) = t a + b} let g = label => { let x = ref(1) let p = {x: x.contents, label} x := 2 (p.x, p.label)}
js
modifiedJS tuple elements read in closures
let snapshots = () => { let i = ref(0) let out = [] while i.contents < 3 { let pair = (i.contents, "x") out->Array.push(() => { let (n, _) = pair n }) i := i.contents + 1 } out->Array.map(get => get())}
js
modifiedJS an element before a call
let f = () => { let x = ref(1) let bump = () => { x := x.contents + 1 x.contents * 10 } let t = (x.contents, bump()) let (a, b) = t a + b}
js
modifiedJS arguments of a function inlined at a tail call
let run = step => { let count = ref(0) let bump = (before, by) => { count := count.contents + by (before, count.contents) } bump(count.contents, step)}
js
modifiedJS a rest pattern parameter of an inlined function
type config = {name: string, version: string, debug: bool}type restConfig = {version: string, debug: bool} let replace = () => { let c = ref({name: "a", version: "1", debug: true}) let k = ({name: _, ...restConfig as rest}: config) => { c := {name: "b", version: "2", debug: false} (rest.version, c.contents.name) } k(c.contents)}
js
modifiedJS JS globals
@val external counter: int = "counter"@val external bump: unit => unit = "bump" let saved = () => { let t = (counter, "b") bump() let (a, _) = t a} let report = () => { let show = before => { bump() (before, counter) } show(counter)}
js
modifiedJS correct programs that get a binding
type item = {id: int, name: string} let single = n => { let id = ref(0) let out = [] for _ in 1 to n { let item = {id: id.contents, name: "x"} out->Array.push(item) id := id.contents + 1 } out} let afterLoop = () => { let i = ref(0) while i.contents < 10 { i := i.contents + 1 } let report = n => { Console.log("report") Console.log(n) n * 2 } report(i.contents)}
js
Review
- Why
Lam_stats.assignableand JS globals, and not every variable that the JS assigns? Binding every variable that the JS assigns changed inline_condition_with_pattern_matching.mjs and mario_game.mjs, whose output was correct. The JS assigns switch results, static-catch bindings, exit variables and the temporaries of&&and||only before reading them, and reads the parameter_xof a tail-recursive loop only through thelet x = _xof each iteration. Aforindex that Lam_compile.compile_for declares before the loop (let i = e; for (; i <= n; ++i)) changes between iterations, so a closure that captures it reads the last value, with or without these passes; that is a bug of compile_for, left to a separate change. WithJs_analyzer.var_may_changeno checked-in output changes, and the set is already computed, so the fix adds no traversal. - Why bind the element instead of keeping
t[i]? Keepingt[i]would keep the block alive and would still read the element after the call of a later element, which is bound before the block. With the binding the block is still dead when every use is a read liket[i], as with other expressions. A block that stays alive, like a variant matched withtypeof, keeps the binding next to it (let v_0 = x; let v = {TAG: "Pair", _0: v_0, ...}), as it does for other expressions. - Does a correct program get longer output? Yes, when the element or argument is such a variable but no assignment comes between, as in the last block: an element of a block that escapes gets a binding, as an element that is a call already does, and so does an argument that the inlined body does not assign. Telling these cases apart needs to know where the variable is assigned, closures included, which is a new analysis; no checked-in output changes.
- Do other JS passes substitute the same way? Js_pass_record_rest replaces
source.labelafter a record rest pattern by the destructured field, and a rest pattern rejects mutable fields. Its source is thematchvariable Lambda binds to the matched value or a parameter, and before this commit Js_pass_tailcall_inline could replace such a parameter by an assigned variable (the rest pattern block); with the argument bound, nothing assigns the source. Js_pass_scope, Js_pass_get_used and Js_shake only analyze or remove code, and Js_pass_flatten and Js_pass_external_shadow only restructure statements or qualify globals. - Does cross-module inlining add assigned variables that the set misses? With the experimental -bs-cross-module-opt, Lam_compile adds the variables an inlined body assigns only to the stats it compiles that body with; the functions with an assigned ref that were tried fail earlier, in Lam_dce, as they did before this commit, so this path is left as it is.