Skip to content
academia.sh

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 Item class 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 binding compute = 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 compute is false) but the original object is shared (single.__wrapped__ is compute is 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 TypeError on 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.

Start typing to search.

↑↓ Esc navigate · open · close