Decorators We have discussed decorators before. Typically we described them as a class that takes an argument that supports some protocol (method calls) and returns an object that supports the same protocol. When the decorator object executes these methods, it performs a bit differently than for the decorated object. The decorator object typically stores a reference to the decorated object and calls methods on it when necessary. Previously, we wrote decorators for iterables (the iterator protocol, calling __iter__ and __next__ methods) using classes and also generator functions. For example, the Unique decorator took as an argument something that was iterable; iterating over the Unique object produced all the values from its iterable argument, but never yields the same one twice (the decorator object produces each value only once). The examples in this lecture, and the Check_Annotations class in Programming Assignment #4, use classes to decorate functions by using the __call__ protocol to decorate/augment how functions are called. Although, some examples don't need classes (and can use just functions) to do the decoration: we show such decorators both ways. Finally, it is appropriate to put this material right after our discussion of recursion and functional programming. Decorating function calls is most interesting when the functions are called many times. And, one call to a recursive function typically results in it calling itself many times. We will use the recursive factorial and fib (Fibonacci) functions in our examples below. Both require an argument >= 0. def factorial(n): if n == 0: return 1 else: return n*factorial(n-1) def fib(n): if n == 0: return 1 # or, if 0 <= n <= 1: return 1 elif n == 1: return 1 else: return fib(n-1) + fib(n-2) The factorial function is linearly-recursive: it calls itself once, recurring downward (as illustrated in class, with call frames in a hand-simulation) to the base case, then going upward returning results to the top. The factorial(n) does n function calls. The fib function always calls itself twice, going downward, upward, downward, upward in a complex way, which leads to an exponential number of function calls. We will examine a picture of what it does, which requires a branching (tree-like) structure to illustrate. ------------------------------------------------------------------------------ Special Python Syntax for Decorators If Decorator is the name of a decorator (a class or a function) that takes one argument, we can use it to decorate a function object, by writing either def f(params-annotation) -> result-annotation: ... f = Decorator(f) or by using the new decoarator syntax of @ @Decorator def f(params-annotation) -> result-annotation: which both have the same meaning. @Decorator is applied when a module is loaded, as is indicated by the first form. We can also use multiple decorators on functions. The meaning of @Decorator1 @Decorator2 def f(...) ... is equivalent to writing f = Decorator1(Decorator2(f)) so Decorator1 decorates the result of Decorator2 decorating f; the decorators are applied in the reverse of the order they appear (with the closest one to the decorated object applying first). We have seen a decorator name "staticmethod", which we have used to decorate methods defined in classes: those methods that don't have a self parameter and are mostly called in the form Classname.staticmethodname(....). But we can call such methods using objects, just like non-static methos. When called by objects in the class, e.g., o.staticmethodname(args), the FEOOP translates calls to static methods without passing o as the first argument: it is called like just Classname.statimethodname(args). This change is what the staticmethod decorator accomplishes. ------------------------------------------------------------------------------ Examples of Function Decorators Here are three decorators for functions (three are classes; some can be written easily as functions too). (1) Track_Calls remembers the function it is decorating and initializes the calls counter to 0; the decorator object overloads the __call__ method so that all calls to the decorator object increment its calls counter and then actually calls the decorated function (which if recursive, increments the calls counter for every recursive call). Once the function's value is computed and returned, the calls counter instance name can be accessed (via the called method) and reset (via the reset method) for tracking further calls. class Track_Calls: def __init__(self,f): self._f = f self._calls = 0 def __call__(self,*args,**kargs): # bundle arbitrary arguments to this call self._calls += 1 return self._f(*args,**kargs) # unbundle arbitrary arguments to call f def called(self): return self._calls def reset(self): self._calls = 0 So, if we wrote @Track_Calls def factorial(n): if n == 0: return 1 else: return n*factorial(n-1) which is equivalent to def factorial(n): if n == 0: return 1 else: return n*factorial(n-1) factorial = Track_Calls(factorial) then the name factorial would refer to a Track_Calls object, whose self._f refers to the actual function object factorial defined and whose self._calls is initialized to 0. Examine the picture that shows how a Track_Calls object decorates the factorial function object (available on the Weekly Schedule). Note that the name factorial is bound to a Track_Calls object. If we called factorial(3), Python executes the factorial.__call__(3) method on the factorial object, which by the FEOOP would call Track_Calls.__call__(factorial,3), which first increments factorial._calls and then calls factorial._f(3) - the original factorial function object: its body would recursively call factorial(2) -recall the name factorial is bound to a Track_Calls object- which Python executes as factorial.__call__(2), again incrementing ._calls and initiating a recursive call. The main take-away is that every time Python calls factorial (including recursively inside factorial) it finds the binding for factorial, which is the Track_Calls object. This process continues, and ultimately factorial.__call__(3) returns 6 with factorial.calls = 4. We have seen before that factorial(n) calls itself for n, n-1, n-2, ... 0 for a total of n+1 times. We could write factorial.reset() print(factorial(10)) print(factorial.called()) Examine the picture accompanying this lecture, showing the fib function (whose body does two recursive calls) and all the recursive calls it makes when called with a variety of numbers. Ignore the colors for now. For example calling fib(2) = 2 does a total of 3 calls to fib (counting itself), fib(3) = 3 does a total of 5 calls, fib(4) = 5 does a total of 9 calls, fib(5) = 8 does a total of 15 calls, and fib(6) = 13 does a total of 25 calls. We can use Track_Calls to verify these numbers (and compute fib for bigger numbers). For example fib(10) = 89 does a total of 177 calls, and fib(20) = 10,946 and does a total of 21,891 calls. The number of calls needed to compute fib(n) is greter than the value of fib(n). Run the program accompanying this lecture to see the value (and number of function calls) needed to evaluate fib(0) through fib(30). Notice that the last few calculations take a noticeable amount of time: computing fib(30) requires 2,692,537 function calls! We can also write the Track_Calls class decorator as the following function def track_calls(f): def call(*args,**kargs): call._calls += 1 return f(*args,**kargs) call._calls = 0 # define calls attribute on call function-object! return call Here we define an inner-function named call, which is returned by track_calls. Before returning call's function object, a calls attribute is defined for that function object and initialized to 0; inside the call function that instance name is incremented before the original function (f) is called and the value it computes returned. We know objects have namespaces stored in __dict__ of the object, and function objects are just a kind of objects. After executing factorial = track_calls(factorial) # Returns the call function object We can examine and rebind factorial.calls: e.g, print(factorial.calls) and factorial.calls = 0. In fact, we can define the track_calls function below, so that we can call the methods called/reset on it. Here we bind the attributes reset/called to functions (on named - because it executes a statement - and one lambda, because it just returns a value) def track_calls(f): def call(*args,**kargs): call._calls += 1 return f(*args,**kargs) call._calls = 0 # define calls, reset, and called attributes on # call function-object; bind the first to a def reset(): call._calls = 0 # data object, the next two to function objects call.reset = reset call.called = lambda : call._calls return call In this case we can write (exactly as we did for the Track_Calls class): factorial = track_calls(factorial) # Returns the call function object factorial.reset() print(factorial(10)) print(factorial.called()) Remember that when calling factorial.reset(), Python will first try to find the reset attribute in the factorial function object (which is the call function object track_calls returns). This attribute will be found there: it has been set by defining the reset function and then adding it to the namespace of the calls function object, which track_calls returns, and is bound to the name factorial. def reset(): call._calls = 0 call.reset = reset I will continue to show equivalent class/function definitions for the decorators described below, but in a simplified form (like the original track_calls above). At the end of this lecture, I will briefly explain the reason for using classes instead of functions: the ability to overload the __getattr__ function, which is useful when using multiple decorators at the same time. ------------------------------------------------------------------------------ (2) Memoize remembers the function it is decorating and initializes a dict to {}. It will use this dict to cache (keep track of and be able to access quickly) the arguments to calls, which are associated in the dict with the values ultimately returned by function calls with those arguments. The decorator object overloads the __call__ method so that all calls to the decorator object first check to see if the arguments are already cached in the dict (if so, the value computed for these arguments is there too). If the arguments are there, their associated value is returned immediately, without executing the code in the decorated function; if the arguments are not there, the decorated function is called, and its answer is cached in the dict with the function's arguments, and the answer is returned. For simplicity here, I'm assuming all arguments are positional (so no **kargs). Also,since the arguments are used as keys in a dictionary, they must be immutable/hashable (which ints are). If the weren't, we could try to convert each argument an immutable one for use in the cache dict: e.g,, convert a list into an equivalent tuple. In this way, a function never has to compute the same value twice. Memoization is useful for multiply-recursive calls, as in the fibonacci function (not so much in factorial, where it computes each value only once), where the fib of a value might be computed many times: fib(2) is computed 5 times when computing fib(6). class Memoize: def __init__(self,f): self._f = f self._cache = {} def __call__(self,*args): if args in self._cache: return self._cache[args] else: answer = self._f(*args) # Recursive calls will set cache too self._cache[args] = answer return answer def reset_cache(self): self._cache = {} Examine the picture accompanying this lecture, showing the Fibonacci function. When decorated by Memoize, the only calls that actually compute a result are those in green. As each green function calls finishes, it caches its result in the dictionary. Afterward, calling fib with that argument again will obtain the result from the cache immediately (obviating the need for all the calls in white). With memoization, calling fib(n) does a total of n+1 function calls. Run the program accompanying this lecture to see the value (and number of function calls) needed to evaluate fib(0) through fib(30). This time, uncomment @Memoize and the fib.reset_cache() call in the loop. Notice that all the calculations are done instantaneously: with memoization, computing fib(30) requires only 31 function calls (with cached values computed immediately millions of times). Memoization can convert a recursive function requiring an exponential number of function calls to one requiring just a linear number. We can also write memoize as a function, to return a wrapper function (it can be named anything) that does the same operations as the class above. Here is a version written in the style of track_calls, declaring the data cache and the method reset_cache as attributes of the wrapper function object.* ---- *We have been picturing a function object just by its code. Actually, a function object is a real object with a __dict__ for storing its attributes. One attribute is __code__; in the example below we create more attributes. We did the same kind of thing showing how partial is implemented in Python. ---- def memoize(f): def wrapper(*args): if args in wrapper._cache: return wrapper._cache[args] else: answer = f(*args) wrapper._cache[args] = answer return answer wrapper._cache = {} def reset_cache(): wrapper._cache = {} wrapper.reset_cache = reset_cache return wrapper We can also use the following code, which defines cache as a local variable in memoize (unlike what we did above, defining it as an attribute of the wrapper function object). def memoize(f): cache = {} def wrapper(*args): if args in cache: return cache[args] else: answer = f(*args) cache[args] = answer return answer def reset_cache(): cache.clear() wrapper.reset_cache = reset_cache return wrapper Finally, we could also define the reset_cache function inside memoize as def reset_cache(): nonlocal cache # Allows cache in enclosing scope to be updated cache = {} Here we need the declaration "nonlocal cache" inside the reset_cache function, so we can rebind the cache variable declared outside this function. Normally, binding any variable makes it local to the function in which it is bound. The nonlocal declaration is like the global declaration that we have studied, but it first looks in the enclosing scope instead of immediately looking in the global scope. In the previous version of memoize, inside reset_cache we called clear to mutate the cache, which does not involve rebinding: looking up names that are not being bound uses the LEGB rule, so it finds that name in the enclosing scope. ------------------------------------------------------------------------------ (3) Illustrate_Recursive remembers the function it is decorating and initializes a tracing variable to False. The decorator object overloads the __call__ method so that all calls to the decorator object just return the result of calling the decorated function (if tracing is off). Calling .illustrate(...) on the decorator calls the illustrate method, which sets up for tracing, and then uses __call__ to trace all entrances and exists to the decorated function printing indented/outdented information for each function call/return. class Illustrate_Recursive: def __init__(self,f): self._f = f self._trace = False def illustrate(self,*args,**kargs): self._indent = 0 self._trace = True answer = self.__call__(*args,**kargs) self._trace = False return answer def __call__(self,*args,**kargs): if self._trace: if self._indent == 0: print('Starting recursive illustration'+30*'-') print (self._indent*"."+"calling", self._f.__name__+str(args)+str(kargs)) self._indent += 2 answer = self._f(*args,**kargs) if self._trace: self._indent -= 2 print (self._indent*"."+self._f.__name__+str(args)+str(kargs)+" returns", answer) if self._indent == 0: print('Ending recursive illustration'+30*'-') return answer Run the program accompanying this lecture to example of this trace (and others by changing the program. Here is what the program prints for a call to factorial and fibonacci. @Illustrate_Recursive def factorial(n): if n == 0: return 1 else: return n*factorial(n-1) print(factorial.illustrate(5)) Starting recursive illustration------------------------------ calling factorial(5,){} ..calling factorial(4,){} ....calling factorial(3,){} ......calling factorial(2,){} ........calling factorial(1,){} ..........calling factorial(0,){} ..........factorial(0,){} returns 1 ........factorial(1,){} returns 1 ......factorial(2,){} returns 2 ....factorial(3,){} returns 6 ..factorial(4,){} returns 24 factorial(5,){} returns 120 Ending recursive illustration------------------------------ Factorial is a linear recursive function (one recursive call in its body) so the structure of its recursion is simple. Each factorial calls the one below it (indented); when the bottom one returns 1 for its base case, each call above it (unindented) can compute and return its result. Here is an illustration of a tail-recursive version of factorial. Notice that that the call to the factorial_helper function is illustrated. Recall that Python does NOT change tail-recursive functions to be iterative. def factorial(n): @Illustrate_Recursive def factorial_helper(n,acc): if n == 0: return acc else: return factorial_helper(n-1,acc*n) return factorial_helper.illustrate(n,1) print(factorial(5)) # For Illustrate_Recursive of tail-recursive helper Starting recursive illustration------------------------------ calling factorial_helper(5, 1){} ..calling factorial_helper(4, 5){} ....calling factorial_helper(3, 20){} ......calling factorial_helper(2, 60){} ........calling factorial_helper(1, 120){} ..........calling factorial_helper(0, 120){} ..........factorial_helper(0, 120){} returns 120 ........factorial_helper(1, 120){} returns 120 ......factorial_helper(2, 60){} returns 120 ....factorial_helper(3, 20){} returns 120 ..factorial_helper(4, 5){} returns 120 factorial_helper(5, 1){} returns 120 Ending recursive illustration------------------------------ Now, on to illustrating the non-linear recursive function fib. @Illustrate_Recursive def fib(n): if n == 0: return 1 elif n == 1: return 1 else: return fib(n-1) + fib(n-2) print(fib.illustrate(5)) Starting recursive illustration------------------------------ calling fib(5,){} ..calling fib(4,){} ....calling fib(3,){} ......calling fib(2,){} ........calling fib(1,){} ........fib(1,){} returns 1 ........calling fib(0,){} ........fib(0,){} returns 1 ......fib(2,){} returns 2 ......calling fib(1,){} ......fib(1,){} returns 1 ....fib(3,){} returns 3 ....calling fib(2,){} ......calling fib(1,){} ......fib(1,){} returns 1 ......calling fib(0,){} ......fib(0,){} returns 1 ....fib(2,){} returns 2 ..fib(4,){} returns 5 ..calling fib(3,){} ....calling fib(2,){} ......calling fib(1,){} ......fib(1,){} returns 1 ......calling fib(0,){} ......fib(0,){} returns 1 ....fib(2,){} returns 2 ....calling fib(1,){} ....fib(1,){} returns 1 ..fib(3,){} returns 3 fib(5,){} returns 8 Ending recursive illustration------------------------------ The fib function is NOT a linear recursive function: its body contains two recursive calls. So the structure of its recursion becomes much more complicated. This is why the fib function is a good one on which to test both Track_Calls and Memoize. Even for fairly small arguments (under 30), it produces a tremendous number of calls and can be sped-up tremendously by memoizing it. The mns function in the previous lecture (minimum number of stamps) does even more than two recursive calls: it does a recursive call one for each stamp denomination in the list constructor. It too can be vastly sped-up by using memoize: see the modules whose names end in "fast" in the stamps download; run its code to see completely computed results produced very quickly. Below traces a the decorated mns function call: mns.illustrate(10,(1,6,14,57)) As you can see, thinking about the execution of this function is unlikely to give you insight. There are way too many elephants. Starting recursive illustration------------------------------ calling mns(10, (1, 6, 14, 57)){} ..calling mns(9, (1, 6, 14, 57)){} ....calling mns(8, (1, 6, 14, 57)){} ......calling mns(7, (1, 6, 14, 57)){} ........calling mns(6, (1, 6, 14, 57)){} ..........calling mns(5, (1, 6, 14, 57)){} ............calling mns(4, (1, 6, 14, 57)){} ..............calling mns(3, (1, 6, 14, 57)){} ................calling mns(2, (1, 6, 14, 57)){} ..................calling mns(1, (1, 6, 14, 57)){} ....................calling mns(0, (1, 6, 14, 57)){} ....................mns(0, (1, 6, 14, 57)){} returns 0 ..................mns(1, (1, 6, 14, 57)){} returns 1 ................mns(2, (1, 6, 14, 57)){} returns 2 ..............mns(3, (1, 6, 14, 57)){} returns 3 ............mns(4, (1, 6, 14, 57)){} returns 4 ..........mns(5, (1, 6, 14, 57)){} returns 5 ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(6, (1, 6, 14, 57)){} returns 1 ........calling mns(1, (1, 6, 14, 57)){} ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(1, (1, 6, 14, 57)){} returns 1 ......mns(7, (1, 6, 14, 57)){} returns 2 ......calling mns(2, (1, 6, 14, 57)){} ........calling mns(1, (1, 6, 14, 57)){} ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(1, (1, 6, 14, 57)){} returns 1 ......mns(2, (1, 6, 14, 57)){} returns 2 ....mns(8, (1, 6, 14, 57)){} returns 3 ....calling mns(3, (1, 6, 14, 57)){} ......calling mns(2, (1, 6, 14, 57)){} ........calling mns(1, (1, 6, 14, 57)){} ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(1, (1, 6, 14, 57)){} returns 1 ......mns(2, (1, 6, 14, 57)){} returns 2 ....mns(3, (1, 6, 14, 57)){} returns 3 ..mns(9, (1, 6, 14, 57)){} returns 4 ..calling mns(4, (1, 6, 14, 57)){} ....calling mns(3, (1, 6, 14, 57)){} ......calling mns(2, (1, 6, 14, 57)){} ........calling mns(1, (1, 6, 14, 57)){} ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(1, (1, 6, 14, 57)){} returns 1 ......mns(2, (1, 6, 14, 57)){} returns 2 ....mns(3, (1, 6, 14, 57)){} returns 3 ..mns(4, (1, 6, 14, 57)){} returns 4 mns(10, (1, 6, 14, 57)){} returns 5 Ending recursive illustration------------------------------ If we memoize mns and trace it for the same problem, we get the following simpler (but still quite complex) trace. Starting recursive illustration------------------------------ calling mns(10, (1, 6, 14, 57)){} ..calling mns(9, (1, 6, 14, 57)){} ....calling mns(8, (1, 6, 14, 57)){} ......calling mns(7, (1, 6, 14, 57)){} ........calling mns(6, (1, 6, 14, 57)){} ..........calling mns(5, (1, 6, 14, 57)){} ............calling mns(4, (1, 6, 14, 57)){} ............mns(4, (1, 6, 14, 57)){} returns 4 ..........mns(5, (1, 6, 14, 57)){} returns 5 ..........calling mns(0, (1, 6, 14, 57)){} ..........mns(0, (1, 6, 14, 57)){} returns 0 ........mns(6, (1, 6, 14, 57)){} returns 1 ........calling mns(1, (1, 6, 14, 57)){} ........mns(1, (1, 6, 14, 57)){} returns 1 ......mns(7, (1, 6, 14, 57)){} returns 2 ......calling mns(2, (1, 6, 14, 57)){} ......mns(2, (1, 6, 14, 57)){} returns 2 ....mns(8, (1, 6, 14, 57)){} returns 3 ....calling mns(3, (1, 6, 14, 57)){} ....mns(3, (1, 6, 14, 57)){} returns 3 ..mns(9, (1, 6, 14, 57)){} returns 4 ..calling mns(4, (1, 6, 14, 57)){} ..mns(4, (1, 6, 14, 57)){} returns 4 mns(10, (1, 6, 14, 57)){} returns 5 Ending recursive illustration------------------------------ ---------- Delegation of attribute lookup: Classes are better than functions for decorators When using (multiple) decorators, we need a way to translate attribute accesses on the decorator object into attribute accesses on the decorated object. There is no simple mechanism to do this with functions, but it easy to do with classes: by overloading the __getattr__ method as follows (which should be done to all three classes above). def __getattr__(self, attr): # if attr not here, try self._f return getattr(self._f ,attr) So, if we use the following two decorators @Track_Calls @Illustrate_Recursive def fib(....): ... fib is a Track_Calls object, whose ._f attribute is an Illustrate_Recursive object, whose ._f attribute is the actual fib function. If we then wrote fib.illustrate(5) Python would try to find the illustrate attribute of the Track_Calls object; there is no such attribute there, so it fails and then calls the __getattr__ of the Track_Calls class, which "translates" the failed attribute access into trying to get the same attribute from the ._f object (from the decorated class object, an object of the Illustrate_Recursive class, which does define such an attribute, as a method which can be called). Generally this is called delegation: where an "outer" object that does not have some attribute delegates the attribute reference to an inner object which might define it. Decorators often use exactly this form of delegation, so the decorator object can process its attributes and delegate looking up other attributes to the decorated object. ------------------------------------------------------------------------------ Now we will study one last interesting combination of using decorators and the functools.partial function (discussed in the previous lecture). Let's look at the following simple decorator, which takes a function and its name as arguments; every time the function is called the decorator prints the function's name and the result it computes. class Trace: def __init__(self,f,f_name): self._f = f self._f_name = f_name def __call__(self,*args,**kargs): result = self._f(*args,**kargs) print(self._f_name+'called, returned: '+str(result)) return result Now suppose we want to decorate a function f defined simple as def f(x): return 2*x If we write @Trace def f(x): return 2*x Python raises a TypeError exception, because the __init__ for Trace requires two arguments, not one. We can avoid the @ form of decorators and write def f(x): return 2*x f = Trace(f,'f') to solve the problem, but without using the @Decorator form. Can we do something to use this form? The problem is that we have have two arguments to __init__ but @Decorator requires just one, so we can use functools.partial to pre-supply the second argument. We can write f_Trace = functools.partial(Trace,f_name='f') @f_Trace def f(x): return 2*x But even that is a bit clunky, because we are defining the name f_Trace but using it only once (we are not likely to trace other functions named f). We don't need this name; instead we can write @functools.partial(Trace,f_name='f') def f(x): return 2*x directly using the result returned from partial as the decorator. This allows us to use the standard @Decorator form (with possibly more than one decorator). Now calling f(1) would return the result 2 and cause Python to print f called, returned: 2 ------------------------------------------------------------------------------ Problems: 1) Define a class that decorates function calls so that it keeps count of how many times the function was called with each combination of arguments. Write a report function that returns a list of 2-tuples, each containing the argument and the number of times the function was called with that argument, sorted from the most frequently to least frequently called argument). Hint: this is similar to what Track_Calls and Memoize does. 2) Define a Memoize class whose constructor also has a max_to_remember argument, which limits the size of the dict to that number of entries (if the argument is None, memoize all calls in the dict). It should remember only the most recent max_to_remember arguments. Hint: do this by keeping a list (really representing a queue with oldest and youngest values) and a dict in synch as follows: (a) if the args are IN the dict, just return the result. (b) if the args are NOT IN the dict, and the list HAS ROOM, add the args to the list (at the end: youngest) and to the dict (with their computed value) (c) if the args are NOT IN the dict, and the list has NO MORE ROOM, pops the value out of index 0 (oldest) in the list and delete that as a key from the dict, then add the args to the list (at the end) and to the dict (with their computed value) Finally, write a function name info that returns a 4-tuple containing the number of hits (times the function was called on memoized arguments), misses (times the function was called on arguments that weren't memoized), the current size of the list/dict, and the maximum size.