# function to greet people
def greet(name):
return f'Hi, {name}'
# storing in a variable
my_var = greet
print(my_var('Kanan'))
my_var('Jack')
from math import sqrt
# we will use first 2 functions as an argument
def sq_root(num):
return sqrt(num)
def cube (num):
return num ** 3
# after finding the result, functions uses arguments to give meaningful answer
def meaningful_func(func, num, message):
res = func(num)
return f'{message} of the {num} is the {res:.2f}'
print(meaningful_func(sq_root, 20, 'square root'))
print(meaningful_func(cube, 5 , 'cube'))
from math import sqrt
# we will create 2 lambda functions and store them into a dictionary with boolean keys
bool_list = [True, False]
func1 = lambda x: x ** 2
func2 = lambda x: x ** 3
func_list = [func1, func2]
dic = dict(zip(bool_list, func_list))
# here we will use stored functions, if key is True
num = 10
for key, value in dic.items():
if key:
print(value(num))
100
# let's first look at the function with pre-defined number of arguments
def add(a, b):
return a + b
print(add(1, 2, 3))
Execution error
TypeError: add() takes 2 positional arguments but 3 were given
def add(*args):
print(args)
return (sum(args))
print(add(1, 2, 3))
(1, 2, 3)
add(1, 2, 3, 4, 5)
(1, 2, 3, 4, 5)
# we can use non-keyword arguments after some predefined arguments
def func(first_argument, *args):
print(f'{first_argument} is a predefined argument')
print('Non-keyword arguments:')
for i in args:
print(i, end = ' ')
print(func('Python', 'Java', 'Julia', 'C++'))
Python is a predefined argument
Non-keyword arguments:
Java Julia C++
def func(**kwargs):
for key, value in kwargs.items():
print(f'{value} has been stored at {key}')
print(func(argument1 = 'Python', argument2 = 'Java'))
Python has been stored at argument1
Java has been stored at argument2
# args and kwargs together
def func(*args, **kwargs):
print('Non-keyword arguments:')
for i in args:
print(i)
print('Keyword arguments:')
for key, value in kwargs.items():
print(f'{key}:{value}')
print(func(65, 80, 90, grade4=95, grade5=100))
Non-keyword arguments:
65
80
90
Keyword arguments:
grade4:95
grade5:100
# let's create a variable and assign a value to it
my_var = 15
print(None)
None
# let's see what happens when we overwrite the built-in function
print = lambda x: f'hello, {x}'
print('Kanan')
print('Python', 'Java')
Execution error
TypeError: <lambda>() takes 1 positional argument but 2 were given
# here is the list of the all built-in names predefined in Python
dir(__builtins__)
# let's delete the custom print function and continue
del print
print('Python', 'Java')
Python Java
for i in range(5):
a = 3
print(a)
3
# creating a local variable
def add(a, b):
c = a + b
return c
add(2, 3)
c
Execution error
NameError: name 'c' is not defined
a = 5
def multiply_a(x):
return a * x
print(multiply_a(4))
a = 5
b = 3
def multiply_a(x):
b = a * x
return b
print(multiply_a(4))
print(b)
a = 5
def func():
global a
a = 20
func()
print(a)
20
a = 10
def my_func():
print(a)
a = 3
return a
my_func()
Execution error
UnboundLocalError: local variable 'a' referenced before assignment
# accessing to global variable from inner local scope
a = 10
def outer():
def inner():
print(a)
inner()
outer()
10
# accessing to the nonlocal scope from the inner local scope
def outer():
a = 10
def inner():
print(a)
inner()
outer()
10
# Modifying global variable from enclosing scope
a = 10
def outer():
global a
a = 5
def inner():
print(a)
inner()
print(outer())
print(a)
5
5
# Modifying the global variable from inner local scope
a = 10
def outer():
def inner():
global a
a = 3
inner()
print(a)
print(outer())
print(a)
3
3
# let's try to change value of nonlocal variable from inner local scope
def outer():
a = 10
def inner():
a = 3
print(a)
inner()
print(a)
print(outer())
3
10
# Modifiying nonlocal variable
def outer():
a = 10
def inner():
nonlocal a
print(a)
a = 3
print(a)
inner()
print(a)
print(outer())
10
3
3
def outer():
a = 10
def inner():
print(a)
a = 3
inner()
print(a)
print(outer())
Execution error
UnboundLocalError: local variable 'a' referenced before assignment
# modifying the global variable from outer function's scope
a = 10
def outer():
global a
a = 5
def inner1():
def inner2():
print(a)
inner2()
inner1()
print(outer())
print(a)
5
5
# modifiying the global variable from first nested scope
a = 10
def outer():
def inner1():
global a
a = 5
def inner2():
print(a)
inner2()
inner1()
print(outer())
print(a)
5
5
# modifying the global variable fron the second nested scope
a = 10
def outer():
def inner1():
def inner2():
global a
a = 5
print(a)
inner2()
inner1()
print(outer())
print(a)
5
5
# modifying nonlocal variable in outer function's scope from the first nested scope
def outer():
a = 10
def inner1():
nonlocal a
a = 5
def inner2():
print(a)
inner2()
inner1()
print(a)
print(outer())
5
5
# We can do the same from the second nested scope
def outer():
a = 10
def inner1():
def inner2():
nonlocal a
a = 2
print(a)
inner2()
inner1()
print(a)
print(outer())
2
2
# first we should be aware of using nonlocal keyword
# nonlocal means Python will only look at local scopes, not global
a = 10
def outer():
def inner1():
def inner2():
nonlocal a
a = 3
print(a)
inner2()
inner1()
print(outer())
Execution error
SyntaxError: no binding for nonlocal 'a' found (<ipython-input-45-8a1647e220a2>, line 7)
# having the same variable in both outer and inner local scopes
def outer():
a = 'Python'
def inner1():
a = 'Java'
def inner2():
nonlocal a
a = 'C++'
print('Before inner2:', a)
inner2()
print('After inner2:', a)
inner1()
print('Outer a:', a)
print(outer())
Before inner2: Java
After inner2: C++
Outer a: Python
# using the nonlocal keyword in both nested scopes
def outer():
a = 'Python'
def inner1():
nonlocal a
a = 'Java'
def inner2():
nonlocal a
a = 'C++'
print('Before inners:', a)
inner2()
print('After inner2:', a)
inner1()
print('outer:', a)
print(outer())
Before inners: Java
After inner2: C++
outer: C++
# what if we also had a global variable with the same name?
a = 'Python'
def outer():
a = 'Java'
def inner1():
nonlocal a
a = 'C++'
def inner2():
global a
a = 'Julia'
print('Before inner2:', a)
inner2()
print('After inner2:', a)
inner1()
print('outer:', a)
print(outer())
print('global:', a)
Before inner2: C++
After inner2: C++
outer: C++
global: Julia
# a simple example of a closure
def outer():
a = 10
def inner():
print(a)
return inner
func = outer()
print(func())
10
# let's just write previous code to keep it simple
def outer():
a = 10
def inner():
print(a)
return inner
func = outer()
print(func())
10
# looking at our free variables and closure
def outer():
a = 10
x = 3
def inner():
x = 5
print(a)
return inner
func = outer()
# looking at the free variables
print(func.__code__.co_freevars)
# looing at the closure
print(func.__closure__)
# let's look at the memory adress of the free variable inside the local scopes
def outer():
a = 10
x = 3
print(hex(id(a)))
def inner():
x = 5
print(hex(id(a)))
print(a)
return inner
func = outer()
print(func())
0x7f9e08adc560
0x7f9e08adc560
10
# modifying our free variable
def outer():
c = 0
def counter():
nonlocal c
c += 1
return(c)
return counter
func1 = outer()
# since our free variable is nonlocal, each time we call the func
# we will modify its current value, i.e add 1 to it
print(func1())
print(func1())
# now let's create a new closure
func2 = outer()
print(func2())
print(func2())
print(func1.__closure__)
print(func2.__closure__)
(<cell at 0x7f9dd55bf210: int object at 0x7f9e08adc460>,)
(<cell at 0x7f9dd55a9450: int object at 0x7f9e08adc460>,)
# creating a shared scope inside outer function
def outer():
c = 0
def counter1():
nonlocal c
c += 1
return c
def counter2():
nonlocal c
c += 1
return c
return counter1, counter2
func1, func2 = outer()
# let's first look at the closures of both functions
# and see the cell memory and free variable memory
print(func1.__closure__)
print(func2.__closure__)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc420>,)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc420>,)
# let's call the func1 several times
print(func1())
print(func1())
# let's now look at the memory address of the free variable
print(func1.__closure__)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc460>,)
print(func2.__closure__)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc460>,)
print(func2())
print(func2())
# we can look at the memory addresses again
print(func1.__closure__)
print(func2.__closure__)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc4a0>,)
(<cell at 0x7f9dd566b890: int object at 0x7f9e08adc4a0>,)
def outer(n):
def inner(x):
return n + x
return inner
func1 = outer(1)
print(func1.__code__.co_freevars)
# creating 2 more closures with different arguments
func2 = outer(2)
func3 = outer(3)
print(func1(5))
print(func2(5))
print(func3(5))
6
7
8
ls = []
for i in range(1, 9):
ls.append(lambda x: x + i)
# let's use the closures
print(ls[0](5))
print(ls[1](5))
print(ls[7](5))
13
13
13
def outer(n):
# inner1 + free variable n is a closure
def inner1(x):
current = x
# inner2 + free variables current and n is a closure
def inner2():
nonlocal current
current += n
return current
return inner2
return inner1
inner1 = outer(10)
print(inner1.__code__.co_freevars)
inner2 = inner1(7)
print(inner2.__code__.co_freevars)
print(inner2())
print(inner2())
# creating a simple decorator that counts how many times a specific function called
def counter(fn):
c = 0
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function is called {c} times.')
return fn(*args, **kwargs)
return wrapper
# let's create some functions to use them with decorator
def mult(a, b):
return a * b
def add(a, b, c):
return a + b + c
mult = counter(mult)
add = counter(add)
print(mult(2, 3))
mult function is called 1 times.
print(mult(3, 4))
mult function is called 2 times.
print(add(1, 2, 3))
add function is called 1 times.
print(add(2, 3, 4))
add function is called 2 times.
print(mult(2, 7))
mult function is called 3 times.
print(mult.__closure__)
print(mult.__code__.co_freevars)
(<cell at 0x7f9dd55a9b10: int object at 0x7f9e08adc480>, <cell at 0x7f9dd55a94d0: function object at 0x7f9dd55ca950>)
('c', 'fn')
print(add.__closure__)
print(add.__code__.co_freevars)
(<cell at 0x7f9dd55a9150: int object at 0x7f9e08adc460>, <cell at 0x7f9dd55a9d90: function object at 0x7f9dd55ca9e0>)
('c', 'fn')
# since mult and add functions are already decorated we should define them again
# otherwise we will decorate them twice
@counter
def mult(a, b):
return a * b
@counter
def add(a, b, c):
return a + b + c
# now we can use them as we did before
print(mult(5, 4))
mult function is called 1 times.
print(mult(3, 4))
mult function is called 2 times.
print(add(3, 6, 8))
add function is called 1 times.
print(add(4, 1, 0))
add function is called 2 times.
print(mult(5, 3))
mult function is called 3 times.
# we can again look at the closures and free variables
print(mult.__closure__)
print(mult.__code__.co_freevars)
(<cell at 0x7f9dd55c3850: int object at 0x7f9e08adc480>, <cell at 0x7f9dd55c3190: function object at 0x7f9dd55cad40>)
('c', 'fn')
print(add.__closure__)
print(add.__code__.co_freevars)
(<cell at 0x7f9dd55a94d0: int object at 0x7f9e08adc460>, <cell at 0x7f9dd55c3f50: function object at 0x7f9dd55ca950>)
('c', 'fn')
# timer decorator also shows which fibanocci number we are calculating at that time
# it will be helpful in recursive fibonacci function
def timer(fn):
from time import perf_counter
def wrapper(*args, **kwargs):
start = perf_counter()
result = fn(*args, **kwargs)
end = perf_counter()
print(f'finding {args[0]}th fibonacci number')
print(f'{fn.__name__} function took {end - start}s to run.\n')
return result
return wrapper
# Let's write 2 fifferent functions to find fibonacci numbers, one with the recursion, other one with the loop.
# Here is the Fibonacci series if you don't know: 1, 1, 2, 3, 5, 8, 13, 21 ... .
# First 2 fibonacci numbers are 1 and following fibonacci numbers are sum of the previos fibonacci numbers.
#
# recursion
@timer
def fib_rec(n):
if n <= 2:
return 1
return fib_rec(n-1) + fib_rec(n-2)
print(fib_rec(6))
finding 2th fibonacci number
fib_rec function took 3.189779818058014e-07s to run.
finding 1th fibonacci number
fib_rec function took 6.430782377719879e-07s to run.
finding 3th fibonacci number
fib_rec function took 0.000272644916549325s to run.
finding 2th fibonacci number
fib_rec function took 5.201436579227448e-07s to run.
finding 4th fibonacci number
fib_rec function took 0.0003853549715131521s to run.
finding 2th fibonacci number
fib_rec function took 3.9604492485523224e-07s to run.
finding 1th fibonacci number
fib_rec function took 4.880130290985107e-07s to run.
finding 3th fibonacci number
fib_rec function took 0.00010116398334503174s to run.
finding 5th fibonacci number
fib_rec function took 0.000586905051022768s to run.
finding 2th fibonacci number
fib_rec function took 2.9383227229118347e-07s to run.
finding 1th fibonacci number
fib_rec function took 4.6892091631889343e-07s to run.
finding 3th fibonacci number
fib_rec function took 0.00010488205589354038s to run.
finding 2th fibonacci number
fib_rec function took 4.530884325504303e-07s to run.
finding 4th fibonacci number
fib_rec function took 0.00020614801906049252s to run.
finding 6th fibonacci number
fib_rec function took 0.0008911860641092062s to run.
8
def fib_rec(n):
if n <= 2:
return 1
return fib_rec(n-1) + fib_rec(n-2)
@timer
def fib_rec_helper(n):
return fib_rec(n)
print(fib_rec_helper(5))
finding 5th fibonacci number
fib_rec_helper function took 5.070003680884838e-06s to run.
print(fib_rec_helper(35))
finding 35th fibonacci number
fib_rec_helper function took 4.738590640001348s to run.
# for loop
@timer
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
print(fib_loop(5))
finding 5th fibonacci number
fib_loop function took 4.1099992813542485e-06s to run.
print(fib_loop(35))
finding 35th fibonacci number
fib_loop function took 6.72000169288367e-06s to run.
# let's write counter decorator again
def counter(fn):
c = 0
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function called {c} times.')
return fn(*args, **kwargs)
return wrapper
@counter
def greet(name):
"""
this function greets people.
"""
return f'Hi, {name}!'
print(greet('Kanan'))
greet function called 1 times.
print(help(greet))
Help on function wrapper in module __main__:
wrapper(*args, **kwargs)
None
# writing decorator with hardcoded function name and docstring
def counter(fn):
c = 0
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function called {c} times.')
return fn(*args, **kwargs)
# overwriting the metadat of the inner function
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
# we also need to write our function again as we had decorated it previously
# this time, let's also add a signature(showing data types of the objects) to our function
@counter
def greet(name:str) -> str:
'''
This function greets people.
'''
return f'Hi, {name}!'
print(greet('Kanan'))
greet function called 1 times.
print(help(greet))
Help on function greet in module __main__:
greet(*args, **kwargs)
This function greets people.
None
# using wrap function manually
# we import wrap from inside of the decorator
# so that whenever the decorator used it will find wrap from enclosing scope
def counter(fn):
c = 0
from functools import wraps
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function called {c} times.')
return fn(*args, **kwargs)
wrapper = wraps(fn)(wrapper)
return wrapper
@counter
def greet(name:str) -> str:
'''
This function greets people.
'''
return f'Hi, {name}!'
print(greet('Kanan'))
greet function called 1 times.
print(help(greet))
Help on function greet in module __main__:
greet(name: str) -> str
This function greets people.
# let's do the same thing using @
def counter(fn):
c = 0
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function called {c} times.')
return fn(*args, **kwargs)
return wrapper
@counter
def greet(name:str) -> str:
'''
This function greets people.
'''
return f'Hi, {name}!'
print(greet('Kanan'))
greet function called 1 times.
print(help(greet))
Help on function greet in module __main__:
greet(name: str) -> str
This function greets people.
def date_time(fn):
from datetime import datetime
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
print(f'{fn.__name__} function run on: {datetime.today().strftime("%Y-%m-%d %H:%M:%S")}')
return fn(*args, **kwargs)
return wrapper
def add(a:int, b:int)->int:
return a + b
# applying time decorator
date_time = date_time(add)
# applying counter decorator
counter = counter(date_time)
# result
print(counter(2, 3))
add function called 1 times.
add function run on: 2021-11-13 08:36:09
5
# let's copy and paste our decorators
def counter(fn):
c = 0
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
nonlocal c
c += 1
print(f'{fn.__name__} function called {c} times.')
return fn(*args, **kwargs)
return wrapper
def date_time(fn):
from datetime import datetime
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
print(f'{fn.__name__} function run on: {datetime.today().strftime("%Y-%m-%d %H:%M:%S")}')
return fn(*args, **kwargs)
return wrapper
@counter
@date_time
def add(a:int, b:int)->int:
return a + b
print(add(2, 3))
add function called 1 times.
add function run on: 2021-11-13 08:39:00
5
# we can also check that after the double decorating, function keeps its metadata
print(help(add))
Help on function add in module __main__:
add(a: int, b: int) -> int
def timer(fn):
from time import perf_counter
from functools import wraps
# we will now create pass our fn function to wraps to create a decorator
dec = wraps(fn)
@dec
def wrapper(*args, **kwargs):
start = perf_counter()
result = fn(*args, **kwargs)
end = perf_counter()
print(f'{fn.__name__} function took {end - start}s to run.\n')
return result
return wrapper
# writing fibonacci number finder function again
@timer
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
print(fib_loop(10))
fib_loop function took 3.970009856857359e-06s to run.
print(help(fib_loop))
Help on function fib_loop in module __main__:
fib_loop(n)
# timer decorator with additional parameter
def timer(fn, n):
from time import perf_counter
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
total = 0
for _ in range(n):
start = perf_counter()
result = fn(*args, **kwargs)
end = perf_counter()
total += end - start
print(f'{fn.__name__} function took {total / n}s to run.\n')
return result
return wrapper
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
# now we can create our closure by passing 2 parameters to time decorator
dec = timer(fib_loop, 15)
print(dec(10))
fib_loop function took 2.139247953891754e-05s to run.
55
def timer(n):
def dec(fn):
from time import perf_counter
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
total = 0
for _ in range(n):
start = perf_counter()
result = fn(*args, **kwargs)
end = perf_counter()
total += end - start
print(f'{fn.__name__} function took {total / n}s to run.\n')
return result
return wrapper
return dec
# Now we can use parametrized timer decorator both manually and by using the @ sign.
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
# we pass the parameter to create an actual decorator
dec = timer(15)
fib_loop = dec(fib_loop)
print(fib_loop(5))
fib_loop function took 1.3313333814342818e-06s to run.
# using @ sign
@timer(15)
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
print(fib_loop(5))
fib_loop function took 1.3519990413139264e-06s to run.
# Passing the function that will be decorated during the initialization(non-parametrized class decorator)
class decorator:
def __init__(self, fn):
self.fn = fn
def __call__(self, *args, **kwargs):
print('Function decorated.')
return self.fn(*args, **kwargs)
def add(a, b, c=9):
return a + b + c
# manual decorating
# first we need to create our object by passing our function to the class
obj = decorator(add)
print(obj(1, 2))
Function decorated.
# using @ sign
@decorator
def add(a, b, c=9):
return a + b + c
print(add(1, 2))
Function decorated.
# let's write our parametrized timer decorator as parametrized class decorator
class class_timer:
def __init__(self, n):
self.n = n
def __call__(self, fn):
from time import perf_counter
def wrapper(*args, **kwargs):
total = 0
for _ in range(self.n):
start = perf_counter()
result = fn(*args, **kwargs)
end = perf_counter()
total += end - start
print(f'{fn.__name__} function took {total / self.n}s to run.')
return result
return wrapper
# manually using the parametrized timer class decorator on fibinacci number finder function
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
# first we need to create a callable object by passing a parameter
# this parameter represents how many times fib_loop function will run, then we pass our function to decorate
obj = class_timer(10)
fib_loop = obj(fib_loop)
print(fib_loop(5))
fib_loop function took 1.4220000593923033e-06s to run.
# using @ sign
# we will use object of a class as a decorator
@class_timer(10)
def fib_loop(n):
prev = 1
curr = 1
for i in range(n-2):
prev, curr = curr, prev + curr
return curr
print(fib_loop(5))
fib_loop function took 1.4630990335717796e-06s to run.