Patrones de diseño creacional en Python

Los patrones de diseño son enfoques bien adoptados y endurecidos por la batalla para resolver problemas comunes. En este artículo, exploraremos los patrones de diseño creativo en Python.

Visión general

This is the first article in a short series dedicated to Patrones de diseño en Python.

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

Patrones de diseño creativo, como su nombre lo indica, se ocupan de la creación de clases u objetos.

Sirven para abstraer los detalles específicos de las clases para que seamos menos dependientes de su implementación exacta, o para que no tengamos que lidiar con construcciones complejas cada vez que las necesitemos, o para que aseguremos algunos propiedades especiales de instanciación.

Son muy útiles para reducir el nivel de dependencia entre nuestras clases y también para controlar cómo interactúa el usuario con ellas.

Los patrones de diseño cubiertos en este artículo son:

Fábrica

Problema

Supongamos que está creando un software para una compañía de seguros que ofrece seguros a personas que trabajan a tiempo completo. Has creado la aplicación usando una clase llamada Worker.

Sin embargo, el cliente decide expandir su negocio y ahora también brindará sus servicios a personas desempleadas, aunque con diferentes procedimientos y condiciones.

¡Ahora debe crear una clase completamente nueva para los desempleados, que requerirá un constructor completamente diferente! Pero ahora no sabe a qué constructor llamar en un caso general, y mucho menos qué argumentos pasarle.

Puedes tener algunos condicionales feos en todo tu código donde cada invocación del constructor está rodeada por declaraciones ‘si’, y usas alguna operación posiblemente costosa para verificar el tipo del objeto en sí.

Si hay errores durante la inicialización, se capturan y el código se edita para hacerlo en cada uno de los cien lugares en los que se usan los constructores.

Sin estresarlo, usted es muy consciente de que este enfoque es menos que deseable, no escalable y totalmente insostenible.

Alternativamente, podría considerar el Patrón de fábrica.

Solución

Las fábricas se utilizan para encapsular la información sobre las clases que estamos usando, mientras las instanciamos en función de ciertos parámetros que les proporcionamos.

Al usar una fábrica, podemos cambiar una implementación por otra simplemente cambiando el parámetro que se usó para decidir la implementación original en primer lugar.

Esto desacopla la implementación del uso de tal manera que podemos escalar fácilmente la aplicación agregando nuevas implementaciones y simplemente instanciarlas a través de la fábrica, con exactamente la misma base de código.

Si solo obtenemos otra fábrica como parámetro, ni siquiera necesitamos saber qué clase produce. Solo necesitamos tener un método de fábrica uniforme que devuelva una clase garantizada para tener un cierto conjunto de comportamientos. Vamos a ver.

Para empezar, no olvide incluir métodos abstractos:

1
from abc import ABC, abstractmethod

Necesitamos que nuestras clases producidas implementen algún conjunto de métodos que nos permitan trabajar con ellas de manera uniforme. Para ello, implementamos la siguiente interfaz:

1
2
3
4
5
class Product(ABC):

    @abstractmethod
    def calculate_risk(self):
        pass

Y ahora heredamos de él a través de un ‘Trabajador’ y ‘Parado’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Worker(Product):
    def __init__(self, name, age, hours):
        self.name = name
        self.age = age
        self.hours = hours

    def calculate_risk(self):
        # Please imagine a more plausible implementation
        return self.age + 100/self.hours

    def __str__(self):
        return self.name+" ["+str(self.age)+"] - "+str(self.hours)+"h/week"


class Unemployed(Product):
    def __init__(self, name, age, able):
        self.name = name
        self.age = age
        self.able = able

    def calculate_risk(self):
        # Please imagine a more plausible implementation
        if self.able:
            return self.age+10
        else:
            return self.age+30

    def __str__(self):
        if self.able:
            return self.name+" ["+str(self.age)+"] - able to work"
        else:
            return self.name+" ["+str(self.age)+"] - unable to work"

Ahora que tenemos a nuestra gente, hagamos su fábrica:

1
2
3
4
5
6
class PersonFactory:
    def get_person(self, type_of_person):
        if type_of_person == "worker":
            return Worker("Oliver", 22, 30)
        if type_of_person == "unemployed":
            return Unemployed("Sophie", 33, False)

Aquí, hemos codificado los parámetros para mayor claridad, aunque por lo general simplemente crearía una instancia de la clase y haría que hiciera su trabajo.

Para probar cómo funciona todo esto, instanciamos nuestra fábrica y dejemos que produzca un par de personas:

1
2
3
4
5
6
7
factory = PersonFactory()

product = factory.get_person("worker")
print(product)

product2 = factory.get_person("unemployed")
print(product2)
1
2
Oliver [22] - 30h/week
Sophie [33] - unable to work

Fábrica abstracta

Problema

Necesitas crear una familia de diferentes objetos. Aunque son diferentes, de alguna manera están agrupados por un cierto rasgo.

Por ejemplo, es posible que necesite crear un plato principal y un postre en un restaurante italiano y francés, pero no mezclará una cocina con la otra.

Solución

La idea es muy similar al patrón de fábrica normal, con la única diferencia de que todas las fábricas tienen múltiples métodos separados para crear objetos, y el tipo de fábrica es lo que determina la familia de objetos.

Una fábrica abstracta es responsable de la creación de grupos completos de objetos, junto con sus respectivas fábricas, pero no se ocupa de las implementaciones concretas de estos objetos. Esa parte queda para sus respectivas fábricas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from abc import ABC, abstractmethod

class Product(ABC):

    @abstractmethod
    def cook(self):
        pass

class FettuccineAlfredo(Product):
    name = "Fettuccine Alfredo"
    def cook(self):
        print("Italian main course prepared: "+self.name)

class Tiramisu(Product):
    name = "Tiramisu"
    def cook(self):
        print("Italian dessert prepared: "+self.name)

class DuckALOrange(Product):
    name = "Duck À L'Orange"
    def cook(self):
        print("French main course prepared: "+self.name)

class CremeBrulee(Product):
    name = "Crème brûlée"
    def cook(self):
        print("French dessert prepared: "+self.name)

class Factory(ABC):

    @abstractmethod
    def get_dish(type_of_meal):
        pass

class ItalianDishesFactory(Factory):
    def get_dish(type_of_meal):
        if type_of_meal == "main":
            return FettuccineAlfredo()
        if type_of_meal == "dessert":
            return Tiramisu()

    def create_dessert(self):
        return Tiramisu()

class FrenchDishesFactory(Factory):
    def get_dish(type_of_meal):
        if type_of_meal == "main":
            return DuckALOrange()

        if type_of_meal == "dessert":
            return CremeBrulee()

class FactoryProducer:
    def get_factory(self, type_of_factory):
        if type_of_factory == "italian":
            return ItalianDishesFactory
        if type_of_factory == "french":
            return FrenchDishesFactory

Podemos probar los resultados creando ambas fábricas y llamando a los respectivos métodos cook() en todos los objetos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fp = FactoryProducer()

fac = fp.get_factory("italian")
main = fac.get_dish("main")
main.cook()
dessert = fac.get_dish("dessert")
dessert.cook()

fac1 = fp.get_factory("french")
main = fac1.get_dish("main")
main.cook()
dessert = fac1.get_dish("dessert")
dessert.cook()
1
2
3
4
Italian main course prepared: Fettuccine Alfredo
Italian dessert prepared: Tiramisu
French main course prepared: Duck À L'Orange
French dessert prepared: Crème brûlée

Constructor

Problema

Necesita representar un robot con su estructura de objeto. El robot puede ser humanoide con cuatro extremidades y de pie hacia arriba, o puede ser como un animal con cola, alas, etc.

Puede usar ruedas para moverse, o puede usar palas de helicóptero. Puede usar cámaras, un módulo de detección de infrarrojos… te haces una idea.

Imagina el constructor de esta cosa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def __init__(self, left_leg, right_leg, left_arm, right_arm,
             left_wing, right_wing, tail, blades, cameras,
             infrared_module, #...
             ):
    self.left_leg = left_leg
    if left_leg == None:
        bipedal = False
    self.right_leg = right_leg
    self.left_arm = left_arm
    self.right_arm = right_arm
    # ...

Crear instancias de esta clase sería extremadamente ilegible, sería muy fácil equivocarse en algunos de los tipos de argumentos ya que estamos trabajando en Python y acumular innumerables argumentos en un constructor es difícil de manejar.

Además, ¿qué pasa si no queremos que el robot implemente todos los campos dentro de la clase? ¿Qué pasa si queremos que solo tenga patas en lugar de tener ambas patas y ruedas?

Python no admite la sobrecarga de constructores, lo que nos ayudaría a definir tales casos (e incluso si pudiéramos, solo generaría aún más constructores desordenados).

Solución

