local lifetimes¶
status: partially implemented.
localandonceparameters parse, lower to clean python, and are enforced by ty today —escaping-local(withoncetreated as a borrow),once-not-called,once-called-twice, andescaping-loop-variable— as arelocal/onceon a callable type's own parameters, which constrain the trailing lambda block filling it. still design sketches: the opt-inonceruntime guard and theT_{x}lifetime notation. those sections are marked below
python is garbage collected: every value lives as long as something references it, and no reference is ever "too old". that safety net also erases a whole class of intent. you cannot say this callback must run exactly once, or this reader is only valid for the duration of this call, or the sequence i hand back is a view into the buffer you gave me and dies with it. so those contracts live in docstrings, and their violations live in production
basedpython adds a static escape analysis that lets you state them in the signature and checks them at transpile time. three cooperating constructs feed it:
local— a parameter (or callback parameter) that must not escape the call it is bound in — a borrowT_{x}— a type whose validity is tied to the lifetime ofx— a viewonce— a callback that must be called exactly once — a linear value
none of this changes what the program does at runtime. like
abstract and override, the markers are compile-time-only:
they are erased in the lowered python, and everything they promise is enforced
by ty's diagnostics before a line runs. once optionally emits a runtime guard,
in the same spirit as soundness checks and
checked casts
local parameters¶
a local parameter is one the callee may use but may not keep. it borrows
the value for the length of the call and no longer:
def f(local fn: () -> None): # fn is only valid throughout the call to f
fn() # ok — used within the call
transpiles to (the marker is stripped):
the check is on the callee. inside f, the value bound to a local parameter
must not outlive f's activation. these are the ways a value escapes, each
reported as escaping-local:
| escape route | example |
|---|---|
| returned | return fn |
| stored on a global / nonlocal | _registry = fn |
| stored on a longer-lived object | self.cb = fn |
| put in a container that escapes | handlers.append(fn) |
| captured by a closure that escapes | return lambda: fn() |
handed to a non-local parameter |
schedule(fn) — schedule might keep it |
and these uses are always fine — a borrow is fully usable, it just cannot be retained:
- calling, reading, indexing, iterating, or calling methods on it
- assigning it to a binding that does not outlive the call (an ordinary local)
- passing it on to another
localparameter — the borrow is re-lent, not extended (see re-borrowing) - constructing something from it (
str(xs),list(xs)) — a constructor is read as consuming what it is given rather than retaining it
the builtins that provably cannot retain what they are given — they return a
fresh scalar and hand no part of the argument back — carry local in their own
signatures, so the ordinary reads of a borrow are not reported:
def f(local xs: list[int]) -> None:
print(len(xs)) # fine
print(sum(xs)) # fine
print(any(xs)) # fine
print(repr(xs)) # fine
that set is len, sum, any, all, repr, ascii, hash, isinstance
and issubclass. the rest of the stdlib is not annotated yet, so a call into it
still escapes even where the callee plainly keeps nothing — min and sorted
among them, which are excluded deliberately because they hand an element back
and that element's lifetime is a separate question. the remedy is to annotate
more of the stdlib, never to guess which callees retain their arguments: that
guesswork is exactly what this feature replaces.
re-borrowing¶
passing a local value to another local parameter is always allowed: the
second callee is under the same no-escape obligation, so the value still cannot
outlive the original call:
def log_each(local items: Iterable[str]): ...
def f(local items: Iterable[str]):
log_each(items) # ok — re-borrowed, still cannot escape
passing it to a non-local parameter is the escape — that callee is free to
retain it, so the borrow would be laundered into an unbounded lifetime. the fix
is to mark the downstream parameter local too, or to hand over a copy (see
severing a tie)
lifetime-bound types¶
planned — the
T_{x}notation and itsdangling-lifetimecheck are not implemented yet; this section is a design sketch
a borrow answers "can the callee keep this?". it does not answer the dual question: "can the callee hand me back something that secretly is this?". consider a function that borrows a buffer and returns its lines lazily:
the returned Sequence[str] is a view over x — reading from it reads from
the buffer. so it is only valid while x is. but the signature above does not
say that, and nothing stops a caller from draining the sequence after the
buffer is gone. we need to tie the return's lifetime to x:
T_{x} reads "T scoped to x": a value of type T whose validity is bounded
by the binding x. it is stripped in the lowered python — Sequence[str]_{x}
becomes plain Sequence[str] — and carries its meaning only through the checker
there is no separate lifetime variable to declare: you name the binding
directly. the lifetime is the parameter. self is available inside
methods, which covers the common "a view into me" case:
at the call site¶
a lifetime tie propagates to the result. wherever f(a) is called, the result
is recorded as a view of the specific argument passed for x, and inherits its
lifetime:
def read_lines() -> list[str]:
reader = open_reader()
lines = f(reader) # lines is a view of reader
return list(lines) # ok — materialized before reader is dropped
def bad() -> Sequence[str]:
reader = open_reader()
return f(reader) # error[dangling-lifetime]: result outlives `reader`
in bad, the returned view would outlive reader, which is local to bad —
dangling-lifetime. in read_lines, list(lines) copies the data out before
reader ends, severing the tie, so the list[str] is
free-standing and safe to return
multiple lifetimes¶
a value can be a view of more than one binding — it is valid only while all of them are, so it dies with the first to end:
severing a tie¶
a lifetime tie is a claim about aliasing, so anything that copies the data out ends it. the checker recognizes a tie as severed when the value's static type is one that cannot alias its source:
- an immutable scalar read out of a view —
int,str,bytes,bool,float,None, an enum.lines[0]is a plainstr; it holds no reference back to the buffer, so it is free-standing even thoughlinesis a view - an eager materialization through a known builtin constructor —
list(v),dict(v),set(v),tuple(v),frozenset(v),bytes(v),bytearray(v),str(v), or a comprehension that copies elements
what stays tied are the things that genuinely alias: declared T_{x} results,
memoryview, iterators and generators, and user view-types you have annotated.
the rule of thumb — scalars and copies sever, views and lazy wrappers do
not — is the same soundness argument the soundness checks rest
on: a value that carries no back-reference to the resource cannot dangle
once callbacks¶
once marks a callback parameter that must be called exactly once on every
path that completes the function normally — a linear value. it is the tool for
completion handlers, one-shot continuations, and any "you must, and must not
forget to, and must not do it twice" protocol:
the static check counts direct calls of commit with their control-flow
context, and reports two failures:
once-not-called— the callback is never called (it is not mentioned anywhere in the body). a callback that is passed on to anotheronceparameter is left alone, since that receiver must call it exactly onceonce-called-twice— two unconditional calls, or a call inside a loop (which may run any number of times)
once is a borrow: a callback you can only guarantee to run exactly once is
one you have not let escape your control. so once is local plus the count
obligation — it is escape-checked exactly like a local (returning it, storing
it, or binding it to a global is escaping-local), and it may only be passed
on to another once parameter. handing it to a plain local (which could
call it zero or many times) or a non-borrow parameter would drop the guarantee,
so both are rejected:
def keep(once cb: () -> None): cb()
def borrow(local cb: () -> None): cb()
def f(once done: () -> None):
keep(done) # ok — the obligation is preserved
borrow(done) # error[escaping-local] — a `local` need not call it once
return done # error[escaping-local] — cannot escape at all
because a once block is confined to its call, it is also safe to capture a loop
variable in a trailing-lambda block bound to a once (or
local) callee — the block runs synchronously, so the variable still holds this
iteration's value. a block bound to a non-borrow callee that captures a loop
variable is the late-binding trap, reported as escaping-loop-variable (the
type-aware companion to ruff's B023)
the check is deliberately conservative — it flags only what it can prove, so it never fires on correct code. two calls in mutually-exclusive branches are one call on every path and pass:
def f(once done: () -> None, ready: bool):
if ready:
done()
else:
done() # ok — exactly one call on every path
a single-branch conditional (if ready: done()) may skip the call, but the
static check does not flag it — proving "skipped on some path" is what the
runtime guard is for. a call inside a loop is rejected
(once-called-twice), matching the rule that the loop may run more than once
borrowed callback arguments¶
the two features compose through the callback's own signature. a callback type
can mark its parameters local, which says: when the callee invokes the
callback, the value it passes in is local to the callee, and the callback body
may not leak it. this is the flagship case, using a
trailing lambda block as the callback:
def f(once fn: (local Resource) -> None): # fn: called once; its argument is local to f
with acquire() as resource:
fn(resource) # resource is local to this call
let result: Resource
f:
result = it # error[escaping-local]: `it` is local to `f`, cannot escape the callback
(local Resource) -> None is a callable type with a single local parameter. the
trailing block becomes fn, and its implicit parameter it
binds that local position — so inside the block, it carries f's lifetime.
print(it), it.read(), or handing it to another local parameter are all
fine; assigning it to the outer result is the escape — a block's assignments
write through to an enclosing binding, so the value would
outlive the call. the parameter may be named for clarity, and that spelling also
covers a type a bare modifier cannot precede:
the modifier is only read when a name follows it, so a bare modifier before a
parenthesized type ((local (int) -> None)), a string forward reference, or a
starred type is not one — once (x) and local (y) are an ordinary call and a
parenthesized name everywhere else, and a parameter list is not always
distinguishable from a value tuple when the modifier is read. naming the
parameter removes the ambiguity. for the same reason there is no bare (local )
spelling: it is the same token stream as (local), the one-parameter list whose
parameter has the type named local
once in a callable type carries the whole obligation, not just the borrow: the
block filling that callable must call the marked parameter exactly once, and is
reported with once-not-called / once-called-twice if it does not.
the borrow is a constraint on the callback's implementation, not on its
callers, so it does not change assignability: a (local int) -> None and an
(int) -> None remain mutually assignable, and passing an ordinary function
where a borrowed callback is expected is not an error. what is checked is the
body of a block written in that position. a callee whose callback shape cannot
be inspected leaves the block unconstrained, as everywhere else here
runtime guard¶
planned — only the static
oncecheck ships today; the runtime guard below is a design sketch
the static check is conservative — it approves only what it can prove. for the cases it cannot (a call buried behind a helper, a hand-rolled event loop), an opt-in runtime guard turns "exactly once" into an enforced runtime invariant, covering exception paths the static analysis deliberately leaves alone:
class _OnceGuard:
def __init__(self, fn):
self._fn = fn
self._called = False
def __call__(self, *args, **kwargs):
if self._called:
raise TypeError("once callback called more than once")
self._called = True
return self._fn(*args, **kwargs)
def _ensure_called(self):
if not self._called:
raise TypeError("once callback was never called")
with the guard enabled, with_transaction lowers to wrap the callback and
assert in a finally, so both "twice" and "never" raise — the latter even when
the body exits by raising:
def with_transaction(commit):
commit = _OnceGuard(commit)
try:
do_work()
commit()
finally:
commit._ensure_called()
grammar¶
local and once are parameter-position keywords, parsed in the same slot as
the visibility and binding modifiers:
localprecedes a function/method parameter (def f(local x: T)) or a parameter inside a callable type ((local T) -> R,(local name: T) -> R). in a callable type it is only read when a name follows it, so(local)stays the one-parameter list whose parameter has the type namedlocal, and a type that does not start with a name has to be reached through the named formonceprecedes a callback parameter (def f(once fn: () -> R)). it is only meaningful on a callable-typed parameter; a plannedonce-on-non-callablecheck will flagonceon a non-callable (today such a parameter simply reads as never-called)- both may apply to one parameter as
once local fnwhen a callback both must run exactly once and must not itself be retained T_{x}is a postfix on any type expression, wherexnames a parameter (orself) in the enclosing signature. an unknown name isunknown-lifetime
all three are .by-only. a .py file that uses them is a parse error, exactly
as with the other syntax extensions
lowering¶
the markers carry no runtime meaning (except the once guard) and are erased in
a single forward pass:
| basedpython | python |
|---|---|
def f(local x: T) |
def f(x: T) |
-> Sequence[str]_{x} |
-> Sequence[str] |
def f(once fn: () -> None) |
def f(fn: Callable[[], None]) (static-only) |
(local T) -> None |
Callable[[T], None] |
the callable-arrow half of the last two rows lowers through the existing
callable transform; local/once are removed before it runs.
because nothing in the lowered python distinguishes a local parameter from an
ordinary one, the markers do not survive a round-trip — the
reverse transform has no lowered shape to
detect and cannot reconstruct them, the same as any other erase-only marker
configuration¶
the escape, lifetime, and once diagnostics are always available — they are
pure static analysis with no codegen, so they run on every check and, like any
ty rule, can be downgraded or silenced per project or per line
the once runtime guard is opt-in, mirroring --no-checked-cast: pass
--once-checks to by run, by build, or by transpile to emit the
_OnceGuard wrapper, or leave it off for a zero-overhead static-only build. the
type is identical either way
composition¶
- with trailing lambdas — the
itparameter of a block inherits thelocalmarker from the callback type, which is what makesescaping-localfire onresult = it - with the callable arrow —
localsits inside the arrow's parameter list and lowers with it - with soundness checks and checked casts — lifetimes are the static complement to those runtime guards: soundness validates what a value is, lifetimes validate how long it may be used. an explicit copy that severs a tie is exactly a runtime materialization the soundness checker already understands
limits¶
the escape analysis is intraprocedural and best-effort — it reasons within
one function body and across signatures, never by inlining a callee. it flags an
escape only where it can see the local reach the exit directly: a bare name (or
one held in a surface container / ternary / boolean) that is returned, stored on
a parameter-rooted or global / nonlocal target, or handed to a resolvable
non-local parameter. that leaves it biased toward false negatives, not
false positives — three consequences:
- an unannotated return is assumed free-standing.
def f(local x: R) -> Vwith no_{x}is taken at its word — if the body actually returns a view ofx, that is a missing annotation, reported at thereturninsidef, not atf's call sites - a local captured by a closure (
return lambda: fn()) or routed through an opaque call that retains it is not currently detected — thereturn lambda: fn()row above is the intended contract, not yet enforced. tightening these toward soundness is future work; the escape hatch meanwhile is an explicit copy (which severs the tie) or a suppression comment - an escape through a callee is caught only when the callee's signature is
resolvable.
schedule(fn)is flagged whenschedule's parameter can be inspected and is notlocal; an opaque callee is left alone
once is checked on paths that return normally. a path that propagates an
exception is exempt from the static count — any line in python may raise, so
requiring the call on every exceptional path would reject almost everything. the
runtime guard closes that gap for code that needs a call-on-error guarantee; the
idiomatic static equivalent is try / finally
open questions¶
- notation —
T_{x}is the leading spelling; a keyword form (T for x,T @x) may read better in nested positions likeIterator[Line for x] once?— an at-most-once (affine) relaxation, following the language's?-means-relaxed convention (cast?,?.,??): may be skipped, never called twice. it is the natural type for a consuming callback that a fast path can decline to runlocallocals — allowinglocal y = expron an ordinary binding to opt a local variable into escape checking, not just parameters- lifetime elision — whether a function with exactly one
localparameter should tie an unannotated return to it automatically (it trades explicitness for brevity, against basedpython's escape-by-default stance)