Python Decorators - The Secret Sauce for Functional Programming
What are Names in Python?
In Python, names serve as identifiers referencing various objects such as variables, functions, classes, modules, or other entities within the codebase. They function as labels facilitating the referencing and manipulation of data or code elements, thereby enhancing the readability and manageability of programs.
GitHub Repo : Python Decorators
Assigning Names
1
2
3
4
5
6
# Assigning a Name to a Variable
x = 10
# Assigning a Name to a Function
def greet(name):
print("Hello, " + name + "!")
Here, x
is a name assigned to an integer object (10
), and greet
is a name assigned to a function object.
Python associates each name with a unique object, and you can check the identity (memory address) of an object using the id()
function:
1
2
3
# Demonstrating Identity
print(id(x)) # Identity of x
print(id(10)) # Identity of integer 10
What are Namespaces in Python?
Namespaces in Python constitute a systematic framework for organizing and governing identifiers such as variable names, function names, class names, etc. They play a pivotal role in mitigating naming conflicts and furnishing a structured means of accessing distinct elements within the codebase.
Python has four types of namespaces:
Built-in Namespace: This namespace encompasses all predefined functions, exceptions, and constants furnished by Python. These entities can be directly utilized without necessitating any import statements.
1 2 3
print(len("Hello")) # Demonstrating the usage of the built-in function 'len' print(ValueError) # Demonstrating the usage of the built-in exception 'ValueError' print(True) # Demonstrating the usage of the built-in constant 'True'
Module-Level/Global Namespace: The global namespace pertains to the outermost scope of a program, housing all built-in objects, functions, and variables pre-defined in Python. It also encompasses any variables or functions defined at the top level of a script or module. Objects within the global namespace are accessible throughout the entirety of the program.
1 2 3 4 5 6 7 8
# Global Variable x = 10 def my_func(): print(x) # Accessing the global variable x my_func() # Output: 10 print(x) # Output: 10
Local Namespace: A local namespace denotes the scope confined within a function or a class. It encompasses the names of variables and objects specifically defined within the confines of that particular function or class. These names are exclusively accessible from within the local scope and remain concealed from external visibility.
1 2 3 4 5 6 7 8 9 10 11
a = 10 def func_1(): a = 20 print(dir()) # Printing the directory of the local namespace, displaying only 'a' def func_2(): a = 30 print(dir()) # Printing the directory of the global namespace/module level, encompassing built-in modules, variable a, and functions func_1, func_2 func_1()
Enclosed Namespace: The enclosed namespace denotes the scope situated between the local and global namespaces. It comes into existence when a nested function is defined within another function. The nested function possesses access to variables from the enclosing function’s scope, alongside its own local namespace and the global namespace.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
# Global Variable x = 'global' def outer_func(): # Enclosed Variable y = 'enclosed' def inner_func(): # Local Variable z = 'local' print(x) # Accessing the global variable print(y) # Accessing the enclosed variable print(z) # Accessing the local variable inner_func() outer_func()
Python follows the LEGB rule to resolve names:
- Local: Python first checks if the name is defined in the current part of the code you’re working on.
- Enclosed: If Python doesn’t find the name locally, it looks in the functions that contain the current part of the code.
- Global: If it’s still not found, Python looks at the entire file to see if the name is defined outside of any functions.
- Built-in: If Python can’t find the name anywhere in your code, it checks if it’s a built-in name that’s always available.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Global scope
x = 10 # global variable
def outer_func():
# Enclosing scope
x = 20 # nonlocal/Enclosed variable
def inner_func():
# Local scope
x = 30 # local variable
print(x)
inner_func()
print(x)
outer_func()
print(x)
In this example, the output will be:
1
2
3
30
20
10
Understanding Closures in Python
In Python, functions are first-class objects, which means they can be assigned to variables, passed as arguments to other functions, and even returned from functions. This ability to treat functions as objects opens up a world of possibilities, including the creation of closures.
Functions as Objects
Before diving into closures, let’s understand how functions can be treated as objects in Python:
1
2
3
4
5
def outer():
print('Hello')
print(outer) # <function outer at 0x7f9b7c6a4dc0>
print(type(outer)) # <class 'function'>
In the above example, we define a function outer()
and then print the function itself using print(outer)
. The output shows the memory address of the function object. We can also check the type of the function using type(outer)
, which confirms that it is indeed a function
object.
Nested Functions
Python allows you to define functions inside other functions, which are called nested functions:
1
2
3
4
5
6
7
def outer():
print('Outer')
def inner():
print('Inner')
inner()
outer()
In this example, we define an inner()
function inside the outer()
function. When we call outer()
, it prints ‘Outer’ and then calls the inner()
function, which prints ‘Inner’.
Aliasing Functions
Just like variables, you can create aliases (or references) for functions:
1
2
3
4
5
6
def outer():
print('Hello')
new = outer # Aliasing outer function
new() # Hello
outer() # Hello
Here, we create an alias new
for the outer
function. Calling new()
or outer()
will both execute the same function and print ‘Hello’.
Closures in Python
A closure is a nested function that has access to variables in the outer (enclosing) function’s scope, even after the outer function has finished executing. The nested function “closes over” the variables from the outer function, allowing it to remember and use those variables even after the outer function has completed.
1
2
3
4
5
6
7
def outer():
def inner():
x = 100
return x
return inner()
print(outer()) # 100
In this example, the inner()
function is a nested function inside outer()
. When we call outer()
, it returns the result of inner()
, which is 100
. However, this is not a closure because the x
variable is local to the inner()
function and doesn’t reference any variables from the outer function’s scope.
To create a closure, we need to have a nested function that references variables from the outer function’s scope:
1
2
3
4
5
6
7
8
def outer():
x = 100
def inner():
return x
return inner # returning inner function object
inner = outer() # The outer function returns the inner function object, and we assign it to inner
print(inner()) # 100
In this example, the inner()
function references the x
variable from the outer()
function’s scope. When we call outer()
, it returns the inner()
function object itself, which we then assign to the inner
variable. When we call inner()
, it has access to the x
variable from the outer()
function’s scope, even though outer()
has already finished executing.
The key points to remember about closures are:
- A closure is a nested function that has access to variables in the outer (enclosing) function’s scope, even after the outer function has finished executing.
- The nested function “closes over” the variables from the outer function, allowing it to remember and use those variables even after the outer function has completed.
- Closures are created when a nested function references variables from the outer function’s scope.
- Closures can be used to create private variables and encapsulate data.
Closures are a powerful concept in Python and have many practical applications, such as implementing decorators, creating private variables, and implementing functional programming patterns like currying.
Understanding Python Decorators: A Detailed Explanation
Python decorators are sophisticated constructs that allow for dynamic alteration of function behavior without altering the original function implementation. They provide a means to wrap or augment a function with additional features.
Let’s delve into decorators step by step, elucidating through examples and detailed explanations.
What is a Decorator?
In Python, a decorator is a function that accepts another function as input, augments it with additional behavior, and then returns the modified function. This capability empowers developers to enhance or modify the functionality of functions or methods.
Consider this fundamental example of a decorator:
1
2
3
4
5
6
7
8
9
10
11
def decorator(func):
def inner():
func() # Execute original function
print('Ahammad Nafiz') # Additional functionality
return inner
@decorator
def welcome():
print('Hello')
welcome() # Output: Hello\nAhammad Nafiz
Here’s a breakdown:
decorator
is a function that takesfunc
as an argument.- Within
decorator
,inner
is defined to wrap aroundfunc
, adding extra functionality (like printing ‘Ahammad Nafiz’ after callingfunc
). - The
@decorator
syntax abovewelcome
is a shorthand to apply the decorator to thewelcome
function.
How Decorators Work
Visualize the process of applying a decorator to a function:
1
2
3
4
5
6
7
8
def func():
---> func()
@decorator
def func():
---> func(func())
|
---> original func
The @decorator
syntax effectively replaces func
with the result of calling decorator(func)
. This implies that func
now points to its decorated version.
Example: Adding New Functionality
Let’s explore a scenario where a decorator calculates the duration of a function’s execution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import time
def duration_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"Duration: {end-start} seconds")
return result
return wrapper
@duration_decorator
def my_function(n):
L = [i for i in range(n)]
my_function(100000)
In this case:
duration_decorator
measures a function’s execution time.@duration_decorator
is utilized to apply this decorator tomy_function
.- Upon calling
my_function(100000)
, theduration_decorator
wraps aroundmy_function
, gauges its execution time, and displays the duration.
Chaining Decorators
Multiple decorators can be chained together:
1
2
3
4
5
6
7
8
9
10
11
12
def double_decorator(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
func(*args, **kwargs)
return wrapper
@double_decorator
@duration_decorator
def another_function(n):
L = [i for i in range(n)]
another_function(100000)
Here, @double_decorator
encapsulates the outcome of @duration_decorator(another_function)
, effectively invoking another_function
twice and gauging its duration.
Employing Decorators with Classes
Decorators can operate within classes, exemplified by the @property
decorator:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyClass:
def __init__(self):
self._x = 0
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
obj = MyClass()
obj.x = 5
print(obj.x) # Output: 5
In this instance, @property
facilitates defining x
as a property of MyClass
, supporting getter, setter, and deleter methods.
Exploring Decorators with Comprehensive Examples
Decorators are potent features in Python, fostering augmentation or extension of function or method behavior. They offer a succinct and elegant approach for integrating functionality sans altering the original function’s code. Let’s explore decorators further through extensive examples.
Example: Timing Execution with a Decorator
Design a decorator to measure a function’s execution time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import time
def timer(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Execution time: {end_time - start_time} seconds")
return result
return wrapper
@timer
def heavy_computation(n):
result = sum(i for i in range(n))
return result
print(heavy_computation(1000000))
Output:
1
2
Execution time: 0.05608105659484863 seconds
499999500000
In this example:
timer
measures a function’s execution time.@timer
is employed to implement this decorator with theheavy_computation
function.- On invoking
heavy_computation(1000000)
, thetimer
decorator encapsulates it, gauges its execution time, and shows the duration.
Example: Memoization with a Decorator
Memoization, a technique to cache outcomes of resource-intensive function calls for subsequent reuse, can be implemented using a decorator:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def memoize(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n <= 1:
return n
else:
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # Output: 55
In this example:
memoize
cachesfibonacci
function results using a dictionary (cache
).@memoize
is applied as a decorator to thefibonacci
function.- On invoking
fibonacci(10)
, the decorator examines if the result forn=10
is in the cache; if absent, it recursively computes the result and stores it in the cache.
Example: Logging with a Decorator
Decorators can log details concerning function calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
def log(func):
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"Function {func.__name__} returned: {result}")
return result
return wrapper
@log
def add(x, y):
return x + y
print(add(3, 5)) # Output:
Output:
1
2
3
Calling function: add with args: (3, 5), kwargs: {}
Function add returned: 8
8
In this example:
log
logs function call specifics.@log
applies this decorator to theadd
function.- On calling
add(3, 5)
, thelog
decorator records details pre and postadd
invocation, encompassing arguments and return value.
Example: Debugging with a Decorator
Let’s explore a debugging scenario using a decorator that logs details about function calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
def debug(func):
def wrapper(*args, **kwargs):
args_value = ', '.join(str(arg) for arg in args)
kwargs_value = ', '.join(f"{k} = {v}" for k, v in kwargs.items())
print(f"Calling: {func.__name__} with args: {args_value} and kwargs: {kwargs_value}")
return func(*args, **kwargs)
return wrapper
@debug
def greet(name, greeting='Hello'):
return f"{greeting}, {name}"
greet('Nafiz', greeting='How are you?')
Output:
1
Calling: greet with args: Nafiz and kwargs: greeting = How are you?
In this example:
debug
is a decorator that logs information about function calls.@debug
is used to apply this decorator to thegreet
function.- Upon calling
greet('Nafiz', greeting='How are you?')
, thedebug
decorator intercepts the call, constructs a formatted string displaying the function name, arguments (args
), and keyword arguments (kwargs
), and then invokes the originalgreet
function.
Conclusion
Decorators are a versatile Python tool for broadening function or method behavior. They’re adept at tasks like timing, caching, logging, authentication, and more. Comprehending decorators is paramount for crafting modular, reusable, and
maintainable code.
Experiment with decorators to realize their full potential and leverage their prowess in Python projects!