Introduction
Design patterns offer solutions to common software design problems. When it comes to OOP (object-oriented programming), design patterns are more focused on solving the problems of object generation and interaction, rather than the larger scale problems of overall software architecture.[1]
Gang of Four
In 1994, four authors -- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides (pictured below) -- also known as the Gang of Four (GoF) released the hugely popular - Design Patterns: Elements of Reusable Object-Oriented Software. This book contained various techniques around software design, including twenty-three software design patterns, categorized into three types: creational, structural, and behavioral.
Within this article, we will cover the Creational Patterns based upon the Python language.
Figure 1: GoF
What is a Creational Pattern?
As per -- Elements of Reusable Object-Oriented Software -- creational design patterns are,
... composed of two dominant ideas. One is encapsulating knowledge about which concrete classes the system uses. The other is hiding how instances of these concrete classes are created and combined.
Factory Pattern
The Factory pattern is a design pattern that allows you to use a function or method (known as the factory method) to handle the creation of an object.
In our example below, we can see that the method get_router
is our factory method. Therefore we only need to interact with the factory method for the creation of our classes.
class JuniperRouter:
def __init__(self, device_id):
self._device_id = device_id
def model_name(self):
return "MX"
class CiscoRouter:
def __init__(self, device_id):
self._device_id = device_id
def model_name(self):
return "ASR"
def get_router(router="cisco"):
"""The factory method"""
routers = dict(juniper=JuniperRouter("011111"),
cisco=CiscoRouter("022222"))
return routers[router]
... c = get_router("cisco")
... print(c.model_name())
ASR
...
... j = get_router("juniper")
... print(j.model_name())
MX
Abstract Factory Pattern
The Abstract Factory pattern is also called a factory of factories.
With the Factory pattern, previously seen, you produce instances of a class via a factory method. With the Abstract Factory pattern, you provide a way for anyone to provide their own factory.
The best way to explain this is via an example. Our example is based upon a factory that manufactures copper cabling. In the world of Python, this is the CopperFactory
class and the class that it returns - Copper
. In addition, we also have a cable shop that retails our cables. However, within this cable shop i.e the CableShop
class, we implement an Abstract Factory. This takes in our CopperFactory
, as shown in the code below.
class Copper:
def get_spec(self):
return "cat5e"
def __str__(self):
return "Copper"
class CopperFactory:
"""Concrete Factory"""
def get_cable(self):
return Copper()
def get_cost(self):
return "$50"
class CableShop:
""" CableShop uses our Abstract Factory """
def __init__(self, cable_factory=None):
""" cable_factory is our Abstract Factory """
self._cable_factory = cable_factory
def show_cable(self):
""" Utility method to display the details of the objects returned by the concrete Factory """
cable = self._cable_factory.get_cable()
cable_cost = self._cable_factory.get_cost()
print("Cable manufactured is '{}'!".format(cable))
print("Cable spec is '{}'".format(cable.get_spec()))
print("Cable cost is '{}' per unit".format(cable_cost))
... #Create a Concrete Factory for Copper Factory
... copper_factory = CopperFactory()
...
... #Create a cable shop containing our Abstract Factory
... cable_shop = CableShop(copper_factory)
... cable_shop.show_cable()
...
Cable manufactured is 'Copper'!
Cable spec is 'cat5e'
Cable cost is '$50' per unit
The company has expanded and a new Factory is required for fiber cabling. Therefore, we create Fiber
and FiberFactory
. We can now pass this into CableShop
, without requiring any change to the Abstract Factory and the methods previously called i.e .show.cable()
class Fiber:
def get_spec(self):
return "single-mode"
def __str__(self):
return "Fiber"
class FiberFactory:
"""Concrete Factory"""
def get_cable(self):
return Fiber()
def get_cost(self):
return "$80"
... #Create a Concrete Factory for Fiber Factory
... fiber_factory = FiberFactory()
...
... #Create a pet store housing our Abstract Factory
... cable_shop = CableShop(fiber_factory)
... cable_shop.show_cable()
Cable manufactured is 'Fiber'!
Cable spec is 'single-mode'
Cable cost is '$80' per unit
Builder Pattern
The main advantages of the Builder pattern are that it provides a clear separation between the construction and representation of an object, and in turn, provides better control over the construction process.[2]
The pattern consists of two main participants: the builder and the director. The builder is responsible for creating the various parts of the complex object. The director controls the building process using a builder instance.[3]
Below shows a clear representation of the various elements[4] based on a restaurant scenario. Makes sense, right? Why would the client care about how the meal is built. I mean, they just want the meal! This is exactly what the Builder pattern provides.
Figure 2: Builder pattern example.
In our example below, we can see that the HardwareEngineer
is the director. Which instructs the builder, ComputerBuilder
to build the computer i.e instantiate the objects.
class Computer:
def __init__(self, serial_number):
self.serial = serial_number
self.memory = None # in gigabytes
self.hdd = None # in gigabytes
self.gpu = None
def __str__(self):
info = ('Memory: {}GB'.format(self.memory),
'Hard Disk: {}GB'.format(self.hdd),
'Graphics Card: {}'.format(self.gpu))
return '\n'.join(info)
# builder
class ComputerBuilder:
def __init__(self):
self.computer = Computer('AG23385193')
def configure_memory(self, amount):
self.computer.memory = amount
def configure_hdd(self, amount):
self.computer.hdd = amount
def configure_gpu(self, gpu_model):
self.computer.gpu = gpu_model
# director
class HardwareEngineer:
def __init__(self):
self.builder = None
def construct_computer(self, memory, hdd, gpu):
self.builder = ComputerBuilder()
[step for step in (self.builder.configure_memory(memory),
self.builder.configure_hdd(hdd),
self.builder.configure_gpu(gpu))]
@property
def computer(self):
return self.builder.computer
... director = HardwareEngineer()
... director.construct_computer(hdd=500, memory=9, gpu='GeForce GTX 650 Ti')
... computer = director.computer
... print(computer)
Memory: 9GB
Hard Disk: 500GB
Graphics Card: GeForce GTX 650 Ti
Singleton Pattern
A Singleton only allows one, and one object only to be instantiated from a class.
This can be useful in instances where you want to provide, for example, a global configuration object.
Below shows an example, which is based on the previously mentioned configuration object scenario. As you can see even though we try to instantiate a new copy of the class, we receive the same one, as per the class id()
shown.
class Singleton(object):
def __new__(cls):
if not hasattr(cls, 'instance') or not cls.instance:
cls.instance = super().__new__(cls)
return cls.instance
... # singleton for app1
... configuration_for_app1 = Singleton()
... configuration_for_app1.db_username = 'Cyril'
...
... print("db_username = {}".format(configuration_for_app1.db_username))
...
... # singleton for app2
... configuration_for_app2 = Singleton()
... print("db_username = {}".format(configuration_for_app2.db_username))
...
... # compare singleton class id's
... print(id(configuration_for_app1))
... print(id(configuration_for_app2))
db_username = Cyril
db_username = Cyril
140484363288928
140484363288928
Prototype Pattern
The Prototype pattern is used when the object creation is a costly affair and requires a lot of time and resources, and you have a similar object already existing. This pattern provides a mechanism to copy the original object to a new object and then modify it according to our needs.[5]
Below shows an example where the creation of a new object would require a huge SQL call. We create a Prototype
class. This contains the required methods to clone our classes, as shown below with the cloning of fw1
to fw2
import copy
class Prototype:
def __init__(self):
self._objects = {}
def register_object(self, name, obj):
"""Register an object"""
self._objects[name] = obj
def unregister_object(self, name):
"""Unregister an object"""
del self._objects[name]
def clone(self, name, **attr):
"""Clone a registered object and update its attributes"""
obj = copy.deepcopy(self._objects.get(name))
obj.__dict__.update(attr)
return obj
class Firewall:
def __init__(self):
self.throughput = "10Gb"
self.max_conn = "50k"
self.max_vpn = "100"
print("WARNING! HUGE SQL query made to CMDB ...")
def __str__(self):
return '{} | {} | {}'.format(self.throughput,
self.max_conn,
self.max_vpn)
... # create original object
... fw1 = Firewall()
... print("original object = {}".format(fw1))
...
... # clone
... prototype = Prototype()
... prototype.register_object('srx', fw1)
... fw2 = prototype.clone('srx')
...
... # print clone
... print("cloned object = {}".format(fw2))
WARNING! HUGE SQL query made to CMDB ...
original object = 10Gb | 50k | 100
cloned object = 10Gb | 50k | 100
References
"2. The Builder Pattern - Mastering Python Design ... - O'Reilly Media." https://www.oreilly.com/library/view/mastering-python-design/9781783989324/ch02.html. Accessed 17 Apr. 2019. ↩︎
"2. The Builder Pattern - Mastering Python Design ... - O'Reilly Media." https://www.oreilly.com/library/view/mastering-python-design/9781783989324/ch02.html. Accessed 17 Apr. 2019. ↩︎
"2. The Builder Pattern - Mastering Python Design ... - O'Reilly Media." https://www.oreilly.com/library/view/mastering-python-design/9781783989324/ch02.html. Accessed 17 Apr. 2019. ↩︎
"2. The Builder Pattern - Mastering Python Design ... - O'Reilly Media." https://www.oreilly.com/library/view/mastering-python-design/9781783989324/ch02.html. Accessed 17 Apr. 2019. ↩︎
"Prototype design pattern in Java and Python | en.proft.me." 27 Sep. 2016, https://en.proft.me/2016/09/27/prototype-design-pattern-java-and-python/. Accessed 17 Apr. 2019. ↩︎