Execution environment of function code (variables it depend on).
A nested function can be returned. This is a common design pattern for creating tailored functions.
1 2 3 4 5 6 7
>>> function_greeting_a = get_greeting_function('A') >>> function_greeting_a() Hello, A >>> >>> function_greeting_b = get_greeting_function('B') >>> function_greeting_b() Hello, B
Look into a closure's cell_contents:
1 2 3 4 5 6 7 8 9 10 11 12 13
>>> function_greeting_a.__closure__ (<cell at 0x7f3c81849ca8: strobject at 0x7f3c8185ac70>,) >>> function_greeting_a.__closure__[0] <cell at 0x7f3c81849ca8: strobject at 0x7f3c8185ac70> >>> function_greeting_a.__closure__[0].cell_contents 'A' >>> >>> function_greeting_b.__closure__ (<cell at 0x7f3c81849c18: strobject at 0x7f3c82f18e30>,) >>> function_greeting_b.__closure__[0] <cell at 0x7f3c81849c18: strobject at 0x7f3c82f18e30> >>> function_greeting_b.__closure__[0].cell_contents 'B'
Should an inner function use an outer function's local variable (instead of shadowing it), that local variable should be declared nonlocal within the inner function. Not using nonlocal:
1 2 3 4 5 6 7
defouter_function(): string = 'Hello' definner_function(): # Shadows the local variable `string` of `outer_function` string = 'World' inner_function() return string
1 2
>>> outer_function() 'Hello'
Should an inner function use an outer function's local variable (instead of shadowing it), that local variable should be declared nonlocal within the inner function. Using nonlocal:
1 2 3 4 5 6 7 8
defouter_function(): string = 'Hello' definner_function(): # Uses the local variable `string` of `outer_function` nonlocal string string = 'World' inner_function() return string
1 2
>>> outer_function() 'World'
Creating and returning a nested function based on a function argument is widely used in Python, called decorating a function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
defcached(function): cache = {} defcached_function(*args): nonlocal function, cache if args in cache: print(f'Cache hit with args: {args}') return cache[args] else: print(f'Cache miss with args: {args}') result = function(*args) print(f'Writing f({args}) => {result} to cache') cache[args] = result return result return cached_function
Python even has special syntatical support for this.
1 2 3 4 5 6 7 8
@cached deffib(n): if n < 1: return0 elif n < 2: return1 else: return fib(n - 1) + fib(n - 2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
In [4]: fib(5) Cache miss with args: (5,) Cache miss with args: (4,) Cache miss with args: (3,) Cache miss with args: (2,) Cache miss with args: (1,) Writing f((1,)) => 1 to cache Cache miss with args: (0,) Writing f((0,)) => 0 to cache Writing f((2,)) => 1 to cache Cache hit with args: (1,) Writing f((3,)) => 2 to cache Cache hit with args: (2,) Writing f((4,)) => 3 to cache Cache hit with args: (3,) Writing f((5,)) => 5 to cache Out[4]: 5
\(O(n)\) time complexity.
LeetCode problem: Given n pairs of parentheses, write a function to generate all combinations of well-formed parentheses.
Example 1:
1 2
Input: n = 3 Output: ["((()))","(()())","(())()","()(())","()()()"]
# s -> ss . if number_of_parenthesis >= 1: for ss_string in ss_generator(number_of_parenthesis): return_value.append(ss_string)
# s -> s ss . if number_of_parenthesis >= 2: for i inrange(1, number_of_parenthesis): for s_string, ss_string in itertools.product( s_generator(i), ss_generator(number_of_parenthesis - i) ): return_value.append(s_string + ss_string)
# ss -> ( ) . if number_of_parenthesis == 1: return_value.append('()') # ss -> ( s ) . if number_of_parenthesis > 1: for s_string in s_generator(number_of_parenthesis - 1): return_value.append('(' + s_string + ')')
return return_value
1 2
Input: n = 3 Output: ["((()))","(()())","(())()","()(())","()()()"]
8 instructions, NO LOAD_ATTR, STORE_ATTR instructions.
Closures
Generators👈
Coroutines
When we define a function containing the yield keyword, we define a generator. Defining a generator allows the user to define a custom iterator in the style of defining a function.
1 2 3 4
defcountdown(n): while n > 0: yield n n -= 1
We create a generator object when we call a generator definition. The generator object can be used like any iterator:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
In [2]: c = countdown(5)
In [3]: next(c) Out[3]: 5
In [4]: next(c) Out[4]: 4
In [5]: for value in c: ...: print(value) ...: 3 2 1
When we call next() on a generator object, it will execute code, until it encounters a yield statement. The yield statement tells the generator object to return a value, and continue execution from here when next() is called again.
1 2 3 4
In [2]: c = countdown(5)
In [3]: next(c) Out[3]: 5
This executes:
1 2
while n > 0: yield n
When we call next() on a generator object, it will execute code, until it encounters a yield statement. The yield statement tells the generator object to return a value, and continue execution from here when next() is called again.
1 2
In [4]: next(c) Out[4]: 4
This executes:
1 2 3
n -= 1 while n > 0: yield n
This is called lazy evaluation. This can dramatically boost performance and reduce memory usage in some applications. For example:
1 2 3 4 5 6 7 8 9 10 11
defget_comments_from_file(file): withopen(file, 'r') as fp: for line in fp: # strip whitespace stripped_line = line.strip() # check if the line is empty after stripping whitespace if stripped_line: # check if the line is a comment if stripped_line[0] == '#': # if it is, yield it yield stripped_line
This will NOT read the whole file into memory. Only when the user calls next() on the generator object, will the generator read the file LINE BY LINE (with only ONE LINE of the file in memory at once), and return the next comment line.
This is an efficient way of extracting comments from GB-sized files (such as logs).
itertools
Python provides many functions for creating an iterator from another iterator. For example:
And the coroutine waits again for an input to be sent.
More complicated example: set-associative cache simulation
number_of_cache_sets * Set
number_of_ways_of_associativity * Block
block_size_in_bytes * Byte
The whole set-associative cache is a coroutine receiving (address, is_write) tuples as input, and calculating (cache_hit, writeback_address) tuples as output.
It models each set as a coroutine receiving (tag, is_write) tuples as input, and calculating (cache_hit, writeback_address) tuples as output.
Different coroutine definitions for round-robin, LRU, etc.
defcache_coroutine(cache_set_coroutine_function, block_size_in_bytes, number_of_ways_of_associativity, number_of_cache_sets): # create cache_set_coroutine_list and activate each cache_set_coroutine cache_set_coroutine_list = [ cache_set_coroutine_function(number_of_ways_of_associativity) for _ inrange(number_of_cache_sets) ] for cache_set_coroutine in cache_set_coroutine_list: next(cache_set_coroutine)
# get function_to_split_address and function_to_merge_address function_to_split_address, function_to_merge_address = get_functions_to_split_and_merge_address( block_size_in_bytes, number_of_cache_sets )
# send (tag, is_write) to the appropriate cache_set_coroutine cache_hit, victim_tag, writeback_required = cache_set_coroutine_list[cache_set_index].send((tag, is_write))
# create writeback_address if (victim_tag is not None) and writeback_required if (victim_tag isnotNone) and writeback_required: writeback_address = function_to_merge_address(victim_tag, cache_set_index, 0) else: writeback_address = None