As usual, let’s stick to the first principles. Let’s start with what was taught in our schools. What is a function in mathematics?
f(x) -> y spelled as
f of x is a function that takes
x as input and returns
y as output. Right, It’s the same thing in programming. A function takes some parameters as input and returns a value or object as output.
Let’s understand the difference between the definition and the calling of a function.
def make_http_request(url: str) -> http.Response: resp = http.request(url) if resp.status_code == 200: return resp return None
In the above code snippet, we defined a function
make_http_request that takes
url as the “parameter” and returns
http.Response the object as output. As the definition we mean, the function will be registered with in-memory (RAM) when the program run. The calling of a function happens when we supply the “arguments” to the function as shown in the below code.
resp = make_http_request("https://twitter.com/api/users") print(resp)
notice the difference between parameter and argument. “parameters” are the variables passed to the function, they are not the real values. “arguments” are the real values passed to the function. It’s not much important to differentiate them as some people use both words interchangeably.
Functions are first-class citizens in python. It means that they can be assigned to a variable, passed as an argument to a function, and returned from another function.
def fetch_movies_by_actor(actor_name, movie_db): if actor_name == '' or actor_name is None: raise ValueError("actor name shouldn't be empty'") return filter(lambda movie: actor_name in movie["actors"], movie_db) if __name__ == "__main__": obj = fetch_movies_by_actor ''' >>> print(obj) <function fetch_movies_by_actor at 0x109f01280> >>> print(fetch_movies_by_actor) <function fetch_movies_by_actor at 0x109f01280> '''
As seen from the above snippet, we defined a function named
fetch_movies_by_actor and assigned it to a variable called
obj. When I print both, they both printed the same.
def fetch_movies_by_actor(actor_name, movie_db): if actor_name == '' or actor_name is None: raise ValueError("actor name shouldn't be empty'") return filter(lambda movie: actor_name in movie["actors"], movie_db) def fetch_movie_details(actor_name, func): if func is not None: print(func) movies = func(actor_name) return movies return  if __name__ == "__main__": result = fetch_movie_details("Tom Cruise", fetch_movies_by_actor)
As seen in the above example,
fetch_movies_by_actor the function is passed as an argument to
fetch_movie_details and it is called there.
def fetch_movie_genres_by_year(year): filtered_movies = list(filter(lambda movie: movie["year"] == year, movie_db)) def get_genres_by_movie_name(movie_name): movie = list(filter(lambda movie: movie["name"] == movie_name ,filtered_movies)) return movie["genre"] return get_genres_by_movie_name
As seen in the above code snippet, the function
fetch_movie_genres_by_year returned another function
get_genres_by_movie_name . To obtain the final result, we need to do this 👇.
movie_genres = fetch_movie_genres_by_year(2009)("XYZ")
Let’s try to understand what’s happening there. In mathematical terms,
f(x) -> g(x) -> result the function
g(x) which returned the result. So, to get the final result, we need to call
f(x) and then
g(x) that’s what we did above.
What is a decorator? A decorator is a function, which takes a function as a parameter and returns another function. It may be slightly confusing at this point, but everything will be clear at the end. Why do decorators exist? What are its uses? Decorators extend the functionality of a function which reduces the lines of code to write. When to use decorators? Simple, when you see a lot of repetitive code.
Let’s say you have hundreds of functions defined and you want to log the parameters of the function and the result of it for troubleshooting purposes. Using decorators, you can add them in a single place and apply them to all the functions.
def decorator(func): # decorator is a function, taking another function "func" as a parameter def wrapper(*args, **kwargs): # do something before evaluating function result = func(*args, **kwargs) # do something with result or some other stuff return result return wrapper # decorator is returning another function @decorator def myfunc(a, b): # function body pass ''' myfunc's identity will be changed as myfunc = decorator(myfunc) '''
In the above code snippet,
myfunc is decorated with the decorator named
@ symbol which is syntactic sugar to use decorators in python. So, What happens when you decorate a function? Let’s understand it.
@decorator def myfunc(a, b): # function body pass def myfunc2(a, b): # function body pass if __name__ == "__main__": print(myfunc) print(myfunc2) ''' >>> print(myfunc) <function decorator.<locals>.wrapper at 0x109e233a0> >>> print(myfunc2) <function myfunc2 at 0x109e23430> '''
As seen in the above code snippet,
myfunc is printed with the name of the decorator and the function returned by the decorator i.e.
myfunc2 is printed as usual.
It is clear that when a function is decorated with a decorator, the original function’s definition will change to
myfunc = decorator(myfunc) i.e.
myfunc = decorator(myfunc) -> wrapper . If we look at the decorator’s definition, the
func parameter is nothing but the original function which is decorated.
Calling the original function will be equal to
myfunc(*args, **kwargs) = decorator(myfunc)(*args, **kwargs) i.e.
myfunc(*args, **kwargs) = decorator(myfunc) -> wrapper(*args, **kwargs). If we look at the decorator definition again, the arguments passed to the
wrapper function inside the decorator are nothing but the arguments passed to the original function. Also, be sure to call the original function in the inner function of the decorator else the whole purpose of the decorator will be lost.
import logging logger = logging.getLogger(__name__) class CustomException(Exception): pass def exception_logger(func): """decorator to log the exceptions""" def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as exc: logger.exception( "Unhandled exception in function %s , exception is %s", func.__name__, exc ) raise CustomException("Unhandled exception in function %s" % func.__name__) from exc return wrapper
In the above code snippet, the original function is called in between try and except block where we are catching and logging the exceptions.
Let’s use the decorator and see what happens.
@exception_logger def func(a, b): # func = exception_logger(func) return a / b print(func(1, 0)) # func(1, 0) = exception_logger(func)(1,0) ''' Unhandled exception in function func , exception is division by zero Traceback (most recent call last): File "<ipython-input-1-5525c8c2a27e>", line 14, in inner return func(*args, **kwargs) File "<ipython-input-3-e825ef26bd9c>", line 3, in func return a / b ZeroDivisionError: division by zero '''
As we see from the above snippet, the exception was logged by the decorator. When the program runs, firstly the
func will be changed to
func = exception_logger(func) . When we call the original function, we are indirectly calling the
wrapper function of the decorator.
Let’s write a decorator to measure the time taken by a function. Below is the code snippet for it. It is self-explanatory.
import functools import time def timeit(func): def wrapper(*args, **kwargs): before_calling = time.time() result = func(*args, **kwargs) after_calling = time.time() print("function %s has took %s seconds" % (func.__name__, (after_calling - before_calling))) return result return wrapper
If you prefer a video version, check out 👇