Lesson 12 / 16
Decorators
Wrapping does not delete the original function, it adds a layer on top: one decorator builds two layers, two decorators build three, and when metadata is not preserved, the function's name, doc, and signature change to the wrapper's.
Contents
In the previous lesson functions always passed as arguments, and none of them was
changed. But the same lesson also measured that a function can be returned:
factor_maker produced a new function object on every call. The two directions can
be combined — a function that takes a function and returns another function
in its place.
Such a function is called a decorator, and the object it returns is called a
wrapper. This lesson’s questions are: how many objects does wrapping bring into
existence, what happens to the original function, and what does the wrapped
function’s __name__ attribute say? The previous lesson’s seemingly minor
column — the name gap between def and lambda — will produce a result here.
The Wrapper Pattern
The pattern has three steps. A decorator takes a function; defines a new function in its body; returns that new function. When the inner function is called, it does its own work and also calls the function it received — this is how the original behavior is preserved while something is added around it.
The form written with @ before a name is a shorthand. When a @decorator line
sits above a function definition, once the definition finishes,
compute = decorator(compute) runs. So nothing new is set up underneath it — the
previous lesson’s higher-order function has its result bound back to the same name.
An important consequence follows: the original function is not destroyed. The name is now bound to the wrapper, but the wrapper’s body refers to the original object and keeps it alive. Wrapping is not a deletion, it is adding a layer — and layers can be counted.
Two Separate Moments
Wrapping does work at two separate moments, and the two are often confused.
Definition time: the decorator’s body runs and produces the wrapper. This happens once — where the function is defined, even if it is never called.
Call time: the wrapper’s body runs. This happens on every call.
The measurable consequence of this split is this: the work inside the decorator body runs as many times as functions are wrapped; the work inside the wrapper body runs as many times as calls are made.
The measurement’s assumptions:
- IF45 — The shared reference’s
Itemclass is used exactly as is; the decorator body, the wrapper, and the original body are kept in separate counters. The oracle is the rig itself. - IF46 — Layer count is found by following the
__wrapped__chain; this chain is only built when metadata preservation is used. - IF47 — All four forms wrap the same original function, and all four are called once. Counters are reset before the measurement.
- IF48 — The parameterized decorator deliberately calls the original body three times; the number is arbitrary and chosen only to make the wrapper’s added behavior visible.
- IF49 — The metadata measurement compares three forms: unwrapped, one that does
not use preservation, and one that does. Identity is shown with
is, no number is printed. - IF50 — The doc column asks whether the doc exists, not what it contains; the signature column is taken from the standard library’s introspection tool.
- IF51 — In the memoization measurement, twenty requests collapse into five distinct values; the request sequence is fixed, and the ratio of repeated requests is the measurement itself.
- IF52 — In the order measurement, the two decorators are the same decorator and differ only by their labels; the trace records entry and exit points separately so order can be read.
- IF53 — The narrow-signature wrapper deliberately accepts only a single positional argument; what is measured is that the call fails, and which exception class is raised.
Measurement
"""Decorator: how many layers does wrapping build, and what is lost when metadata is not preserved.""" import functools import inspect COUNTER = {"produced": 0, "decorator": 0, "wrapper": 0, "body": 0} class Item: """An item that counts every production of itself.""" def __init__(self, value): COUNTER["produced"] += 1 self.value = value def __repr__(self): return f"Item({self.value})" def reset(): for k in COUNTER: COUNTER[k] = 0 def bare(function): """Wrapper that does not preserve metadata.""" COUNTER["decorator"] += 1 def wrapper(*a, **k): COUNTER["wrapper"] += 1 return function(*a, **k) return wrapper def guarded(function): """Wrapper carrying metadata via functools.wraps.""" COUNTER["decorator"] += 1 @functools.wraps(function) def wrapper(*a, **k): COUNTER["wrapper"] += 1 return function(*a, **k) return wrapper def repeating(times): """Parameterized decorator: the factory is called first, then the decorator.""" def decorator(function): COUNTER["decorator"] += 1 @functools.wraps(function) def wrapper(*a, **k): COUNTER["wrapper"] += 1 for _ in range(times): result = function(*a, **k) return result return wrapper return decorator def compute(n: int): """Produces an item from the given number.""" COUNTER["body"] += 1 return Item(n) def layer_count(function): count = 1 while hasattr(function, "__wrapped__"): function, count = function.__wrapped__, count + 1 return count def core(function): while hasattr(function, "__wrapped__"): function = function.__wrapped__ return function reset() unwrapped = compute single = guarded(compute) double = guarded(guarded(compute)) parameterized = repeating(3)(compute) at_definition = COUNTER["decorator"] print(f"{'form':<26s} {'layers':>7s} {'core is original':>18s}" f" {'body calls':>12s}") for name, f in (("unwrapped", unwrapped), ("single decorator", single), ("two decorators", double), ("parameterized decorator", parameterized)): COUNTER["body"] = 0 f(7) print(f" {name:<24s} {layer_count(f):7d}" f" {str(core(f) is compute):>18s} {COUNTER['body']:12d}") print() print(f"decorator body run while building the four forms: {at_definition};" f" wrapper run once each is called:" f" {COUNTER['wrapper']}") print(f"single is compute -> {single is compute}," f" single.__wrapped__ is compute -> {single.__wrapped__ is compute}") print() print(f"{'form':<16s} {'__name__':>12s} {'has doc':>9s} {'signature':>18s}") for name, f in (("unwrapped", compute), ("no wraps", bare(compute)), ("with wraps", guarded(compute))): print(f" {name:<14s} {f.__name__:>12s} {str(bool(f.__doc__)):>9s}" f" {str(inspect.signature(f)):>18s}") @functools.cache def expensive(n): COUNTER["body"] += 1 return Item(n % 5) reset() requests = [i % 5 for i in range(20)] for i in requests: expensive(i) print() print(f"memoization: {len(requests)} requests, body calls {COUNTER['body']}," f" produced {COUNTER['produced']}, held in cache" f" {expensive.cache_info().currsize}") TRACE = [] def tracing(label): def decorator(function): @functools.wraps(function) def wrapper(*a, **k): TRACE.append(f"{label} enter") result = function(*a, **k) TRACE.append(f"{label} exit") return result return wrapper return decorator @tracing("outer") @tracing("inner") def top_down(n): TRACE.append("body") return Item(n) @tracing("inner") @tracing("outer") def bottom_up(n): TRACE.append("body") return Item(n) print() for name, f in (("@outer @inner", top_down), ("@inner @outer", bottom_up)): TRACE.clear() f(1) print(f" {name:<14s} {' -> '.join(TRACE)}") def narrow(function): @functools.wraps(function) def wrapper(n): return function(n) return wrapper @narrow def two_args(n, k=2): return n * k try: two_args(3, k=5) except TypeError as e: print(f"narrow-signature wrapper with a keyword argument: {type(e).__name__}") print(f"wide-signature wrapper on the same call:" f" {tracing('x')(lambda n, k=2: n * k)(3, k=5)}")
form layers core is original body calls unwrapped 1 True 1 single decorator 2 True 1 two decorators 3 True 1 parameterized decorator 2 True 3 decorator body run while building the four forms: 4; wrapper run once each is called: 4 single is compute -> False, single.__wrapped__ is compute -> True form __name__ has doc signature unwrapped compute True (n: int) no wraps wrapper False (*a, **k) with wraps compute True (n: int) memoization: 20 requests, body calls 5, produced 5, held in cache 5 @outer @inner outer enter -> inner enter -> body -> inner exit -> outer exit @inner @outer inner enter -> outer enter -> body -> outer exit -> inner exit narrow-signature wrapper with a keyword argument: TypeError wide-signature wrapper on the same call: 15
The Number of Layers
In the top table, the layers column counts directly: the unwrapped function is 1, a single decorator is 2, two decorators are 3. Every wrapping adds one link to the chain, and link count is one more than decorator count.
The second column says the more important thing: on every row, the object sitting
at the bottom of the chain is the original function itself. Wrapping neither
changes nor destroys the original object; it keeps it alive at the end of a
reference. The bottom two lines show this directly: single is compute is
false — the name is now bound to a different object — but
single.__wrapped__ is compute is true. A new object was materialized, the old
object was shared.
Read from the course’s axis, wrapping is not a copy, it is building a container: the outer object is new, the one inside it is old. The same pattern as the first topic’s slicing measurement, this time on functions.
The fourth row shows that a layer adds behavior: when the function wrapped by the parameterized decorator is called once, the original body runs 3 times. Layer count is still 2 — the parameter does not add a layer, it only decides the layer’s behavior. The parameterized decorator has three stages: first the factory is called and produces the decorator, then the decorator is called and produces the wrapper, then the wrapper is called.
The number’s cost side reads just as directly. Every wrapping materializes a function object and keeps the original object alive; a function wrapped by two decorators leaves 3 function objects standing in memory, and every call opens three frames. Wrapping a function is cheap, but not free, and the cost is paid per layer.
The next line separates the two moments. While the four forms were being built, the decorator body ran 4 times — once for every wrapped function, before any of them was called. The wrappers, in turn, ran only once calls were made. The work at definition time is paid once; the work at call time is paid on every call.
Lost Metadata
The middle table answers the lesson’s second question, and the middle row shows the loss.
When metadata preservation is not used, the wrapped function’s __name__ attribute
becomes wrapper, its doc disappears, and its signature shows as
(*a, **k). All three pieces of information still sit on the original object, but
nobody looks at it anymore: the object bound to the name is the wrapper, and these
three attributes are its own.
Where the loss is paid matters. The name appears in error traces and log records; if
ten separate functions are wrapped by the same decorator, all ten carry the same
name, and a trace cannot say which function ran. The doc is read in online help and
in doc generation. The signature is the most deceptive of the three: (*a, **k) is
not a signature, it is the absence of one — no tool inspecting which arguments are
expected gets a correct answer.
The bottom row shows the fix. The standard library’s wraps tool is a decorator,
and when applied to a wrapper, it copies the original object’s name, doc, and
signature information onto it, and also sets up the __wrapped__ link. The layer
count in the top table was possible exactly because of this link — without
preservation the chain cannot be followed, and what lies beneath a wrapped function
cannot be seen from outside.
The distinction in one sentence: the wrapper preserves the original behavior on its own, because it calls it; it does not preserve the original identity, because that has to be carried separately.
What Retention Buys
The last line measures one of the decorator’s most common uses, and it reverses the course’s axis. Twenty requests are made, and they collapse into five distinct values; the original body runs only 5 times, produced is 5, and held in cache is 5.
Unwrapped, twenty requests would have meant twenty body calls and twenty objects. The wrapper cuts production to a quarter by storing the result for the arguments it has seen. The Programming Fundamentals course established memoization as a technique; the contribution here is giving it a number — and the number has to be read across two columns at once.
Production dropped from 20 to 5, but retention rose from 0 to 5. The third lesson measured that laziness lowers retention; this line does the opposite: it raises retention to lower production. The two measurements together give the course’s axis its final form — there is a trade-off between materializing and retaining, and which side to land on depends on the job itself. For a thousand items used once and discarded, not retaining is correct; for five values that will be asked for twenty times, retaining is correct.
The cache’s unbounded growth is this trade-off’s open end: retained count grows with the number of distinct arguments, and nothing shrinks it. The standard library therefore also offers a bounded form; once a bound is set, retention is capped and body calls rise again.
Order and Signature
The last four lines measure two practical details of wrapping.
When two decorators are stacked, order changes the result. With @outer on
top, the trace runs: outer enter, inner enter, body, inner exit, outer exit. When
the order is reversed, the trace reverses too. The rule is: the bottommost
decorator applies first and lands deepest in the chain; the topmost applies last
and sits outermost. Call order is the reverse of this — the outermost layer runs
first, the body is reached last, and on the way back the layers close in reverse
order.
The distinction is not minor. If one layer does access control and another does logging, which one is outermost decides whether a rejected call gets logged at all. The two forms look the same; the trace differs.
The bottom two lines show why a wrapper’s signature is written wide. A wrapper
accepting only a single positional argument cannot pass a call through even if the
original function accepts a keyword argument, and a TypeError is raised. The same
call works on a wide-signature wrapper that passes arguments through as given, and
gives 15.
The reason is that the wrapper is now the call surface: every argument form
coming from outside passes through it first. If it does not accept every form the
original function accepts, it cannot pass through the ones it does not accept. The
variable-argument collecting form is therefore not a style preference but a
requirement of wrapping — and the (*a, **k) signature in the earlier table is
exactly the trace of this. When preservation is not used, the signature visible from
outside is this wide form; when it is used, the visible signature is the original
function’s, but what actually satisfies the call is still the wide form.
Summary
- A decorator takes a function and returns another function in its place; the
@form is shorthand for the bindingcompute = decorator(compute). - Wrapping does not destroy the original object, it adds a layer: unwrapped 1,
single decorator 2, two decorators 3 layers. The object bound to the name
changes (
single is computeis false) but the original object is shared (single.__wrapped__ is computeis true). - The decorator body runs at definition time, once per wrapped function; the wrapper runs at call time, on every call. The parameterized decorator has three stages and does not increase layer count.
- When metadata is not preserved, the name becomes
wrapper, the doc is empty, and the signature is(*a, **k); when preservation is used, all three take on the original value and the__wrapped__link is set up. The wrapper preserves behavior on its own; identity has to be carried separately. - A memoizing wrapper cuts twenty requests down to 5 body calls and retains 5 items in exchange: production drops, retention rises. The direction of the trade-off depends on the job itself.
- The bottommost decorator applies first and sits innermost; call order is the
reverse. Because the wrapper is a call surface, it has to pass arguments through
as given — a narrow-signature wrapper raises a
TypeErroron a call the original function accepts.
Next Step
Up to this point, everything sat inside a single file. Every measured class, every generator, every decorator was defined in the same body and used in the same body; the question of where a name is written never came up, because there was only one place to write it. Yet this lesson’s wrapper could just as well have wrapped a function defined in another file. When code is spread across multiple files, where are names written, how does one file see another’s names, and if the same file is requested twice, is it read twice? The next topic sets up this machinery.
To keep your progress and take notes, Log in
My notes
Log in to take notes.