ICS 33 Fall 2025
Exercise Set 7 Solutions


Problem 1

The trick here is for partially_call to accept arbitrary positional and keyword arguments, then to combine them with those that are passed at the time the call is completed. This can be done most succinctly with a combination of tuple- and dictionary-packing parameters, iterable- and dictionary-unpacking arguments, along with the | operator for combining two dictionaries. (Note, too, that the | operator will smoothly handle the situation where both kwargs and remaining_kwargs contain a value for the same keyword argument; the latter of the two wins, as it should.)


def partially_call(f, *args, **kwargs):
    def complete(*remaining_args, **remaining_kwargs):
        return f(*args, *remaining_args, **(kwargs | remaining_kwargs))

    return complete


Problem 2

Why does the order of decorators matter?

Decorators can apply any of the following transformations to a function.

All of those transformations lead to situations where the order in which they're applied can potentially matter. If one decorator had the job of adjusting the arguments — let's say, by taking anything numeric and rounding it to an integer — and another had the job of determining whether the execute the body of a function based on whether the input was an integer, then the order would surely matter: Rounding before checking would lead to many more sets of arguments being valid than vice versa.

That said, it's certainly true that there might be cases in which the order is irrelevant, but if any cases are ones where the order matters (and plenty are), then establishing a definitive ordering is a wise choice. Otherwise, there exists the realistic possibility of someone writing a program whose meaning cannot be determined by reading it, even with a full understanding of the language.

Why did Python choose its particular order of decorator application?

Designing the syntax of a programming language requires putting oneself in the mind of someone who hasn't internalized every rule of the language already; this is something that can be quite difficult for a language designer, whose understanding is, by definition, a thorough one. While not every syntax has a simple, obvious meaning, it's often the case that one meaning will be a lot less surprising to an uninitiated reader than another; that's one guidepost toward determining what a syntax ought to mean. (This idea is sometimes referred to as the "principle of least surprise": Do what would seem natural based on someone's first instinct.)

A single decorator on a function appears directly above it, but it's a bit more like a call to a function. In other words, we might write this in Python.


@with_greeting
def square(n):
    return n * n

But our mental model of that code's meaning — particularly the decoration aspect of it — might look something more like this.


with_greeting(square)

Ultimately, with_greeting is applied to square a lot like a function call, so we might rightly think of it that way, too. With that idea in our head, what would we expect when we see this?


@cached
@with_greeting
def square(n):
    return n * n

It seems reasonable to imagine that we'd think of it this way.


cached(with_greeting(square))

And it seems a lot less reasonable to imagine that we'd think of it this way instead, because it's the opposite of the order in which the code was written.


with_greeting(cached(square))

Reasonable people might certainly disagree with this assessment, of course — after all, people have differing opinions on aesthetics like this, and, besides, we're speculating here! But, fortunately, we don't have to speculate, because Python's designers write down the rationales for their design decisions and share them with the broader community. PEP 318 describes the particulars of the design of decorators, including the following quote.

The rationale for the order of application (bottom to top) is that it matches the usual order for function-application. In mathematics, composition of functions (g ∘ f)(x) translates to g(f(x)). In Python, @g @f def foo() translates to foo = g(f(foo)).

Problem 3

There are a few moving parts in this solution, but it's best explained with code and comments, so check those out.


import random


def cached(cache_size):
    def decorate(func):
        # By creating a dictionary locally within our decorator, we know that
        # there will be a fresh one for each use of that decorator (i.e., a
        # separate cache for each cached function).  By using that dictionary
        # within the body of execute() -- which is the decorated function,
        # ultimately -- we know it'll live for as long as the decorated
        # function does.
        cache = {}

        def execute(*args, **kwargs):
            # Make a unique key out of our positional and keyword arguments.
            key = _make_key(args, kwargs)

            # If the key could be built (i.e., all arguments were hashable) and
            # it's already in our cache, return the previously stored result.
            if key is not None and key in cache:
                return cache[key]

            # If we got here, we know the cache didn't have a result for these
            # arguments already, so we'll call the original function.
            result = func(*args, **kwargs)

            # If there's a key (i.e., all arguments were hashable), store the
            # result in the cache before returning it, removing an entry from
            # the cache randomly if it's already reached its maximum size.
            if key is not None:
                if len(cache) == cache_size:
                    del cache[random.choice(list(cache.keys()))]

                cache[key] = result

            return result

        return execute

    return decorate


def _make_key(args, kwargs):
    key = []

    # First, we'll put the positional arguments into our key, along with an
    # index for each one.  This way, we can easily tell the difference between
    # positional arguments and keyword arguments.
    for index, arg in enumerate(args):
        if not _is_hashable(arg):
            return None

        key.append((index, arg))

    # We want the same collection of keyword arguments to be equivalent, even
    # when they're passed in a different order.  But since we know that keyword
    # arguments will have names that are strings, we know that we can safely
    # sort them (i.e., we know that strings have a natural ordering).
    for kw, kwarg in sorted(kwargs.items(), key = lambda a: a[0]):
        if not _is_hashable(kwarg):
            return None

        key.append((kw, kwarg))

    # Finally, we'll turn our key into a tuple, so that it'll be hashable.
    return tuple(key)


def _is_hashable(value):
    try:
        hash(value)
        return True
    except TypeError:
        return False


Problem 4

At the point in time when a decorator is applied to a class, the original (unmodified) class has already been created. Consequently, a decorator on a class will only be able to make the same modifications that we would be able to make to a class in the Python shell.

Essentially, any attribute that can be modified at all can be modified by a decorator, in any way it's legal to modify it.

It's also true that a class could be entirely replaced by something else (e.g., a decorator could take a class and return a function — or even an integer! — instead).