trailing lambda blocks¶
a statement-level expression followed by : and an indented suite calls that
expression with the suite as its last argument. the suite becomes a function
taking the single implicit parameter it — kotlin-style trailing lambdas:
customising the lambda's parameters is not supported yet — the block takes
exactly one parameter named it (plus the callback's
receiver, where it declares one). a callback that takes
more than that one argument is rejected with trailing-lambda-parameters: the
extra arguments have no parameter to land in, and no spelling in the body
def f(a: (int, str) -> None):
a(1, "two")
f: # error: the block binds only `it`, so `"two"` has nowhere to go
print(it)
it exists exactly when the callback passes one. a callback that takes nothing
is invoked as a(), so a block filling it binds no it at all, and the name
means nothing more there than any other name nobody wrote
def g(a: () -> None):
a()
g:
print(it) # error[unresolved-reference] — `a` passes no argument
g:
print("hi") # fine — the block just doesn't take one
nothing holds the name, so such a block is free to use it for a local of its
own. a callback whose shape cannot be read — a gradual (...) -> None, an
imported callee — keeps it, untyped
as a value¶
a block can stand as an assignment's value, where it binds the call it stands for — not the callee:
def f(x: int, a: (int) -> None) -> str:
a(x)
return "done"
result = f(2):
print(it)
reveal_type(result) # str
→
an annotation works the same way (result: str = f(2):). the target has to be a
single name, though: a block's value is worked out together with the binding its
target makes, and only one binding can do that — so neither a chain
(a = b = f:) nor an unpacking (a, b = f:) takes a block
binding¶
the block binds the callee's last declared parameter, passed by keyword when
ty can inspect the callee's signature. that is what lets f: above leave the
defaulted x untouched. when the signature is not inspectable (an unresolved
import, a *args last parameter, a positional-only parameter) the block is
appended as the last positional argument instead
it is context-typed from the callee: the sole positional parameter of the
callable the last parameter is declared as. in the example above a is
(int) -> str, so it is int.
the block itself always returns None — in a once block a return targets
the enclosing function, not the block (see once blocks) — so
the callee's callback must be declared to return a type that accepts None
(None, int | None, object). a callback declared to return anything else is
rejected with trailing-lambda-return-type; those return types are not yet
supported
the call is type-checked with the block counted as an argument: missing earlier arguments, an over-supplied last parameter, or a non-callable target are all reported:
the parameter the block lands in has to be able to hold one. a callee whose last parameter is not callable takes the block as an ordinary argument, where nothing the block's body says ever runs — so that is an error rather than a silent drop:
def br(src: str? = None, extra: dict[str, str]? = None): ...
br: # error: expected `dict[str, str] | None`, found `(...) -> Unknown`
print("this block has nowhere to go")
the block's own shape stays gradual in that check: its it parameter is typed
from the callback, and its return is checked by trailing-lambda-return-type,
so neither is re-checked as an argument
lowering¶
the block lowers to a named function followed by the call:
comments and nested lowerings inside the block and the call arguments are preserved in place
implicit receivers¶
when the callback declares an implicit receiver
(str.(int) -> None), the block binds that receiver itself, ahead of it: the
body sees the receiver's members unqualified, spells the receiver self, and
it is the callback's own argument — the one after the receiver. a name bound
anywhere in the lexical chain keeps its ordinary meaning, so only names that
would otherwise be unresolved resolve this way
enclosing scope¶
a block shares the enclosing scope for its assignments: writing to a name that
is already bound outside the block updates that binding instead of shadowing it
with a fresh block local. the lowering inserts the global / nonlocal
declaration the closure needs, so no manual nonlocal is required:
→
(a is f's last parameter, from the definition at the top of the page — the
keyword names the callback slot, not anything the block assigns)
a module-level binding is captured with global, an enclosing function's local
with nonlocal. a name bound in no enclosing scope stays a plain block local,
and an attribute or item target (obj.x = …) rebinds no name, so neither is
declared
ty's flow analysis reflects the write too. a once block runs exactly once at
the call site (like a with body), so an unconditional assignment narrows the
enclosing binding definitely — a reveal_type after the block sees the block's
value, not the value before it:
a conditional assignment unions with the prior value — if c(): a = 2 leaves
a as 1 | 2, because the block runs but the write itself might not; only a
write that happens on every branch drops the prior.
a non-once callback may run any number of times (including zero), so even
an unconditional write unions with the prior value (a stays 1 | 2). the
once-ness that drives this narrowing is resolved syntactically while the
semantic index is built — before type inference — so it is recognised only for a
same-file callee whose def is visible; an imported once callee is treated
conservatively as non-once for narrowing (the union is sound, just wider),
though the runtime lowering, which runs after inference, still honours it. a
callback the callee never actually calls is the same accepted imprecision as a
nonlocal write ty can't prove happens
once blocks¶
when the callee marks its callback parameter once,
the block runs exactly once, with with-body semantics. that unlocks three
behaviours a non-once block (an ordinary closure, run any number of times)
does not get:
- a
returnin the block targets the enclosing function. the lowering carries the returned value out in a one-element cell and re-returns it after the call:return itbecomescell.append(it); return, and the enclosing function runsif cell: return cell[0]once the call comes back. a non-onceblock'sreturnwould only leave the closure, so it is rejected withtrailing-lambda-control-flow - a name the block unconditionally binds but no enclosing scope binds survives
the block as an enclosing local; from a non-
onceblock it stays a block local (it might never be bound) - an enclosing
let/finalmay not be assigned from a non-onceblock (invalid-assignment), since a repeated run would rebind theFinal
caveat — because the block is a real closure passed to the callee, the return is not a true stack unwind: the callee still finishes its own body after invoking the callback (this is what runs a resource manager's cleanup, matching
with), and a callee that swallows the callback's control flow — or never calls it — defeats the propagation. tighteningreturn/break/continueout of aonceblock into a guaranteed unwind is tracked as future work
borrowed it¶
when the callee declares its callback's parameter local or once
— def f(fn: (local Resource) -> None) — the block is the implementation of that
callback, so the value bound to it is borrowed for the duration of the call and
may not escape the block:
def f(fn: (local Resource) -> None):
with acquire() as resource:
fn(resource)
var kept: Resource | None = None
f:
borrow(it) # fine — re-lent to another borrow
kept = it # error[escaping-local] — the write-back outlives the call
the escape routes are the ones escaping-local checks
everywhere else, with the block's write-through counting as a store: a name an
enclosing scope binds, and — from a once block, where such a name survives —
one only the block binds. a once on the callback's parameter also puts the
exactly-once obligation on the block. a callee whose callback shape cannot be
inspected leaves the block unconstrained
required parameters after defaulted ones¶
so a trailing block can bind the last parameter while earlier parameters keep
defaults, a def (not a lambda) may declare a required parameter after a
defaulted one. python rejects that shape at runtime, so the required parameter
is lowered to a _MISSING sentinel default plus a guard that raises —
mirroring python's own error:
the checker still treats the parameter as required. see mutable defaults for the sentinel machinery this shares
inlay hints¶
the parameters the block binds are shown as an inlay hint with the types the
callee gives them, written just past the : that opens the suite:
a callback that declares an implicit receiver runs
against a value as well as being passed one, so the receiver is hinted first,
spelled self — with it after it whenever the callback takes an argument of
its own:
def against(fn: str.() -> None) -> None:
"a".fn()
def against_with(fn: str.(int) -> None) -> None:
"a".fn(1)
against:⟨self: str⟩
print(upper())
against_with:⟨self: str, it: int⟩
print(upper(), it)
the same hint covers the other parameters basedpython synthesizes rather than
spells — an init(...) method's receiver, and a
property accessor's. it carries the separator it would need as
source, so accepting it reads correctly: