Decorators¶
@dec above a def means what it means in Python: f is
rebound to dec(f). Stacked decorators apply bottom-up, the closest one
to def first:
Preserving identity¶
A decorator that wraps a function in a new closure replaces it with an
object that has its own __name__, __doc__, and signature, unrelated
to the function it replaced. Python's fix is functools.wraps — a
decorator author has to remember to call it inside their own decorator, on
every decorator, or the replacement silently carries the wrong identity.
Lucid makes @ itself responsible for identity instead of the decorator.
Applying @ always carries the pre-decoration function's name,
__path__ (see No module or qualname), doc, and signature onto whatever the
decorator returns, along with a link back to the original:
def timed[P: Parameters, R](f: P -> R) -> P -> R:
def wrapper(***args: P) -> R:
start = now()
result = f(***args)
log(f.__name__, now() - start)
return result
return wrapper
@timed
def slow_query(id: int) -> Row:
...
slow_query.__name__ # "slow_query" — guaranteed by @, not opted into
P, bounded by Parameters rather than a bespoke ParamSpec, is
inferred at each @timed application to a Parameters wrapping
whatever shape f's real parameter list turns out to be — here,
Parameters[(id: int), Never, {:}], pargs holding (id: int)
with nothing variadic. Because P is a Parameters, ***args: P
in wrapper's own signature gathers by role
(Gather) instead of one-to-one, so
wrapper receives and forwards exactly the arguments f accepts
regardless of what they turn out to be, and f(***args) spreads them back
out the same way. A bare, unparenthesized name on a
function type's left side, like P here, stands for a whole
parameter shape not yet known — distinct from (A, B) -> R's parenthesized
list of already-concrete types.
There is nothing to forget: identity preservation is not a convention a
decorator can skip, it is what @ means. A decorator that genuinely
wants to produce something with a different identity is free to — just not
through @; plain function application (f = dec(f)) carries no such
guarantee.
Decorator factories need no partial application¶
A parameterized decorator in Python — @lru_cache(maxsize=128) — is
built from a function that returns a function that returns a function: one
level to take the configuration, one to take the function being decorated,
one to wrap the call. The middle level exists only to delay receiving the
decorated function, which is manual partial application done by hand.
Lucid's decorator factories are just ordinary functions, with the decorated function as an ordinary parameter:
def lru_cache[P: Parameters, R](f: P -> R, *, maxsize: int) -> P -> R:
...
@lru_cache(maxsize=128)
def slow_query(id: int) -> Row:
...
lru_cache(maxsize=128) is missing exactly one required parameter,
f, and it is shaped like the decoration target — a P -> R
producing a P -> R. At a @ site, that missing parameter is
filled with the decorated function automatically, with no _ needed:
unlike Partial application with _ in an ordinary call, @ already
knows exactly which argument is missing and what has to go there, so
there is nothing ambiguous left to mark. If a decorator factory leaves more
than one P -> R-shaped parameter unfilled, which one is the
decoration target is genuinely ambiguous, and that is a compile error;
_ is the way to disambiguate explicitly, the same as any other partial
application:
Python needs functools.wraps inside every wrapping decorator and a
nested closure inside every parameterized one, both by convention, both
easy to get wrong and invisible at the call site when they are. Lucid
makes both guarantees instead: identity survives @ because that is
what @ does, and a decorator factory is just a function, because
partial application is a general call-site mechanism rather than a pattern
decorators have to hand-roll.