The Prototype Design Pattern in Python

Introduction

In this guide, we'll take a look at the theory and implementation of the Prototype Design Pattern in Python and when you can benefit from leveraging it.

The Object-Oriented Programming (OOP) Paradigm

Design Patterns are solutions to common issues, typically present, but not limited to, the Object-Oriented Programming (OOP) architectures. OOP is one of the most most common programming paradigms, due to its intuitive nature and how well it can reflect the real world. Through OOP, we abstract the physical world into software, allowing us to naturally observe and write code. Each entity becomes an object and these objects can relate to other objects - forming a hierarchy of objects in a system.

While this approach is very intuitive and natural for us - things can get hectic real quick, just like the real world can. With many relationships, interactions and outcomes - it's hard to maintain everything in a coherent manner. Be it creation, structure or behavior, scaling these systems can become very tricky, and with each wrong step - you're deeper entrenched into the problem. This is why Design Patterns are applied and widely-used today.

The ABC Library

The OOP paradigm commonly leverages the usage of abstract classes, which are not a built-in feature in Python. To achieve this functionality, we use the ABC (Abstract Base Classes) library.

Through ABC, we'll be able to define abstract classes and form subclasses based on them, which allows us to implement this pattern.

Design Patterns

Again, Design Patterns are standardized practices and structures that help us build scalable, clean implementations in OOP architectures. They typically provide a base structure to follow when writing code, and can be customized as long as you follow the fundamental concept of the pattern.

There are three main Design Pattern categories:

  • Creational Design Patterns - concerned with enabling the creation of objects while abstracting/hiding away the object's creation logic.
  • Structural Design Patterns - intended to handle the composition of objects and classes, relying on inheritance to control how objects are structured.
  • Behavioral Design Patterns - focused on the communication that occurs between objects, controlling how data moves between objects, and distributing behavior between classes.

The Prototype Pattern Intuition

The Prototype Pattern is a Creational Design Pattern used to clone a Prototype Object, which is a superclass defining fundamental properties. Naturally, the subclasses have the same fundamental properties, with some of their own particular ones.

The Prototype Design Pattern is typically applied when cloning is a cheaper operation than creating a new object and when the creation necessitates long, expensive calls. These calls are commonly tied to expensive database operations, but can be other expensive processes.

To simulate this - we'll mock an expensive process call in the creation of our objects, lasting the entire three seconds. Then, using the Prototype Design Pattern - we'll create new objects while avoiding this limitation.

To facilitate this functionality, we'll make use of two classes:

  • The Prototype: The superclass will contain all the base obligatory attributes and methods that the clones will have when they copy the Prototype class. Also, the Prototype has an abstract clone() method, which has to be implemented by all subclasses.
  • Concrete Class(es): Once we have created the Prototype, we can start defining the concrete classes based on it. The concrete classes can have their own attributes and methods but they’ll always have the original prototype attributes and an overwritten version of the clone().

The Prototype Pattern Implementation in Python

We'll be creating a couple of NPC types for a fictional video game - a Shopkeeper, a Warrior and a Mage.

Each of them are an NPC, a common superclass - but they'll have different attributes. The Shopkeeper has charisma, so they can barter better, while the Mage has mana instead of stamina, like the Warrior does.

Our Prototype class will signify general NPCs and from it, we can implement our concrete classes. We'll have delays in both the Prototype constructor and the concrete classes themselves, mocking an expensive call in the constructor - delaying the code execution by several seconds, making the creation of any new objects an extremely expensive operation.

Finally, since the classes would be unusable in a reasonable manner otherwise - we'll leverage the Prototype pattern to mitigate this issue and regain performance.

Defining the Prototype Class

Let's start off with the superclass - the Prototype for the NPCs. Its clone() method will be empty, but its subclasses will implement it. Naturally, it'll also contain all of the base attributes for the subclasses. Since we want all subclasses to necessarily implement the clone() method, it's marked as an @abstractmethod. The annotation stems from the ABC library and abstract methods don't provide an implementation, but have to be implemented by subclasses:

from abc import ABC, abstractmethod
import time

# Class Creation
class Prototype(ABC):
    # Constructor:
    def __init__(self):
        # Mocking an expensive call
        time.sleep(3)
        # Base attributes
        self.height = None
        self.age = None
        self.defense = None
        self.attack = None

    # Clone Method:
    @abstractmethod
    def clone(self):
        pass  

Concrete Classes

Now, let's define our concrete classes based on the Prototype. We'll be overriding the clone() method and actually provide an implementation for it. To copy the objects, we'll make use of the copy library, which is built-in into Python. The copy() method of the library performs a shallow copy of an object, while a deepcopy() creates a deep copy of an object. Depending on the structure of your objects - you'll prefer one or the other.

A shallow copy will just copy the references to non-primitive fields, such as dictionaries, lists, sets or other classes. A deep copy will create new instances, with the same data. This means that for shallow copies - you might end up changing the fields of multiple classes instead of one, since multiple objects may share the same field through the same reference.

Shallow copies are cheaper operations, since they don't instantiate anything new for non-primitive types. In general, these types might not be expensive to instantiate, so you won't gain much. But, if your class has expensive fields as well - those that take time to instantiate, a shallow copy will be much more performant than a deep copy, at the cost of sharing the same objects in memory.

That being said, let's define our subclasses. Instead of accepting values through the constructor, we'll supply some base-values for all instances of these concrete classes:

from prototype import Prototype
import copy
import time

