Factory Pattern with Enums in Python
The factory pattern is one of my favorites when working with applications that require dynamically instantiated classes based on varying conditions, things can get out of hand as time passes and new classes are added and new parameters pop up. The factory pattern often allows to have a solution to these issues with a simple implementation that can stand longer the test of time and reduce the quantity of spaghetti-code you will generate.
Our example will be based on the code needed by a mothership to generate and deploy UAPs to a planet. We will have different possible types of UAP that can be built and deployed, and we want a simple way to dynamically generate them and provide them the parameters they need.
Classes with something in common
In our example we have 2 classes, each one for a different type of UAP, cigar shaped and tictac shaped. They have the same methods and attributes.
class CigarUAP:
warp_speed: bool
def call_mothership(self):
...
def turn_90_degrees(self, mach: int = 14):
...
class TicTacUAP:
warp_speed: bool
def call_mothership(self):
...
def turn_90_degrees(self, mach: int = 12):
...
Since they share the same attributes and methods, we can create an abstract class to describe the same data, and a protocol class to describe the same behavior. Meaning the same attributes, and the same method signatures.
from typing import Protocol
class ABCUAP:
warp_speed: bool
class UAPProtocol(Protocol):
def call_mothership(self)->None:
...
def turn_90_degrees(self, mach: int) -> None:
...
Factory Function, Enum types and Mappings
Now, if we want to make a factory for our UAP classes, we can use a function for that. We do so in order to decouple the class definitions from their instantiation and the parts of your program that need to use them. In this way we can add, remove and even change drastically the classes without risking to affect the main program using them.
In our case, we can define an enum for the UAP types we have, this enum will help us make the factory function stricter in what it can accepts and use, both by the logic within the function, and by using a static type checker like mypy.
from enum import Enum
class UAPType(Enum):
TICTAC = "tictac"
CIGAR = "cigar"
def deploy_uap(uap_type: UAPType) -> ABCUAP:
if uap_type == UAPType.TICTAC:
return TicTacUAP()
elif uap_type == UAPType.CIGAR:
return CigarUAP()
else:
raise ValueError("The UAPType passed is not yet available on your planet.")
Here is one way to instantiate a UAP class:
uap_type_I_want = UAPType("tictac")
new_uap: ABCUAP = deploy_uap(uap_type_I_want)
However, the logic within the factory function is not very dynamic, if
we add a new UAP class, for example, we will have to add a new elif
statement, which can be daunting. One way I like to deal with this is
by having a map of enums to class types as in the following:
UAP_TYPES_MAP = {UAPType.TICTAC: TicTacUAP, UAPType.CIGAR: CigarUAP}
def deploy_uap(uap_type: UAPType) -> ABCUAP:
uap_obj = UAP_TYPES_MAP.get(uap_type)
if not uap_obj:
raise ValueError("The UAPType passed is not yet available on your planet.")
return uap_obj
In this way, we can retrieve the class corresponding to the enum passed from the map, This approach simplifies adding or removing classes without requiring changes to the factory function's logic.
Different attributes, options and acorns
So far we have instantiated the UAP classes without worrying about the
attributes. However, if we want to pass different values to our
factory function we may incur in some challenges if we were just to
add warp_speed
to the parameters of the deploy_uap
function.
As a way of example, let's add now a new class of UAP:
class AcornUAP(ABCUAP):
warp_speed: bool
irf_cloak: bool
def call_mothership(self):
...
def turn_90_degrees(self, mach: int = 12):
...
We now have a new attribute. It could also become the case that new UAP classes lack an attribute, in our current case only the AcornUAP has a irf_cloak attribute, so passing these parameters to the deploy_uap function directly may not be feasible if we keep adding new classes with different uneven attributes. What we can do, then, is define a dataclass which will collect all the possible attributes of a UAP.
@dataclass
class UAPOptions:
warp_speed: bool
irf_cloak: bool
The __init__
dunder method of each class will be
responsible of extracting from it the values it needs for its
attributes. We also need to update our abstract class.
class ABCUAP:
options: UAPOptions
class AcornUAP(ABCUAP):
options: UAPOptions
def __init__(self, options: UAPOptions):
self.warp_speed = options.warp_speed
self.irf_cloak = options.irf_cloak
def call_mothership(self):
...
def turn_90_degrees(self, mach: int = 12):
...
We also need to update our previous UAP cigar and tictac classes.
Great. Now that we have an option object to pass the parameters to any UAP class, we can just add that as a parameter to the factory function, without having to worry about edge cases in the parameters needed by any UAP class.
def deploy_uap(uap_type: UAPType, options: UAPOptions) -> UAPProtocol:
uap_obj = UAP_TYPES_MAP.get(uap_type)
if not uap_obj:
raise ValueError("The UAPType passed is not yet available on your planet.")
return uap_obj(options)
new_acorn_uap: UAPProtocol = deploy_uap(UAPType.ACORN, UAPOptions(True, True))
It is also an "option" to make different option classes that may match with specific or subsets of UAP classes, however that may be beyond of the scope of this article, since it's mostly about the factory function. As a note, you may incur in issues with your type checker, this is because you may want to specify a specific class in the variable you store the instantiated class into, but that can be tricky with these factory functions, so I generally prefer to just use the protocol or abstract class if I have to add type hinting to the instantiated object variable.
A final example where we deploy different types of UAP's would look like this:
earth_uap = deploy_uap(UAPType.TICTAC, UAPOptions(True, False))
moon_uap = deploy_uap(UAPType.CIGAR, UAPOptions(False, False))
mars_uap = deploy_uap(UAPType.ACORN, UAPOptions(True, True))
Conclusion
This was a short and dirty explanation of how to generalize your classes, attributes and parameters so that you can have simple factory functions that can be easy to use/implement/maintain and allow for easy extension. The factory pattern is one of the most common patterns, and although there are OOB ways of implementing it, in python a single function can get the job done in most cases given that the premises (generalized classes and parameters) are met.