Podemos crear una clase Builder que construya nuestro objeto y agregue los módulos apropiados a nuestro robot. En lugar de un constructor intrincado, podemos instanciar un objeto y agregar los componentes necesarios usando funciones.

Llamamos a la construcción de cada módulo por separado, después de instanciar el objeto. Avancemos y definamos un Robot con algunos valores predeterminados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class Robot:
    def __init__(self):
        self.bipedal = False
        self.quadripedal = False
        self.wheeled = False
        self.flying = False
        self.traversal = []
        self.detection_systems = []

    def __str__(self):
        string = ""
        if self.bipedal:
            string += "BIPEDAL "
        if self.quadripedal:
            string += "QUADRIPEDAL "
        if self.flying:
            string += "FLYING ROBOT "
        if self.wheeled:
            string += "ROBOT ON WHEELS\n"
        else:
            string += "ROBOT\n"

        if self.traversal:
            string += "Traversal modules installed:\n"

        for module in self.traversal:
            string += "- " + str(module) + "\n"

        if self.detection_systems:
            string += "Detection systems installed:\n"

        for system in self.detection_systems:
            string += "- " + str(system) + "\n"

        return string

class BipedalLegs:
    def __str__(self):
        return "two legs"

class QuadripedalLegs:
    def __str__(self):
        return "four legs"

class Arms:
    def __str__(self):
        return "four legs"

class Wings:
    def __str__(self):
        return "wings"

class Blades:
    def __str__(self):
        return "blades"

class FourWheels:
    def __str__(self):
        return "four wheels"

class TwoWheels:
    def __str__(self):
        return "two wheels"

class CameraDetectionSystem:
    def __str__(self):
        return "cameras"

class InfraredDetectionSystem:
    def __str__(self):
        return "infrared"

Tenga en cuenta que hemos omitido inicializaciones específicas en el constructor y, en su lugar, hemos utilizado valores predeterminados. Esto se debe a que usaremos las clases Builder para inicializar estos valores.

Primero, implementamos un Builder abstracto que define nuestra interfaz para construir:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from abc import ABC, abstractmethod

class RobotBuilder(ABC):

    @abstractmethod
    def reset(self):
        pass

    @abstractmethod
    def build_traversal(self):
        pass

    @abstractmethod
    def build_detection_system(self):
        pass

Ahora podemos implementar múltiples tipos de Constructores que obedezcan a esta interfaz, por ejemplo para un android y para un auto autónomo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class AndroidBuilder(RobotBuilder):
    def __init__(self):
        self.product = Robot()

    def reset(self):
        self.product = Robot()

    def get_product(self):
        return self.product

    def build_traversal(self):
        self.product.bipedal = True
        self.product.traversal.append(BipedalLegs())
        self.product.traversal.append(Arms())

    def build_detection_system(self):
        self.product.detection_systems.append(CameraDetectionSystem())

class AutonomousCarBuilder(RobotBuilder):
    def __init__(self):
        self.product = Robot()

    def reset(self):
        self.product = Robot()

    def get_product(self):
        return self.product

    def build_traversal(self):
        self.product.wheeled = True
        self.product.traversal.append(FourWheels())

    def build_detection_system(self):
        self.product.detection_systems.append(InfraredDetectionSystem())

Observe cómo implementan los mismos métodos, pero hay una estructura inherentemente diferente de objetos debajo, y el usuario final no necesita lidiar con los detalles de esa estructura.

Por supuesto, podríamos hacer un Robot que puede tener patas y ruedas, y el usuario tendría que agregar cada una por separado, pero también podemos hacer constructores muy específicos que agreguen solo un módulo apropiado para cada "parte" .

Probemos usando un AndroidBuilder para construir un Android:

1
2
3
4
builder = AndroidBuilder()
builder.build_traversal()
builder.build_detection_system()
print(builder.get_product())

Ejecutar este código producirá:

1
2
3
4
5
6
BIPEDAL ROBOT
Traversal modules installed:
- two legs
- four legs
Detection systems installed:
- cameras

Y ahora, usemos un AutonomousCarBuilder para construir un auto:

1
2
3
4
builder = AutonomousCarBuilder()
builder.build_traversal()
builder.build_detection_system()
print(builder.get_product())

Ejecutar este código producirá:

1
2
3
4
5
ROBOT ON WHEELS
Traversal modules installed:
- four wheels
Detection systems installed:
- infrared

La inicialización es mucho más limpia y legible en comparación con el desordenado constructor anterior y tenemos la flexibilidad de agregar los módulos queremos.