class Shopkeeper(Prototype):
    def __init__(self, height, age, defense, attack):
        super().__init__()
        # Mock expensive call
        time.sleep(3)
        self.height = height
        self.age = age
        self.defense = defense
        self.attack = attack
        # Subclass-specific Attribute
        self.charisma = 30

    # Overwriting Cloning Method:
    def clone(self):
        return copy.deepcopy(self)    

The Warrior NPC has another set of base-values:

from prototype import Prototype
import copy
import time

class Warrior(Prototype):
    def __init__(self, height, age, defense, attack):
        # Call superclass constructor, time.sleep() and assign base values
        # Concrete class attribute
        self.stamina = 60
    # Overwriting Cloning Method
    def clone(self):
        return copy.deepcopy(self)  

And finally, the Mage:

from prototype import Prototype
import copy
import time

class Mage(Prototype):
     def __init__(self, height, age, defense, attack):
     # Call superclass constructor, time.sleep() and assign base values
     self.mana = 100

    # Overwriting Cloning Method
    def clone(self):
        return copy.deepcopy(self) 

Testing the Prototype Design Pattern in Python

Now, we can test out the pattern. First, we'll create an instance of a Shopkeeper as it is, keeping note of the time it takes:

print('Starting to create a Shopkeeper NPC: ', datetime.datetime.now().time())
shopkeeper = Shopkeeper(180, 22, 5, 8)
print('Finished creating a Shopkeeper NPC: ', datetime.datetime.now().time())
print('Attributes: ' + ', '.join("%s: %s" % item for item in vars(shopkeeper).items()))

This results in a 6 second wait - 3 from the Prototype and 3 from the Shopkeeper, but ultimately creates the object 6 seconds later:

Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Starting to create a Shopkeeper NPC:  15:57:40.852336
Finished creating a Shopkeeper NPC:  15:57:46.859203
Attributes: height: 180, age: 22, defense: 5, attack: 8, charisma: 30

As expected, this is a painfully slow operation. What happens if we need another shopkeeper? Or better yet - what if we need 5 more shopkeepers? Let's instantiate a guild that contains 5 shopkeepers:

print('Instantiating trader guild at: ', datetime.datetime.now().time())
for i in range(5):
    shopkeeper = Shopkeeper(180, 22, 5, 8)
    print(f'Finished creating a Shopkeeper NPC {i} at: ', datetime.datetime.now().time())
print('Finished instantiating trader guild at: ', datetime.datetime.now().time())
Instantiating trader guild at:  16:15:14.353285
Finished creating a Shopkeeper NPC 0 at:  16:15:20.360971
Finished creating a Shopkeeper NPC 1 at:  16:15:26.365997
Finished creating a Shopkeeper NPC 2 at:  16:15:32.370327
Finished creating a Shopkeeper NPC 3 at:  16:15:38.378361
Finished creating a Shopkeeper NPC 4 at:  16:15:44.383375
Finished instantiating trader guild at:  16:15:44.383674

Instead, we can clone the first shopkeeper, considering the fact that they all follow the same pattern, and can be substituted:

print('Instantiating trader guild at: ', datetime.datetime.now().time())
shopkeeper_template = Shopkeeper(180, 22, 5, 8)
for i in range(5):
    shopkeeper_clone = shopkeeper_template.clone()
    print(f'Finished creating a Shopkeeper clone {i} at: ', datetime.datetime.now().time())
print('Finished instantiating trader guild at: ', datetime.datetime.now().time())

Which results in:

Instantiating trader guild at:  16:19:24.965780
Finished creating a Shopkeeper clone 0 at:  16:19:30.975445
Finished creating a Shopkeeper clone 1 at:  16:19:30.975763
Finished creating a Shopkeeper clone 2 at:  16:19:30.975911
Finished creating a Shopkeeper clone 3 at:  16:19:30.976058
Finished creating a Shopkeeper clone 4 at:  16:19:30.976132
Finished instantiating trader guild at:  16:19:30.976529

Now, all it takes is the first template Shopkeeper to be instantiated, and we can clone it in nanoseconds. The entire 5 clones took only 0.001 seconds to perform.

Now, we can create an entire population of different NPCs without an issue:

print('Instantiating 1000 NPCs: ', datetime.datetime.now().time())
shopkeeper_template = Shopkeeper(180, 22, 5, 8)
warrior_template = Warrior(185, 22, 4, 21)
mage_template = Mage(172, 65, 8, 15)
for i in range(333):
    shopkeeper_clone = shopkeeper_template.clone()
    warrior_clone = warrior_template.clone()
    mage_clone = mage_template.clone()
    print(f'Finished creating NPC trio clone {i} at: ', datetime.datetime.now().time())
print('Finished instantiating NPC population at: ', datetime.datetime.now().time())

Which results in ~1000 copies, all of which took ~0.1s to copy in total:

Instantiating 1000 NPCs:  16:27:14.566635
Finished creating NPC trio clone 0 at:  16:27:32.591992
...
Finished creating NPC trrio clone 331 at:  16:27:32.625681
Finished creating NPC trio clone 332 at:  16:27:32.625764
Finished instantiating NPC population at:  16:27:32.625794

Conclusion

Creating complex objects, especially if they require expensive database calls is time-consuming.

In this guide, we've taken a look at how to implement the Prototype Design Pattern in Python and demonstrated a tremendous boost in performance when using it to clone expensive instances rather than creating new ones.

Last Updated: March 7th, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms