Patrones de diseño creativos en Python

    Visión general

    Este es el primer artículo de una breve serie dedicada a los patrones de diseño en Python.

    Patrones de diseño creacional

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

    Sirven para abstraer los detalles de las clases para que seamos menos dependientes de su implementación exacta, o para que no tengamos que lidiar con construcciones complejas cuando las necesitemos, o para garantizar algunas propiedades especiales de instanciación. .

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

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

    • Fábrica
    • Fábrica abstracta
    • Constructor
    • Prototipo
    • único
    • Grupo de objetos

    Fábrica

    Problema

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

    Sin embargo, el cliente decide ampliar su negocio y ahora prestará sus servicios también a personas desempleadas, aunque con diferentes trámites y condiciones.

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

    Puede tener algunos condicionales desagradables en todo su código donde cada invocación de constructor está rodeada de ifdeclaraciones, y usa alguna operación posiblemente costosa para verificar el tipo de objeto en sí.

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

    Sin estresarse para 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 instanciandolas 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:

    from abc import ABC, abstractmethod
    

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

    class Product(ABC):
    
        @abstractmethod
        def calculate_risk(self):
            pass
    

    Y ahora heredamos de él a través de un Workery Unemployed:

    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:

    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 normalmente solo crea una instancia de la clase y hace que haga lo suyo.

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

    factory = PersonFactory()
    
    product = factory.get_person("worker")
    print(product)
    
    product2 = factory.get_person("unemployed")
    print(product2)
    
    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 determinado rasgo.

    Por ejemplo, es posible que deba 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, la única diferencia es 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 enteros de objetos, junto con sus respectivas fábricas, pero no se preocupa por las implementaciones concretas de estos objetos. Esa parte queda para sus respectivas fábricas:

    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 cook()métodos respectivos en todos los objetos:

    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()
    
    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

    Necesitas representar un robot con la estructura de tu objeto. El robot puede ser humanoide con cuatro extremidades y estar de pie hacia arriba, o puede ser parecido a 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:

    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
        # ...
    

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

    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 piernas en lugar de tener ambas piernas y ruedas?

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

    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 complicado, 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. Sigamos adelante y definamos a Robotcon algunos valores predeterminados:

    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 WHEELSn"
            else:
                string += "ROBOTn"
    
            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"
    

    Observe 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 generador abstracto que define nuestra interfaz para construir:

    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 varios tipos de constructores que obedezcan a esta interfaz, por ejemplo, para un Android y para un automóvil autónomo:

    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())
    

    ¿Observa cómo implementan los mismos métodos, pero hay una estructura de objetos intrínsecamente diferente debajo, y el usuario final no necesita lidiar con los detalles de esa estructura?

    Por supuesto, podríamos hacer una Robotque pueda 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 con an AndroidBuilderpara construir un Android:

    builder = AndroidBuilder()
    builder.build_traversal()
    builder.build_detection_system()
    print(builder.get_product())
    

    Ejecutar este código producirá:

    BIPEDAL ROBOT
    Traversal modules installed:
    - two legs
    - four legs
    Detection systems installed:
    - cameras
    

    Y ahora, usemos an AutonomousCarBuilderpara construir un automóvil:

    builder = AutonomousCarBuilder()
    builder.build_traversal()
    builder.build_detection_system()
    print(builder.get_product())
    

    Ejecutar este código producirá:

    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 de antes y tenemos la flexibilidad de agregar los módulos que queremos.

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

    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á:

    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 las 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, parámetros, es posible que no todos se asignen a través del propio constructor o que dependan del estado del sistema en un punto particular durante el tiempo de ejecución.

    Si intentamos hacerlo directamente, agregaremos muchas dependencias ramificadas en nuestro código, y es posible que ni siquiera funcione al final.

    Solución

    El patrón de diseño de prototipos aborda el problema de copiar objetos delegándolo en los propios objetos. Todos los objetos que se pueden copiar deben implementar un método llamado cloney usarlo para devolver copias exactas de sí mismos.

    Sigamos adelante y definamos una clonefunción común para todas las clases secundarias y luego la heredemos de la clase principal:

    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 deepcopyfunción en lugar de simplemente asignar campos como en el ejemplo anterior:

    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 Prototype puede ser realmente útil en aplicaciones a gran escala que instancian muchos objetos. A veces, copiar un objeto ya existente es menos costoso que crear una instancia de 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 las personas llamar a algo Singleton incluso si solo tiene una de estas propiedades.

    Tener solo una 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 que ambos lo abran por separado, un Singleton puede proporcionar un punto de acceso único para ambos.

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

    Solución

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

    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
    

    Optionalaquí hay un tipo de datos que puede contener una clase indicada en []o None.

    La definición de un __call__método le 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()debajo del capó, llamará al __call__método de su clase base .

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

    Lo que esto significa es que typees una metaclase: otras clases son instancias de type, al igual que los objetos variables son instancias de esas clases. En nuestro caso, Singletones una instancia de MetaSingleton.

    Todo esto significa que __call__se llamará a nuestro método cada vez que se cree un nuevo objeto y proporcionará una nueva instancia si aún no hemos inicializado una. Si es así, solo 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 __call__implementación que realizará la inicialización con los argumentos dados.

    Hemos especificado nuestro tipo ( MetaSingleton), el valor que se asignará al _instancecampo ( cls) y otros argumentos que podamos estar pasando.

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

    En este caso, derivamos una clase de ella, 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:

    a = Singleton()
    b = Singleton()
    
    a == b
    
    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 MetaSingletonligeramente:

    def __call__(cls, *args, **kwargs):
        with cls._lock:
            if not cls._instance:
                cls._instance = super().__call__(*args, **kwargs)
        return cls._instance
    

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

    Grupo de objetos

    Problema

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

    Sin embargo, su instanciación e inicialización son muy costosas y nuestro programa se ejecuta muy lentamente porque necesita constantemente 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í. Siempre que necesitemos usar el objeto de tipo MyClass, lo adquiriremos del grupo, lo usaremos y luego lo soltaremos nuevamente en el 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 haya terminado con él, lo devolverán al grupo para que se use nuevamente.

    Sigamos adelante y primero definamos MyClass:

    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:

    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 único grupo accesible globalmente.

    Tenga en cuenta que la utilidad de este patrón se disputa en los lenguajes que utilizan el recolector de basura.

    La asignación de objetos que solo ocupan memoria (es decir, que no requieren 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 de creación más importantes en Python: 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.

    Conociendo tanto las motivaciones como las soluciones, también puede evitar encontrar accidentalmente un anti-patrón mientras trata de resolver un problema.

     

    Etiquetas:

    Deja una respuesta

    Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *