Expand description
The conservative local uniqueness proof Vector.freeze() needs.
ADR 0001 has said from the beginning what this pass is:
Vector.freeze()consumes a vector with uniquely owned storage and returns anArray<T>in O(1). The compiler only performs conservative, local uniqueness checking for this explicit transition. If uniqueness cannot be proved,toArray()creates an independent O(n) immutable array.
Until issue #240 that sentence
described nobody: the tree-walking interpreter counted Rc handles at run
time and refused there, which is a different thing with a different
failure time and a different blast radius. It also cannot be carried
anywhere else. A handle in the linear-memory machine is a word, words are
not counted, and the sharing bit that could have carried the answer went
out with the copy-on-write design. So the choice was to give up the O(1)
transition or to establish uniqueness where the language always said it
was established — in the compiler — and this is the second one.
§What is proved
For one freeze() call, that the vector it consumes is reached through
exactly one place, and that the place is not read again afterwards. The
four conditions are #240’s own list:
- it originates at a locally known creation —
Vector.of(...),array.toVector(),vector.snapshot(), or a field initialised by one in a struct literal this body wrote; - it has not been copied to another live place — no
let/varbinds it, no assignment writes it anywhere else; - it has not escaped — no closure captures it, no
returncarries it out, nothing stores it in another value, and no call it is passed to can keep it; - it is consumed by the
freeze()and not used afterward.
One more condition is not on that list and belongs to a static pass rather
than a dynamic one: the site must not be somewhere that runs twice. A
freeze() in a loop body or a closure body, over storage created outside
it, would consume on the first turn what the second turn would find gone,
so the binding has to be created inside the same region the site is in.
§What is deliberately not treated as an escape, and why each is safe
Four positions read a place without keeping the handle. Treating them as escapes would refuse most of the corpus for nothing:
- a method call’s receiver.
items.push(n)anditems.length()write and read through the handle; neither stores it. The exception is a declared method whose result can reach aVector, which may be a getter handing the field back — that is an escape. - a string interpolation operand.
"{items}"formats the value and keeps nothing. - a
forloop’s iterable. Iterating reads elements out; the sequence is not retained. - a by-value call argument, when the call has no way to keep it. This
is the one that needs an argument rather than an observation, and the
argument is the language’s own: a
Vectoris not task-safe, so it cannot be put in aSharedor carried into aspawn, and the Host API boundary materialises aValue, so a host cannot hold one either. The ways out of a callee are therefore its result, avarparameter, and another operand it could write into. SofirstFree(seed, w, h, cells)— whose result is anIntand whose other operands hold no vector — keeps nothing, andinto.push(cells)does. A call whose result can reach aVector, a call with avarargument, and a call where some other operand can reach aVectorare all escapes.
§The one obligation that crosses a call
A builder’s finish is the shape that made a purely intraprocedural pass
insufficient:
fn finish(var self) -> Router {
Router(routes: self.routes.freeze())
}self.routes does not originate here, so this body cannot prove anything
about it — and refusing would refuse examples/values and
examples/callbacks, both of which are demonstrating the language’s own
rule. What the body can do is state the condition it needs and make its
callers prove it. So a method that freezes a path rooted at var self
becomes a method that demands a uniquely owned receiver, and every
call to it is checked by the same local proof, on the receiver place, at
the call site. fresh.finish() on a draft this body built passes;
original.finish() after var alias = original does not.
The demand is deliberately narrow — a var self receiver of a method
written in a plain impl block, and nothing else. That is the only
declaration form whose every call site the checker resolves precisely
(Facts::target), so it is the only one where the obligation cannot be
lost. A freeze() rooted at an ordinary parameter, at a captured name, or
at the receiver of a trait method is refused rather than propagated,
because a call through a bound or through dyn names no declaration and
there would be nowhere to discharge it.
There is one way an obligation can be lost, and it is worth naming rather
than leaving implicit: a call whose receiver type the checker declined to
settle records no target, so a finish() reached through a value of
unknown type is not checked. That needs a receiver the checker abstained
about — a Host API result a schema declared Any — reaching a method of a
declared type, and no program in the corpus does it. Closing it would mean
refusing every unresolved call that shares a name with a demanding method,
which is a diagnostic about a coincidence of names; it is left open, and
written down here, until a program asks for it.
§What it refuses that the oracle admits
Conservative means this list is not empty, and the diagnostic’s job is to make each entry legible rather than mysterious. The ones that showed up while this was written:
- storage a call produced.
var log = freshVector()thenlog.freeze()is refused: the initialiser is a call, and whether its answer is fresh is a fact about another body. Proving it would be an “answers unaliased storage” summary, which nothing in the corpus asks for yet. - storage an assignment brought in.
log = linesgiveslogwhatever the caller is holding, and this refuseslog.freeze()afterwards — correctly, in that case. - a
varparameter that is notself. The obligation only travels back through a method receiver, sofn take(var v: Vector<Int>) { ... v.freeze() }is refused. - a name a pattern or a
forbound.match maybe { Some(v) => v.freeze() }has no creation to point at.
Every one of them has the same correction, and the diagnostic gives it:
toArray(), which copies in O(n) and asks nothing.
§This is not a borrow checker
It answers one question about one method. There is no sharing bit, no
reference count, no copy-on-write and no runtime table; a program that
this pass cannot prove is not a program that is wrong, it is a program
that pays toArray()’s O(n) copy instead. The diagnostic says so, and
naming the alias that defeated the proof is most of what it is for.
Constants§
- NOT_
UNIQUE - A
freeze()whose receiver’s storage could not be proved uniquely owned. - USED_
AFTER_ FREEZE - A read of a vector a
freeze()has already consumed.
Functions§
- check
- Checks every
freeze()inprogram.