Dream on Python

Posts About Python & Programming

Decorators

Decorators ⏤ Part 1 (May 17)

Let’s show how to add additional properties to a function without changing its code. Suppose, for example, we have a function:

def test_func(x, y):
    ...

One simple way to achieve this ⏤ is to write a decorator. As an example, we can create a decorator that, when calling a function, prints the input parameters and return value.

To apply a decorator, say log_call, to the function test_func, we need to decorate the latter in a special way, adding @log_call before the function definition:

@log_call
def test_func(x, y):
    print('I am doing some magic!')
    print(f'I am using the parameters: {x=}, {y=}')
    print('Biggest magic has done.')
    return 'done'

The effect of the decorator will appear when calling the function test_func:

print(test_func(10, 'hi'))

Output:

test_func(10,hi)
I am doing some magic!
I am using the parameters: x=10, y='hi'
Biggest magic has done.
test_func: done
done

The first and second-to-last lines are printed by the decorator log_call itself, which is implemented as follows:

def log_call(some_func):
    def call_it(*args):
        print(f'{some_func.__name__}({",".join(str(arg) for arg in args)})')
        ret = some_func(*args)
        print(f'{some_func.__name__}: {ret}')
        return ret

    return call_it

As we can see, the decorator log_call ⏤ is a function that takes another function (some_func) as input. The decorator wraps the call to some_func in another function call_it, which outputs to the screen

Note that call_it() packs all received parameters into a tuple: args. And then, unpacks the tuple when passing parameters to the function some_func().

Code: https://onlinegdb.com/kML69mab7


Decorators ⏤ Part 2 (May 18)

Let’s consider another simple decorator: @cache. This decorator remembers the result of a function call and on repeated calls with the same parameters uses the saved value.

Parameters passed to the function are used as a key in the cached dictionary, which stores return values.

def cache(some_func):
    cached = {}

    def call_it(*args):
        if args in cached:
            return cached[args]
        ret = some_func(*args)
        cached[args] = ret
        return ret

    return call_it

Now, if we decorate the function with two decorators: @cache and @log_call as follows:

@cache
@log_call
def test_func(x, y):
    print('I am doing some magic!')
    print(f'I am using the parameters: {x=}, {y=}')
    print('Biggest magic has done.')
    return 'done'

then on repeated calls to the function test_func:

print(test_func(10, 'hi'))
print(test_func(10, 'hi'))

on the screen we’ll get the following:

test_func(10,hi)
I am doing some magic!
I am using the parameters: x=10, y='hi'
Biggest magic has done.
test_func: done
done
done

The result of the second call ⏤ is just one line: done. The decorator remembered the answer (done) and simply returned it on repeated call.

If we call the function with other parameters, the function will execute as expected:

print(test_func(10, 'hello'))

Output:

test_func(10,hello)
I am doing some magic!
I am using the parameters: x=10, y='hello'
Biggest magic has done.
test_func: done
done

The decorator @cache is also sometimes called “memoize” (or “memoization”). Most often it can be found for recursive functions. For example, let’s write a function that calculates Fibonacci numbers:

@cache
@log_call
def fib(n: int) -> int:
    return fib(n - 1) + fib(n - 2) if n >= 2 else n

The function can optionally be decorated with @cache and/or @log_call.

Without memoization, calling fib(5) will produce a huge number of repeated recursive calls, try fib(5) with and without @cache. Actually @cache greatly reduces the execution time of the function for large n.

Let’s return to the decorator @log_call, and add indentation (shifting “prints” to the right) depending on the depth of recursive calls:

def log_call(some_func):
    depth = [0]
    def call_it(*args):
        indent = 't' * depth[0]
        print(f'{indent}{some_func.__name__}({",".join(str(arg) for arg in args)})')
        depth[0] += 1
        try:
            ret = some_func(*args)
            print(f'{indent}{some_func.__name__}: {ret}')
            return ret
        finally:
            depth[0] -= 1

    return call_it

In the variable depth (yes, here we use a list of one element, why so?) we save the depth of the recursive call. The shift to the right (indentation) depends on depth.

The try-finally operator is not necessary for a tutorial example, but here we want to show that in case of any failure we need to return depth to its previous value. Try removing try-finally and artificially add a call to an exceptional situation (for example divide by 0) ⏤ as a result, indentation will break.

Apply both decorators to fib():

print(f'{fib(5)=}')
print(f'{fib(5)=}')

Output:

fib(5)
	fib(4)
		fib(3)
			fib(2)
				fib(1)
				fib: 1
				fib(0)
				fib: 0
			fib: 1
		fib: 2
	fib: 3
fib: 5
fib(5)=5
fib(5)=5

We see how indentation works. Also we can see that a repeated call leads to an immediate answer! In the functools module you can find standard decorators. For example, @functools.cache is already implemented there: https://docs.python.org/3/library/functools.html

Code: https://onlinegdb.com/zYm7xeYeg