Object oriented design in python
In this article will be looking at some of the ways to write object based design code in python.
Iteratoring over collection of items#
Iterators are classes which are defined to iterate over the collection of objects. You can use the iterator for example iterating over students enrolled in a course. This can be done let’s say when the internal structure of the students is not exposed or we need to lazy load the students from the database or the network only when iterate over a particular object rather than loading the whole collection of objects.
To define the iterator two functions need to be defined in the class:
# Intializing the iterator, should return the iterator class object itself.
def __iter__(self):
# Initialize the current pointer of the iterator
# Return the iterator class object
return self
# Next value of the iterator, raise StopIteration when end is reached
def __next__(self):
# If the index is out of bounds raise StopIteration
# return the element pointed by the index
# increment the current index
A generator can also be used to iterate over the collection of objects:
To create a generator only __iter__()
needs to be defined with the yield
keyword when iterating over the values. There is no need to add __next__
with the generators.
In the below code, I have used both iterator
and generator
for the student collection. Both do the same thing, but as you can see generator code is more succinct and also doesn’t need to load all the self.student_ids
in memory for indexing an element as in __next__
function.
from dataclasses import dataclass
@dataclass
class Student:
id: int
name: str
age: int
class StudentFactory:
def getStudent(self, student_id: int) -> Student:
# Call API to get the student for id
student = Student(student_id, "Name: " + str(student_id), "Age: " + str(student_id))
return student
class StudentCollection:
def __init__(self, students: list[int], student_factory: StudentFactory):
self.student_ids = students
self.student_factory = student_factory
class StudentIterator:
def __init__(self, student_collection: StudentCollection):
self.student_ids = student_collection.student_ids
self.student_factory = student_collection.student_factory
def __iter__(self):
self.idx = 0
return self
def __next__(self):
if self.idx >= len(self.student_ids):
raise StopIteration
cur_student = self.student_factory.getStudent(self.student_ids[self.idx])
self.idx += 1
return cur_student
class StudentGenerator:
def __init__(self, student_collection: StudentCollection):
self.student_ids = student_collection.student_ids
self.student_factory = student_collection.student_factory
def __iter__(self):
for idx in self.student_ids:
cur_student = self.student_factory.getStudent(idx)
yield cur_student
if __name__ == '__main__':
student_fac = StudentFactory()
student_ids = [1, 4, 6, 9, 10]
student_coll = StudentCollection(student_ids, student_fac)
print("Use Iterator to fetch all the students in the collection")
for student in StudentIterator(student_coll):
print(student)
print("Use generator to fetch all the students in the collection")
for student in StudentGenerator(student_coll):
print(student)
Decorators#
Decorator or Wrapper is a design pattern which is used to extend the functionality of a class or method. Python provides a neat way to add decorators to your own functions to enhance the functionality of the functions which are written. This is done using @decorator_name
when written on top of the class or other functions.
The template the decorator follows is given as:
def decorator_name(func):
def wrapper(*args, **kwargs):
# call func with *args, **kwargs
result = func(**args, **kwargs)
# decorate the result example: add a prefix to the msg
decorated_result = "Decorated Msg: result"
return decorated_result
return wrapper
# Adding decorator `decorator_name` to the utility
@decorator_name
def utility(msg: str)->str:
return msg
# Overall runs the utility as
# decorator_name(utility)(msg)
Let’s look at an example to calculate and print the time to run the function.
import time
def capture_time(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f'Name: {func.__name__}, {end_time - start_time:.2f} seconds')
return result
return wrapper
# Adding decorator to capture the time.
@capture_time
def waiting_func(n):
for i in range(1, n):
_ = i ** (i - 1)
if __name__ == '__main__':
# with decorator waiting_func will be called as:
# capture_time(waiting_func)(10000)
waiting_func(10000)
The main advantage of the decorator here is the ease of adding the decorator on top of the function which needs decoration.
Multiple decorators can also be added, and the order of the decorator matters. As the wrapping of the function is done based on the order defined.
# basically will be treated as decorator_1(decorator_2(func))()
@decorator_1
@decorator_2
def func():
return
There are also other use cases to add decorators like:
- authenticating user or raising exception, if not already authenticated.
- connecting to db, if the connection expired or not available.
Common Decorators#
Many decorators which can be added on top of functions are defined in:
https://docs.python.org/3/library/functools.html#module-functools
Some of the common examples are:
-
@cache
-
@lru_cache