In the first post of this series, I'll talk about Design Patterns that make sense in Python. We'll see how to implement them and how they are used in the standard library and other third-party packages. In this post, we'll go over the Simple Factory Pattern and understand why it makes sense in Python.
Introduction
Design Patterns has been a popular subject since the Design Patterns: Elements of Reusable Object-Oriented Software (a.k.a GoF) book was released back in 1994. GoF’s goals were to show techniques, a.k.a patterns, to improve an object-oriented design. In total, the book demonstrated 23 patterns, classified in 3 groups:
-
Creational
-
Behavioral
-
Structural
Among the creational patterns, we have the Factory Method. According to the book, the goal of this pattern is to define an interface to create an object. The sub classes will then decide which class will be instantiated. There’s also another variation called Simple Factory. This pattern creates an instance of an object without exposing the details behind the construction. In this article, we’ll see how to do that in Python in an idiomatic way.
When this is useful? Can we just call the constructor directly?
This pattern is helpful when you need to perform an extra setup before calling a constructor. In the next section we’ll see several examples on how they are used in the Python standard library and also in third-party packages such as pandas.
Usage
In this part, we’ll see how this pattern is used in practice and how you can implement it yourself.
Python Standard Library
The datetime
module is one of the most important ones in the standard library. It defines a few classes such as date
, datetime
, and timedelta
. This module uses the simple factory pattern extensively. A real example is the date
class. It has a method called fromtimestamp
that creates date
instances given a timestamp.
In [3]: from datetime import date
In [4]: date.fromtimestamp(time.time())
Out[4]: datetime.date(2020, 11, 10)
If we look at the implementation, we can see that it extracts the year, month and day from the time
instance and the call the constructor (cls
). This is the kind of setup that is abstracted away from the user.
@classmethod
def fromtimestamp(cls, t):
"Construct a date from a POSIX timestamp (like time.time())."
y, m, d, hh, mm, ss, weekday, jday, dst = _time.localtime(t)
return cls(y, m, d)
Another great example is the fromisocalendar
method, which performs an extensive setup. Instead of leaving it to the user, the class provides the functionality “for free” by hiding that from you.
# https://github.com/python/cpython/blob/c304c9a7efa8751b5bc7526fa95cd5f30aac2b92/Lib/datetime.py#L860-L893
...
@classmethod
def fromisocalendar(cls, year, week, day):
"""Construct a date from the ISO year, week number and weekday.
This is the inverse of the date.isocalendar() function"""
# Year is bounded this way because 9999-12-31 is (9999, 52, 5)
if not MINYEAR <= year <= MAXYEAR:
raise ValueError(f"Year is out of range: {year}")
if not 0 < week < 53:
out_of_range = True
if week == 53:
# ISO years have 53 weeks in them on years starting with a
# Thursday and leap years starting on a Wednesday
first_weekday = _ymd2ord(year, 1, 1) % 7
if (first_weekday == 4 or (first_weekday == 3 and
_is_leap(year))):
out_of_range = False
if out_of_range:
raise ValueError(f"Invalid week: {week}")
if not 0 < day < 8:
raise ValueError(f"Invalid weekday: {day} (range is [1, 7])")
# Now compute the offset from (Y, 1, 1) in days:
day_offset = (week - 1) * 7 + (day - 1)
# Calculate the ordinal day for monday, week 1
day_1 = _isoweek1monday(year)
ord_day = day_1 + day_offset
return cls(*_ord2ymd(ord_day))
....
Pandas
pandas
is one of the most used Python packages thanks to the rise of Data Science and Machine Learning. Just like Python, pandas
also makes use of factory methods. A classic example is the from_dict
method that belongs to the DataFrame
class.
>>> data = {'row_1': [3, 2, 1, 0], 'row_2': ['a', 'b', 'c', 'd']}
>>> pd.DataFrame.from_dict(data, orient='index')
0 1 2 3
row_1 3 2 1 0
row_2 a b c d
When we inspect the implementation we can also see a lot of setup and extra checks.
@classmethod
def from_dict(cls, data, orient="columns", dtype=None, columns=None) -> DataFrame:
...
index = None
orient = orient.lower()
if orient == "index":
if len(data) > 0:
# TODO speed up Series case
if isinstance(list(data.values())[0], (Series, dict)):
data = _from_nested_dict(data)
else:
data, index = list(data.values()), list(data.keys())
elif orient == "columns":
if columns is not None:
raise ValueError("cannot use columns parameter with orient='columns'")
else: # pragma: no cover
raise ValueError("only recognize index or columns for orient")
return cls(data, index=index, columns=columns, dtype=dtype)
How to Implement It
The most idiomatic way of implementing factory methods in Python is by decorating them as classmethod
. In Python, regular methods are attached to an object instance. We can access the objects’ fields via the self
argument. classmethod
, on the other hand, are bound not to an instance but to a class
. That means when we call MyClass.factory_method
we are passing MyClass
as the first argument, called cls
. This property makes them an excellent alternative for factory methods since calling cls(args)
inside a classmethod
is the same as MyClass(args)
.
To design your own factory methods, it’s sufficient to decorate it as a classmethod
and return a new instance built with the cls
argument. For example, presume that we want to implement a Point
class and we want it also be constructed from Polar coordinates. The extra setup to convert from Polar to Cartesian is kept inside the method. Not simply it’s more readable, but also simplifies the constructor.
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
@classmethod
def from_polar(cls, r: float, theta: float) -> "Point":
"""
Converts a polar coordinate into cartesian point.
>>> Point.from_polar(r=-2**0.5, theta=math.pi / 4)
Point(x=-1.00, y=-1.00)
"""
return cls(r * math.cos(theta), r * math.sin(theta))
def __repr__(self):
return f"{self.__class__.__name__}(x={self.x:.2f}, y={self.y:.2f})"
>>> Point.from_polar(r=-2**0.5, theta=math.pi / 4)
Point(x=-1.00, y=-1.00)
Conclusion
That's pretty much it! I hope you’ve learned something different and useful. Simple Factory methods are very cool and can abstract a lot of boilerplate. Not to mention that it makes your code clean and readable. In this post I showed how this pattern is used in the standard library and in other packages such as pandas
.
Other posts you may like:
- How to Pass Multiple Arguments to a map Function in Python
- 73 Examples to Help You Master Python's f-strings
- How to Check if an Exception Is Raised (or Not) With pytest
- 3 Ways to Test API Client Applications in Python
- Everything You Need to Know About Python's Namedtuples
- The Best Way to Compare Two Dictionaries in Python
- All other posts from me
See you next time!
This post was originally published at https://miguendes.me
Comments (0)