Si los campos de nuestro producto usan constructores relativamente estándar, incluso podemos crear un llamado Director para administrar los constructores particulares:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Director:
    def make_android(self, builder):
        builder.build_traversal()
        builder.build_detection_system()
        return builder.get_product()

    def make_autonomous_car(self, builder):
        builder.build_traversal()
        builder.build_detection_system()
        return builder.get_product()

director = Director()
builder = AndroidBuilder()
print(director.make_android(builder))

Ejecutar este fragmento de código producirá:

1
2
3
4
5
6
BIPEDAL ROBOT
Traversal modules installed:
- two legs
- four legs
Detection systems installed:
- cameras

Dicho esto, el patrón Builder no tiene mucho sentido en clases pequeñas y simples, ya que la lógica adicional para construirlas solo agrega más complejidad.

Sin embargo, cuando se trata de clases grandes y complicadas con numerosos campos, como redes neuronales multicapa, el patrón Builder es un salvavidas.

Prototipo

Problema

Necesitamos clonar un objeto, pero es posible que no sepamos su tipo exacto, los parámetros, es posible que no todos se asignen a través del constructor en sí o que dependan del estado del sistema en un punto particular durante el tiempo de ejecución.

Si tratamos de hacerlo directamente, agregaremos muchas dependencias que se ramifican en nuestro código, y es posible que ni siquiera funcione al final.

Solución

El patrón de diseño Prototipo aborda el problema de copiar objetos delegándolo a los propios objetos. Todos los objetos que son copiables deben implementar un método llamado clonar y usarlo para devolver copias exactas de sí mismos.

Avancemos y definamos una función clonar común para todas las clases secundarias y luego heredémosla de la clase principal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from abc import ABC, abstractmethod

class Prototype(ABC):
    def clone(self):
        pass

class MyObject(Prototype):
    def __init__(self, arg1, arg2):
        self.field1 = arg1
        self.field2 = arg2

    def __operation__(self):
        self.performed_operation = True

    def clone(self):
        obj = MyObject(self.field1, field2)
        obj.performed_operation = self.performed_operation
        return obj

Alternativamente, puede usar la función deepcopy en lugar de simplemente asignar campos como en el ejemplo anterior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyObject(Prototype):
    def __init__(self, arg1, arg2):
        self.field1 = arg1
        self.field2 = arg2

    def __operation__(self):
        self.performed_operation = True

    def clone(self):
        return deepcopy(self)

El patrón Prototipo puede ser realmente útil en aplicaciones a gran escala que instancian muchos objetos. A veces, copiar un objeto ya existente es menos costoso que instanciar uno nuevo.

Único

Problema

Un Singleton es un objeto con dos características principales:

  • Puede tener como máximo una instancia
  • Debe ser accesible globalmente en el programa.

Ambas propiedades son importantes, aunque en la práctica a menudo escuchará a la gente llamar a algo Singleton incluso si tiene solo una de estas propiedades.

Tener una sola instancia suele ser un mecanismo para controlar el acceso a algún recurso compartido. Por ejemplo, dos subprocesos pueden funcionar con el mismo archivo, por lo que en lugar de abrirlo ambos por separado, un Singleton puede proporcionar un punto de acceso único para ambos.

La accesibilidad global es importante porque después de que su clase haya sido instanciada una vez, necesitará pasar esa única instancia para poder trabajar con ella. No se puede volver a instanciar. Es por eso que es más fácil asegurarse de que cada vez que intente instanciar la clase nuevamente, obtenga la misma instancia que ya tuvo.

Solución

Avancemos e implementemos el patrón Singleton haciendo que un objeto sea globalmente accesible y limitado a una sola instancia:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import Optional

class MetaSingleton(type):
    _instance : Optional[type] = None
    def __call__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super(MetaSingleton, cls).__call__(*args, **kwargs)
        return cls._instance

class BaseClass:
    field = 5

class Singleton(BaseClass, metaclass=MetaSingleton):
    pass

Opcional aquí hay un tipo de datos que puede contener una clase indicada en [] o Ninguno.

Definir un método __call__ te permite usar instancias de la clase como funciones. El método también se llama durante la inicialización, por lo que cuando llamamos a algo como a = Singleton() bajo el capó, llamará a su método de clase base __call__.

En Python, todo es un objeto. Eso incluye clases. Todas las clases habituales que escribe, así como las clases estándar, tienen tipo como su tipo de objeto. Incluso tipo es de tipo tipo.

