El patrón de diseño de prototipos en Python

En esta guía, veremos la teoría y la implementación del patrón de diseño de prototipos en Python y compararemos el aumento de rendimiento.

Introducción

En esta guía, veremos la teoría y la implementación de el patrón de diseño de prototipos en Python y cuándo puede beneficiarse de su aprovechamiento.

El paradigma de la programación orientada a objetos (POO) {#el paradigma de la programación orientada a objetos}

Los Patrones de diseño son soluciones a problemas comunes, típicamente presentes, pero no limitados a, las arquitecturas de Programación orientada a objetos (POO). OOP es uno de los paradigmas de programación más comunes, debido a su naturaleza intuitiva y lo bien que puede reflejar el mundo real. A través de OOP, abstraemos el mundo físico en software, lo que nos permite observar y escribir código de forma natural. Cada entidad se convierte en un objeto y estos objetos pueden relacionarse con otros objetos, formando una jerarquía de objetos en un sistema.

Si bien este enfoque es muy intuitivo y natural para nosotros, las cosas pueden volverse agitadas muy rápido, al igual que el mundo real. Con muchas relaciones, interacciones y resultados, es difícil mantener todo de manera coherente. Ya sea en la creación, la estructura o el comportamiento, escalar estos sistemas puede volverse muy complicado y, con cada paso en falso, se atrinchera más en el problema. Esta es la razón por la cual los patrones de diseño se aplican y se usan ampliamente en la actualidad.

La biblioteca ABC

El paradigma OOP comúnmente aprovecha el uso de clases abstractas, que no son una función integrada en Python. Para lograr esta funcionalidad utilizamos la Biblioteca ABC (clases base abstractas).

A través de ABC, podremos definir clases abstractas y formar subclases en base a ellas, lo que nos permite implementar este patrón.

Patrones de diseño {#patrones de diseño}

Nuevamente, los patrones de diseño son prácticas y estructuras estandarizadas que nos ayudan a construir implementaciones limpias y escalables en arquitecturas OOP. Por lo general, proporcionan una estructura base a seguir al escribir código y se pueden personalizar siempre que siga el concepto fundamental del patrón.

Hay tres categorías principales de patrones de diseño:

  • Patrones de diseño creativo: se ocupa de permitir la creación de objetos mientras se abstrae/oculta la lógica de creación del objeto.
  • Patrones de diseño estructural: destinados a manejar la composición de objetos y clases, confiando en la herencia para controlar cómo se estructuran los objetos.
  • Patrones de diseño de comportamiento: centrado en la comunicación que se produce entre objetos, controlando cómo se mueven los datos entre objetos y distribuyendo el comportamiento entre clases.

La intuición del patrón prototipo {#la intuición del patrón prototipo}

El patrón prototipo es un patrón de diseño creativo que se utiliza para clonar un objeto prototipo, que es una superclase que define propiedades fundamentales. Naturalmente, las subclases tienen las mismas propiedades fundamentales, con algunas propias.

El patrón de diseño de prototipo se aplica normalmente cuando la clonación es una operación más económica que la creación de un nuevo objeto y cuando la creación requiere llamadas largas y costosas. Estas llamadas suelen estar vinculadas a costosas operaciones de base de datos, pero pueden ser otros procesos costosos.

Para simular esto, nos burlaremos de una llamada de proceso costosa en la creación de nuestros objetos, que dura tres segundos completos. Luego, usando el patrón de diseño de prototipo, crearemos nuevos objetos mientras evitamos esta limitación.

Para facilitar esta funcionalidad, haremos uso de dos clases:

  • El Prototipo: La superclase y contendrá todos los atributos y métodos básicos obligatorios que tendrán los clones cuando copien la clase Prototipo. Además, el Prototipo tiene un método clone() abstracto, que debe ser implementado por todas las subclases.
  • Clase(s) concreta(s): Una vez que hemos creado el Prototipo, podemos empezar a definir las clases concretas en base a él. Las clases concretas pueden tener sus propios atributos y métodos pero siempre tendrán los atributos del prototipo original y una versión sobrescrita del clone().

prototype design pattern illustration

La implementación del patrón prototipo en Python

Vamos a crear un par de tipos de NPC para un videojuego ficticio: un “comerciante”, un “guerrero” y un “mago”.

Cada uno de ellos es un NPC, una superclase común, pero tendrán diferentes atributos. El Tendero tiene carisma, por lo que puede negociar mejor, mientras que el Mage tiene mana en lugar de stamina, como el Warrior.

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 - retrasando la ejecución del código por varios segundos, making the creation of any new objects an extremely expensive operation.

Finalmente, dado que las clases serían inutilizables de manera razonable de lo contrario, aprovecharemos el patrón Prototype para mitigar este problema y recuperar el rendimiento.

Definición de la clase prototipo {#definición de la clase prototipo}

Comencemos con la superclase: el ‘Prototipo’ para los NPC. Su método clone() estará vacío, pero sus subclases lo implementarán. Naturalmente, también contendrá todos los atributos básicos de las subclases. Dado que queremos que todas las subclases implementen necesariamente el método clone(), se marca como @abstractmethod. La anotación proviene de la biblioteca ABC y los métodos abstractos no proporcionan una implementación, pero deben ser implementados por subclases:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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  

Clases concretas

Ahora, definamos nuestras clases concretas basadas en el Prototipo. Anularemos el método clone() y proporcionaremos una implementación para él. Para copiar los objetos, haremos uso de la biblioteca copy, que está integrada en Python. El método copy() de la biblioteca realiza una copia superficial de un objeto, mientras que deepcopy() crea una copia profunda de un objeto. Dependiendo de la estructura de sus objetos, preferirá uno u otro.

Una copia superficial simplemente copiará las referencias a campos no primitivos, como diccionarios, listas, conjuntos u otras clases. Una copia profunda creará nuevas instancias, con los mismos datos. Esto significa que para copias superficiales, podría terminar cambiando los campos de clases múltiples en lugar de uno, ya que varios objetos pueden compartir el mismo campo a través de la misma referencia.

Las copias superficiales son operaciones más baratas, ya que no instancian nada nuevo para los tipos no primitivos. En general, es posible que no resulte costoso crear instancias de estos tipos, por lo que no ganará mucho. Pero, si su clase también tiene campos costosos, aquellos que toman tiempo para instanciar, una copia superficial tendrá mucho más rendimiento que una copia profunda, a costa de compartir los mismos objetos en la memoria.

Dicho esto, definamos nuestras subclases. En lugar de aceptar valores a través del constructor, proporcionaremos algunos valores base para todas las instancias de estas clases concretas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 = defennse
        self.attack = attack
        # Subclass-specific Attribute
        self.charisma = 30

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

El PNJ Guerrero tiene otro conjunto de valores base:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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
    # Overwritting Cloning Method
    def clone(self):
        return copy.deepcopy(self)  

Y finalmente, el Mago:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

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

Prueba del patrón de diseño del prototipo en Python

Ahora, podemos probar el patrón. Primero, crearemos una instancia de Shopkeeper tal como es, anotando el tiempo que lleva:

1
2
3
4
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()))

Esto da como resultado una espera de 6 segundos: 3 del ‘Prototipo’ y 3 del ‘Tienda’, pero finalmente crea el objeto 6 segundos después:

1
2
3
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

Como era de esperar, esta es una operación dolorosamente lenta. ¿Qué pasa si necesitamos otro comerciante? O mejor aún, ¿qué pasa si necesitamos 5 comerciantes más? Vamos a crear una instancia de un gremio que contenga 5 comerciantes:

1
2
3
4
5
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())
1
2
3
4
5
6
7
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

En cambio, podemos clonar al primer tendero, considerando el hecho de que todos siguen el mismo patrón y pueden ser sustituidos:

1
2
3
4
5
6
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())

Lo que resulta en:

1
2
3
4
5
6
7
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

Ahora, todo lo que se necesita es instanciar la primera plantilla Shopkeeper, y podemos clonarla en nanosegundos. Los 5 clones completos tardaron solo 0.001 segundos en realizarse.

Ahora, podemos crear una población completa de diferentes NPC sin ningún problema:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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())

Lo que resulta en ~1000 copias, todas las cuales tardaron ~0.1s en copiar en total:

1
2
3
4
5
6
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

Conclusión

La creación de objetos complejos, especialmente si requieren costosas llamadas a la base de datos, requiere mucho tiempo.

En esta guía, analizamos cómo implementar el patrón de diseño de prototipos en Python y demostramos un tremendo aumento en el rendimiento cuando se usa para clonar instancias costosas en lugar de crear otras nuevas.