Lo que esto significa es que tipo es una metaclase; otras clases son instancias de tipo, al igual que los objetos variables son instancias de esas clases. En nuestro caso, Singleton es una instancia de MetaSingleton.

Todo esto significa que nuestro método __call__ será llamado cada vez que se cree un nuevo objeto y proporcionará una nueva instancia si aún no hemos inicializado una. Si lo hemos hecho, simplemente devolverá la instancia ya inicializada.

super(MetaSingleton, cls).__call__(*args, **kwargs) llama a la superclase' __call__. Nuestra superclase en este caso es type, que tiene una implementación __call__ que realizará la inicialización con los argumentos dados.

Hemos especificado nuestro tipo (MetaSingleton), el valor que se asignará al campo _instance (cls) y otros argumentos que podemos pasar.

El propósito de usar una metaclase en este caso en lugar de una implementación más simple es esencialmente la capacidad de reutilizar el código.

Derivamos una clase de ella en este caso, pero si necesitáramos otro Singleton para otro propósito, podríamos derivar la misma metaclase en lugar de implementar esencialmente lo mismo.

Ahora podemos intentar usarlo:

1
2
3
4
a = Singleton()
b = Singleton()

a == b
1
True

Debido a su punto de acceso global, es aconsejable integrar la seguridad de subprocesos en Singleton. Afortunadamente, no tenemos que editarlo demasiado para hacer eso. Simplemente podemos editar MetaSingleton ligeramente:

1
2
3
4
5
def __call__(cls, *args, **kwargs):
    with cls._lock:
        if not cls._instance:
            cls._instance = super().__call__(*args, **kwargs)
    return cls._instance

De esta forma, si dos subprocesos comienzan a instanciar el Singleton al mismo tiempo, uno se detendrá en el bloqueo. Cuando el administrador de contexto libera el bloqueo, el otro ingresará la declaración if y verá que la instancia ya ha sido creada por el otro hilo.

Conjunto de objetos {#conjunto de objetos}

Problema

Tenemos una clase en nuestro proyecto, llamémosla MyClass. MyClass es muy útil y se usa a menudo durante todo el proyecto, aunque por períodos cortos de tiempo.

Sin embargo, su creación de instancias e inicialización son muy costosas y nuestro programa se ejecuta muy lentamente porque constantemente necesita crear nuevas instancias solo para usarlas en algunas operaciones.

Solución

Crearemos un grupo de objetos que se instanciarán cuando creemos el grupo en sí. Cada vez que necesitemos usar el objeto de tipo MyClass, lo adquiriremos del grupo, lo usaremos y luego lo devolveremos al grupo para usarlo nuevamente.

Si el objeto tiene algún tipo de estado de inicio predeterminado, la liberación siempre lo reiniciará. Si el grupo se deja vacío, inicializaremos un nuevo objeto para el usuario, pero cuando el usuario termine con él, lo liberará nuevamente en el grupo para usarlo nuevamente.

Avancemos y primero definamos MyClass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class MyClass:
    # Return the resource to default setting
    def reset(self):
        self.setting = 0

class ObjectPool:

    def __init__(self, size):
        self.objects = [MyClass() for _ in range(size)]

    def acquire(self):
        if self.objects:
            return self.objects.pop()
        else:
            self.objects.append(MyClass())
            return self.objects.pop()

    def release(self, reusable):
        reusable.reset()
        self.objects.append(reusable)

Y para probarlo:

1
2
3
pool = ObjectPool(10)
reusable = pool.acquire()
pool.release(reusable)

Tenga en cuenta que esta es una implementación básica y que, en la práctica, este patrón se puede usar junto con Singleton para proporcionar un solo grupo accesible globalmente.

Tenga en cuenta que la utilidad de este patrón está en disputa en los lenguajes que usan el recolector de basura.

La asignación de objetos que ocupan solo memoria (lo que significa que no hay recursos externos) tiende a ser relativamente económica en dichos lenguajes, mientras que muchas referencias "en vivo" a objetos pueden ralentizar la recolección de basura porque GC pasa por todas las referencias.

Conclusión

Con esto, hemos cubierto los Patrones de diseño creativo en Python más importantes: los problemas que resuelven y cómo los resuelven.

Estar familiarizado con los patrones de diseño es un conjunto de habilidades extremadamente útil para todos los desarrolladores, ya que brindan soluciones a problemas comunes que se encuentran en la programación.

Al ser consciente tanto de las motivaciones como de las soluciones, también puede evitar encontrar accidentalmente un antipatrón al intentar resolver un problema.